@open-mercato/cli 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/agentic/shared/AGENTS.md.template +1 -1
  3. package/dist/lib/__integration__/TC-INT-007.spec.js +201 -0
  4. package/dist/lib/__integration__/TC-INT-007.spec.js.map +7 -0
  5. package/dist/lib/dev-env-reload.js +89 -0
  6. package/dist/lib/dev-env-reload.js.map +7 -0
  7. package/dist/lib/generators/extensions/ai-agents.js +218 -0
  8. package/dist/lib/generators/extensions/ai-agents.js.map +7 -0
  9. package/dist/lib/generators/extensions/ai-tools.js +56 -1
  10. package/dist/lib/generators/extensions/ai-tools.js.map +2 -2
  11. package/dist/lib/generators/extensions/index.js +2 -0
  12. package/dist/lib/generators/extensions/index.js.map +2 -2
  13. package/dist/lib/testing/integration-discovery.js +102 -5
  14. package/dist/lib/testing/integration-discovery.js.map +2 -2
  15. package/dist/mercato.js +85 -44
  16. package/dist/mercato.js.map +2 -2
  17. package/package.json +5 -5
  18. package/src/__tests__/mercato.test.ts +112 -0
  19. package/src/lib/__integration__/TC-INT-007.spec.ts +228 -0
  20. package/src/lib/__tests__/dev-env-reload.test.ts +62 -0
  21. package/src/lib/dev-env-reload.ts +110 -0
  22. package/src/lib/generators/__tests__/module-subset.test.ts +14 -0
  23. package/src/lib/generators/__tests__/output-snapshots.test.ts +17 -0
  24. package/src/lib/generators/__tests__/scanner.test.ts +1 -1
  25. package/src/lib/generators/__tests__/structural-contracts.test.ts +26 -0
  26. package/src/lib/generators/extensions/ai-agents.ts +240 -0
  27. package/src/lib/generators/extensions/ai-tools.ts +72 -1
  28. package/src/lib/generators/extensions/index.ts +2 -0
  29. package/src/lib/testing/__tests__/integration-discovery.test.ts +68 -0
  30. package/src/lib/testing/integration-discovery.ts +127 -3
  31. package/src/mercato.ts +100 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.5.1-develop.3036.f02c281f23",
3
+ "version": "0.5.1-develop.3045.b4b3320cc2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.0.13",
60
60
  "@mikro-orm/migrations": "^7.0.13",
61
61
  "@mikro-orm/postgresql": "^7.0.13",
62
- "@open-mercato/queue": "0.5.1-develop.3036.f02c281f23",
63
- "@open-mercato/shared": "0.5.1-develop.3036.f02c281f23",
62
+ "@open-mercato/queue": "0.5.1-develop.3045.b4b3320cc2",
63
+ "@open-mercato/shared": "0.5.1-develop.3045.b4b3320cc2",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.20.0",
66
66
  "semver": "^7.7.4",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.5.1-develop.3036.f02c281f23"
73
+ "@open-mercato/shared": "0.5.1-develop.3045.b4b3320cc2"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.5.1-develop.3036.f02c281f23",
76
+ "@open-mercato/shared": "0.5.1-develop.3045.b4b3320cc2",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.3.0",
79
79
  "ts-jest": "^29.4.9"
@@ -630,6 +630,7 @@ describe('server dev managed process exits', () => {
630
630
  afterEach(() => {
631
631
  jest.dontMock('child_process')
632
632
  jest.dontMock('node:fs')
633
+ jest.dontMock('../lib/dev-env-reload')
633
634
  jest.dontMock('../lib/generators')
634
635
  jest.dontMock('../lib/resolver')
635
636
  jest.resetModules()
@@ -844,4 +845,115 @@ describe('server start managed process exits', () => {
844
845
  consoleErrorSpy.mockRestore()
845
846
  consoleLogSpy.mockRestore()
846
847
  })
848
+
849
+ it('restarts the managed dev runtime when an app env file changes', async () => {
850
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
851
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
852
+ let envChangeCallback: ((filePath: string) => void) | null = null
853
+ let reloadCount = 0
854
+
855
+ jest.doMock('node:fs', () => {
856
+ const actual = jest.requireActual('node:fs')
857
+ return {
858
+ ...actual,
859
+ existsSync: jest.fn((candidate: string) =>
860
+ candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
861
+ ),
862
+ unlinkSync: jest.fn(),
863
+ }
864
+ })
865
+ jest.doMock('../lib/dev-env-reload', () => ({
866
+ createDevEnvReloader: () => ({
867
+ reload: jest.fn(() => {
868
+ reloadCount += 1
869
+ process.env.RESTART_TOKEN = reloadCount === 1 ? 'initial' : 'changed'
870
+ }),
871
+ getWatchedFiles: () => ['/tmp/test-app/.env'],
872
+ }),
873
+ watchDevEnvFiles: jest.fn((_appDir: string, onChange: (filePath: string) => void) => {
874
+ envChangeCallback = onChange
875
+ return jest.fn()
876
+ }),
877
+ }))
878
+ jest.doMock('../lib/generators', () => ({
879
+ generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
880
+ }))
881
+ jest.doMock('../lib/resolver', () => ({
882
+ resolveEnvironment: () => ({
883
+ appDir: '/tmp/test-app',
884
+ rootDir: '/tmp/test-root',
885
+ }),
886
+ createResolver: () => ({}),
887
+ }))
888
+ jest.doMock('child_process', () => {
889
+ const { EventEmitter } = jest.requireActual('node:events')
890
+ let nextSpawnCount = 0
891
+
892
+ const createChild = (
893
+ autoExit?: { code: number | null; signal?: NodeJS.Signals | null },
894
+ ) => {
895
+ const child = new EventEmitter() as any
896
+ child.stdout = new EventEmitter()
897
+ child.stderr = new EventEmitter()
898
+ child.killed = false
899
+ child.exitCode = null
900
+ child.signalCode = null
901
+ child.kill = jest.fn((signal: NodeJS.Signals = 'SIGTERM') => {
902
+ child.killed = true
903
+ if (child.exitCode !== null || child.signalCode !== null) {
904
+ return true
905
+ }
906
+ child.signalCode = signal
907
+ queueMicrotask(() => {
908
+ child.emit('exit', null, signal)
909
+ })
910
+ return true
911
+ })
912
+
913
+ if (autoExit) {
914
+ queueMicrotask(() => {
915
+ if (child.exitCode !== null || child.signalCode !== null) return
916
+ child.exitCode = autoExit.code
917
+ child.signalCode = autoExit.signal ?? null
918
+ child.emit('exit', child.exitCode, child.signalCode)
919
+ })
920
+ }
921
+
922
+ return child
923
+ }
924
+
925
+ return {
926
+ spawn: jest.fn((_command: string, args: string[]) => {
927
+ if (args[0]?.includes('next/dist/bin/next')) {
928
+ nextSpawnCount += 1
929
+ if (nextSpawnCount === 1) {
930
+ queueMicrotask(() => envChangeCallback?.('/tmp/test-app/.env'))
931
+ return createChild()
932
+ }
933
+ return createChild({ code: null, signal: 'SIGTERM' })
934
+ }
935
+ return createChild()
936
+ }),
937
+ }
938
+ })
939
+
940
+ const mercado = await import('../mercato')
941
+ const exitCode = await mercado.run(['node', 'mercato', 'server', 'dev'])
942
+ const { spawn } = await import('child_process')
943
+ const nextSpawns = (spawn as jest.Mock).mock.calls.filter((call) =>
944
+ call[1]?.[0]?.includes('next/dist/bin/next'),
945
+ )
946
+
947
+ expect(exitCode).toBe(0)
948
+ expect(nextSpawns).toHaveLength(2)
949
+ expect(nextSpawns[0][2].env.RESTART_TOKEN).toBe('initial')
950
+ expect(nextSpawns[1][2].env.RESTART_TOKEN).toBe('changed')
951
+ expect(consoleLogSpy).toHaveBeenCalledWith(
952
+ '[server] Detected environment file change (.env). Restarting app runtime...',
953
+ )
954
+
955
+ delete process.env.RESTART_TOKEN
956
+ consoleErrorSpy.mockRestore()
957
+ consoleLogSpy.mockRestore()
958
+ })
847
959
  })
@@ -0,0 +1,228 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { execFileSync } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const repoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..')
10
+ const cliBin = path.join(repoRoot, 'packages', 'cli', 'dist', 'bin.js')
11
+ const fixtureModulePackage = path.join(
12
+ repoRoot,
13
+ 'packages',
14
+ 'cli',
15
+ 'src',
16
+ 'lib',
17
+ '__fixtures__',
18
+ 'official-module-package',
19
+ )
20
+
21
+ function yarnBinary(): string {
22
+ return process.platform === 'win32' ? 'yarn.cmd' : 'yarn'
23
+ }
24
+
25
+ function runCommand(command: string, args: string[], cwd: string): string {
26
+ const yarnCacheFolder = path.join(cwd, '.yarn', 'cache')
27
+ return execFileSync(command, args, {
28
+ cwd,
29
+ encoding: 'utf8',
30
+ env: {
31
+ ...process.env,
32
+ FORCE_COLOR: '0',
33
+ NODE_NO_WARNINGS: '1',
34
+ YARN_CACHE_FOLDER: yarnCacheFolder,
35
+ YARN_ENABLE_GLOBAL_CACHE: '0',
36
+ YARN_ENABLE_IMMUTABLE_INSTALLS: '0',
37
+ YARN_NODE_LINKER: 'node-modules',
38
+ },
39
+ })
40
+ }
41
+
42
+ function runMercato(args: string[], cwd: string): string {
43
+ return runCommand(process.execPath, [cliBin, ...args], cwd)
44
+ }
45
+
46
+ function writeFile(filePath: string, content: string): void {
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
48
+ fs.writeFileSync(filePath, content)
49
+ }
50
+
51
+ function createCoreWorkspacePackage(rootDir: string, relativeDir: string): string {
52
+ const coreDir = path.join(rootDir, relativeDir)
53
+ writeFile(
54
+ path.join(coreDir, 'package.json'),
55
+ JSON.stringify(
56
+ {
57
+ name: '@open-mercato/core',
58
+ version: '0.4.7',
59
+ type: 'module',
60
+ },
61
+ null,
62
+ 2,
63
+ ),
64
+ )
65
+ return coreDir
66
+ }
67
+
68
+ function createMonorepoFixture(rootDir: string): string {
69
+ writeFile(
70
+ path.join(rootDir, 'package.json'),
71
+ JSON.stringify(
72
+ {
73
+ name: 'cli-module-monorepo-fixture',
74
+ private: true,
75
+ workspaces: ['apps/*', 'packages/*'],
76
+ },
77
+ null,
78
+ 2,
79
+ ),
80
+ )
81
+ writeFile(
82
+ path.join(rootDir, '.yarnrc.yml'),
83
+ ['nodeLinker: node-modules', 'enableGlobalCache: false', 'cacheFolder: ./.yarn/cache', ''].join('\n'),
84
+ )
85
+ createCoreWorkspacePackage(rootDir, path.join('packages', 'core'))
86
+
87
+ const appDir = path.join(rootDir, 'apps', 'mercato')
88
+ writeFile(
89
+ path.join(appDir, 'package.json'),
90
+ JSON.stringify(
91
+ {
92
+ name: '@open-mercato/app',
93
+ version: '0.0.0',
94
+ private: true,
95
+ },
96
+ null,
97
+ 2,
98
+ ),
99
+ )
100
+ writeFile(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
101
+ return appDir
102
+ }
103
+
104
+ function createStandaloneFixture(rootDir: string): string {
105
+ writeFile(
106
+ path.join(rootDir, '.yarnrc.yml'),
107
+ ['nodeLinker: node-modules', 'enableGlobalCache: false', 'cacheFolder: ./.yarn/cache', ''].join('\n'),
108
+ )
109
+ createCoreWorkspacePackage(rootDir, path.join('vendor', 'core'))
110
+ writeFile(
111
+ path.join(rootDir, 'package.json'),
112
+ JSON.stringify(
113
+ {
114
+ name: 'cli-module-standalone-fixture',
115
+ private: true,
116
+ dependencies: {
117
+ '@open-mercato/core': 'file:./vendor/core',
118
+ },
119
+ },
120
+ null,
121
+ 2,
122
+ ),
123
+ )
124
+ writeFile(path.join(rootDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
125
+ return rootDir
126
+ }
127
+
128
+ function readFile(filePath: string): string {
129
+ return fs.readFileSync(filePath, 'utf8')
130
+ }
131
+
132
+ test.describe('TC-INT-007: CLI official module install and eject flows', () => {
133
+ test('module add installs and registers a package-backed module in a monorepo app', () => {
134
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-cli-monorepo-'))
135
+
136
+ try {
137
+ const appDir = createMonorepoFixture(rootDir)
138
+ runCommand(yarnBinary(), ['install'], rootDir)
139
+
140
+ runMercato(
141
+ [
142
+ 'module',
143
+ 'add',
144
+ `@open-mercato/test-package@file:${fixtureModulePackage}`,
145
+ ],
146
+ rootDir,
147
+ )
148
+
149
+ const modulesSource = readFile(path.join(appDir, 'src', 'modules.ts'))
150
+ const cssSource = readFile(path.join(appDir, '.mercato', 'generated', 'module-package-sources.css'))
151
+ const appPackageJson = JSON.parse(readFile(path.join(appDir, 'package.json'))) as {
152
+ dependencies?: Record<string, string>
153
+ }
154
+
155
+ expect(modulesSource).toContain("{ id: 'test_package', from: '@open-mercato/test-package' }")
156
+ expect(cssSource).toContain('node_modules/@open-mercato/test-package/src/**/*.{ts,tsx}')
157
+ expect(appPackageJson.dependencies?.['@open-mercato/test-package']).toBeTruthy()
158
+ } finally {
159
+ fs.rmSync(rootDir, { recursive: true, force: true })
160
+ }
161
+ })
162
+
163
+ test('module add --eject copies module source and omits package CSS entries', () => {
164
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-cli-standalone-source-'))
165
+
166
+ try {
167
+ createStandaloneFixture(appDir)
168
+ runCommand(yarnBinary(), ['install'], appDir)
169
+
170
+ runMercato(
171
+ [
172
+ 'module',
173
+ 'add',
174
+ `@open-mercato/test-package@file:${fixtureModulePackage}`,
175
+ '--eject',
176
+ ],
177
+ appDir,
178
+ )
179
+
180
+ const modulesSource = readFile(path.join(appDir, 'src', 'modules.ts'))
181
+ const cssSource = readFile(path.join(appDir, '.mercato', 'generated', 'module-package-sources.css'))
182
+
183
+ expect(modulesSource).toContain("{ id: 'test_package', from: '@app' }")
184
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'test_package', 'index.ts'))).toBe(true)
185
+ expect(cssSource).toBe('')
186
+ } finally {
187
+ fs.rmSync(appDir, { recursive: true, force: true })
188
+ }
189
+ })
190
+
191
+ test('module enable supports package-backed and ejected flows plus both eject entrypoints', () => {
192
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-cli-standalone-eject-'))
193
+
194
+ try {
195
+ createStandaloneFixture(appDir)
196
+ runCommand(yarnBinary(), ['install'], appDir)
197
+ runCommand(
198
+ yarnBinary(),
199
+ ['add', `@open-mercato/test-package@file:${fixtureModulePackage}`],
200
+ appDir,
201
+ )
202
+
203
+ runMercato(['module', 'enable', '@open-mercato/test-package'], appDir)
204
+ expect(readFile(path.join(appDir, 'src', 'modules.ts'))).toContain(
205
+ "{ id: 'test_package', from: '@open-mercato/test-package' }",
206
+ )
207
+
208
+ writeFile(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
209
+ fs.rmSync(path.join(appDir, 'src', 'modules', 'test_package'), { recursive: true, force: true })
210
+ runMercato(['module', 'enable', '@open-mercato/test-package', '--eject'], appDir)
211
+ expect(readFile(path.join(appDir, 'src', 'modules.ts'))).toContain("{ id: 'test_package', from: '@app' }")
212
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'test_package', 'index.ts'))).toBe(true)
213
+ expect(readFile(path.join(appDir, '.mercato', 'generated', 'module-package-sources.css'))).toBe('')
214
+
215
+ writeFile(path.join(appDir, 'src', 'modules.ts'), "export const enabledModules = [{ id: 'test_package', from: '@open-mercato/test-package' }]\n")
216
+ fs.rmSync(path.join(appDir, 'src', 'modules', 'test_package'), { recursive: true, force: true })
217
+ runMercato(['module', 'eject', 'test_package'], appDir)
218
+ expect(readFile(path.join(appDir, 'src', 'modules.ts'))).toContain("{ id: 'test_package', from: '@app' }")
219
+
220
+ writeFile(path.join(appDir, 'src', 'modules.ts'), "export const enabledModules = [{ id: 'test_package', from: '@open-mercato/test-package' }]\n")
221
+ fs.rmSync(path.join(appDir, 'src', 'modules', 'test_package'), { recursive: true, force: true })
222
+ runMercato(['eject', 'test_package'], appDir)
223
+ expect(readFile(path.join(appDir, 'src', 'modules.ts'))).toContain("{ id: 'test_package', from: '@app' }")
224
+ } finally {
225
+ fs.rmSync(appDir, { recursive: true, force: true })
226
+ }
227
+ })
228
+ })
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { createDevEnvReloader, resolveDevEnvFilePaths } from '../dev-env-reload'
5
+
6
+ describe('dev env reload helpers', () => {
7
+ let appDir: string
8
+
9
+ beforeEach(() => {
10
+ appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-dev-env-'))
11
+ })
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(appDir, { recursive: true, force: true })
15
+ })
16
+
17
+ it('resolves app env files in low-to-high dev precedence order', () => {
18
+ expect(resolveDevEnvFilePaths('/tmp/app')).toEqual([
19
+ '/tmp/app/.env',
20
+ '/tmp/app/.env.development',
21
+ '/tmp/app/.env.local',
22
+ '/tmp/app/.env.development.local',
23
+ ])
24
+ })
25
+
26
+ it('reloads changed app env files without overriding shell-provided values', () => {
27
+ fs.writeFileSync(path.join(appDir, '.env'), [
28
+ 'APP_URL=http://env.example',
29
+ 'DATABASE_URL=postgres://env-database',
30
+ 'REMOVED_LATER=present',
31
+ ].join('\n'))
32
+ fs.writeFileSync(path.join(appDir, '.env.local'), [
33
+ 'APP_URL=http://local.example',
34
+ 'SHELL_VALUE=env-file-value',
35
+ ].join('\n'))
36
+
37
+ const environment: NodeJS.ProcessEnv = {
38
+ SHELL_VALUE: 'shell-value',
39
+ }
40
+ const reloader = createDevEnvReloader(appDir, environment, Object.entries(environment))
41
+
42
+ reloader.reload()
43
+
44
+ expect(environment.APP_URL).toBe('http://local.example')
45
+ expect(environment.DATABASE_URL).toBe('postgres://env-database')
46
+ expect(environment.SHELL_VALUE).toBe('shell-value')
47
+ expect(environment.REMOVED_LATER).toBe('present')
48
+
49
+ fs.writeFileSync(path.join(appDir, '.env'), [
50
+ 'APP_URL=http://env.example',
51
+ 'DATABASE_URL=postgres://changed-database',
52
+ ].join('\n'))
53
+ fs.rmSync(path.join(appDir, '.env.local'))
54
+
55
+ reloader.reload()
56
+
57
+ expect(environment.APP_URL).toBe('http://env.example')
58
+ expect(environment.DATABASE_URL).toBe('postgres://changed-database')
59
+ expect(environment.SHELL_VALUE).toBe('shell-value')
60
+ expect(environment.REMOVED_LATER).toBeUndefined()
61
+ })
62
+ })
@@ -0,0 +1,110 @@
1
+ import dotenv from 'dotenv'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const DEV_ENV_PRIORITY = [
6
+ '.env',
7
+ '.env.development',
8
+ '.env.local',
9
+ '.env.development.local',
10
+ ] as const
11
+
12
+ export type DevEnvReloader = {
13
+ reload: () => void
14
+ getWatchedFiles: () => string[]
15
+ }
16
+
17
+ export function resolveDevEnvFilePaths(appDir: string): string[] {
18
+ return DEV_ENV_PRIORITY.map((fileName) => path.join(appDir, fileName))
19
+ }
20
+
21
+ export function createDevEnvReloader(
22
+ appDir: string,
23
+ environment: NodeJS.ProcessEnv = process.env,
24
+ baseEnvironmentEntries: Iterable<[string, string | undefined]> = Object.entries(environment),
25
+ ): DevEnvReloader {
26
+ const baseEnvironment = new Map<string, string | undefined>(
27
+ Array.from(baseEnvironmentEntries, ([key, value]) => [key, value]),
28
+ )
29
+ const managedKeys = new Set<string>()
30
+ const envFilePaths = resolveDevEnvFilePaths(appDir)
31
+
32
+ const resetManagedKeys = () => {
33
+ for (const key of managedKeys) {
34
+ const baseValue = baseEnvironment.get(key)
35
+ if (baseValue === undefined) {
36
+ delete environment[key]
37
+ } else {
38
+ environment[key] = baseValue
39
+ }
40
+ }
41
+ managedKeys.clear()
42
+ }
43
+
44
+ const reload = () => {
45
+ resetManagedKeys()
46
+
47
+ for (const envFilePath of envFilePaths) {
48
+ if (!fs.existsSync(envFilePath)) continue
49
+
50
+ const parsed = dotenv.parse(fs.readFileSync(envFilePath))
51
+ for (const [key, value] of Object.entries(parsed)) {
52
+ if (baseEnvironment.has(key) && baseEnvironment.get(key) !== undefined) {
53
+ continue
54
+ }
55
+ environment[key] = value
56
+ managedKeys.add(key)
57
+ }
58
+ }
59
+ }
60
+
61
+ return {
62
+ reload,
63
+ getWatchedFiles: () => [...envFilePaths],
64
+ }
65
+ }
66
+
67
+ export function watchDevEnvFiles(
68
+ appDir: string,
69
+ onChange: (filePath: string) => void,
70
+ options: { debounceMs?: number } = {},
71
+ ): () => void {
72
+ const debounceMs = options.debounceMs ?? 250
73
+ const envFilePaths = resolveDevEnvFilePaths(appDir)
74
+ const timers = new Map<string, NodeJS.Timeout>()
75
+ const watchers = envFilePaths.map((envFilePath) => {
76
+ const watchDir = path.dirname(envFilePath)
77
+ const watchFileName = path.basename(envFilePath)
78
+
79
+ if (!fs.existsSync(watchDir)) return null
80
+
81
+ try {
82
+ return fs.watch(watchDir, (eventType, fileName) => {
83
+ if (eventType !== 'change' && eventType !== 'rename') return
84
+ if (String(fileName ?? '') !== watchFileName) return
85
+
86
+ const existingTimer = timers.get(envFilePath)
87
+ if (existingTimer) {
88
+ clearTimeout(existingTimer)
89
+ }
90
+
91
+ timers.set(envFilePath, setTimeout(() => {
92
+ timers.delete(envFilePath)
93
+ onChange(envFilePath)
94
+ }, debounceMs))
95
+ })
96
+ } catch {
97
+ return null
98
+ }
99
+ }).filter((watcher): watcher is fs.FSWatcher => watcher !== null)
100
+
101
+ return () => {
102
+ for (const timer of timers.values()) {
103
+ clearTimeout(timer)
104
+ }
105
+ timers.clear()
106
+ for (const watcher of watchers) {
107
+ watcher.close()
108
+ }
109
+ }
110
+ }
@@ -1383,6 +1383,7 @@ describe('all generated files are valid with varying subsets', () => {
1383
1383
  'search.generated.ts',
1384
1384
  'notifications.generated.ts',
1385
1385
  'ai-tools.generated.ts',
1386
+ 'ai-agents.generated.ts',
1386
1387
  'events.generated.ts',
1387
1388
  'analytics.generated.ts',
1388
1389
  'translations-fields.generated.ts',
@@ -1441,6 +1442,19 @@ describe('all generated files are valid with varying subsets', () => {
1441
1442
  expect(aiTools).not.toContain('no_ai')
1442
1443
  })
1443
1444
 
1445
+ it('ai-agents.generated.ts is empty when no module provides ai-agents.ts', async () => {
1446
+ scaffoldModule(tmpDir, 'no_ai_agents', 'pkg', ['setup.ts'])
1447
+ const resolver = createMockResolver(tmpDir, [
1448
+ { id: 'no_ai_agents', from: '@open-mercato/core' },
1449
+ ])
1450
+ await generateModuleRegistry({ resolver, quiet: true })
1451
+
1452
+ const aiAgents = readGenerated(tmpDir, 'ai-agents.generated.ts')!
1453
+ expect(aiAgents).toContain('export const aiAgentConfigEntries')
1454
+ expect(aiAgents).toContain('export const allAiAgents')
1455
+ expect(aiAgents).not.toContain('no_ai_agents')
1456
+ })
1457
+
1444
1458
  it('security generated registries are empty when no module provides security convention files', async () => {
1445
1459
  scaffoldModule(tmpDir, 'no_security', 'pkg', ['setup.ts'])
1446
1460
  touchFile(
@@ -370,6 +370,22 @@ export default aiTools
370
370
  `,
371
371
  )
372
372
 
373
+ // -- AI agents --
374
+ touchFile(
375
+ pkgModulePath('orders', 'ai-agents.ts'),
376
+ `export const aiAgents = [
377
+ {
378
+ id: 'orders.assistant',
379
+ module: 'orders',
380
+ displayName: 'Orders Assistant',
381
+ allowedTools: ['list_orders'],
382
+ readOnly: true,
383
+ },
384
+ ]
385
+ export default aiAgents
386
+ `,
387
+ )
388
+
373
389
  // -- Frontend middleware --
374
390
  touchFile(
375
391
  pkgModulePath('orders', 'frontend', 'middleware.ts'),
@@ -652,6 +668,7 @@ function captureGeneratedFiles(): Map<string, string> {
652
668
  describe('generator output compatibility', () => {
653
669
  const registryFiles = [
654
670
  'ai-tools.generated.ts',
671
+ 'ai-agents.generated.ts',
655
672
  'analytics.generated.ts',
656
673
  'api-routes.generated.ts',
657
674
  'backend-middleware.generated.ts',
@@ -380,7 +380,7 @@ describe('resolveModuleFile', () => {
380
380
  it('app override takes precedence for all convention files', () => {
381
381
  const conventionFiles = [
382
382
  'acl.ts', 'ce.ts', 'search.ts', 'notifications.ts',
383
- 'ai-tools.ts', 'events.ts', 'analytics.ts', 'setup.ts',
383
+ 'ai-tools.ts', 'ai-agents.ts', 'events.ts', 'analytics.ts', 'setup.ts',
384
384
  'translations.ts', 'security.mfa-providers.ts', 'security.sudo.ts',
385
385
  'data/extensions.ts', 'data/fields.ts',
386
386
  ]
@@ -209,6 +209,7 @@ function scaffoldFixture(): ModuleEntry[] {
209
209
  touchFile(pkgModulePath('orders', 'inbox-actions.ts'), `export const inboxActions = [\n { type: 'orders.approve', id: 'orders.approve-order', label: 'Approve Order', icon: 'check', description: 'Approve pending', async execute(a: any) { return { ok: true } } },\n]\nexport default inboxActions\n`)
210
210
  touchFile(pkgModulePath('orders', 'analytics.ts'), `export const analyticsConfig = {\n entities: [{ entityId: 'orders:sales_order', requiredFeatures: ['orders.view'], entityConfig: { tableName: 'sales_orders', dateField: 'created_at' }, fieldMappings: { id: { dbColumn: 'id', type: 'uuid' } } }],\n}\nexport default analyticsConfig\n`)
211
211
  touchFile(pkgModulePath('orders', 'ai-tools.ts'), `export const aiTools = [\n { name: 'list_orders', description: 'List recent orders', inputSchema: {}, requiredFeatures: ['orders.view'] },\n]\nexport default aiTools\n`)
212
+ touchFile(pkgModulePath('orders', 'ai-agents.ts'), `export const aiAgents = [\n { id: 'orders.assistant', module: 'orders', displayName: 'Orders Assistant', allowedTools: ['list_orders'], readOnly: true },\n]\nexport default aiAgents\n`)
212
213
  touchFile(pkgModulePath('orders', 'frontend', 'middleware.ts'), `export const middleware = [\n { id: 'orders.auth-check', pattern: '/orders/**', handler: async (req: any) => req },\n]\nexport default middleware\n`)
213
214
  touchFile(pkgModulePath('orders', 'backend', 'middleware.ts'), `export const middleware = [\n { id: 'orders.admin-check', pattern: '/backend/orders/**', handler: async (req: any) => req },\n]\nexport default middleware\n`)
214
215
  touchFile(pkgModulePath('orders', 'message-types.ts'), `export const messageTypes = [\n { type: 'orders.order_confirmation', module: 'orders', labelKey: 'orders.messages.confirmation.label', icon: 'mail', color: 'blue', allowReply: false, allowForward: true },\n]\nexport default messageTypes\n`)
@@ -937,6 +938,31 @@ describe('ai-tools.generated.ts', () => {
937
938
  })
938
939
  })
939
940
 
941
+ // ---------------------------------------------------------------------------
942
+ // ai-agents.generated.ts
943
+ // ---------------------------------------------------------------------------
944
+
945
+ describe('ai-agents.generated.ts', () => {
946
+ let content: string
947
+
948
+ beforeEach(async () => {
949
+ const enabled = scaffoldFixture()
950
+ const resolver = createMockResolver(enabled)
951
+ await generateModuleRegistry({ resolver, quiet: true })
952
+ content = readGenerated('ai-agents.generated.ts')
953
+ })
954
+
955
+ it('exports filtered entries and flattened allAiAgents', () => {
956
+ expect(content).toContain('export const aiAgentConfigEntries')
957
+ expect(content).toContain('export const allAiAgents')
958
+ })
959
+
960
+ it('has orders module entry with agents property', () => {
961
+ expectModuleIds(content, ['orders'])
962
+ expect(content).toContain('agents:')
963
+ })
964
+ })
965
+
940
966
  // ---------------------------------------------------------------------------
941
967
  // translations-fields.generated.ts
942
968
  // ---------------------------------------------------------------------------