@tanstack/cta-cli 0.46.2 → 0.48.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 +27 -0
- package/dist/cli.js +67 -7
- 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 +39 -10
- 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 +4 -1
- package/dist/types/ui-prompts.d.ts +1 -1
- package/dist/ui-prompts.js +3 -0
- package/package.json +8 -3
- package/src/cli.ts +104 -17
- 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 +45 -10
- package/src/types.ts +4 -1
- package/src/ui-prompts.ts +5 -2
- package/tests/command-line.test.ts +6 -2
- package/tests/options.test.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @tanstack/cta-cli
|
|
2
|
+
|
|
3
|
+
## 0.48.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- no will prompt about overriding a directory that has contents ([#289](https://github.com/TanStack/create-tsrouter-app/pull/289))
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [[`3087532`](https://github.com/TanStack/create-tsrouter-app/commit/308753249af11bf5c9e374789e973a934c753520)]:
|
|
12
|
+
- @tanstack/cta-engine@0.48.0
|
|
13
|
+
- @tanstack/cta-ui@0.48.0
|
|
14
|
+
|
|
15
|
+
## 0.47.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- Smakll content fixes ([`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2))
|
|
20
|
+
|
|
21
|
+
- allowing for no tailwind ([#151](https://github.com/TanStack/create-tsrouter-app/pull/151))
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [[`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2), [`f1f58fe`](https://github.com/TanStack/create-tsrouter-app/commit/f1f58feed7d7df1e0c5e0fc4dd3af02e11df09e5)]:
|
|
26
|
+
- @tanstack/cta-engine@0.47.0
|
|
27
|
+
- @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)) {
|
|
@@ -209,16 +212,19 @@ Remove your node_modules directory and package lock file and re-install.`);
|
|
|
209
212
|
});
|
|
210
213
|
}
|
|
211
214
|
if (toolchains.size > 0) {
|
|
212
|
-
program
|
|
215
|
+
program
|
|
216
|
+
.option(`--toolchain <${Array.from(toolchains).join('|')}>`, `Explicitly tell the CLI to use this toolchain`, (value) => {
|
|
213
217
|
if (!toolchains.has(value)) {
|
|
214
218
|
throw new InvalidArgumentError(`Invalid toolchain: ${value}. The following are allowed: ${Array.from(toolchains).join(', ')}`);
|
|
215
219
|
}
|
|
216
220
|
return value;
|
|
217
|
-
})
|
|
221
|
+
})
|
|
222
|
+
.option('--no-toolchain', 'skip toolchain selection');
|
|
218
223
|
}
|
|
219
224
|
program
|
|
220
225
|
.option('--interactive', 'interactive mode', false)
|
|
221
|
-
.option('--tailwind', 'add Tailwind CSS'
|
|
226
|
+
.option('--tailwind', 'add Tailwind CSS')
|
|
227
|
+
.option('--no-tailwind', 'skip Tailwind CSS')
|
|
222
228
|
.option('--add-ons [...add-ons]', 'pick from a list of available add-ons (comma separated list)', (value) => {
|
|
223
229
|
let addOns = !!value;
|
|
224
230
|
if (typeof value === 'string') {
|
|
@@ -233,7 +239,8 @@ Remove your node_modules directory and package lock file and re-install.`);
|
|
|
233
239
|
.option('--mcp', 'run the MCP server', false)
|
|
234
240
|
.option('--mcp-sse', 'run the MCP server in SSE mode', false)
|
|
235
241
|
.option('--ui', 'Add with the UI')
|
|
236
|
-
.option('--add-on-config <config>', 'JSON string with add-on configuration options')
|
|
242
|
+
.option('--add-on-config <config>', 'JSON string with add-on configuration options')
|
|
243
|
+
.option('-f, --force', 'force project creation even if the target directory is not empty', false);
|
|
237
244
|
program.action(async (projectName, options) => {
|
|
238
245
|
if (options.listAddOns) {
|
|
239
246
|
const addOns = await getAllAddOns(getFrameworkByName(options.framework || defaultFramework || 'React'), defaultMode ||
|
|
@@ -309,6 +316,59 @@ Remove your node_modules directory and package lock file and re-install.`);
|
|
|
309
316
|
appName,
|
|
310
317
|
});
|
|
311
318
|
}
|
|
319
|
+
else if (options.devWatch) {
|
|
320
|
+
// Validate dev watch options
|
|
321
|
+
const validation = validateDevWatchOptions({ ...options, projectName });
|
|
322
|
+
if (!validation.valid) {
|
|
323
|
+
console.error(validation.error);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
// Enter dev watch mode
|
|
327
|
+
if (!projectName && !options.targetDir) {
|
|
328
|
+
console.error('Project name/target directory is required for dev watch mode');
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
if (!options.framework) {
|
|
332
|
+
console.error('Failed to detect framework');
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
const framework = getFrameworkByName(options.framework);
|
|
336
|
+
if (!framework) {
|
|
337
|
+
console.error('Failed to detect framework');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
// First, create the app normally using the standard flow
|
|
341
|
+
const normalizedOpts = await normalizeOptions({
|
|
342
|
+
...options,
|
|
343
|
+
projectName,
|
|
344
|
+
framework: framework.id,
|
|
345
|
+
}, defaultMode, forcedAddOns);
|
|
346
|
+
if (!normalizedOpts) {
|
|
347
|
+
throw new Error('Failed to normalize options');
|
|
348
|
+
}
|
|
349
|
+
normalizedOpts.targetDir =
|
|
350
|
+
options.targetDir || resolve(process.cwd(), projectName);
|
|
351
|
+
// Create the initial app with minimal output for dev watch mode
|
|
352
|
+
console.log(chalk.bold('\ndev-watch'));
|
|
353
|
+
console.log(chalk.gray('├─') + ' ' + `creating initial ${appName} app...`);
|
|
354
|
+
if (normalizedOpts.install !== false) {
|
|
355
|
+
console.log(chalk.gray('├─') + ' ' + chalk.yellow('⟳') + ' installing packages...');
|
|
356
|
+
}
|
|
357
|
+
const silentEnvironment = createUIEnvironment(appName, true);
|
|
358
|
+
await createApp(silentEnvironment, normalizedOpts);
|
|
359
|
+
console.log(chalk.gray('└─') + ' ' + chalk.green('✓') + ` app created`);
|
|
360
|
+
// Now start the dev watch mode
|
|
361
|
+
const manager = new DevWatchManager({
|
|
362
|
+
watchPath: options.devWatch,
|
|
363
|
+
targetDir: normalizedOpts.targetDir,
|
|
364
|
+
framework,
|
|
365
|
+
cliOptions: normalizedOpts,
|
|
366
|
+
packageManager: normalizedOpts.packageManager,
|
|
367
|
+
environment,
|
|
368
|
+
frameworkDefinitionInitializers,
|
|
369
|
+
});
|
|
370
|
+
await manager.start();
|
|
371
|
+
}
|
|
312
372
|
else {
|
|
313
373
|
try {
|
|
314
374
|
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
|
+
}
|