-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat(root): single-command startup for scaffolded agent projects fixes NV-7450 #11000
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
Changes from all commits
2161657
26bb1e7
4e626a0
1bb6717
b64b158
48cc801
bdaa253
412d505
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { type ChildProcess, execSync, spawn } from 'node:child_process'; | ||
| import { NtfrTunnel } from '@novu/ntfr-client'; | ||
| import chalk from 'chalk'; | ||
| import open from 'open'; | ||
|
|
@@ -11,9 +12,31 @@ import { showWelcomeScreen } from '../shared'; | |
| import { DevCommandOptions, LocalTunnelResponse } from './types'; | ||
| import { parseOptions, wait } from './utils'; | ||
|
|
||
| process.on('SIGINT', () => { | ||
| process.exit(); | ||
| }); | ||
| let appProcess: ChildProcess | null = null; | ||
|
|
||
| function killProcessTree(child: ChildProcess) { | ||
| if (!child.pid) return; | ||
|
|
||
| try { | ||
| if (process.platform === 'win32') { | ||
| execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore' }); | ||
| } else { | ||
| process.kill(-child.pid, 'SIGTERM'); | ||
| } | ||
| } catch { | ||
| child.kill('SIGTERM'); | ||
| } | ||
| } | ||
|
|
||
| function cleanup() { | ||
| if (appProcess && !appProcess.killed) { | ||
| killProcessTree(appProcess); | ||
| } | ||
| setTimeout(() => process.exit(), 200).unref(); | ||
| } | ||
|
|
||
| process.on('SIGINT', cleanup); | ||
| process.on('SIGTERM', cleanup); | ||
|
|
||
| let tunnelClient: NtfrTunnel | null = null; | ||
|
|
||
|
|
@@ -26,6 +49,10 @@ const { version } = packageJson; | |
| export async function devCommand(options: DevCommandOptions, anonymousId?: string) { | ||
| await showWelcomeScreen(); | ||
|
|
||
| if (options.run) { | ||
| appProcess = spawnAppServer(options.run); | ||
| } | ||
|
|
||
| const parsedOptions = parseOptions(options); | ||
| const NOVU_ENDPOINT_PATH = options.route; | ||
| let tunnelOrigin: string; | ||
|
|
@@ -157,7 +184,10 @@ function createWatchdogTick(getSocket: () => WatchdogSocket | undefined): () => | |
| } | ||
|
|
||
| function startTunnelWatchdog(): void { | ||
| setInterval(createWatchdogTick(() => tunnelClient?.socket), WATCHDOG_INTERVAL_MS); | ||
| setInterval( | ||
| createWatchdogTick(() => tunnelClient?.socket), | ||
| WATCHDOG_INTERVAL_MS | ||
| ); | ||
| } | ||
|
|
||
| async function startTunnelProbe(tunnelOrigin: string, endpointRoute: string, localOrigin: string): Promise<void> { | ||
|
|
@@ -174,7 +204,9 @@ async function startTunnelProbe(tunnelOrigin: string, endpointRoute: string, loc | |
| const tunnelHealthy = await tunnelHealthCheck(`${tunnelOrigin}${endpointRoute}`); | ||
|
|
||
| if (!tunnelHealthy && tunnelClient?.socket) { | ||
| tunnelClient.socket.addEventListener('open', () => console.log(chalk.green('\n ✓ Tunnel reconnected')), { once: true }); | ||
| tunnelClient.socket.addEventListener('open', () => console.log(chalk.green('\n ✓ Tunnel reconnected')), { | ||
| once: true, | ||
| }); | ||
| tunnelClient.socket.reconnect(); | ||
| } | ||
| } catch { | ||
|
|
@@ -242,6 +274,34 @@ async function connectToNewTunnel(originUrl: URL) { | |
| return parsedUrl.origin; | ||
| } | ||
|
|
||
| function spawnAppServer(command: string): ChildProcess { | ||
| const isWindows = process.platform === 'win32'; | ||
| const shell = isWindows ? 'cmd' : 'sh'; | ||
| const shellFlag = isWindows ? '/c' : '-c'; | ||
|
|
||
| const child = spawn(shell, [shellFlag, command], { | ||
| stdio: ['ignore', 'inherit', 'inherit'], | ||
| detached: !isWindows, | ||
| }); | ||
|
|
||
| child.on('error', (err) => { | ||
| console.error(chalk.red(`\n ✗ Failed to start app server: ${err.message}`)); | ||
| }); | ||
|
|
||
| child.on('exit', (code, signal) => { | ||
| if (signal === 'SIGINT' || signal === 'SIGTERM') { | ||
| process.exit(0); | ||
| } | ||
|
|
||
| console.error(chalk.red(`\n ✗ App server exited with code ${code ?? 1}`)); | ||
| process.exit(code ?? 1); | ||
| }); | ||
|
Comment on lines
+287
to
+298
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spurious "App server exited with code 1" on intentional shutdown (Windows). When 🛡️ Proposed fix let appProcess: ChildProcess | null = null;
+let isShuttingDown = false; function cleanup() {
+ isShuttingDown = true;
if (appProcess && !appProcess.killed) {
killProcessTree(appProcess);
}
setTimeout(() => process.exit(), 200).unref();
} child.on('exit', (code, signal) => {
- if (signal === 'SIGINT' || signal === 'SIGTERM') {
+ if (isShuttingDown || signal === 'SIGINT' || signal === 'SIGTERM') {
process.exit(0);
}
console.error(chalk.red(`\n ✗ App server exited with code ${code ?? 1}`));
process.exit(code ?? 1);
});🤖 Prompt for AI Agents |
||
|
|
||
| console.log(chalk.green(` ▶ App server → ${command}`)); | ||
|
|
||
| return child; | ||
| } | ||
|
|
||
| interface DiscoverResponse { | ||
| workflows: unknown[]; | ||
| agents?: Array<{ agentId: string }>; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,21 +10,37 @@ import { install } from '../helpers/install'; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { GetTemplateFileArgs, InstallTemplateArgs, TemplateTypeEnum } from './types'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function resolveFrameworkVersion(): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function resolveCliPackageJson(): Record<string, any> | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const distIndex = __dirname.lastIndexOf(`${path.sep}dist${path.sep}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (distIndex === -1) return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (distIndex === -1) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pkgRoot = __dirname.slice(0, distIndex); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pkg = JSON.parse(readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ver = pkg.dependencies?.['@novu/framework']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ver || ver.startsWith('workspace:')) return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ver; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return JSON.parse(readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function resolveFrameworkVersion(): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pkg = resolveCliPackageJson(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!pkg) return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ver = pkg.dependencies?.['@novu/framework']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ver || ver.startsWith('workspace:')) return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ver; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function resolveCliTag(): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pkg = resolveCliPackageJson(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!pkg?.version) return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (pkg.version.includes('-rc')) return 'rc'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (pkg.version.includes('-alpha')) return 'rc'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 'latest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: The command Citations:
🏁 Script executed: # Get the exact dist-tags for novu from npm registry
npm view novu dist-tags --json 2>/dev/null || npm view novu dist-tagsRepository: novuhq/novu Length of output: 157 🏁 Script executed: # Check the file to see the referenced lines and context
wc -l packages/novu/src/commands/init/templates/index.tsRepository: novuhq/novu Length of output: 110 🏁 Script executed: # Read the relevant sections of the file
sed -n '35,43p' packages/novu/src/commands/init/templates/index.ts
sed -n '255,258p' packages/novu/src/commands/init/templates/index.tsRepository: novuhq/novu Length of output: 455 🏁 Script executed: # Search for release/publish configuration in the repo
fd -type f \( -name "release*.yml" -o -name "release*.yaml" -o -name ".npmrc" -o -name "lerna.json" -o -name "release.config.*" \) | head -20Repository: novuhq/novu Length of output: 226 🏁 Script executed: # Check package.json scripts for publish/release commands
rg -A 5 '"(publish|release|npm)"' packages/novu/package.json || cat packages/novu/package.jsonRepository: novuhq/novu Length of output: 3435 🏁 Script executed: # Check the other location mentioned at lines 255-258
sed -n '250,260p' packages/novu/src/commands/init/templates/index.tsRepository: novuhq/novu Length of output: 330 Fix the dist-tag mapping to align with actual npm tags published for The current code maps both Update the mapping to handle Example: Extract pre-release id from version stringfunction resolveCliTag(): string {
const pkg = resolveCliPackageJson();
if (!pkg?.version) return 'latest';
- if (pkg.version.includes('-rc')) return 'rc';
- if (pkg.version.includes('-alpha')) return 'rc';
-
- return 'latest';
+ // Extract pre-release identifier (part after '-' and before '.' or end of string)
+ const match = /-([0-9A-Za-z]+)/.exec(pkg.version);
+ const preId = match?.[1]?.toLowerCase();
+ if (!preId) return 'latest';
+
+ // Map pre-release IDs to dist-tags available on npm registry
+ const TAG_BY_PRE_ID: Record<string, string> = {
+ rc: 'rc',
+ alpha: 'alpha',
+ next: 'next',
+ beta: 'latest', // adjust if beta has its own tag
+ canary: 'latest', // adjust if canary has its own tag
+ };
+
+ return TAG_BY_PRE_ID[preId] ?? 'latest';
}This also affects the generated script template at lines 255–258. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Get the file path for a given file in a template, e.g. "next.config.js". | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -229,16 +245,23 @@ export const installTemplate = async ({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| baseDependencies['@novu/nextjs'] = '^2.5.0'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const scripts: Record<string, string> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dev: 'next dev --port=4000', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| build: 'next build', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| start: 'next start', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lint: 'next lint', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isAgentTemplate) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cliTag = resolveCliTag(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scripts['dev:novu'] = `npx novu@${cliTag} dev -p 4000 --no-studio --run "next dev --port=4000"`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const packageJson: any = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: appName, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| version: '0.1.0', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scripts: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dev: `next dev --port=4000`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| build: 'next build', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| start: 'next start', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lint: 'next lint', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scripts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dependencies: baseDependencies, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| devDependencies: {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.