@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/agentic/shared/AGENTS.md.template +1 -1
- package/dist/lib/__integration__/TC-INT-007.spec.js +201 -0
- package/dist/lib/__integration__/TC-INT-007.spec.js.map +7 -0
- package/dist/lib/dev-env-reload.js +89 -0
- package/dist/lib/dev-env-reload.js.map +7 -0
- package/dist/lib/generators/extensions/ai-agents.js +218 -0
- package/dist/lib/generators/extensions/ai-agents.js.map +7 -0
- package/dist/lib/generators/extensions/ai-tools.js +56 -1
- package/dist/lib/generators/extensions/ai-tools.js.map +2 -2
- package/dist/lib/generators/extensions/index.js +2 -0
- package/dist/lib/generators/extensions/index.js.map +2 -2
- package/dist/lib/testing/integration-discovery.js +102 -5
- package/dist/lib/testing/integration-discovery.js.map +2 -2
- package/dist/mercato.js +85 -44
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/__tests__/mercato.test.ts +112 -0
- package/src/lib/__integration__/TC-INT-007.spec.ts +228 -0
- package/src/lib/__tests__/dev-env-reload.test.ts +62 -0
- package/src/lib/dev-env-reload.ts +110 -0
- package/src/lib/generators/__tests__/module-subset.test.ts +14 -0
- package/src/lib/generators/__tests__/output-snapshots.test.ts +17 -0
- package/src/lib/generators/__tests__/scanner.test.ts +1 -1
- package/src/lib/generators/__tests__/structural-contracts.test.ts +26 -0
- package/src/lib/generators/extensions/ai-agents.ts +240 -0
- package/src/lib/generators/extensions/ai-tools.ts +72 -1
- package/src/lib/generators/extensions/index.ts +2 -0
- package/src/lib/testing/__tests__/integration-discovery.test.ts +68 -0
- package/src/lib/testing/integration-discovery.ts +127 -3
- 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.
|
|
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.
|
|
63
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
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.
|
|
73
|
+
"@open-mercato/shared": "0.5.1-develop.3045.b4b3320cc2"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
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
|
// ---------------------------------------------------------------------------
|