Skip to content
18 changes: 3 additions & 15 deletions apps/dashboard/src/components/agents/agent-code-setup-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,21 +326,9 @@ export function AgentCodeSetupSection({
<SetupStep
index={stepOffset + 1}
status={deriveStepStatus(stepOffset + 1, firstIncompleteStep)}
title="Start the dev tunnel"
description={
<span>
Start your app with npm run dev, then run this in a second terminal from your project directory.
<br />
<br />
It creates a tunnel and registers the bridge URL with Novu.
</span>
}
rightContent={
<TerminalBlock
displayCommand={`npx novu@${CLI_PACKAGE_TAG} dev -p 4000 --no-studio`}
copyCommand={`npx novu@${CLI_PACKAGE_TAG} dev -p 4000 --no-studio`}
/>
}
title="Start your agent locally"
description="Run this from your project directory. It starts the app, opens a dev tunnel, and registers the bridge URL with Novu."
rightContent={<TerminalBlock displayCommand="npm run dev:novu" copyCommand="npm run dev:novu" />}
/>

<SetupStep
Expand Down
70 changes: 65 additions & 5 deletions packages/novu/src/commands/dev/dev.ts
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';
Expand All @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let tunnelClient: NtfrTunnel | null = null;

Expand All @@ -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;
Expand Down Expand Up @@ -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> {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Spurious "App server exited with code 1" on intentional shutdown (Windows).

When cleanup() runs taskkill /T /F on Windows, the child's exit event fires with code=1, signal=null — Windows has no real POSIX signals, so the signal === 'SIGINT' || signal === 'SIGTERM' guard does not match and the handler falls through to the red error log and process.exit(1). The same can happen on POSIX if the fallback child.kill('SIGTERM') path is taken after a failed process.kill(-pid). Track the shutdown intent explicitly so the handler can distinguish "we asked it to die" from a real crash.

🛡️ 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/novu/src/commands/dev/dev.ts` around lines 287 - 298, The exit
handler is logging a failure when the child was intentionally terminated;
introduce a module-scoped boolean flag (e.g., isShuttingDown or
intentionalShutdown) that cleanup() sets to true before calling
taskkill/child.kill, then update the child.on('exit', ...) handler to check this
flag and treat exits during intentional shutdown as normal (skip the red error
log and exit with 0 or no-op) while preserving the existing behavior for
unexpected exits; also ensure child.on('error', ...) behavior is unchanged but
can consult the same flag if you want to suppress error noise during shutdown.


console.log(chalk.green(` ▶ App server → ${command}`));

return child;
}

interface DiscoverResponse {
workflows: unknown[];
agents?: Array<{ agentId: string }>;
Expand Down
1 change: 1 addition & 0 deletions packages/novu/src/commands/dev/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type DevCommandOptions = {
tunnel: string;
headless: boolean;
studio: boolean;
run?: string;
};

export type LocalTunnelResponse = {
Expand Down
78 changes: 50 additions & 28 deletions packages/novu/src/commands/init/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,49 +102,71 @@ export async function createApp({
}

console.log(`${green('Success!')} Created ${appName} at ${appPath}`);
printNextSteps({ template, cdPath, skipCd: appPath === originalDirectory });
printNextSteps({ template, cdPath, root, agentIdentifier, skipCd: appPath === originalDirectory });
}

function terminalLink(text: string, url: string): string {
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
}

function printNextSteps({
template,
cdPath,
root,
agentIdentifier,
skipCd,
}: {
template: TemplateType;
cdPath: string;
root: string;
agentIdentifier?: string;
skipCd: boolean;
}): void {
const isAgent = template === TemplateTypeEnum.APP_AGENT;
const devCommand = isAgent ? 'npx novu@latest dev -p 4000 --no-studio' : 'npx novu@latest dev';
const devCommandHint = isAgent
? 'Opens a tunnel and registers the bridge URL with Novu'
: 'Starts Novu Studio and a dev tunnel';

console.log();
console.log(bold('Next steps:'));
console.log();
if (isAgent) {
const cmd = skipCd ? 'npm run dev:novu' : `cd ${cdPath} && npm run dev:novu`;
const cmdLine = `$ ${cmd}`;
const innerWidth = Math.max(cmdLine.length + 4, 50);

let step = 1;
if (!skipCd) {
console.log(` ${step}. ${cyan(`cd ${cdPath}`)}`);
step += 1;
}
console.log(` ${step}. ${cyan('npm run dev')}${dim(' Start your app on :4000')}`);
step += 1;
console.log(` ${step}. In a second terminal, run:`);
console.log(` ${cyan(devCommand)}`);
console.log(` ${dim(devCommandHint)}`);
console.log();
const agentFileName = agentIdentifier ? `${agentIdentifier}.tsx` : 'support-agent.tsx';
const agentFilePath = path.join(root, 'app', 'novu', 'agents', agentFileName);
const agentRelPath = `app/novu/agents/${agentFileName}`;
const fileUrl = `file://${agentFilePath}`;

if (isAgent) {
console.log(
'Then send a message to your bot from the connected chat provider — your local agent will handle the reply.'
);
console.log(`Edit ${cyan('app/novu/agents/')} to customize how your agent responds.`);
console.log(`Docs: ${cyan('https://docs.novu.co/agents/overview')}`);
console.log();
console.log(dim(` ╭${'─'.repeat(innerWidth)}╮`));
console.log(dim(` │${' '.repeat(innerWidth)}│`));
console.log(dim(' │') + ` ${cyan(cmdLine)}${' '.repeat(innerWidth - cmdLine.length - 2)}` + dim('│'));
console.log(dim(` │${' '.repeat(innerWidth)}│`));
console.log(dim(` ╰${'─'.repeat(innerWidth)}╯`));
console.log();
console.log(` Send a message from your chat provider — your agent will reply.`);
console.log();
console.log(` ${dim('npm run dev')} ${dim('Start app without tunnel')}`);
console.log(` ${dim('npm run dev:novu')} ${dim('Start app + dev tunnel')}`);
console.log();
console.log(` ${dim('Your agent')} ${cyan(terminalLink(agentRelPath, fileUrl))}`);
console.log(` ${dim('Docs')} ${cyan('https://docs.novu.co/agents/overview')}`);
console.log();
} else {
console.log(`Edit ${cyan('app/novu/workflows/')} to customize your notification workflows.`);
console.log(`Docs: ${cyan('https://docs.novu.co/framework/introduction')}`);
console.log();
console.log(bold('Next steps:'));
console.log();

let step = 1;
if (!skipCd) {
console.log(` ${step}. ${cyan(`cd ${cdPath}`)}`);
step += 1;
}
console.log(` ${step}. ${cyan('npm run dev')}${dim(' Start your app on :4000')}`);
step += 1;
console.log(` ${step}. In a second terminal, run:`);
console.log(` ${cyan('npx novu@latest dev')}`);
console.log(` ${dim('Starts Novu Studio and a dev tunnel')}`);
console.log();
console.log(` Edit ${cyan('app/novu/workflows/')} to customize your notification workflows.`);
console.log(` Docs: ${cyan('https://docs.novu.co/framework/introduction')}`);
console.log();
}
console.log();
}
3 changes: 2 additions & 1 deletion packages/novu/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ export async function init(program: IInitCommandOptions, anonymousId?: string):
}

if (!projectPath) {
const defaultName = program.agentIdentifier || 'my-novu-app';
const res = await prompts({
onState: onPromptState,
type: 'text',
name: 'path',
message: 'What is your project named?',
initial: 'my-novu-app',
initial: defaultName,
validate: (name: string) => {
const validation = validateNpmName(path.basename(path.resolve(name)));
if (validation.valid) {
Expand Down
51 changes: 37 additions & 14 deletions packages/novu/src/commands/init/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

npm view novu dist-tags

💡 Result:

The command npm view novu dist-tags displays the distribution tags for the novu npm package. Distribution tags are human-readable labels like 'latest' that point to specific versions [1][2]. For the novu package (CLI tool), the latest version is 2.8.0 (published Mar 27, 2026), with previous versions including RC tags like 2.8.0-rc.1 to 2.8.0-rc.8, 2.7.0, etc. [3][4]. No explicit list of current dist-tags (e.g., latest: 2.8.0) was found in search results, but npm pages typically show only the latest version prominently, implying 'latest' points to 2.8.0 [3]. Other packages like @novu/node show canary tags in history (e.g., 2.0.0-canary.0) [5]. To get the exact current output, run the command directly, as it queries the npm registry in real-time [2].

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-tags

Repository: 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.ts

Repository: 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.ts

Repository: 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 -20

Repository: 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.json

Repository: 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.ts

Repository: novuhq/novu

Length of output: 330


Fix the dist-tag mapping to align with actual npm tags published for novu.

The current code maps both -rc and -alpha versions to the rc dist-tag, but the npm registry shows distinct tags: alpha (0.3.1-alpha.8), next (0.10.0-alpha.0), latest (2.8.0), and rc (2.8.1-rc.6). If a user scaffolds a project with novu@alpha, the generated dev:novu script will resolve to novu@rc (version 2.8.1-rc.6), creating major version skew (0.3.x → 2.8.x). The same issue affects novu@next and any unrecognized pre-release suffixes, which fall through to latest.

Update the mapping to handle alpha, next, and other pre-release identifiers correctly, or align it with your actual release pipeline's dist-tag strategy:

Example: Extract pre-release id from version string
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';
+  // 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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';
}
function resolveCliTag(): string {
const pkg = resolveCliPackageJson();
if (!pkg?.version) 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';
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/novu/src/commands/init/templates/index.ts` around lines 35 - 43,
resolveCliTag currently collapses all prerelease versions to "rc", causing
alpha/next installs to resolve to the wrong dist-tag; update resolveCliTag to
parse the prerelease identifier from pkg.version (e.g., const prerelease =
pkg.version.split('-')[1]?.split('.')[0]) and return that identifier when it
matches known tags ("alpha", "next", "rc"), otherwise return that identifier for
unknown prereleases or fall back to "latest"; also update the generated dev:novu
script template (the template that injects the tag for the dev script) to use
the new resolveCliTag value so the scaffolded script references the correct npm
dist-tag instead of always using "rc" or "latest".

/**
* Get the file path for a given file in a template, e.g. "next.config.js".
*/
Expand Down Expand Up @@ -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: {},
};
Expand Down
1 change: 1 addition & 0 deletions packages/novu/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ program
.option('-t, --tunnel <url>', 'Self hosted tunnel. e.g. https://my-tunnel.ngrok.app')
.option('-H, --headless', 'Run the Bridge in headless mode without opening the browser', false)
.option('--no-studio', 'Skip starting the local Studio server')
.option('--run <command>', 'Spawn a local app server before opening the tunnel')
.action(async (options: DevCommandOptions) => {
analytics.track({
identity: {
Expand Down
Loading