@tanstack/cta-cli 0.46.2 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/cli.js +61 -4
- package/dist/command-line.js +57 -1
- package/dist/dev-watch.js +290 -0
- package/dist/file-syncer.js +148 -0
- package/dist/options.js +19 -8
- package/dist/types/cli.d.ts +3 -1
- package/dist/types/command-line.d.ts +4 -0
- package/dist/types/dev-watch.d.ts +27 -0
- package/dist/types/file-syncer.d.ts +18 -0
- package/dist/types/types.d.ts +2 -0
- package/package.json +8 -3
- package/src/cli.ts +83 -3
- package/src/command-line.ts +69 -1
- package/src/dev-watch.ts +430 -0
- package/src/file-syncer.ts +205 -0
- package/src/options.ts +21 -8
- package/src/types.ts +2 -0
- package/tests/command-line.test.ts +6 -2
- package/tests/options.test.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @tanstack/cta-cli
|
|
2
|
+
|
|
3
|
+
## 0.47.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Smakll content fixes ([`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2))
|
|
8
|
+
|
|
9
|
+
- allowing for no tailwind ([#151](https://github.com/TanStack/create-tsrouter-app/pull/151))
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [[`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2), [`f1f58fe`](https://github.com/TanStack/create-tsrouter-app/commit/f1f58feed7d7df1e0c5e0fc4dd3af02e11df09e5)]:
|
|
14
|
+
- @tanstack/cta-engine@0.47.0
|
|
15
|
+
- @tanstack/cta-ui@0.47.0
|
package/dist/cli.js
CHANGED
|
@@ -8,11 +8,12 @@ import { SUPPORTED_PACKAGE_MANAGERS, addToApp, compileAddOn, compileStarter, cre
|
|
|
8
8
|
import { launchUI } from '@tanstack/cta-ui';
|
|
9
9
|
import { runMCPServer } from './mcp.js';
|
|
10
10
|
import { promptForAddOns, promptForCreateOptions } from './options.js';
|
|
11
|
-
import { normalizeOptions } from './command-line.js';
|
|
11
|
+
import { normalizeOptions, validateDevWatchOptions } from './command-line.js';
|
|
12
12
|
import { createUIEnvironment } from './ui-environment.js';
|
|
13
13
|
import { convertTemplateToMode } from './utils.js';
|
|
14
|
+
import { DevWatchManager } from './dev-watch.js';
|
|
14
15
|
// This CLI assumes that all of the registered frameworks have the same set of toolchains, deployments, modes, etc.
|
|
15
|
-
export function cli({ name, appName, forcedMode, forcedAddOns = [], defaultTemplate = 'javascript', forcedDeployment, defaultFramework, craCompatible = false, webBase, showDeploymentOptions = false, }) {
|
|
16
|
+
export function cli({ name, appName, forcedMode, forcedAddOns = [], defaultTemplate = 'javascript', forcedDeployment, defaultFramework, craCompatible = false, webBase, frameworkDefinitionInitializers, showDeploymentOptions = false, }) {
|
|
16
17
|
const environment = createUIEnvironment(appName, false);
|
|
17
18
|
const program = new Command();
|
|
18
19
|
const availableFrameworks = getFrameworks().map((f) => f.name);
|
|
@@ -194,12 +195,14 @@ Remove your node_modules directory and package lock file and re-install.`);
|
|
|
194
195
|
}
|
|
195
196
|
program
|
|
196
197
|
.option('--starter [url]', 'initialize this project from a starter URL', false)
|
|
198
|
+
.option('--no-install', 'skip installing dependencies')
|
|
197
199
|
.option(`--package-manager <${SUPPORTED_PACKAGE_MANAGERS.join('|')}>`, `Explicitly tell the CLI to use this package manager`, (value) => {
|
|
198
200
|
if (!SUPPORTED_PACKAGE_MANAGERS.includes(value)) {
|
|
199
201
|
throw new InvalidArgumentError(`Invalid package manager: ${value}. The following are allowed: ${SUPPORTED_PACKAGE_MANAGERS.join(', ')}`);
|
|
200
202
|
}
|
|
201
203
|
return value;
|
|
202
|
-
})
|
|
204
|
+
})
|
|
205
|
+
.option('--dev-watch <path>', 'Watch a framework directory for changes and auto-rebuild');
|
|
203
206
|
if (deployments.size > 0) {
|
|
204
207
|
program.option(`--deployment <${Array.from(deployments).join('|')}>`, `Explicitly tell the CLI to use this deployment adapter`, (value) => {
|
|
205
208
|
if (!deployments.has(value)) {
|
|
@@ -218,7 +221,8 @@ Remove your node_modules directory and package lock file and re-install.`);
|
|
|
218
221
|
}
|
|
219
222
|
program
|
|
220
223
|
.option('--interactive', 'interactive mode', false)
|
|
221
|
-
.option('--tailwind', 'add Tailwind CSS'
|
|
224
|
+
.option('--tailwind', 'add Tailwind CSS')
|
|
225
|
+
.option('--no-tailwind', 'skip Tailwind CSS')
|
|
222
226
|
.option('--add-ons [...add-ons]', 'pick from a list of available add-ons (comma separated list)', (value) => {
|
|
223
227
|
let addOns = !!value;
|
|
224
228
|
if (typeof value === 'string') {
|
|
@@ -309,6 +313,59 @@ Remove your node_modules directory and package lock file and re-install.`);
|
|
|
309
313
|
appName,
|
|
310
314
|
});
|
|
311
315
|
}
|
|
316
|
+
else if (options.devWatch) {
|
|
317
|
+
// Validate dev watch options
|
|
318
|
+
const validation = validateDevWatchOptions({ ...options, projectName });
|
|
319
|
+
if (!validation.valid) {
|
|
320
|
+
console.error(validation.error);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
// Enter dev watch mode
|
|
324
|
+
if (!projectName && !options.targetDir) {
|
|
325
|
+
console.error('Project name/target directory is required for dev watch mode');
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
if (!options.framework) {
|
|
329
|
+
console.error('Failed to detect framework');
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
const framework = getFrameworkByName(options.framework);
|
|
333
|
+
if (!framework) {
|
|
334
|
+
console.error('Failed to detect framework');
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
// First, create the app normally using the standard flow
|
|
338
|
+
const normalizedOpts = await normalizeOptions({
|
|
339
|
+
...options,
|
|
340
|
+
projectName,
|
|
341
|
+
framework: framework.id,
|
|
342
|
+
}, defaultMode, forcedAddOns);
|
|
343
|
+
if (!normalizedOpts) {
|
|
344
|
+
throw new Error('Failed to normalize options');
|
|
345
|
+
}
|
|
346
|
+
normalizedOpts.targetDir =
|
|
347
|
+
options.targetDir || resolve(process.cwd(), projectName);
|
|
348
|
+
// Create the initial app with minimal output for dev watch mode
|
|
349
|
+
console.log(chalk.bold('\ndev-watch'));
|
|
350
|
+
console.log(chalk.gray('├─') + ' ' + `creating initial ${appName} app...`);
|
|
351
|
+
if (normalizedOpts.install !== false) {
|
|
352
|
+
console.log(chalk.gray('├─') + ' ' + chalk.yellow('⟳') + ' installing packages...');
|
|
353
|
+
}
|
|
354
|
+
const silentEnvironment = createUIEnvironment(appName, true);
|
|
355
|
+
await createApp(silentEnvironment, normalizedOpts);
|
|
356
|
+
console.log(chalk.gray('└─') + ' ' + chalk.green('✓') + ` app created`);
|
|
357
|
+
// Now start the dev watch mode
|
|
358
|
+
const manager = new DevWatchManager({
|
|
359
|
+
watchPath: options.devWatch,
|
|
360
|
+
targetDir: normalizedOpts.targetDir,
|
|
361
|
+
framework,
|
|
362
|
+
cliOptions: normalizedOpts,
|
|
363
|
+
packageManager: normalizedOpts.packageManager,
|
|
364
|
+
environment,
|
|
365
|
+
frameworkDefinitionInitializers,
|
|
366
|
+
});
|
|
367
|
+
await manager.start();
|
|
368
|
+
}
|
|
312
369
|
else {
|
|
313
370
|
try {
|
|
314
371
|
const cliOptions = {
|
package/dist/command-line.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { DEFAULT_PACKAGE_MANAGER, finalizeAddOns, getFrameworkById, getPackageManager, loadStarter, populateAddOnOptionsDefaults, } from '@tanstack/cta-engine';
|
|
3
4
|
import { getCurrentDirectoryName, sanitizePackageName, validateProjectName, } from './utils.js';
|
|
4
5
|
export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opts) {
|
|
@@ -83,8 +84,23 @@ export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opt
|
|
|
83
84
|
}
|
|
84
85
|
const chosenAddOns = await selectAddOns();
|
|
85
86
|
if (chosenAddOns.length) {
|
|
86
|
-
tailwind = true;
|
|
87
87
|
typescript = true;
|
|
88
|
+
// Check if any add-on explicitly requires tailwind
|
|
89
|
+
const addOnsRequireTailwind = chosenAddOns.some((addOn) => addOn.tailwind === true);
|
|
90
|
+
// Only set tailwind to true if:
|
|
91
|
+
// 1. An add-on explicitly requires it, OR
|
|
92
|
+
// 2. User explicitly set it via CLI
|
|
93
|
+
if (addOnsRequireTailwind) {
|
|
94
|
+
tailwind = true;
|
|
95
|
+
}
|
|
96
|
+
else if (cliOptions.tailwind === true) {
|
|
97
|
+
tailwind = true;
|
|
98
|
+
}
|
|
99
|
+
else if (cliOptions.tailwind === false) {
|
|
100
|
+
tailwind = false;
|
|
101
|
+
}
|
|
102
|
+
// If cliOptions.tailwind is undefined and no add-ons require it,
|
|
103
|
+
// leave tailwind as is (will be prompted in interactive mode)
|
|
88
104
|
}
|
|
89
105
|
// Handle add-on configuration option
|
|
90
106
|
let addOnOptionsFromCLI = {};
|
|
@@ -108,6 +124,7 @@ export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opt
|
|
|
108
124
|
getPackageManager() ||
|
|
109
125
|
DEFAULT_PACKAGE_MANAGER,
|
|
110
126
|
git: !!cliOptions.git,
|
|
127
|
+
install: cliOptions.install,
|
|
111
128
|
chosenAddOns,
|
|
112
129
|
addOnOptions: {
|
|
113
130
|
...populateAddOnOptionsDefaults(chosenAddOns),
|
|
@@ -116,3 +133,42 @@ export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opt
|
|
|
116
133
|
starter: starter,
|
|
117
134
|
};
|
|
118
135
|
}
|
|
136
|
+
export function validateDevWatchOptions(cliOptions) {
|
|
137
|
+
if (!cliOptions.devWatch) {
|
|
138
|
+
return { valid: true };
|
|
139
|
+
}
|
|
140
|
+
// Validate watch path exists
|
|
141
|
+
const watchPath = resolve(process.cwd(), cliOptions.devWatch);
|
|
142
|
+
if (!fs.existsSync(watchPath)) {
|
|
143
|
+
return {
|
|
144
|
+
valid: false,
|
|
145
|
+
error: `Watch path does not exist: ${watchPath}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Validate it's a directory
|
|
149
|
+
const stats = fs.statSync(watchPath);
|
|
150
|
+
if (!stats.isDirectory()) {
|
|
151
|
+
return {
|
|
152
|
+
valid: false,
|
|
153
|
+
error: `Watch path is not a directory: ${watchPath}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Ensure target directory is specified
|
|
157
|
+
if (!cliOptions.projectName && !cliOptions.targetDir) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
error: 'Project name or target directory is required for dev watch mode',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Check for framework structure
|
|
164
|
+
const hasAddOns = fs.existsSync(resolve(watchPath, 'add-ons'));
|
|
165
|
+
const hasAssets = fs.existsSync(resolve(watchPath, 'assets'));
|
|
166
|
+
const hasFrameworkJson = fs.existsSync(resolve(watchPath, 'framework.json'));
|
|
167
|
+
if (!hasAddOns && !hasAssets && !hasFrameworkJson) {
|
|
168
|
+
return {
|
|
169
|
+
valid: false,
|
|
170
|
+
error: `Watch path does not appear to be a valid framework directory: ${watchPath}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { valid: true };
|
|
174
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chokidar from 'chokidar';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { temporaryDirectory } from 'tempy';
|
|
6
|
+
import { createApp, getFrameworkById, registerFramework, } from '@tanstack/cta-engine';
|
|
7
|
+
import { FileSyncer } from './file-syncer.js';
|
|
8
|
+
import { createUIEnvironment } from './ui-environment.js';
|
|
9
|
+
class DebounceQueue {
|
|
10
|
+
constructor(callback, delay = 1000) {
|
|
11
|
+
this.delay = delay;
|
|
12
|
+
this.timer = null;
|
|
13
|
+
this.changes = new Set();
|
|
14
|
+
this.callback = callback;
|
|
15
|
+
}
|
|
16
|
+
add(path) {
|
|
17
|
+
this.changes.add(path);
|
|
18
|
+
if (this.timer) {
|
|
19
|
+
clearTimeout(this.timer);
|
|
20
|
+
}
|
|
21
|
+
this.timer = setTimeout(() => {
|
|
22
|
+
const currentChanges = new Set(this.changes);
|
|
23
|
+
this.callback(currentChanges);
|
|
24
|
+
this.changes.clear();
|
|
25
|
+
}, this.delay);
|
|
26
|
+
}
|
|
27
|
+
size() {
|
|
28
|
+
return this.changes.size;
|
|
29
|
+
}
|
|
30
|
+
clear() {
|
|
31
|
+
if (this.timer) {
|
|
32
|
+
clearTimeout(this.timer);
|
|
33
|
+
this.timer = null;
|
|
34
|
+
}
|
|
35
|
+
this.changes.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class DevWatchManager {
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.watcher = null;
|
|
42
|
+
this.tempDir = null;
|
|
43
|
+
this.isBuilding = false;
|
|
44
|
+
this.buildCount = 0;
|
|
45
|
+
this.log = {
|
|
46
|
+
tree: (prefix, msg, isLast = false) => {
|
|
47
|
+
const connector = isLast ? '└─' : '├─';
|
|
48
|
+
console.log(chalk.gray(prefix + connector) + ' ' + msg);
|
|
49
|
+
},
|
|
50
|
+
treeItem: (prefix, msg, isLast = false) => {
|
|
51
|
+
const connector = isLast ? '└─' : '├─';
|
|
52
|
+
console.log(chalk.gray(prefix + ' ' + connector) + ' ' + msg);
|
|
53
|
+
},
|
|
54
|
+
info: (msg) => console.log(msg),
|
|
55
|
+
error: (msg) => console.error(chalk.red('✗') + ' ' + msg),
|
|
56
|
+
success: (msg) => console.log(chalk.green('✓') + ' ' + msg),
|
|
57
|
+
warning: (msg) => console.log(chalk.yellow('⚠') + ' ' + msg),
|
|
58
|
+
section: (title) => console.log('\n' + chalk.bold('▸ ' + title)),
|
|
59
|
+
subsection: (msg) => console.log(' ' + msg),
|
|
60
|
+
};
|
|
61
|
+
this.syncer = new FileSyncer();
|
|
62
|
+
this.debounceQueue = new DebounceQueue((changes) => this.rebuild(changes));
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
// Validate watch path
|
|
66
|
+
if (!fs.existsSync(this.options.watchPath)) {
|
|
67
|
+
throw new Error(`Watch path does not exist: ${this.options.watchPath}`);
|
|
68
|
+
}
|
|
69
|
+
// Validate target directory exists (should have been created by createApp)
|
|
70
|
+
if (!fs.existsSync(this.options.targetDir)) {
|
|
71
|
+
throw new Error(`Target directory does not exist: ${this.options.targetDir}`);
|
|
72
|
+
}
|
|
73
|
+
if (this.options.cliOptions.install === false) {
|
|
74
|
+
throw new Error('Cannot use the --no-install flag when using --dev-watch');
|
|
75
|
+
}
|
|
76
|
+
// Log startup with tree style
|
|
77
|
+
console.log();
|
|
78
|
+
console.log(chalk.bold('dev-watch'));
|
|
79
|
+
this.log.tree('', `watching: ${chalk.cyan(this.options.watchPath)}`);
|
|
80
|
+
this.log.tree('', `target: ${chalk.cyan(this.options.targetDir)}`);
|
|
81
|
+
this.log.tree('', 'ready', true);
|
|
82
|
+
// Setup signal handlers
|
|
83
|
+
process.on('SIGINT', () => this.cleanup());
|
|
84
|
+
process.on('SIGTERM', () => this.cleanup());
|
|
85
|
+
// Start watching
|
|
86
|
+
this.startWatcher();
|
|
87
|
+
}
|
|
88
|
+
async stop() {
|
|
89
|
+
console.log();
|
|
90
|
+
this.log.info('Stopping dev watch mode...');
|
|
91
|
+
if (this.watcher) {
|
|
92
|
+
await this.watcher.close();
|
|
93
|
+
this.watcher = null;
|
|
94
|
+
}
|
|
95
|
+
this.debounceQueue.clear();
|
|
96
|
+
this.cleanup();
|
|
97
|
+
}
|
|
98
|
+
startWatcher() {
|
|
99
|
+
const watcherConfig = {
|
|
100
|
+
ignored: [
|
|
101
|
+
'**/node_modules/**',
|
|
102
|
+
'**/.git/**',
|
|
103
|
+
'**/dist/**',
|
|
104
|
+
'**/build/**',
|
|
105
|
+
'**/.DS_Store',
|
|
106
|
+
'**/*.log',
|
|
107
|
+
this.tempDir,
|
|
108
|
+
],
|
|
109
|
+
persistent: true,
|
|
110
|
+
ignoreInitial: true,
|
|
111
|
+
awaitWriteFinish: {
|
|
112
|
+
stabilityThreshold: 100,
|
|
113
|
+
pollInterval: 100,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
this.watcher = chokidar.watch(this.options.watchPath, watcherConfig);
|
|
117
|
+
this.watcher.on('add', (filePath) => this.handleChange('add', filePath));
|
|
118
|
+
this.watcher.on('change', (filePath) => this.handleChange('change', filePath));
|
|
119
|
+
this.watcher.on('unlink', (filePath) => this.handleChange('unlink', filePath));
|
|
120
|
+
this.watcher.on('error', (error) => this.log.error(`Watcher error: ${error.message}`));
|
|
121
|
+
this.watcher.on('ready', () => {
|
|
122
|
+
// Already shown in startup, no need to repeat
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
handleChange(_type, filePath) {
|
|
126
|
+
const relativePath = path.relative(this.options.watchPath, filePath);
|
|
127
|
+
// Log change only once for the first file in debounce queue
|
|
128
|
+
if (this.debounceQueue.size() === 0) {
|
|
129
|
+
this.log.section('change detected');
|
|
130
|
+
this.log.subsection(`└─ ${relativePath}`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.log.subsection(`└─ ${relativePath}`);
|
|
134
|
+
}
|
|
135
|
+
this.debounceQueue.add(filePath);
|
|
136
|
+
}
|
|
137
|
+
async rebuild(changes) {
|
|
138
|
+
if (this.isBuilding) {
|
|
139
|
+
this.log.warning('Build already in progress, skipping...');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.isBuilding = true;
|
|
143
|
+
this.buildCount++;
|
|
144
|
+
const buildId = this.buildCount;
|
|
145
|
+
try {
|
|
146
|
+
this.log.section(`build #${buildId}`);
|
|
147
|
+
const startTime = Date.now();
|
|
148
|
+
if (!this.options.frameworkDefinitionInitializers) {
|
|
149
|
+
throw new Error('There must be framework initalizers passed to frameworkDefinitionInitializers to use --dev-watch');
|
|
150
|
+
}
|
|
151
|
+
const refreshedFrameworks = this.options.frameworkDefinitionInitializers.map((frameworkInitalizer) => frameworkInitalizer());
|
|
152
|
+
const refreshedFramework = refreshedFrameworks.find((f) => f.id === this.options.framework.id);
|
|
153
|
+
if (!refreshedFramework) {
|
|
154
|
+
throw new Error('Could not identify the framework');
|
|
155
|
+
}
|
|
156
|
+
// Update the chosen addons to use the latest code
|
|
157
|
+
const chosenAddonIds = this.options.cliOptions.chosenAddOns.map((m) => m.id);
|
|
158
|
+
const updatedChosenAddons = refreshedFramework.addOns.filter((f) => chosenAddonIds.includes(f.id));
|
|
159
|
+
// Create temp directory for this build using tempy
|
|
160
|
+
this.tempDir = temporaryDirectory();
|
|
161
|
+
// Register the scanned framework
|
|
162
|
+
registerFramework({
|
|
163
|
+
...refreshedFramework,
|
|
164
|
+
id: `${refreshedFramework.id}-updated`,
|
|
165
|
+
});
|
|
166
|
+
// Get the registered framework
|
|
167
|
+
const registeredFramework = getFrameworkById(`${refreshedFramework.id}-updated`);
|
|
168
|
+
if (!registeredFramework) {
|
|
169
|
+
throw new Error(`Failed to register framework: ${this.options.framework.id}`);
|
|
170
|
+
}
|
|
171
|
+
// Check if package.json was modified
|
|
172
|
+
const packageJsonModified = Array.from(changes).some((filePath) => path.basename(filePath) === 'package.json');
|
|
173
|
+
const updatedOptions = {
|
|
174
|
+
...this.options.cliOptions,
|
|
175
|
+
chosenAddOns: updatedChosenAddons,
|
|
176
|
+
framework: registeredFramework,
|
|
177
|
+
targetDir: this.tempDir,
|
|
178
|
+
git: false,
|
|
179
|
+
install: packageJsonModified,
|
|
180
|
+
};
|
|
181
|
+
// Show package installation indicator if needed
|
|
182
|
+
if (packageJsonModified) {
|
|
183
|
+
this.log.tree(' ', `${chalk.yellow('⟳')} installing packages...`);
|
|
184
|
+
}
|
|
185
|
+
// Create app in temp directory with silent environment
|
|
186
|
+
const silentEnvironment = createUIEnvironment(this.options.environment.appName, true);
|
|
187
|
+
await createApp(silentEnvironment, updatedOptions);
|
|
188
|
+
// Sync files to target directory
|
|
189
|
+
const syncResult = await this.syncer.sync(this.tempDir, this.options.targetDir);
|
|
190
|
+
// Clean up temp directory after sync is complete
|
|
191
|
+
try {
|
|
192
|
+
await fs.promises.rm(this.tempDir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
catch (cleanupError) {
|
|
195
|
+
this.log.warning(`Failed to clean up temp directory: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
196
|
+
}
|
|
197
|
+
const elapsed = Date.now() - startTime;
|
|
198
|
+
// Build tree-style summary
|
|
199
|
+
this.log.tree(' ', `duration: ${chalk.cyan(elapsed + 'ms')}`);
|
|
200
|
+
if (packageJsonModified) {
|
|
201
|
+
this.log.tree(' ', `packages: ${chalk.green('✓ installed')}`);
|
|
202
|
+
}
|
|
203
|
+
// Always show the last item in tree without checking for files to show
|
|
204
|
+
const noMoreTreeItems = syncResult.updated.length === 0 &&
|
|
205
|
+
syncResult.created.length === 0 &&
|
|
206
|
+
syncResult.errors.length === 0;
|
|
207
|
+
if (syncResult.updated.length > 0) {
|
|
208
|
+
this.log.tree(' ', `updated: ${chalk.green(syncResult.updated.length + ' file' + (syncResult.updated.length > 1 ? 's' : ''))}`, syncResult.created.length === 0 && syncResult.errors.length === 0);
|
|
209
|
+
}
|
|
210
|
+
if (syncResult.created.length > 0) {
|
|
211
|
+
this.log.tree(' ', `created: ${chalk.green(syncResult.created.length + ' file' + (syncResult.created.length > 1 ? 's' : ''))}`, syncResult.errors.length === 0);
|
|
212
|
+
}
|
|
213
|
+
if (syncResult.errors.length > 0) {
|
|
214
|
+
this.log.tree(' ', `failed: ${chalk.red(syncResult.errors.length + ' file' + (syncResult.errors.length > 1 ? 's' : ''))}`, true);
|
|
215
|
+
}
|
|
216
|
+
// If nothing changed, show that
|
|
217
|
+
if (noMoreTreeItems) {
|
|
218
|
+
this.log.tree(' ', `no changes`, true);
|
|
219
|
+
}
|
|
220
|
+
// Always show changed files with diffs
|
|
221
|
+
if (syncResult.updated.length > 0) {
|
|
222
|
+
syncResult.updated.forEach((update, index) => {
|
|
223
|
+
const isLastFile = index === syncResult.updated.length - 1 &&
|
|
224
|
+
syncResult.created.length === 0;
|
|
225
|
+
// For files with diffs, always use ├─
|
|
226
|
+
const fileIsLast = isLastFile && !update.diff;
|
|
227
|
+
this.log.treeItem(' ', update.path, fileIsLast);
|
|
228
|
+
// Always show diff if available
|
|
229
|
+
if (update.diff) {
|
|
230
|
+
const diffLines = update.diff.split('\n');
|
|
231
|
+
const relevantLines = diffLines
|
|
232
|
+
.slice(4)
|
|
233
|
+
.filter((line) => line.startsWith('+') ||
|
|
234
|
+
line.startsWith('-') ||
|
|
235
|
+
line.startsWith('@'));
|
|
236
|
+
if (relevantLines.length > 0) {
|
|
237
|
+
// Always use │ to continue the tree line through the diff
|
|
238
|
+
const prefix = ' │ ';
|
|
239
|
+
relevantLines.forEach((line) => {
|
|
240
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
241
|
+
console.log(chalk.gray(prefix) + ' ' + chalk.green(line));
|
|
242
|
+
}
|
|
243
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
244
|
+
console.log(chalk.gray(prefix) + ' ' + chalk.red(line));
|
|
245
|
+
}
|
|
246
|
+
else if (line.startsWith('@')) {
|
|
247
|
+
console.log(chalk.gray(prefix) + ' ' + chalk.cyan(line));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// Show created files
|
|
255
|
+
if (syncResult.created.length > 0) {
|
|
256
|
+
syncResult.created.forEach((file, index) => {
|
|
257
|
+
const isLast = index === syncResult.created.length - 1;
|
|
258
|
+
this.log.treeItem(' ', `${chalk.green('+')} ${file}`, isLast);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Always show errors
|
|
262
|
+
if (syncResult.errors.length > 0) {
|
|
263
|
+
console.log(); // Add spacing
|
|
264
|
+
syncResult.errors.forEach((err, index) => {
|
|
265
|
+
this.log.tree(' ', `${chalk.red('error:')} ${err}`, index === syncResult.errors.length - 1);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
this.log.error(`Build #${buildId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
this.isBuilding = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
cleanup() {
|
|
277
|
+
console.log();
|
|
278
|
+
console.log('Cleaning up...');
|
|
279
|
+
// Clean up temp directory
|
|
280
|
+
if (this.tempDir && fs.existsSync(this.tempDir)) {
|
|
281
|
+
try {
|
|
282
|
+
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
this.log.error(`Failed to clean up temp directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import * as diff from 'diff';
|
|
5
|
+
export class FileSyncer {
|
|
6
|
+
async sync(sourceDir, targetDir) {
|
|
7
|
+
const result = {
|
|
8
|
+
updated: [],
|
|
9
|
+
skipped: [],
|
|
10
|
+
created: [],
|
|
11
|
+
errors: [],
|
|
12
|
+
};
|
|
13
|
+
// Ensure directories exist
|
|
14
|
+
if (!fs.existsSync(sourceDir)) {
|
|
15
|
+
throw new Error(`Source directory does not exist: ${sourceDir}`);
|
|
16
|
+
}
|
|
17
|
+
if (!fs.existsSync(targetDir)) {
|
|
18
|
+
throw new Error(`Target directory does not exist: ${targetDir}`);
|
|
19
|
+
}
|
|
20
|
+
// Walk through source directory and sync files
|
|
21
|
+
await this.syncDirectory(sourceDir, targetDir, sourceDir, result);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
async syncDirectory(currentPath, targetBase, sourceBase, result) {
|
|
25
|
+
const entries = await fs.promises.readdir(currentPath, {
|
|
26
|
+
withFileTypes: true,
|
|
27
|
+
});
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const sourcePath = path.join(currentPath, entry.name);
|
|
30
|
+
const relativePath = path.relative(sourceBase, sourcePath);
|
|
31
|
+
const targetPath = path.join(targetBase, relativePath);
|
|
32
|
+
// Skip certain directories
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (this.shouldSkipDirectory(entry.name)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Ensure target directory exists
|
|
38
|
+
if (!fs.existsSync(targetPath)) {
|
|
39
|
+
await fs.promises.mkdir(targetPath, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
// Recursively sync subdirectory
|
|
42
|
+
await this.syncDirectory(sourcePath, targetBase, sourceBase, result);
|
|
43
|
+
}
|
|
44
|
+
else if (entry.isFile()) {
|
|
45
|
+
// Skip certain files
|
|
46
|
+
if (this.shouldSkipFile(entry.name)) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const shouldUpdate = await this.shouldUpdateFile(sourcePath, targetPath);
|
|
51
|
+
if (shouldUpdate) {
|
|
52
|
+
// Check if file exists to generate diff
|
|
53
|
+
let fileDiff;
|
|
54
|
+
const targetExists = fs.existsSync(targetPath);
|
|
55
|
+
if (targetExists) {
|
|
56
|
+
// Generate diff for existing files
|
|
57
|
+
const oldContent = await fs.promises.readFile(targetPath, 'utf-8');
|
|
58
|
+
const newContent = await fs.promises.readFile(sourcePath, 'utf-8');
|
|
59
|
+
const changes = diff.createPatch(relativePath, oldContent, newContent, 'Previous', 'Current');
|
|
60
|
+
// Only include diff if there are actual changes
|
|
61
|
+
if (changes && changes.split('\n').length > 5) {
|
|
62
|
+
fileDiff = changes;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Copy file
|
|
66
|
+
await fs.promises.copyFile(sourcePath, targetPath);
|
|
67
|
+
// Touch file to trigger dev server reload
|
|
68
|
+
const now = new Date();
|
|
69
|
+
await fs.promises.utimes(targetPath, now, now);
|
|
70
|
+
if (!targetExists) {
|
|
71
|
+
result.created.push(relativePath);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
result.updated.push({
|
|
75
|
+
path: relativePath,
|
|
76
|
+
diff: fileDiff,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
result.skipped.push(relativePath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
result.errors.push(`${relativePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async shouldUpdateFile(sourcePath, targetPath) {
|
|
91
|
+
// If target doesn't exist, definitely update
|
|
92
|
+
if (!fs.existsSync(targetPath)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
// Compare file sizes first (quick check)
|
|
96
|
+
const [sourceStats, targetStats] = await Promise.all([
|
|
97
|
+
fs.promises.stat(sourcePath),
|
|
98
|
+
fs.promises.stat(targetPath),
|
|
99
|
+
]);
|
|
100
|
+
if (sourceStats.size !== targetStats.size) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// Compare MD5 hashes for content
|
|
104
|
+
const [sourceHash, targetHash] = await Promise.all([
|
|
105
|
+
this.calculateHash(sourcePath),
|
|
106
|
+
this.calculateHash(targetPath),
|
|
107
|
+
]);
|
|
108
|
+
return sourceHash !== targetHash;
|
|
109
|
+
}
|
|
110
|
+
async calculateHash(filePath) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const hash = crypto.createHash('md5');
|
|
113
|
+
const stream = fs.createReadStream(filePath);
|
|
114
|
+
stream.on('data', (data) => hash.update(data));
|
|
115
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
116
|
+
stream.on('error', reject);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
shouldSkipDirectory(name) {
|
|
120
|
+
const skipDirs = [
|
|
121
|
+
'node_modules',
|
|
122
|
+
'.git',
|
|
123
|
+
'dist',
|
|
124
|
+
'build',
|
|
125
|
+
'.next',
|
|
126
|
+
'.nuxt',
|
|
127
|
+
'.cache',
|
|
128
|
+
'.tmp-dev',
|
|
129
|
+
'coverage',
|
|
130
|
+
'.turbo',
|
|
131
|
+
];
|
|
132
|
+
return skipDirs.includes(name) || name.startsWith('.');
|
|
133
|
+
}
|
|
134
|
+
shouldSkipFile(name) {
|
|
135
|
+
const skipFiles = [
|
|
136
|
+
'.DS_Store',
|
|
137
|
+
'Thumbs.db',
|
|
138
|
+
'desktop.ini',
|
|
139
|
+
'.cta.json', // Skip .cta.json as it contains framework ID that changes each build
|
|
140
|
+
];
|
|
141
|
+
const skipExtensions = ['.log', '.lock', '.pid', '.seed', '.sqlite'];
|
|
142
|
+
if (skipFiles.includes(name)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const ext = path.extname(name).toLowerCase();
|
|
146
|
+
return skipExtensions.includes(ext);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/options.js
CHANGED
|
@@ -44,13 +44,6 @@ export async function promptForCreateOptions(cliOptions, { forcedAddOns = [], fo
|
|
|
44
44
|
if (!options.typescript && options.mode === 'code-router') {
|
|
45
45
|
options.typescript = await selectTypescript();
|
|
46
46
|
}
|
|
47
|
-
// Tailwind selection
|
|
48
|
-
if (!cliOptions.tailwind && options.framework.id === 'react-cra') {
|
|
49
|
-
options.tailwind = await selectTailwind();
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
options.tailwind = true;
|
|
53
|
-
}
|
|
54
47
|
// Package manager selection
|
|
55
48
|
if (cliOptions.packageManager) {
|
|
56
49
|
options.packageManager = cliOptions.packageManager;
|
|
@@ -92,9 +85,27 @@ export async function promptForCreateOptions(cliOptions, { forcedAddOns = [], fo
|
|
|
92
85
|
}
|
|
93
86
|
options.chosenAddOns = Array.from(await finalizeAddOns(options.framework, options.mode, Array.from(addOns)));
|
|
94
87
|
if (options.chosenAddOns.length) {
|
|
95
|
-
options.tailwind = true;
|
|
96
88
|
options.typescript = true;
|
|
97
89
|
}
|
|
90
|
+
// Tailwind selection
|
|
91
|
+
// Only treat add-ons as requiring tailwind if they explicitly have "tailwind": true
|
|
92
|
+
const addOnsRequireTailwind = options.chosenAddOns.some((addOn) => addOn.tailwind === true);
|
|
93
|
+
if (addOnsRequireTailwind) {
|
|
94
|
+
// If any add-on explicitly requires tailwind, enable it automatically
|
|
95
|
+
options.tailwind = true;
|
|
96
|
+
}
|
|
97
|
+
else if (cliOptions.tailwind !== undefined) {
|
|
98
|
+
// User explicitly provided a CLI flag, respect it
|
|
99
|
+
options.tailwind = !!cliOptions.tailwind;
|
|
100
|
+
}
|
|
101
|
+
else if (options.framework.id === 'react-cra') {
|
|
102
|
+
// Only show prompt for react-cra when no CLI flag and no add-ons require it
|
|
103
|
+
options.tailwind = await selectTailwind();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// For other frameworks (like solid), default to true
|
|
107
|
+
options.tailwind = true;
|
|
108
|
+
}
|
|
98
109
|
// Prompt for add-on options in interactive mode
|
|
99
110
|
if (Array.isArray(cliOptions.addOns)) {
|
|
100
111
|
// Non-interactive mode: use defaults
|