@open-mercato/cli 0.6.7-develop.6828.1.ab1620a63e → 0.6.7-develop.6834.1.e76f4b8cbc

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 (73) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/agentic/guides/module-facts.json +110 -110
  3. package/dist/agentic/guides/modules/ai_assistant.md +1 -1
  4. package/dist/agentic/guides/modules/api_docs.md +1 -1
  5. package/dist/agentic/guides/modules/api_keys.md +1 -1
  6. package/dist/agentic/guides/modules/attachments.md +1 -1
  7. package/dist/agentic/guides/modules/audit_logs.md +1 -1
  8. package/dist/agentic/guides/modules/auth.md +1 -1
  9. package/dist/agentic/guides/modules/business_rules.md +1 -1
  10. package/dist/agentic/guides/modules/catalog.md +1 -1
  11. package/dist/agentic/guides/modules/channel_gmail.md +1 -1
  12. package/dist/agentic/guides/modules/channel_imap.md +1 -1
  13. package/dist/agentic/guides/modules/checkout.md +1 -1
  14. package/dist/agentic/guides/modules/communication_channels.md +1 -1
  15. package/dist/agentic/guides/modules/configs.md +1 -1
  16. package/dist/agentic/guides/modules/content.md +1 -1
  17. package/dist/agentic/guides/modules/currencies.md +1 -1
  18. package/dist/agentic/guides/modules/customer_accounts.md +1 -1
  19. package/dist/agentic/guides/modules/customers.md +1 -1
  20. package/dist/agentic/guides/modules/dashboards.md +1 -1
  21. package/dist/agentic/guides/modules/data_sync.md +1 -1
  22. package/dist/agentic/guides/modules/design_system.md +1 -1
  23. package/dist/agentic/guides/modules/dictionaries.md +1 -1
  24. package/dist/agentic/guides/modules/directory.md +1 -1
  25. package/dist/agentic/guides/modules/entities.md +1 -1
  26. package/dist/agentic/guides/modules/events.md +1 -1
  27. package/dist/agentic/guides/modules/feature_toggles.md +1 -1
  28. package/dist/agentic/guides/modules/gateway_stripe.md +1 -1
  29. package/dist/agentic/guides/modules/generators.md +1 -1
  30. package/dist/agentic/guides/modules/inbox_ops.md +1 -1
  31. package/dist/agentic/guides/modules/integrations.md +1 -1
  32. package/dist/agentic/guides/modules/messages.md +1 -1
  33. package/dist/agentic/guides/modules/notifications.md +1 -1
  34. package/dist/agentic/guides/modules/onboarding.md +1 -1
  35. package/dist/agentic/guides/modules/payment_gateways.md +1 -1
  36. package/dist/agentic/guides/modules/perspectives.md +1 -1
  37. package/dist/agentic/guides/modules/planner.md +1 -1
  38. package/dist/agentic/guides/modules/portal.md +1 -1
  39. package/dist/agentic/guides/modules/progress.md +1 -1
  40. package/dist/agentic/guides/modules/query_index.md +1 -1
  41. package/dist/agentic/guides/modules/record_locks.md +1 -1
  42. package/dist/agentic/guides/modules/resources.md +1 -1
  43. package/dist/agentic/guides/modules/sales.md +1 -1
  44. package/dist/agentic/guides/modules/scheduler.md +1 -1
  45. package/dist/agentic/guides/modules/search.md +1 -1
  46. package/dist/agentic/guides/modules/security.md +1 -1
  47. package/dist/agentic/guides/modules/shipping_carriers.md +1 -1
  48. package/dist/agentic/guides/modules/sso.md +1 -1
  49. package/dist/agentic/guides/modules/staff.md +1 -1
  50. package/dist/agentic/guides/modules/storage_s3.md +1 -1
  51. package/dist/agentic/guides/modules/sync_akeneo.md +1 -1
  52. package/dist/agentic/guides/modules/sync_excel.md +1 -1
  53. package/dist/agentic/guides/modules/system_status_overlays.md +1 -1
  54. package/dist/agentic/guides/modules/translations.md +1 -1
  55. package/dist/agentic/guides/modules/webhooks.md +1 -1
  56. package/dist/agentic/guides/modules/wms.md +1 -1
  57. package/dist/agentic/guides/modules/workflows.md +1 -1
  58. package/dist/agentic/guides/upstream/manifest.json +1 -1
  59. package/dist/bin.js +13 -1
  60. package/dist/bin.js.map +2 -2
  61. package/dist/lib/load-env.js +24 -0
  62. package/dist/lib/load-env.js.map +7 -0
  63. package/dist/lib/telemetry-init.js +358 -0
  64. package/dist/lib/telemetry-init.js.map +7 -0
  65. package/dist/mercato.js +15 -1
  66. package/dist/mercato.js.map +2 -2
  67. package/package.json +6 -5
  68. package/src/bin.ts +33 -1
  69. package/src/lib/__tests__/load-env.test.ts +65 -0
  70. package/src/lib/__tests__/telemetry-init.test.ts +298 -0
  71. package/src/lib/load-env.ts +30 -0
  72. package/src/lib/telemetry-init.ts +461 -0
  73. package/src/mercato.ts +18 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.7-develop.6828.1.ab1620a63e",
3
+ "version": "0.6.7-develop.6834.1.e76f4b8cbc",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,9 @@
60
60
  "@mikro-orm/decorators": "^7.1.8",
61
61
  "@mikro-orm/migrations": "^7.1.8",
62
62
  "@mikro-orm/postgresql": "^7.1.8",
63
- "@open-mercato/queue": "0.6.7-develop.6828.1.ab1620a63e",
64
- "@open-mercato/shared": "0.6.7-develop.6828.1.ab1620a63e",
63
+ "@open-mercato/queue": "0.6.7-develop.6834.1.e76f4b8cbc",
64
+ "@open-mercato/shared": "0.6.7-develop.6834.1.e76f4b8cbc",
65
+ "@open-mercato/telemetry": "0.6.7-develop.6834.1.e76f4b8cbc",
65
66
  "cross-spawn": "^7.0.6",
66
67
  "pg": "8.22.0",
67
68
  "semver": "^7.8.5",
@@ -72,10 +73,10 @@
72
73
  "typescript-js": "npm:typescript@6.0.3"
73
74
  },
74
75
  "peerDependencies": {
75
- "@open-mercato/shared": "0.6.7-develop.6828.1.ab1620a63e"
76
+ "@open-mercato/shared": "0.6.7-develop.6834.1.e76f4b8cbc"
76
77
  },
77
78
  "devDependencies": {
78
- "@open-mercato/shared": "0.6.7-develop.6828.1.ab1620a63e",
79
+ "@open-mercato/shared": "0.6.7-develop.6834.1.e76f4b8cbc",
79
80
  "@types/jest": "^30.0.0",
80
81
  "jest": "^30.4.2",
81
82
  "ts-jest": "^29.4.12"
package/src/bin.ts CHANGED
@@ -4,7 +4,12 @@
4
4
  * Called from within a Next.js app directory as: yarn mercato <command>
5
5
  * Uses dynamic app resolution to find generated files at .mercato/generated/
6
6
  */
7
- import { run } from './mercato.js'
7
+ import {
8
+ getTelemetryRuntime,
9
+ isTelemetryBackendEnabled,
10
+ } from '@open-mercato/shared/lib/telemetry/runtime'
11
+ // `run` is imported dynamically inside `main()` so telemetry can initialize
12
+ // before the mercato entry (and its Postgres driver) loads — see main().
8
13
 
9
14
  // Commands that can run without bootstrap (without generated files)
10
15
  // - generate: creates the generated files
@@ -18,6 +23,7 @@ const BOOTSTRAP_FREE_COMMANDS = [
18
23
  'db',
19
24
  'init',
20
25
  'agentic:init',
26
+ 'telemetry',
21
27
  'eject',
22
28
  'test',
23
29
  'test:integration',
@@ -85,6 +91,26 @@ async function main(): Promise<void> {
85
91
  const requiresBootstrap = needsBootstrap(process.argv)
86
92
 
87
93
  if (requiresBootstrap) {
94
+ // Load the app's `.env` BEFORE initTelemetry(): `run()` only dotenv-loads it
95
+ // later (ensureEnvLoaded), which is too late — TELEMETRY_BACKEND set only in
96
+ // `.env` would silently resolve to `noop` for worker/scheduler processes.
97
+ // The loader touches only the resolver + dotenv (no `pg`), preserving the
98
+ // instrumentation load-order guarantee below.
99
+ const { loadAppEnv } = await import('./lib/load-env.js')
100
+ await loadAppEnv()
101
+
102
+ // Initialize telemetry BEFORE bootstrapping the app graph. Bootstrap and the
103
+ // per-command handlers load MikroORM's Postgres driver → `pg`, and the
104
+ // OpenTelemetry pg/undici auto-instrumentation only records spans for a
105
+ // driver required AFTER the SDK has started. Registering here — ahead of any
106
+ // app module — is what lets long-running worker/scheduler processes emit DB
107
+ // spans (not just the bullmq-otel add/process envelope). The package itself
108
+ // is not imported unless an explicit supported backend is selected.
109
+ if (isTelemetryBackendEnabled()) {
110
+ const { initTelemetry } = await import('@open-mercato/telemetry')
111
+ await initTelemetry()
112
+ }
113
+
88
114
  const bootstrapSucceeded = await tryBootstrap()
89
115
  if (!bootstrapSucceeded) {
90
116
  console.error('╔═══════════════════════════════════════════════════════════════════╗')
@@ -100,7 +126,13 @@ async function main(): Promise<void> {
100
126
  }
101
127
  }
102
128
 
129
+ // Dynamic import (not a top-level static import) so the mercato entry — and the
130
+ // Postgres driver it pulls in — loads only after initTelemetry() above.
131
+ const { run } = await import('./mercato.js')
103
132
  const code = await run(process.argv)
133
+ // Flush spans/logs for commands that return (workers block forever and flush via
134
+ // their own shutdown handler instead).
135
+ await getTelemetryRuntime()?.shutdown()
104
136
  process.exit(code ?? 0)
105
137
  }
106
138
 
@@ -0,0 +1,65 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import { loadAppEnv } from '../load-env'
5
+
6
+ /**
7
+ * Regression: bin.ts must load the app's `.env` before initTelemetry(), so
8
+ * TELEMETRY_BACKEND set only in `.env` reaches worker and scheduler processes
9
+ * (ensureEnvLoaded in run() happens after init — too late).
10
+ */
11
+
12
+ describe('loadAppEnv', () => {
13
+ const TEST_KEY = 'OM_LOAD_ENV_TEST_BACKEND'
14
+ let tmpDir: string
15
+ let cwd: string
16
+
17
+ beforeEach(() => {
18
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'load-env-'))
19
+ cwd = process.cwd()
20
+ process.chdir(tmpDir)
21
+ delete process.env[TEST_KEY]
22
+ })
23
+
24
+ afterEach(() => {
25
+ process.chdir(cwd)
26
+ delete process.env[TEST_KEY]
27
+ fs.rmSync(tmpDir, { recursive: true, force: true })
28
+ })
29
+
30
+ it('loads .env from the app directory into process.env', async () => {
31
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"env-test-app"}\n')
32
+ fs.mkdirSync(path.join(tmpDir, 'src'))
33
+ fs.writeFileSync(path.join(tmpDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
34
+ fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=console\n`)
35
+
36
+ await loadAppEnv()
37
+
38
+ expect(process.env[TEST_KEY]).toBe('console')
39
+ })
40
+
41
+ it('does not override values already set in the real process env', async () => {
42
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"env-test-app"}\n')
43
+ fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=from-dotenv\n`)
44
+ process.env[TEST_KEY] = 'from-shell'
45
+
46
+ await loadAppEnv()
47
+
48
+ expect(process.env[TEST_KEY]).toBe('from-shell')
49
+ })
50
+
51
+ it('is a safe no-op when no .env exists', async () => {
52
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"env-test-app"}\n')
53
+ await expect(loadAppEnv()).resolves.toBeUndefined()
54
+ expect(process.env[TEST_KEY]).toBeUndefined()
55
+ })
56
+
57
+ it('bin.ts awaits loadAppEnv() before initTelemetry() (the guarded ordering)', () => {
58
+ const binSource = fs.readFileSync(path.resolve(__dirname, '../../bin.ts'), 'utf8')
59
+ const loadEnvCall = binSource.indexOf('await loadAppEnv()')
60
+ const initTelemetryCall = binSource.indexOf('await initTelemetry()')
61
+ expect(loadEnvCall).toBeGreaterThan(-1)
62
+ expect(initTelemetryCall).toBeGreaterThan(-1)
63
+ expect(loadEnvCall).toBeLessThan(initTelemetryCall)
64
+ })
65
+ })
@@ -0,0 +1,298 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import * as ts from 'typescript'
5
+ import { runTelemetryInit } from '../telemetry-init'
6
+
7
+ const TEMPLATE_DIR = path.join(__dirname, '../../../../../packages/create-app/template')
8
+ const TEMPLATE_DISPATCHER = path.join(TEMPLATE_DIR, 'src/app/api/[...slug]/route.ts')
9
+ const TEMPLATE_NEXT_CONFIG = path.join(TEMPLATE_DIR, 'next.config.ts')
10
+ const TEMPLATE_INSTRUMENTATION = path.join(TEMPLATE_DIR, 'src/instrumentation.ts')
11
+
12
+ // The telemetry statements the shipped scaffold wires into the dispatcher — the
13
+ // oracle for "did the command reproduce the real wiring?". Read from the live
14
+ // template so this test fails loudly if the scaffold's wiring changes and the
15
+ // command doesn't keep up.
16
+ const TELEMETRY_DISPATCHER_LINES = [
17
+ "import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'",
18
+ 'getTelemetryRuntime()?.recordHttpDuration(method, match.route.path, finalResponse.status, startedAt)',
19
+ 'getTelemetryRuntime()?.recordHttpDuration(method, match.route.path, 500, startedAt)',
20
+ 'getTelemetryRuntime()?.reportError(error, {',
21
+ ]
22
+
23
+ /** Assert a string is syntactically valid TypeScript (parse errors, not types). */
24
+ function assertParses(code: string, label: string): void {
25
+ const sf = ts.createSourceFile(`${label}.ts`, code, ts.ScriptTarget.Latest, true)
26
+ const diagnostics = (sf as unknown as { parseDiagnostics?: ts.Diagnostic[] }).parseDiagnostics ?? []
27
+ const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
28
+ expect({ label, messages }).toEqual({ label, messages: [] })
29
+ }
30
+
31
+ /** Turn the live wired template dispatcher back into a pre-telemetry one. */
32
+ function stripTelemetry(src: string): string {
33
+ return src
34
+ .replace(/\n[ \t]*getTelemetryRuntime\(\)\?\.reportError\(error, \{[\s\S]*?\n[ \t]*\}\)/, '')
35
+ .split('\n')
36
+ .filter(
37
+ (line) =>
38
+ !line.includes("from '@open-mercato/telemetry'") &&
39
+ !line.includes("from '@open-mercato/telemetry/nextjs'") &&
40
+ !line.includes("from '@open-mercato/shared/lib/telemetry/runtime'") &&
41
+ !line.includes('recordHttpDuration('),
42
+ )
43
+ .join('\n')
44
+ }
45
+
46
+ const NEXT_CONFIG = `import type { NextConfig } from 'next'
47
+
48
+ const nextConfig: NextConfig = {
49
+ serverExternalPackages: [
50
+ 'esbuild',
51
+ ],
52
+ }
53
+
54
+ export default nextConfig
55
+ `
56
+
57
+ function baseFixture(dir: string): void {
58
+ fs.mkdirSync(path.join(dir, 'src', 'app', 'api', '[...slug]'), { recursive: true })
59
+ fs.writeFileSync(path.join(dir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
60
+ fs.writeFileSync(
61
+ path.join(dir, 'package.json'),
62
+ JSON.stringify({ name: 'my-app', dependencies: { '@open-mercato/shared': '1.2.3' } }, null, 2) + '\n',
63
+ )
64
+ fs.writeFileSync(path.join(dir, '.env.example'), 'DATABASE_URL=postgres://localhost/app\n')
65
+ fs.writeFileSync(path.join(dir, 'next.config.ts'), NEXT_CONFIG)
66
+ }
67
+
68
+ const SIMPLE_DISPATCHER = `import { NextResponse, type NextRequest } from 'next/server'
69
+ import { findApiRouteManifestMatch } from '@open-mercato/shared/modules/registry'
70
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
71
+
72
+ async function handleRequest(method: string, req: NextRequest): Promise<Response> {
73
+ const startedAt = Date.now()
74
+ const match = { route: { path: '/api/thing' } }
75
+ try {
76
+ const finalResponse = NextResponse.json({ ok: true })
77
+ return finalResponse
78
+ } catch (error) {
79
+ throw error
80
+ }
81
+ }
82
+ `
83
+
84
+ describe('mercato telemetry init', () => {
85
+ let tmpDir: string
86
+ let cwd: string
87
+ let logSpy: jest.SpyInstance
88
+ let errorSpy: jest.SpyInstance
89
+
90
+ beforeEach(() => {
91
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-init-'))
92
+ cwd = process.cwd()
93
+ process.chdir(tmpDir)
94
+ logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
95
+ errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
96
+ })
97
+
98
+ afterEach(() => {
99
+ process.chdir(cwd)
100
+ logSpy.mockRestore()
101
+ errorSpy.mockRestore()
102
+ fs.rmSync(tmpDir, { recursive: true, force: true })
103
+ })
104
+
105
+ const read = (rel: string) => fs.readFileSync(path.join(tmpDir, rel), 'utf8')
106
+ const dispatcherPath = 'src/app/api/[...slug]/route.ts'
107
+
108
+ it('rejects a directory that is not an Open Mercato app', async () => {
109
+ const code = await runTelemetryInit([])
110
+ expect(code).toBe(1)
111
+ expect(errorSpy).toHaveBeenCalled()
112
+ })
113
+
114
+ it('wires all files in a pre-telemetry app', async () => {
115
+ baseFixture(tmpDir)
116
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
117
+ const code = await runTelemetryInit([])
118
+ expect(code).toBe(0)
119
+
120
+ const pkg = JSON.parse(read('package.json'))
121
+ expect(pkg.dependencies['@open-mercato/telemetry']).toBe('1.2.3')
122
+ expect(pkg.optionalDependencies['bullmq-otel']).toBe('^1.3.1')
123
+
124
+ expect(read('.env.example')).toContain('TELEMETRY_BACKEND')
125
+ expect(read('.env.example')).toContain('OTEL_EXPORTER_OTLP_ENDPOINT')
126
+ expect(read('src/instrumentation.ts')).toContain('registerTelemetryForNextjs')
127
+
128
+ const nextConfig = read('next.config.ts')
129
+ expect(nextConfig).toContain('@open-mercato/telemetry/nextjs-config')
130
+ expect(nextConfig).toContain('...telemetryServerExternalPackages')
131
+
132
+ const route = read(dispatcherPath)
133
+ for (const line of TELEMETRY_DISPATCHER_LINES) expect(route).toContain(line)
134
+ })
135
+
136
+ it('every patched file is syntactically valid TypeScript', async () => {
137
+ baseFixture(tmpDir)
138
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
139
+ await runTelemetryInit([])
140
+ assertParses(read('next.config.ts'), 'next.config')
141
+ assertParses(read('src/instrumentation.ts'), 'instrumentation')
142
+ assertParses(read(dispatcherPath), 'dispatcher')
143
+ JSON.parse(read('package.json')) // valid JSON
144
+ })
145
+
146
+ it('reproduces the live template dispatcher wiring (round-trip)', async () => {
147
+ baseFixture(tmpDir)
148
+ const wired = fs.readFileSync(TEMPLATE_DISPATCHER, 'utf8')
149
+ const preTelemetry = stripTelemetry(wired)
150
+ // sanity: the derived pre-telemetry file no longer imports telemetry but still parses
151
+ expect(preTelemetry).not.toContain('@open-mercato/telemetry')
152
+ assertParses(preTelemetry, 'stripped-template')
153
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), preTelemetry)
154
+
155
+ await runTelemetryInit([])
156
+
157
+ const patched = read(dispatcherPath)
158
+ assertParses(patched, 'patched-template')
159
+ for (const line of TELEMETRY_DISPATCHER_LINES) expect(patched).toContain(line)
160
+ // the success metric lands before the response return, the 500 funnel in the catch
161
+ expect(patched.indexOf('finalResponse.status, startedAt')).toBeLessThan(patched.indexOf('return finalResponse'))
162
+ expect(patched.indexOf('reportError(error, {')).toBeLessThan(patched.indexOf(', 500, startedAt'))
163
+ })
164
+
165
+ it('is a no-op on the real (already-wired) template files', async () => {
166
+ baseFixture(tmpDir)
167
+ fs.copyFileSync(TEMPLATE_DISPATCHER, path.join(tmpDir, dispatcherPath))
168
+ fs.copyFileSync(TEMPLATE_NEXT_CONFIG, path.join(tmpDir, 'next.config.ts'))
169
+ fs.copyFileSync(TEMPLATE_INSTRUMENTATION, path.join(tmpDir, 'src', 'instrumentation.ts'))
170
+ const before = {
171
+ route: read(dispatcherPath),
172
+ nextConfig: read('next.config.ts'),
173
+ instrumentation: read('src/instrumentation.ts'),
174
+ }
175
+ await runTelemetryInit([])
176
+ expect(read(dispatcherPath)).toBe(before.route)
177
+ expect(read('next.config.ts')).toBe(before.nextConfig)
178
+ expect(read('src/instrumentation.ts')).toBe(before.instrumentation)
179
+ })
180
+
181
+ it('patches a recognizable-but-modified dispatcher (extra imports/code, anchors intact)', async () => {
182
+ baseFixture(tmpDir)
183
+ const modified = SIMPLE_DISPATCHER.replace(
184
+ "import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'",
185
+ "import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { auditLog } from '@/lib/audit'",
186
+ ).replace('const match = { route: { path: \'/api/thing\' } }', 'const match = { route: { path: \'/api/thing\' } }\n auditLog(method)')
187
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), modified)
188
+
189
+ await runTelemetryInit([])
190
+ const patched = read(dispatcherPath)
191
+ assertParses(patched, 'modified-dispatcher')
192
+ for (const line of TELEMETRY_DISPATCHER_LINES) expect(patched).toContain(line)
193
+ expect(patched).toContain("import { auditLog } from '@/lib/audit'") // preserved custom code
194
+ })
195
+
196
+ it('leaves an unrecognizable dispatcher untouched and prints the manual snippet', async () => {
197
+ baseFixture(tmpDir)
198
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), `export async function GET() { return new Response('custom') }\n`)
199
+ await runTelemetryInit([])
200
+ const route = read(dispatcherPath)
201
+ expect(route).not.toContain('@open-mercato/telemetry')
202
+ expect(route).toBe(`export async function GET() { return new Response('custom') }\n`)
203
+ const printed = logSpy.mock.calls.flat().join('\n')
204
+ expect(printed).toContain('manual step for src/app/api/[...slug]/route.ts')
205
+ })
206
+
207
+ it('handles next.config variations (single-line array, no trailing comma) and stays valid', async () => {
208
+ baseFixture(tmpDir)
209
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
210
+ fs.writeFileSync(
211
+ path.join(tmpDir, 'next.config.ts'),
212
+ `import type { NextConfig } from 'next'\nconst nextConfig: NextConfig = { serverExternalPackages: ['esbuild'] }\nexport default nextConfig\n`,
213
+ )
214
+ await runTelemetryInit([])
215
+ const nextConfig = read('next.config.ts')
216
+ assertParses(nextConfig, 'single-line-next-config')
217
+ expect(nextConfig).toContain('...telemetryServerExternalPackages')
218
+ expect(nextConfig).toContain('@open-mercato/telemetry/nextjs-config')
219
+ })
220
+
221
+ it('migrates the legacy nextjs import before adding the missing spread', async () => {
222
+ baseFixture(tmpDir)
223
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
224
+ fs.writeFileSync(
225
+ path.join(tmpDir, 'next.config.ts'),
226
+ `import type { NextConfig } from 'next'\nimport { telemetryServerExternalPackages } from '@open-mercato/telemetry/nextjs'\nconst nextConfig: NextConfig = { serverExternalPackages: ['esbuild', 'bullmq'] }\nexport default nextConfig\n`,
227
+ )
228
+
229
+ await runTelemetryInit([])
230
+
231
+ const nextConfig = read('next.config.ts')
232
+ assertParses(nextConfig, 'legacy-next-config')
233
+ expect(nextConfig).toContain("from '@open-mercato/telemetry/nextjs-config'")
234
+ expect(nextConfig).not.toContain("from '@open-mercato/telemetry/nextjs'")
235
+ expect(nextConfig).toContain("['esbuild', 'bullmq', ...telemetryServerExternalPackages]")
236
+ })
237
+
238
+ it('flags next.config as manual when serverExternalPackages is absent', async () => {
239
+ baseFixture(tmpDir)
240
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
241
+ fs.writeFileSync(
242
+ path.join(tmpDir, 'next.config.ts'),
243
+ `import type { NextConfig } from 'next'\nconst nextConfig: NextConfig = { distDir: '.next' }\nexport default nextConfig\n`,
244
+ )
245
+ await runTelemetryInit([])
246
+ expect(read('next.config.ts')).not.toContain('telemetryServerExternalPackages')
247
+ const printed = logSpy.mock.calls.flat().join('\n')
248
+ expect(printed).toContain('manual step for next.config.ts')
249
+ })
250
+
251
+ it('inserts into a pre-existing custom instrumentation.ts and preserves its body', async () => {
252
+ baseFixture(tmpDir)
253
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
254
+ fs.writeFileSync(
255
+ path.join(tmpDir, 'src', 'instrumentation.ts'),
256
+ `export async function register(): Promise<void> {\n console.log('custom warmup')\n}\n`,
257
+ )
258
+ await runTelemetryInit([])
259
+ const instrumentation = read('src/instrumentation.ts')
260
+ assertParses(instrumentation, 'custom-instrumentation')
261
+ expect(instrumentation).toContain('registerTelemetryForNextjs')
262
+ expect(instrumentation).toContain("console.log('custom warmup')") // preserved
263
+ })
264
+
265
+ it('is idempotent — a second run changes nothing and does not double-insert', async () => {
266
+ baseFixture(tmpDir)
267
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
268
+ await runTelemetryInit([])
269
+ const after1 = {
270
+ pkg: read('package.json'),
271
+ env: read('.env.example'),
272
+ instrumentation: read('src/instrumentation.ts'),
273
+ nextConfig: read('next.config.ts'),
274
+ route: read(dispatcherPath),
275
+ }
276
+
277
+ await runTelemetryInit([])
278
+ expect(read('package.json')).toBe(after1.pkg)
279
+ expect(read('.env.example')).toBe(after1.env)
280
+ expect(read('src/instrumentation.ts')).toBe(after1.instrumentation)
281
+ expect(read('next.config.ts')).toBe(after1.nextConfig)
282
+ expect(read(dispatcherPath)).toBe(after1.route)
283
+
284
+ const route = read(dispatcherPath)
285
+ expect((read('next.config.ts').match(/@open-mercato\/telemetry\/nextjs-config/g) ?? []).length).toBe(1)
286
+ expect((route.match(/from '@open-mercato\/shared\/lib\/telemetry\/runtime'/g) ?? []).length).toBe(1)
287
+ })
288
+
289
+ it('dry run writes nothing', async () => {
290
+ baseFixture(tmpDir)
291
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
292
+ const before = read('next.config.ts')
293
+ await runTelemetryInit(['--dry-run'])
294
+ expect(read('next.config.ts')).toBe(before)
295
+ expect(fs.existsSync(path.join(tmpDir, 'src', 'instrumentation.ts'))).toBe(false)
296
+ expect(JSON.parse(read('package.json')).dependencies['@open-mercato/telemetry']).toBeUndefined()
297
+ })
298
+ })
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Bootstrap-free `.env` loader for the CLI binary. `bin.ts` must load the app's
6
+ * `.env` BEFORE `initTelemetry()` so `TELEMETRY_*` set only in `.env` resolves a
7
+ * real backend for worker/scheduler processes — `run()`'s `ensureEnvLoaded()`
8
+ * happens after telemetry init, which is too late. Deliberately read-only (no
9
+ * `.env.example` copy — that UX stays in `ensureEnvLoaded`) and pulls in only the
10
+ * resolver + dotenv, so nothing here loads `pg` and the OTEL instrumentation
11
+ * load-order guarantee is preserved.
12
+ */
13
+ export async function loadAppEnv(options: { cwd?: string } = {}): Promise<void> {
14
+ try {
15
+ const { createResolver } = await import('./resolver.js')
16
+ const appDir = createResolver(options.cwd).getAppDir()
17
+ const envPath = path.join(appDir, '.env')
18
+ if (fs.existsSync(envPath)) {
19
+ const dotenv = await import('dotenv')
20
+ dotenv.config({ path: envPath, quiet: true })
21
+ return
22
+ }
23
+ } catch {
24
+ // Resolver can fail outside an app dir — fall through to plain cwd loading.
25
+ }
26
+ try {
27
+ const dotenv = await import('dotenv')
28
+ dotenv.config({ quiet: true })
29
+ } catch {}
30
+ }