From f9e69587df64044ddc9b221b18a0accbdd8152b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 11:43:47 +0000 Subject: [PATCH 1/4] fix(root): use agent identifier as scaffolded file name fixes NV-7479 Previously, the scaffolding command always created the agent file as support-agent.tsx regardless of the --agent-identifier value. Now the file, export name, and all references (index.ts, route.ts, page.tsx, README) are renamed to match the provided agent identifier. Co-authored-by: Adam Chmara --- .../novu/src/commands/init/templates/index.ts | 37 +++++- .../init/templates/install-template.spec.ts | 114 ++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 packages/novu/src/commands/init/templates/install-template.spec.ts diff --git a/packages/novu/src/commands/init/templates/index.ts b/packages/novu/src/commands/init/templates/index.ts index a4747f873f8..e4e52198f8b 100644 --- a/packages/novu/src/commands/init/templates/index.ts +++ b/packages/novu/src/commands/init/templates/index.ts @@ -95,10 +95,41 @@ export const installTemplate = async ({ `Invalid agent identifier: "${agentIdentifier}". Must be a lowercase slug (a-z, 0-9, hyphens, underscores).` ); } - const agentFile = path.join(root, 'app', 'novu', 'agents', 'support-agent.tsx'); + + const camelName = agentIdentifier.replace(/[-_]([a-z0-9])/g, (_, c) => c.toUpperCase()); + const agentsDir = path.join(root, 'app', 'novu', 'agents'); + const oldFile = path.join(agentsDir, 'support-agent.tsx'); + const newFile = path.join(agentsDir, `${agentIdentifier}.tsx`); + + let agentSrc = await fs.readFile(oldFile, 'utf8'); + agentSrc = agentSrc.replace('supportAgent', camelName).replace("agent('support-agent',", `agent('${agentIdentifier}',`); + await fs.writeFile(newFile, agentSrc); + await fs.unlink(oldFile); + + const indexFile = path.join(agentsDir, 'index.ts'); + await fs.writeFile( + indexFile, + (await fs.readFile(indexFile, 'utf8')) + .replace('supportAgent', camelName) + .replace('./support-agent', `./${agentIdentifier}`) + ); + + const routeFile = path.join(root, 'app', 'api', 'novu', 'route.ts'); + await fs.writeFile( + routeFile, + (await fs.readFile(routeFile, 'utf8')).replace(/supportAgent/g, camelName) + ); + + const pageFile = path.join(root, 'app', 'page.tsx'); + await fs.writeFile( + pageFile, + (await fs.readFile(pageFile, 'utf8')).replace('support-agent.tsx', `${agentIdentifier}.tsx`) + ); + + const readmeFile = path.join(root, 'README.md'); await fs.writeFile( - agentFile, - (await fs.readFile(agentFile, 'utf8')).replace("agent('support-agent',", `agent('${agentIdentifier}',`) + readmeFile, + (await fs.readFile(readmeFile, 'utf8')).replace(/support-agent\.tsx/g, `${agentIdentifier}.tsx`) ); } diff --git a/packages/novu/src/commands/init/templates/install-template.spec.ts b/packages/novu/src/commands/init/templates/install-template.spec.ts new file mode 100644 index 00000000000..d61c2a2609b --- /dev/null +++ b/packages/novu/src/commands/init/templates/install-template.spec.ts @@ -0,0 +1,114 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installTemplate } from './index'; +import { TemplateTypeEnum } from './types'; + +vi.mock('../helpers/install', () => ({ + install: vi.fn().mockResolvedValue(undefined), +})); + +describe('installTemplate – agent identifier renaming', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'novu-agent-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + const baseArgs = { + appName: 'test-app', + packageManager: 'npm' as const, + isOnline: false, + template: TemplateTypeEnum.APP_AGENT as const, + mode: 'ts' as const, + eslint: false, + srcDir: false, + importAlias: '@/*', + secretKey: 'test-secret', + apiUrl: 'https://api.novu.co', + applicationId: '', + userId: '', + }; + + it('keeps support-agent.tsx when no agentIdentifier is provided', async () => { + await installTemplate({ ...baseArgs, root: tmpDir }); + + const agentFile = path.join(tmpDir, 'app', 'novu', 'agents', 'support-agent.tsx'); + expect(await fs.stat(agentFile).then(() => true)).toBe(true); + + const content = await fs.readFile(agentFile, 'utf8'); + expect(content).toContain("agent('support-agent',"); + expect(content).toContain('export const supportAgent'); + }); + + it('renames agent file to match agentIdentifier', async () => { + await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); + + const oldFile = path.join(tmpDir, 'app', 'novu', 'agents', 'support-agent.tsx'); + await expect(fs.stat(oldFile)).rejects.toThrow(); + + const newFile = path.join(tmpDir, 'app', 'novu', 'agents', 'first-agent.tsx'); + const content = await fs.readFile(newFile, 'utf8'); + expect(content).toContain("agent('first-agent',"); + expect(content).toContain('export const firstAgent'); + expect(content).not.toContain('supportAgent'); + }); + + it('updates agents/index.ts export to match agentIdentifier', async () => { + await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); + + const indexContent = await fs.readFile(path.join(tmpDir, 'app', 'novu', 'agents', 'index.ts'), 'utf8'); + expect(indexContent).toContain('firstAgent'); + expect(indexContent).toContain('./first-agent'); + expect(indexContent).not.toContain('supportAgent'); + expect(indexContent).not.toContain('./support-agent'); + }); + + it('updates route.ts imports to match agentIdentifier', async () => { + await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); + + const routeContent = await fs.readFile(path.join(tmpDir, 'app', 'api', 'novu', 'route.ts'), 'utf8'); + expect(routeContent).toContain('firstAgent'); + expect(routeContent).not.toContain('supportAgent'); + }); + + it('updates page.tsx reference to match agentIdentifier', async () => { + await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); + + const pageContent = await fs.readFile(path.join(tmpDir, 'app', 'page.tsx'), 'utf8'); + expect(pageContent).toContain('first-agent.tsx'); + expect(pageContent).not.toContain('support-agent.tsx'); + }); + + it('updates README.md references to match agentIdentifier', async () => { + await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); + + const readmeContent = await fs.readFile(path.join(tmpDir, 'README.md'), 'utf8'); + expect(readmeContent).toContain('first-agent.tsx'); + expect(readmeContent).not.toContain('support-agent.tsx'); + }); + + it('handles underscored identifiers correctly', async () => { + await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'my_cool_agent' }); + + const newFile = path.join(tmpDir, 'app', 'novu', 'agents', 'my_cool_agent.tsx'); + const content = await fs.readFile(newFile, 'utf8'); + expect(content).toContain("agent('my_cool_agent',"); + expect(content).toContain('export const myCoolAgent'); + }); + + it('rejects invalid agent identifiers', async () => { + await expect( + installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'INVALID' }) + ).rejects.toThrow('Invalid agent identifier'); + + await expect( + installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'has spaces' }) + ).rejects.toThrow('Invalid agent identifier'); + }); +}); From 4ff392f4825b184a2c76d4abe208c18697758447 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 11:46:28 +0000 Subject: [PATCH 2/4] refactor: simplify agent renaming with batch replace and remove test Co-authored-by: Adam Chmara --- .../novu/src/commands/init/templates/index.ts | 53 ++++---- .../init/templates/install-template.spec.ts | 114 ------------------ 2 files changed, 21 insertions(+), 146 deletions(-) delete mode 100644 packages/novu/src/commands/init/templates/install-template.spec.ts diff --git a/packages/novu/src/commands/init/templates/index.ts b/packages/novu/src/commands/init/templates/index.ts index e4e52198f8b..acb6a39c5aa 100644 --- a/packages/novu/src/commands/init/templates/index.ts +++ b/packages/novu/src/commands/init/templates/index.ts @@ -98,38 +98,27 @@ export const installTemplate = async ({ const camelName = agentIdentifier.replace(/[-_]([a-z0-9])/g, (_, c) => c.toUpperCase()); const agentsDir = path.join(root, 'app', 'novu', 'agents'); - const oldFile = path.join(agentsDir, 'support-agent.tsx'); - const newFile = path.join(agentsDir, `${agentIdentifier}.tsx`); - - let agentSrc = await fs.readFile(oldFile, 'utf8'); - agentSrc = agentSrc.replace('supportAgent', camelName).replace("agent('support-agent',", `agent('${agentIdentifier}',`); - await fs.writeFile(newFile, agentSrc); - await fs.unlink(oldFile); - - const indexFile = path.join(agentsDir, 'index.ts'); - await fs.writeFile( - indexFile, - (await fs.readFile(indexFile, 'utf8')) - .replace('supportAgent', camelName) - .replace('./support-agent', `./${agentIdentifier}`) - ); - - const routeFile = path.join(root, 'app', 'api', 'novu', 'route.ts'); - await fs.writeFile( - routeFile, - (await fs.readFile(routeFile, 'utf8')).replace(/supportAgent/g, camelName) - ); - - const pageFile = path.join(root, 'app', 'page.tsx'); - await fs.writeFile( - pageFile, - (await fs.readFile(pageFile, 'utf8')).replace('support-agent.tsx', `${agentIdentifier}.tsx`) - ); - - const readmeFile = path.join(root, 'README.md'); - await fs.writeFile( - readmeFile, - (await fs.readFile(readmeFile, 'utf8')).replace(/support-agent\.tsx/g, `${agentIdentifier}.tsx`) + await fs.rename(path.join(agentsDir, 'support-agent.tsx'), path.join(agentsDir, `${agentIdentifier}.tsx`)); + + const replacements: [RegExp, string][] = [ + [/supportAgent/g, camelName], + [/support-agent/g, agentIdentifier], + ]; + const targets = [ + path.join(agentsDir, `${agentIdentifier}.tsx`), + path.join(agentsDir, 'index.ts'), + path.join(root, 'app', 'api', 'novu', 'route.ts'), + path.join(root, 'app', 'page.tsx'), + path.join(root, 'README.md'), + ]; + await Promise.all( + targets.map(async (file) => { + let content = await fs.readFile(file, 'utf8'); + for (const [pattern, replacement] of replacements) { + content = content.replace(pattern, replacement); + } + await fs.writeFile(file, content); + }) ); } diff --git a/packages/novu/src/commands/init/templates/install-template.spec.ts b/packages/novu/src/commands/init/templates/install-template.spec.ts deleted file mode 100644 index d61c2a2609b..00000000000 --- a/packages/novu/src/commands/init/templates/install-template.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { installTemplate } from './index'; -import { TemplateTypeEnum } from './types'; - -vi.mock('../helpers/install', () => ({ - install: vi.fn().mockResolvedValue(undefined), -})); - -describe('installTemplate – agent identifier renaming', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'novu-agent-')); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - const baseArgs = { - appName: 'test-app', - packageManager: 'npm' as const, - isOnline: false, - template: TemplateTypeEnum.APP_AGENT as const, - mode: 'ts' as const, - eslint: false, - srcDir: false, - importAlias: '@/*', - secretKey: 'test-secret', - apiUrl: 'https://api.novu.co', - applicationId: '', - userId: '', - }; - - it('keeps support-agent.tsx when no agentIdentifier is provided', async () => { - await installTemplate({ ...baseArgs, root: tmpDir }); - - const agentFile = path.join(tmpDir, 'app', 'novu', 'agents', 'support-agent.tsx'); - expect(await fs.stat(agentFile).then(() => true)).toBe(true); - - const content = await fs.readFile(agentFile, 'utf8'); - expect(content).toContain("agent('support-agent',"); - expect(content).toContain('export const supportAgent'); - }); - - it('renames agent file to match agentIdentifier', async () => { - await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); - - const oldFile = path.join(tmpDir, 'app', 'novu', 'agents', 'support-agent.tsx'); - await expect(fs.stat(oldFile)).rejects.toThrow(); - - const newFile = path.join(tmpDir, 'app', 'novu', 'agents', 'first-agent.tsx'); - const content = await fs.readFile(newFile, 'utf8'); - expect(content).toContain("agent('first-agent',"); - expect(content).toContain('export const firstAgent'); - expect(content).not.toContain('supportAgent'); - }); - - it('updates agents/index.ts export to match agentIdentifier', async () => { - await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); - - const indexContent = await fs.readFile(path.join(tmpDir, 'app', 'novu', 'agents', 'index.ts'), 'utf8'); - expect(indexContent).toContain('firstAgent'); - expect(indexContent).toContain('./first-agent'); - expect(indexContent).not.toContain('supportAgent'); - expect(indexContent).not.toContain('./support-agent'); - }); - - it('updates route.ts imports to match agentIdentifier', async () => { - await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); - - const routeContent = await fs.readFile(path.join(tmpDir, 'app', 'api', 'novu', 'route.ts'), 'utf8'); - expect(routeContent).toContain('firstAgent'); - expect(routeContent).not.toContain('supportAgent'); - }); - - it('updates page.tsx reference to match agentIdentifier', async () => { - await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); - - const pageContent = await fs.readFile(path.join(tmpDir, 'app', 'page.tsx'), 'utf8'); - expect(pageContent).toContain('first-agent.tsx'); - expect(pageContent).not.toContain('support-agent.tsx'); - }); - - it('updates README.md references to match agentIdentifier', async () => { - await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'first-agent' }); - - const readmeContent = await fs.readFile(path.join(tmpDir, 'README.md'), 'utf8'); - expect(readmeContent).toContain('first-agent.tsx'); - expect(readmeContent).not.toContain('support-agent.tsx'); - }); - - it('handles underscored identifiers correctly', async () => { - await installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'my_cool_agent' }); - - const newFile = path.join(tmpDir, 'app', 'novu', 'agents', 'my_cool_agent.tsx'); - const content = await fs.readFile(newFile, 'utf8'); - expect(content).toContain("agent('my_cool_agent',"); - expect(content).toContain('export const myCoolAgent'); - }); - - it('rejects invalid agent identifiers', async () => { - await expect( - installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'INVALID' }) - ).rejects.toThrow('Invalid agent identifier'); - - await expect( - installTemplate({ ...baseArgs, root: tmpDir, agentIdentifier: 'has spaces' }) - ).rejects.toThrow('Invalid agent identifier'); - }); -}); From 15893cee15a06d3c5d8da1182a20cd9d8e2bc6b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 11:50:22 +0000 Subject: [PATCH 3/4] refactor: use copy rename callback and glob-based content replacement Co-authored-by: Adam Chmara --- .../novu/src/commands/init/templates/index.ts | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/packages/novu/src/commands/init/templates/index.ts b/packages/novu/src/commands/init/templates/index.ts index acb6a39c5aa..9c53becc7fb 100644 --- a/packages/novu/src/commands/init/templates/index.ts +++ b/packages/novu/src/commands/init/templates/index.ts @@ -66,6 +66,13 @@ export const installTemplate = async ({ copySource.push(mode === 'ts' ? 'tailwind.config.ts' : '!tailwind.config.js', '!postcss.config.cjs'); } + const renameAgent = template === TemplateTypeEnum.APP_AGENT && agentIdentifier; + if (renameAgent && !/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(agentIdentifier)) { + throw new Error( + `Invalid agent identifier: "${agentIdentifier}". Must be a lowercase slug (a-z, 0-9, hyphens, underscores).` + ); + } + await copy(copySource, root, { parents: true, cwd: templatePath, @@ -82,6 +89,9 @@ export const installTemplate = async ({ case 'README-template.md': { return 'README.md'; } + case 'support-agent.tsx': { + return renameAgent ? `${agentIdentifier}.tsx` : name; + } default: { return name; } @@ -89,35 +99,14 @@ export const installTemplate = async ({ }, }); - if (template === TemplateTypeEnum.APP_AGENT && agentIdentifier) { - if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(agentIdentifier)) { - throw new Error( - `Invalid agent identifier: "${agentIdentifier}". Must be a lowercase slug (a-z, 0-9, hyphens, underscores).` - ); - } - + if (renameAgent) { const camelName = agentIdentifier.replace(/[-_]([a-z0-9])/g, (_, c) => c.toUpperCase()); - const agentsDir = path.join(root, 'app', 'novu', 'agents'); - await fs.rename(path.join(agentsDir, 'support-agent.tsx'), path.join(agentsDir, `${agentIdentifier}.tsx`)); - - const replacements: [RegExp, string][] = [ - [/supportAgent/g, camelName], - [/support-agent/g, agentIdentifier], - ]; - const targets = [ - path.join(agentsDir, `${agentIdentifier}.tsx`), - path.join(agentsDir, 'index.ts'), - path.join(root, 'app', 'api', 'novu', 'route.ts'), - path.join(root, 'app', 'page.tsx'), - path.join(root, 'README.md'), - ]; + const files = await glob('**/*.{tsx,ts,md}', { cwd: root, absolute: true }); await Promise.all( - targets.map(async (file) => { - let content = await fs.readFile(file, 'utf8'); - for (const [pattern, replacement] of replacements) { - content = content.replace(pattern, replacement); - } - await fs.writeFile(file, content); + files.map(async (file) => { + const before = await fs.readFile(file, 'utf8'); + const after = before.replace(/supportAgent/g, camelName).replace(/support-agent/g, agentIdentifier); + if (after !== before) await fs.writeFile(file, after); }) ); } From a0e86c11e0b872ddd3833ebb2f41dfae2cebb883 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 12:31:59 +0000 Subject: [PATCH 4/4] fix: disable symlink following in glob to prevent out-of-root writes Co-authored-by: Adam Chmara --- packages/novu/src/commands/init/templates/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/novu/src/commands/init/templates/index.ts b/packages/novu/src/commands/init/templates/index.ts index 9c53becc7fb..724a3fe78d3 100644 --- a/packages/novu/src/commands/init/templates/index.ts +++ b/packages/novu/src/commands/init/templates/index.ts @@ -101,7 +101,7 @@ export const installTemplate = async ({ if (renameAgent) { const camelName = agentIdentifier.replace(/[-_]([a-z0-9])/g, (_, c) => c.toUpperCase()); - const files = await glob('**/*.{tsx,ts,md}', { cwd: root, absolute: true }); + const files = await glob('**/*.{tsx,ts,md}', { cwd: root, absolute: true, followSymbolicLinks: false }); await Promise.all( files.map(async (file) => { const before = await fs.readFile(file, 'utf8');