Skip to content

feat: add dev watch command for framework development #141

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,38 @@
- Run `pnpm build` and `pnpm test` to make sure the changes work
- Check your work and PR

# Framework Development with --dev-watch

The `--dev-watch` command provides real-time feedback while developing frameworks, add-ons, and starters. It watches for changes in your framework files and automatically rebuilds them.

## Using --dev-watch

To start developing a framework with live rebuilding:

```bash
node [root of the monorepo]/cli/create-tsrouter-app/dist/index.js --dev-watch ./frameworks/react-cra test-app --template typescript --package-manager bun --tailwind --add-ons shadcn
```

This command will:

- Watch the selected folder for changes (the folder with the add-ons in it)
- Automatically rebuild your app / install packages in the target folder when changes are detected (in this case it will install the shadcn addon)
- Show build output, diffs detected and any errors in real-time

## Example Workflow

1. Start the dev watch mode:

```bash
pnpm dev # Build in watch mode
rm -rf test-app && node cli/create-tsrouter-app/dist/index.js --dev-watch ./frameworks/react-cra test-app --template typescript --package-manager bun --tailwind --add-ons shadcn
cd my-test-app && pnpm run dev # run the tsrouter vite app
```

2. Select the framework you want to work on from the displayed list

3. Make changes to the add-ons - they will be automatically rebuilt and your vite app will reflect the changes

# Testing Add-ons and Starters

Create the add-on or starter using the CLI. Then serve it locally from the project directory using `npx static-server`.
Expand Down
14 changes: 12 additions & 2 deletions cli/create-start-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
#!/usr/bin/env node
import { cli } from '@tanstack/cta-cli'

import { register as registerReactCra } from '@tanstack/cta-framework-react-cra'
import { register as registerSolid } from '@tanstack/cta-framework-solid'
import {
createFrameworkDefinition as createReactCraFrameworkDefinitionInitalizer,
register as registerReactCra,
} from '@tanstack/cta-framework-react-cra'
import {
createFrameworkDefinition as createSolidFrameworkDefinitionInitalizer,
register as registerSolid,
} from '@tanstack/cta-framework-solid'

registerReactCra()
registerSolid()
Expand All @@ -13,4 +19,8 @@ cli({
forcedMode: 'file-router',
forcedAddOns: ['start'],
craCompatible: true,
frameworkDefinitionInitializers: [
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jherr how do you feel about this part in particular?

I need to re-initalize the framework when files change. My thought was it's a bit messy to do the registration step + pass initalizers. Perhaps just pass initalizer functions and then have them run by in the CLI

createReactCraFrameworkDefinitionInitalizer,
createSolidFrameworkDefinitionInitalizer,
],
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createServerFileRoute } from '@tanstack/react-start/server'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { trpcRouter } from '@/integrations/trpc/router'

Expand Down
4 changes: 4 additions & 0 deletions packages/cta-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@
"@tanstack/cta-engine": "workspace:*",
"@tanstack/cta-ui": "workspace:*",
"chalk": "^5.4.1",
"chokidar": "^3.6.0",
"commander": "^13.1.0",
"diff": "^7.0.0",
"express": "^4.21.2",
"semver": "^7.7.2",
"tempy": "^3.1.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@tanstack/config": "^0.16.2",
"@types/diff": "^5.2.0",
"@types/express": "^5.0.1",
"@types/node": "^22.13.4",
"@types/semver": "^7.7.0",
Expand Down
84 changes: 81 additions & 3 deletions packages/cta-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
createApp,
createSerializedOptions,
getAllAddOns,
getFrameworkById,
getFrameworkByName,
getFrameworks,
initAddOn,
Expand All @@ -25,13 +24,18 @@ import { launchUI } from '@tanstack/cta-ui'
import { runMCPServer } from './mcp.js'

import { promptForAddOns, promptForCreateOptions } from './options.js'
import { normalizeOptions } from './command-line.js'
import { normalizeOptions, validateDevWatchOptions } from './command-line.js'

import { createUIEnvironment } from './ui-environment.js'
import { convertTemplateToMode } from './utils.js'
import { DevWatchManager } from './dev-watch.js'

import type { CliOptions, TemplateOptions } from './types.js'
import type { Options, PackageManager } from '@tanstack/cta-engine'
import type {
FrameworkDefinition,
Options,
PackageManager,
} from '@tanstack/cta-engine'

// This CLI assumes that all of the registered frameworks have the same set of toolchains, modes, etc.

Expand All @@ -44,6 +48,7 @@ export function cli({
defaultFramework,
craCompatible = false,
webBase,
frameworkDefinitionInitializers,
}: {
name: string
appName: string
Expand All @@ -53,6 +58,7 @@ export function cli({
defaultFramework?: string
craCompatible?: boolean
webBase?: string
frameworkDefinitionInitializers?: Array<() => FrameworkDefinition>
}) {
const environment = createUIEnvironment(appName, false)

Expand Down Expand Up @@ -280,6 +286,7 @@ Remove your node_modules directory and package lock file and re-install.`,
'initialize this project from a starter URL',
false,
)
.option('--no-install', 'skip installing dependencies')
.option<PackageManager>(
`--package-manager <${SUPPORTED_PACKAGE_MANAGERS.join('|')}>`,
`Explicitly tell the CLI to use this package manager`,
Expand All @@ -294,6 +301,10 @@ Remove your node_modules directory and package lock file and re-install.`,
return value as PackageManager
},
)
.option(
'--dev-watch <path>',
'Watch a framework directory for changes and auto-rebuild',
)

if (toolchains.size > 0) {
program.option<string>(
Expand Down Expand Up @@ -352,6 +363,73 @@ Remove your node_modules directory and package lock file and re-install.`,
forcedAddOns,
appName,
})
} else if (options.devWatch) {
// Validate dev watch options
const validation = validateDevWatchOptions({ ...options, projectName })
if (!validation.valid) {
console.error(validation.error)
process.exit(1)
}

// Enter dev watch mode
if (!projectName && !options.targetDir) {
console.error(
'Project name/target directory is required for dev watch mode',
)
process.exit(1)
}

if (!options.framework) {
console.error('Failed to detect framework')
process.exit(1)
}

const framework = getFrameworkByName(options.framework)
if (!framework) {
console.error('Failed to detect framework')
process.exit(1)
}

// First, create the app normally using the standard flow
const normalizedOpts = await normalizeOptions(
{
...options,
projectName,
framework: framework.id,
},
defaultMode,
forcedAddOns,
)

if (!normalizedOpts) {
throw new Error('Failed to normalize options')
}

normalizedOpts.targetDir =
options.targetDir || resolve(process.cwd(), projectName)

// Create the initial app with minimal output for dev watch mode
console.log(chalk.bold('\ndev-watch'))
console.log(chalk.gray('├─') + ' ' + `creating initial ${appName} app...`)
if (normalizedOpts.install !== false) {
console.log(chalk.gray('├─') + ' ' + chalk.yellow('⟳') + ' installing packages...')
}
const silentEnvironment = createUIEnvironment(appName, true)
await createApp(silentEnvironment, normalizedOpts)
console.log(chalk.gray('└─') + ' ' + chalk.green('✓') + ` app created`)

// Now start the dev watch mode
const manager = new DevWatchManager({
watchPath: options.devWatch,
targetDir: normalizedOpts.targetDir,
framework,
cliOptions: normalizedOpts,
packageManager: normalizedOpts.packageManager,
environment,
frameworkDefinitionInitializers,
})

await manager.start()
} else {
try {
const cliOptions = {
Expand Down
51 changes: 51 additions & 0 deletions packages/cta-cli/src/command-line.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolve } from 'node:path'
import fs from 'node:fs'

import {
DEFAULT_PACKAGE_MANAGER,
Expand Down Expand Up @@ -114,7 +115,57 @@ export async function normalizeOptions(
getPackageManager() ||
DEFAULT_PACKAGE_MANAGER,
git: !!cliOptions.git,
install: cliOptions.install,
chosenAddOns,
starter: starter,
}
}

export function validateDevWatchOptions(cliOptions: CliOptions): {
valid: boolean
error?: string
} {
if (!cliOptions.devWatch) {
return { valid: true }
}

// Validate watch path exists
const watchPath = resolve(process.cwd(), cliOptions.devWatch)
if (!fs.existsSync(watchPath)) {
return {
valid: false,
error: `Watch path does not exist: ${watchPath}`,
}
}

// Validate it's a directory
const stats = fs.statSync(watchPath)
if (!stats.isDirectory()) {
return {
valid: false,
error: `Watch path is not a directory: ${watchPath}`,
}
}

// Ensure target directory is specified
if (!cliOptions.projectName && !cliOptions.targetDir) {
return {
valid: false,
error: 'Project name or target directory is required for dev watch mode',
}
}

// Check for framework structure
const hasAddOns = fs.existsSync(resolve(watchPath, 'add-ons'))
const hasAssets = fs.existsSync(resolve(watchPath, 'assets'))
const hasFrameworkJson = fs.existsSync(resolve(watchPath, 'framework.json'))

if (!hasAddOns && !hasAssets && !hasFrameworkJson) {
return {
valid: false,
error: `Watch path does not appear to be a valid framework directory: ${watchPath}`,
}
}

return { valid: true }
}
Loading