@open-mercato/cli 0.6.3-develop.3894.1.352abf4240 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.3-develop.3894.1.352abf4240",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,21 +59,21 @@
59
59
  "@mikro-orm/decorators": "^7.1.1",
60
60
  "@mikro-orm/migrations": "^7.1.1",
61
61
  "@mikro-orm/postgresql": "^7.1.1",
62
- "@open-mercato/queue": "0.6.3-develop.3894.1.352abf4240",
63
- "@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240",
62
+ "@open-mercato/queue": "0.6.3",
63
+ "@open-mercato/shared": "0.6.3",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.1",
67
- "testcontainers": "^12.0.0",
67
+ "testcontainers": "^12.0.1",
68
68
  "ts-morph": "^28.0.0",
69
69
  "tsx": "^4.22.3",
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240"
73
+ "@open-mercato/shared": "0.6.3"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240",
76
+ "@open-mercato/shared": "0.6.3",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -85,6 +85,5 @@
85
85
  "type": "git",
86
86
  "url": "https://github.com/open-mercato/open-mercato",
87
87
  "directory": "packages/cli"
88
- },
89
- "stableVersion": "0.6.2"
88
+ }
90
89
  }
@@ -37,9 +37,18 @@ function buildMockChildProcessModule(routeAutoExit: MockChildSpawnRouter) {
37
37
  if (autoExit) {
38
38
  queueMicrotask(() => {
39
39
  if (child.exitCode !== null || child.signalCode !== null) return
40
- child.exitCode = autoExit.code
41
- child.signalCode = autoExit.signal ?? null
42
- child.emit('exit', child.exitCode, child.signalCode)
40
+ if (pathIncludes(spawnargs[1] ?? '', 'next/dist/bin/next') && spawnargs.includes('dev')) {
41
+ child.stdout.emit('data', '✓ Ready in 1ms\n')
42
+ }
43
+ queueMicrotask(() => {
44
+ child.exitCode = autoExit.code
45
+ child.signalCode = autoExit.signal ?? null
46
+ child.emit('exit', child.exitCode, child.signalCode)
47
+ })
48
+ })
49
+ } else if (pathIncludes(spawnargs[1] ?? '', 'next/dist/bin/next') && spawnargs.includes('dev')) {
50
+ queueMicrotask(() => {
51
+ child.stdout.emit('data', '✓ Ready in 1ms\n')
43
52
  })
44
53
  }
45
54
 
@@ -1049,6 +1058,7 @@ describe('server start managed process exits', () => {
1049
1058
  envChangeCallback = onChange
1050
1059
  return jest.fn()
1051
1060
  }),
1061
+ watchDevRuntimeFiles: jest.fn(() => jest.fn()),
1052
1062
  }))
1053
1063
  jest.doMock('../lib/generators', () => ({
1054
1064
  generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
- import { createDevEnvReloader, resolveDevEnvFilePaths } from '../dev-env-reload'
4
+ import { createDevEnvReloader, resolveDevEnvFilePaths, watchDevRuntimeFiles } from '../dev-env-reload'
5
5
  import { normalizeTestPath } from './path-helpers'
6
6
 
7
7
  describe('dev env reload helpers', () => {
@@ -60,4 +60,24 @@ describe('dev env reload helpers', () => {
60
60
  expect(environment.SHELL_VALUE).toBe('shell-value')
61
61
  expect(environment.REMOVED_LATER).toBeUndefined()
62
62
  })
63
+
64
+ it('watches generated runtime files when explicitly requested', async () => {
65
+ const generatedDir = path.join(appDir, '.mercato', 'generated')
66
+ fs.mkdirSync(generatedDir, { recursive: true })
67
+ const generatedFile = path.join(generatedDir, 'backend-routes.generated.ts')
68
+ fs.writeFileSync(generatedFile, 'export const routes = []\n')
69
+
70
+ let stop = () => {}
71
+ const seen = new Promise<string>((resolve) => {
72
+ stop = watchDevRuntimeFiles(appDir, (filePath) => {
73
+ stop()
74
+ resolve(filePath)
75
+ }, { debounceMs: 10 })
76
+ })
77
+
78
+ await new Promise((resolve) => setTimeout(resolve, 25))
79
+ fs.writeFileSync(generatedFile, 'export const routes = [1]\n')
80
+
81
+ await expect(seen.finally(stop)).resolves.toBe(generatedFile)
82
+ })
63
83
  })
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { calculateGenerateWatchStructureChecksum } from '../generate-watch-structure'
5
+
6
+ function write(filePath: string, content: string): void {
7
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
8
+ fs.writeFileSync(filePath, content)
9
+ }
10
+
11
+ describe('calculateGenerateWatchStructureChecksum', () => {
12
+ let root: string
13
+ let appDir: string
14
+ let pkgModule: string
15
+ let appModule: string
16
+
17
+ beforeEach(() => {
18
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'om-generate-watch-'))
19
+ appDir = path.join(root, 'apps', 'mercato')
20
+ pkgModule = path.join(root, 'packages', 'core', 'src', 'modules', 'customers')
21
+ appModule = path.join(appDir, 'src', 'modules', 'customers')
22
+ write(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
23
+ write(path.join(pkgModule, 'index.ts'), 'export const metadata = { id: "customers" }\n')
24
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.tsx'), 'export default function Page() { return null }\n')
25
+ write(path.join(pkgModule, 'components', 'detail', 'PersonDetailTabs.tsx'), 'export function PersonDetailTabs() { return null }\n')
26
+ })
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(root, { recursive: true, force: true })
30
+ })
31
+
32
+ function currentChecksum(): string {
33
+ return calculateGenerateWatchStructureChecksum({
34
+ modulesFile: path.join(appDir, 'src', 'modules.ts'),
35
+ moduleRoots: [{ appBase: appModule, pkgBase: pkgModule }],
36
+ })
37
+ }
38
+
39
+ it('ignores ordinary component edits outside generator discovery paths', () => {
40
+ const before = currentChecksum()
41
+
42
+ write(path.join(pkgModule, 'components', 'detail', 'PersonDetailTabs.tsx'), 'export function PersonDetailTabs() { return "changed" }\n')
43
+
44
+ expect(currentChecksum()).toBe(before)
45
+ })
46
+
47
+ it('changes when a discovered backend page is added', () => {
48
+ const before = currentChecksum()
49
+
50
+ write(path.join(pkgModule, 'backend', 'customers', 'companies', 'page.tsx'), 'export default function Page() { return null }\n')
51
+
52
+ expect(currentChecksum()).not.toBe(before)
53
+ })
54
+
55
+ it('changes when route metadata changes', () => {
56
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.meta.ts'), 'export const metadata = { nav: { label: "People" } }\n')
57
+ const before = currentChecksum()
58
+
59
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.meta.ts'), 'export const metadata = { nav: { label: "Contacts" } }\n')
60
+
61
+ expect(currentChecksum()).not.toBe(before)
62
+ })
63
+
64
+ it('changes when inline page metadata changes', () => {
65
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.tsx'), 'export const metadata = { nav: { label: "People" } }\nexport default function Page() { return null }\n')
66
+ const before = currentChecksum()
67
+
68
+ write(path.join(pkgModule, 'backend', 'customers', 'people', 'page.tsx'), 'export const metadata = { nav: { label: "Contacts" } }\nexport default function Page() { return null }\n')
69
+
70
+ expect(currentChecksum()).not.toBe(before)
71
+ })
72
+
73
+ it('changes when a convention file changes', () => {
74
+ const before = currentChecksum()
75
+
76
+ write(path.join(pkgModule, 'acl.ts'), 'export const features = [{ id: "customers.view" }]\n')
77
+
78
+ expect(currentChecksum()).not.toBe(before)
79
+ })
80
+ })
@@ -2,6 +2,8 @@ import dotenv from 'dotenv'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
 
5
+ const RUNTIME_GRAPH_FILE_PATTERN = /\.generated(?:\.[a-z0-9]+)?(?:\.tsx?|\.checksum)$/i
6
+
5
7
  const DEV_ENV_PRIORITY = [
6
8
  '.env',
7
9
  '.env.development',
@@ -108,3 +110,48 @@ export function watchDevEnvFiles(
108
110
  }
109
111
  }
110
112
  }
113
+
114
+ export function watchDevRuntimeFiles(
115
+ appDir: string,
116
+ onChange: (filePath: string) => void,
117
+ options: { debounceMs?: number } = {},
118
+ ): () => void {
119
+ const debounceMs = options.debounceMs ?? 250
120
+ const generatedDir = path.join(appDir, '.mercato', 'generated')
121
+ const timers = new Map<string, NodeJS.Timeout>()
122
+
123
+ if (!fs.existsSync(generatedDir)) {
124
+ return () => {}
125
+ }
126
+
127
+ let watcher: fs.FSWatcher | null = null
128
+ try {
129
+ watcher = fs.watch(generatedDir, (eventType, fileName) => {
130
+ if (eventType !== 'change' && eventType !== 'rename') return
131
+ const name = String(fileName ?? '')
132
+ if (!name) return
133
+ if (!RUNTIME_GRAPH_FILE_PATTERN.test(name)) return
134
+
135
+ const filePath = path.join(generatedDir, name)
136
+ const existingTimer = timers.get(filePath)
137
+ if (existingTimer) {
138
+ clearTimeout(existingTimer)
139
+ }
140
+
141
+ timers.set(filePath, setTimeout(() => {
142
+ timers.delete(filePath)
143
+ onChange(filePath)
144
+ }, debounceMs))
145
+ })
146
+ } catch {
147
+ return () => {}
148
+ }
149
+
150
+ return () => {
151
+ for (const timer of timers.values()) {
152
+ clearTimeout(timer)
153
+ }
154
+ timers.clear()
155
+ watcher?.close()
156
+ }
157
+ }
@@ -0,0 +1,170 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import {
5
+ MODULE_CODE_EXTENSIONS,
6
+ SCAN_CONFIGS,
7
+ scanModuleDir,
8
+ stripModuleCodeExtension,
9
+ type ModuleRoots,
10
+ } from './generators/scanner'
11
+
12
+ const STRUCTURAL_CONVENTION_FILES = [
13
+ 'index.ts',
14
+ 'cli.ts',
15
+ 'di.ts',
16
+ 'acl.ts',
17
+ 'setup.ts',
18
+ 'encryption.ts',
19
+ 'ce.ts',
20
+ 'search.ts',
21
+ 'events.ts',
22
+ 'notifications.ts',
23
+ 'notifications.client.ts',
24
+ 'notifications.handlers.ts',
25
+ 'translations.ts',
26
+ 'generators.ts',
27
+ 'ai-tools.ts',
28
+ 'ai-agents.ts',
29
+ 'analytics.ts',
30
+ 'workflows.ts',
31
+ 'inbox-actions.ts',
32
+ 'message-types.ts',
33
+ 'message-objects.ts',
34
+ 'integration.ts',
35
+ 'security.mfa-providers.ts',
36
+ 'security.sudo.ts',
37
+ 'data/entities.ts',
38
+ 'data/extensions.ts',
39
+ 'data/fields.ts',
40
+ 'data/enrichers.ts',
41
+ 'data/guards.ts',
42
+ 'api/interceptors.ts',
43
+ 'commands/interceptors.ts',
44
+ 'widgets/components.ts',
45
+ 'widgets/injection-table.ts',
46
+ 'frontend/middleware.ts',
47
+ 'backend/middleware.ts',
48
+ ] as const
49
+
50
+ const CONTENT_SENSITIVE_SCAN_CONFIGS = [
51
+ SCAN_CONFIGS.apiRoutes,
52
+ SCAN_CONFIGS.apiPlainFiles,
53
+ SCAN_CONFIGS.subscribers,
54
+ SCAN_CONFIGS.workers,
55
+ SCAN_CONFIGS.dashboardWidgets,
56
+ SCAN_CONFIGS.injectionWidgets,
57
+ ] as const
58
+
59
+ const ROUTE_SHAPE_SCAN_CONFIGS = [
60
+ SCAN_CONFIGS.frontendPages,
61
+ SCAN_CONFIGS.backendPages,
62
+ ] as const
63
+
64
+ function checksum(value: string): string {
65
+ return crypto.createHash('md5').update(value).digest('hex')
66
+ }
67
+
68
+ function fileRecord(filePath: string, base: string, mode: 'content' | 'shape'): string | null {
69
+ if (!fs.existsSync(filePath)) return null
70
+ let stat: fs.Stats
71
+ try {
72
+ stat = fs.statSync(filePath)
73
+ } catch {
74
+ return null
75
+ }
76
+ if (!stat.isFile()) return null
77
+ const rel = path.relative(base, filePath).replace(/\\/g, '/')
78
+ if (mode === 'shape') {
79
+ return `file:${rel}`
80
+ }
81
+ try {
82
+ return `file:${rel}:${stat.size}:${checksum(fs.readFileSync(filePath, 'utf8'))}`
83
+ } catch {
84
+ return `file:${rel}:unreadable`
85
+ }
86
+ }
87
+
88
+ function resolveCodeFile(base: string, relativePath: string): string | null {
89
+ const stripped = stripModuleCodeExtension(relativePath)
90
+ const candidates = MODULE_CODE_EXTENSIONS.map((extension) => `${stripped}${extension}`)
91
+ for (const candidate of candidates) {
92
+ const filePath = path.join(base, ...candidate.split('/'))
93
+ if (fs.existsSync(filePath)) return filePath
94
+ }
95
+ return null
96
+ }
97
+
98
+ function hasInlinePageMetadata(filePath: string): boolean {
99
+ try {
100
+ const source = fs.readFileSync(filePath, 'utf8')
101
+ return /\bexport\s+(?:const|let|var|function|class)\s+metadata\b/.test(source)
102
+ || /\bexport\s+\{[^}]*\bmetadata\b[^}]*\}/.test(source)
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ function addConventionRecords(records: string[], roots: ModuleRoots): void {
109
+ for (const base of [roots.pkgBase, roots.appBase]) {
110
+ records.push(`module-root:${base}:${fs.existsSync(base) ? 'present' : 'missing'}`)
111
+ for (const relativePath of STRUCTURAL_CONVENTION_FILES) {
112
+ const filePath = resolveCodeFile(base, relativePath)
113
+ if (!filePath) {
114
+ records.push(`missing:${base}:${relativePath}`)
115
+ continue
116
+ }
117
+ const record = fileRecord(filePath, base, 'content')
118
+ if (record) records.push(record)
119
+ }
120
+ }
121
+ }
122
+
123
+ function addScannedRecords(records: string[], roots: ModuleRoots): void {
124
+ for (const config of CONTENT_SENSITIVE_SCAN_CONFIGS) {
125
+ for (const scanned of scanModuleDir(roots, config)) {
126
+ const base = scanned.fromApp ? roots.appBase : roots.pkgBase
127
+ const filePath = path.join(base, ...config.folder.split('/'), ...scanned.relPath.split('/'))
128
+ const record = fileRecord(filePath, base, 'content')
129
+ if (record) records.push(`${config.folder}:${record}`)
130
+ }
131
+ }
132
+
133
+ for (const config of ROUTE_SHAPE_SCAN_CONFIGS) {
134
+ for (const scanned of scanModuleDir(roots, config)) {
135
+ const base = scanned.fromApp ? roots.appBase : roots.pkgBase
136
+ const folderPath = path.join(base, ...config.folder.split('/'))
137
+ const filePath = path.join(folderPath, ...scanned.relPath.split('/'))
138
+ const pageRecord = fileRecord(filePath, base, hasInlinePageMetadata(filePath) ? 'content' : 'shape')
139
+ if (pageRecord) records.push(`${config.folder}:${pageRecord}`)
140
+
141
+ const dir = path.dirname(filePath)
142
+ const stem = stripModuleCodeExtension(path.basename(filePath))
143
+ const metaCandidates = stem === 'page'
144
+ ? ['page.meta', 'meta']
145
+ : [`${stem}.meta`, 'meta']
146
+ for (const candidate of metaCandidates) {
147
+ const metaPath = resolveCodeFile(dir, candidate)
148
+ if (!metaPath) continue
149
+ const record = fileRecord(metaPath, base, 'content')
150
+ if (record) records.push(`${config.folder}:meta:${record}`)
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ export function calculateGenerateWatchStructureChecksum(options: {
157
+ modulesFile: string
158
+ moduleRoots: ModuleRoots[]
159
+ }): string {
160
+ const records: string[] = []
161
+ const modulesRecord = fileRecord(options.modulesFile, path.dirname(options.modulesFile), 'content')
162
+ records.push(modulesRecord ?? `missing:${options.modulesFile}`)
163
+
164
+ for (const roots of options.moduleRoots) {
165
+ addConventionRecords(records, roots)
166
+ addScannedRecords(records, roots)
167
+ }
168
+
169
+ return checksum(records.sort((a, b) => a.localeCompare(b)).join('\n'))
170
+ }
@@ -2942,6 +2942,20 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
2942
2942
  TENANT_DATA_ENCRYPTION_FALLBACK_KEY: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? 'om-ephemeral-integration-fallback-key',
2943
2943
  AUTO_SPAWN_WORKERS: process.env.AUTO_SPAWN_WORKERS ?? 'true',
2944
2944
  AUTO_SPAWN_SCHEDULER: 'false',
2945
+ // Hide the demo feedback floating action button — it lives at
2946
+ // `fixed bottom-6 right-6 z-banner` and consistently intercepts pointer
2947
+ // events on row-action menus and other bottom-of-viewport UI elements
2948
+ // (e.g. TC-WF-006 Delete menuitem click). The widget is already gated
2949
+ // on `DEMO_MODE !== 'false'` in the backend layout, so opting out here
2950
+ // only affects the integration test runtime — dev + prod stay unchanged.
2951
+ DEMO_MODE: 'false',
2952
+ // Disable the feature-toggle resolution cache. Tests flip overrides
2953
+ // (e.g. example_customers_sync enabled/bidirectional) between cases; the
2954
+ // 1-minute resolution cache can serve a stale value across set/clear
2955
+ // under fast churn, which made the example.todo.* persistent subscribers
2956
+ // skip enqueueing inbound sync jobs (TC-CRM-028 inbound trio flake).
2957
+ // Fresh DB reads make flag-gated sync deterministic in tests only.
2958
+ OM_FEATURE_TOGGLES_CACHE_DISABLED: '1',
2945
2959
  OM_CLI_QUIET: '1',
2946
2960
  MERCATO_QUIET: '1',
2947
2961
  QUEUE_BASE_DIR: EPHEMERAL_QUEUE_BASE_DIR,