@open-mercato/cli 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2

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/jest.config.cjs CHANGED
@@ -10,6 +10,8 @@ module.exports = {
10
10
  {
11
11
  tsconfig: {
12
12
  jsx: 'react-jsx',
13
+ rootDir: '.',
14
+ ignoreDeprecations: '6.0',
13
15
  },
14
16
  },
15
17
  ],
@@ -21,7 +23,7 @@ module.exports = {
21
23
  '^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
22
24
  },
23
25
  transformIgnorePatterns: [
24
- 'node_modules/(?!(@mikro-orm)/)',
26
+ 'node_modules/(?!(@mikro-orm|kysely)/)',
25
27
  ],
26
28
  testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
27
29
  passWithNoTests: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.2-develop.3461.1.605f31c2c9",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -55,25 +55,25 @@
55
55
  "node": "24.x"
56
56
  },
57
57
  "dependencies": {
58
- "@mikro-orm/core": "^7.0.15",
59
- "@mikro-orm/decorators": "^7.0.15",
60
- "@mikro-orm/migrations": "^7.0.15",
61
- "@mikro-orm/postgresql": "^7.0.15",
62
- "@open-mercato/queue": "0.6.2-develop.3461.1.605f31c2c9",
63
- "@open-mercato/shared": "0.6.2-develop.3461.1.605f31c2c9",
58
+ "@mikro-orm/core": "^7.0.17",
59
+ "@mikro-orm/decorators": "^7.0.17",
60
+ "@mikro-orm/migrations": "^7.0.17",
61
+ "@mikro-orm/postgresql": "^7.0.17",
62
+ "@open-mercato/queue": "0.6.2",
63
+ "@open-mercato/shared": "0.6.2",
64
64
  "cross-spawn": "^7.0.6",
65
- "pg": "8.20.0",
65
+ "pg": "8.21.0",
66
66
  "semver": "^7.8.0",
67
67
  "testcontainers": "^11.14.0",
68
68
  "ts-morph": "^28.0.0",
69
- "tsx": "^4.21.0",
70
- "typescript": "^5.9.3"
69
+ "tsx": "^4.22.2",
70
+ "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.2-develop.3461.1.605f31c2c9"
73
+ "@open-mercato/shared": "0.6.2"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.2-develop.3461.1.605f31c2c9",
76
+ "@open-mercato/shared": "0.6.2",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.9"
@@ -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.1"
88
+ }
90
89
  }
@@ -6,6 +6,7 @@ import {
6
6
  padByCodePointWidth,
7
7
  run,
8
8
  } from '../mercato'
9
+ import { pathIncludes } from '../lib/__tests__/path-helpers'
9
10
 
10
11
  type MockChildAutoExit = { code: number | null; signal?: NodeJS.Signals | null } | undefined
11
12
  type MockChildSpawnRouter = (args: string[]) => MockChildAutoExit
@@ -674,7 +675,7 @@ describe('server dev managed process exits', () => {
674
675
  return {
675
676
  ...actual,
676
677
  existsSync: jest.fn((candidate: string) =>
677
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
678
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
678
679
  ),
679
680
  unlinkSync: jest.fn(),
680
681
  }
@@ -691,7 +692,7 @@ describe('server dev managed process exits', () => {
691
692
  }))
692
693
  jest.doMock('child_process', () =>
693
694
  buildMockChildProcessModule((args) =>
694
- args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
695
+ pathIncludes(args[0] ?? '', 'next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
695
696
  ),
696
697
  )
697
698
 
@@ -715,7 +716,7 @@ describe('server dev managed process exits', () => {
715
716
  return {
716
717
  ...actual,
717
718
  existsSync: jest.fn((candidate: string) =>
718
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
719
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
719
720
  ),
720
721
  unlinkSync: jest.fn(),
721
722
  }
@@ -763,7 +764,7 @@ describe('server dev managed process exits', () => {
763
764
  return {
764
765
  ...actual,
765
766
  existsSync: jest.fn((candidate: string) =>
766
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
767
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
767
768
  ),
768
769
  unlinkSync: jest.fn(),
769
770
  }
@@ -792,7 +793,7 @@ describe('server dev managed process exits', () => {
792
793
 
793
794
  jest.doMock('child_process', () =>
794
795
  buildMockChildProcessModule((args) =>
795
- args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
796
+ pathIncludes(args[0] ?? '', 'next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
796
797
  ),
797
798
  )
798
799
 
@@ -830,7 +831,7 @@ describe('server dev managed process exits', () => {
830
831
  return {
831
832
  ...actual,
832
833
  existsSync: jest.fn((candidate: string) =>
833
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
834
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
834
835
  ),
835
836
  unlinkSync: jest.fn(),
836
837
  }
@@ -859,7 +860,7 @@ describe('server dev managed process exits', () => {
859
860
 
860
861
  jest.doMock('child_process', () =>
861
862
  buildMockChildProcessModule((args) =>
862
- args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
863
+ pathIncludes(args[0] ?? '', 'next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
863
864
  ),
864
865
  )
865
866
 
@@ -932,7 +933,7 @@ describe('server start managed process exits', () => {
932
933
  return {
933
934
  ...actual,
934
935
  existsSync: jest.fn((candidate: string) =>
935
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
936
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
936
937
  ),
937
938
  }
938
939
  })
@@ -949,7 +950,7 @@ describe('server start managed process exits', () => {
949
950
  }))
950
951
  jest.doMock('child_process', () =>
951
952
  buildMockChildProcessModule((args) =>
952
- args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
953
+ pathIncludes(args[0] ?? '', 'next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
953
954
  ),
954
955
  )
955
956
 
@@ -973,7 +974,7 @@ describe('server start managed process exits', () => {
973
974
  return {
974
975
  ...actual,
975
976
  existsSync: jest.fn((candidate: string) =>
976
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
977
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
977
978
  ),
978
979
  }
979
980
  })
@@ -1022,7 +1023,7 @@ describe('server start managed process exits', () => {
1022
1023
  return {
1023
1024
  ...actual,
1024
1025
  existsSync: jest.fn((candidate: string) =>
1025
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
1026
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
1026
1027
  ),
1027
1028
  unlinkSync: jest.fn(),
1028
1029
  }
@@ -1089,7 +1090,7 @@ describe('server start managed process exits', () => {
1089
1090
 
1090
1091
  return {
1091
1092
  spawn: jest.fn((_command: string, args: string[]) => {
1092
- if (args[0]?.includes('next/dist/bin/next')) {
1093
+ if (pathIncludes(args[0] ?? '', 'next/dist/bin/next')) {
1093
1094
  nextSpawnCount += 1
1094
1095
  if (nextSpawnCount === 1) {
1095
1096
  queueMicrotask(() => envChangeCallback?.('/tmp/test-app/.env'))
@@ -1106,7 +1107,7 @@ describe('server start managed process exits', () => {
1106
1107
  const exitCode = await mercado.run(['node', 'mercato', 'server', 'dev'])
1107
1108
  const { spawn } = await import('child_process')
1108
1109
  const nextSpawns = (spawn as jest.Mock).mock.calls.filter((call) =>
1109
- call[1]?.[0]?.includes('next/dist/bin/next'),
1110
+ pathIncludes(call[1]?.[0] ?? '', 'next/dist/bin/next'),
1110
1111
  )
1111
1112
 
1112
1113
  expect(exitCode).toBe(0)
@@ -1132,7 +1133,7 @@ describe('server start managed process exits', () => {
1132
1133
  return {
1133
1134
  ...actual,
1134
1135
  existsSync: jest.fn((candidate: string) =>
1135
- candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
1136
+ pathIncludes(candidate, 'next/dist/bin/next') || pathIncludes(candidate, '@open-mercato/cli/bin/mercato'),
1136
1137
  ),
1137
1138
  }
1138
1139
  })
@@ -1159,7 +1160,7 @@ describe('server start managed process exits', () => {
1159
1160
 
1160
1161
  jest.doMock('child_process', () =>
1161
1162
  buildMockChildProcessModule((args) =>
1162
- args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
1163
+ pathIncludes(args[0] ?? '', 'next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
1163
1164
  ),
1164
1165
  )
1165
1166
 
@@ -2,6 +2,7 @@ import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { createDevEnvReloader, resolveDevEnvFilePaths } from '../dev-env-reload'
5
+ import { normalizeTestPath } from './path-helpers'
5
6
 
6
7
  describe('dev env reload helpers', () => {
7
8
  let appDir: string
@@ -15,7 +16,7 @@ describe('dev env reload helpers', () => {
15
16
  })
16
17
 
17
18
  it('resolves app env files in low-to-high dev precedence order', () => {
18
- expect(resolveDevEnvFilePaths('/tmp/app')).toEqual([
19
+ expect(resolveDevEnvFilePaths('/tmp/app').map(normalizeTestPath)).toEqual([
19
20
  '/tmp/app/.env',
20
21
  '/tmp/app/.env.development',
21
22
  '/tmp/app/.env.local',
@@ -0,0 +1,7 @@
1
+ export function normalizeTestPath(value: string): string {
2
+ return value.replace(/\\/g, '/')
3
+ }
4
+
5
+ export function pathIncludes(value: string, needle: string): boolean {
6
+ return normalizeTestPath(value).includes(normalizeTestPath(needle))
7
+ }
@@ -13,6 +13,7 @@ import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
15
  import type { ModuleEntry, PackageResolver } from '../../resolver'
16
+ import { normalizeTestPath } from '../../__tests__/path-helpers'
16
17
 
17
18
  describe('db commands security', () => {
18
19
  describe('sanitizeModuleId', () => {
@@ -189,7 +190,7 @@ describe('resolveGeneratedMigrationPath', () => {
189
190
  '/repo/src/modules/example/migrations',
190
191
  )
191
192
 
192
- expect(result).toBe('/repo/src/modules/example/migrations/Migration20260506101927.ts')
193
+ expect(normalizeTestPath(result)).toBe('/repo/src/modules/example/migrations/Migration20260506101927.ts')
193
194
  })
194
195
 
195
196
  it('keeps absolute MikroORM results unchanged', () => {
@@ -768,6 +768,148 @@ export function GET() {
768
768
  )
769
769
  })
770
770
 
771
+ it('emits imports for third-party scoped npm packages registered via src/modules.ts', async () => {
772
+ // Simulate a third-party module package published at @dainamite/cpq
773
+ // (see issue #1998). The generator must accept @<vendor>/<package> specifiers
774
+ // and emit imports that point at the package's modules/<id> subtree.
775
+ const pkgModuleBase = path.join(
776
+ tmpDir,
777
+ 'packages',
778
+ 'thirdparty',
779
+ 'src',
780
+ 'modules',
781
+ 'cpq',
782
+ )
783
+ touchFile(
784
+ path.join(pkgModuleBase, 'subscribers', 'on-event.ts'),
785
+ `export const metadata = { event: 'cpq.quote.created' }\nexport default async function handler() {}\n`,
786
+ )
787
+
788
+ const baseResolver = createMockResolver(tmpDir, [
789
+ { id: 'cpq', from: '@dainamite/cpq' },
790
+ ])
791
+ const resolver: PackageResolver = {
792
+ ...baseResolver,
793
+ getModulePaths: (entry: ModuleEntry) => ({
794
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
795
+ pkgBase: pkgModuleBase,
796
+ }),
797
+ getModuleImportBase: (entry: ModuleEntry) => ({
798
+ appBase: `@/modules/${entry.id}`,
799
+ pkgBase: `@dainamite/cpq/modules/${entry.id}`,
800
+ }),
801
+ }
802
+
803
+ const result = await generateModuleRegistry({ resolver, quiet: true })
804
+ expect(result.errors).toEqual([])
805
+
806
+ const output = readGenerated(tmpDir, 'modules.generated.ts')!
807
+ expect(output).toContain('id: "cpq"')
808
+ expect(output).toContain('@dainamite/cpq/modules/cpq/subscribers/on-event')
809
+ })
810
+
811
+ it('emits imports for bare (unscoped) third-party npm packages', async () => {
812
+ const pkgModuleBase = path.join(
813
+ tmpDir,
814
+ 'packages',
815
+ 'thirdparty-bare',
816
+ 'src',
817
+ 'modules',
818
+ 'foo',
819
+ )
820
+ touchFile(
821
+ path.join(pkgModuleBase, 'subscribers', 'on-event.ts'),
822
+ `export const metadata = { event: 'foo.thing.created' }\nexport default async function handler() {}\n`,
823
+ )
824
+
825
+ const baseResolver = createMockResolver(tmpDir, [
826
+ { id: 'foo', from: 'acme-modules' },
827
+ ])
828
+ const resolver: PackageResolver = {
829
+ ...baseResolver,
830
+ getModulePaths: (entry: ModuleEntry) => ({
831
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
832
+ pkgBase: pkgModuleBase,
833
+ }),
834
+ getModuleImportBase: (entry: ModuleEntry) => ({
835
+ appBase: `@/modules/${entry.id}`,
836
+ pkgBase: `acme-modules/modules/${entry.id}`,
837
+ }),
838
+ }
839
+
840
+ const result = await generateModuleRegistry({ resolver, quiet: true })
841
+ expect(result.errors).toEqual([])
842
+
843
+ const output = readGenerated(tmpDir, 'modules.generated.ts')!
844
+ expect(output).toContain('id: "foo"')
845
+ expect(output).toContain('acme-modules/modules/foo/subscribers/on-event')
846
+ })
847
+
848
+ it('fails fast when a third-party module entry points at a package without the expected OM module shape', async () => {
849
+ // No files scaffolded — the package directory does not exist, so the
850
+ // entry is almost certainly not an Open Mercato module.
851
+ const enabled: ModuleEntry[] = [{ id: 'cpq', from: '@dainamite/cpq' }]
852
+ const baseResolver = createMockResolver(tmpDir, enabled)
853
+ const resolver: PackageResolver = {
854
+ ...baseResolver,
855
+ getModulePaths: (entry: ModuleEntry) => ({
856
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
857
+ pkgBase: path.join(
858
+ tmpDir,
859
+ 'node_modules',
860
+ '@dainamite',
861
+ 'cpq',
862
+ 'dist',
863
+ 'modules',
864
+ entry.id,
865
+ ),
866
+ }),
867
+ getModuleImportBase: (entry: ModuleEntry) => ({
868
+ appBase: `@/modules/${entry.id}`,
869
+ pkgBase: `@dainamite/cpq/modules/${entry.id}`,
870
+ }),
871
+ }
872
+
873
+ await expect(generateModuleRegistry({ resolver, quiet: true })).rejects.toThrow(
874
+ /no Open Mercato module shape was found/,
875
+ )
876
+ })
877
+
878
+ it('rejects third-party specifiers with path-traversal segments', async () => {
879
+ const pkgModuleBase = path.join(
880
+ tmpDir,
881
+ 'packages',
882
+ 'thirdparty-traversal',
883
+ 'src',
884
+ 'modules',
885
+ 'evil',
886
+ )
887
+ touchFile(
888
+ path.join(pkgModuleBase, 'subscribers', 'on-event.ts'),
889
+ `export const metadata = { event: 'evil.x' }\nexport default async function handler() {}\n`,
890
+ )
891
+
892
+ const baseResolver = createMockResolver(tmpDir, [
893
+ { id: 'evil', from: '@vendor/pkg' },
894
+ ])
895
+ const resolver: PackageResolver = {
896
+ ...baseResolver,
897
+ getModulePaths: (entry: ModuleEntry) => ({
898
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
899
+ pkgBase: pkgModuleBase,
900
+ }),
901
+ // Inject a malicious specifier with a ".." segment.
902
+ getModuleImportBase: (entry: ModuleEntry) => ({
903
+ appBase: `@/modules/${entry.id}`,
904
+ pkgBase: `@vendor/pkg/../escape/modules/${entry.id}`,
905
+ }),
906
+ }
907
+
908
+ await expect(generateModuleRegistry({ resolver, quiet: true })).rejects.toThrow(
909
+ /Unsafe generated module specifier/,
910
+ )
911
+ })
912
+
771
913
  it('mixed subset: core module + app module, then remove core module', async () => {
772
914
  scaffoldModule(tmpDir, 'core_mod', 'pkg', [
773
915
  'backend/page.tsx',
@@ -123,6 +123,7 @@ export function createNotificationsExtension(): GeneratorExtension {
123
123
  fileName: 'notifications.generated.ts',
124
124
  imports: [
125
125
  { namedImports: [{ name: 'NotificationTypeDefinition', isTypeOnly: true }], moduleSpecifier: '@open-mercato/shared/modules/notifications/types' },
126
+ { namedImports: [{ name: 'applyNotificationTypeOverridesToEntries' }], moduleSpecifier: '@open-mercato/shared/modules/overrides' },
126
127
  ...notificationImports,
127
128
  ],
128
129
  build(sourceFile) {
@@ -138,9 +139,13 @@ export function createNotificationsExtension(): GeneratorExtension {
138
139
  type: 'NotificationTypeEntry[]',
139
140
  initializer: arrayLiteral(notificationTypeEntries, writeValue),
140
141
  },
142
+ {
143
+ name: 'entries',
144
+ initializer: callExpression(identifier('applyNotificationTypeOverridesToEntries'), [identifier('entriesRaw')]),
145
+ },
141
146
  {
142
147
  name: 'allTypes',
143
- initializer: methodCall(identifier('entriesRaw'), 'flatMap', [
148
+ initializer: methodCall(identifier('entries'), 'flatMap', [
144
149
  arrowFunction({
145
150
  parameters: ['entry'],
146
151
  body: propertyAccess(identifier('entry'), 'types'),
@@ -153,7 +158,7 @@ export function createNotificationsExtension(): GeneratorExtension {
153
158
  declarationKind: VariableDeclarationKind.Const,
154
159
  isExported: true,
155
160
  declarations: [
156
- { name: 'notificationTypeEntries', initializer: identifier('entriesRaw') },
161
+ { name: 'notificationTypeEntries', initializer: identifier('entries') },
157
162
  { name: 'notificationTypes', initializer: identifier('allTypes') },
158
163
  ],
159
164
  })
@@ -193,6 +198,7 @@ export function createNotificationsExtension(): GeneratorExtension {
193
198
  ],
194
199
  moduleSpecifier: '@open-mercato/shared/modules/notifications/types',
195
200
  },
201
+ { namedImports: [{ name: 'applyNotificationTypeOverridesToEntries' }], moduleSpecifier: '@open-mercato/shared/modules/overrides' },
196
202
  ...notificationClientImports,
197
203
  ],
198
204
  build(sourceFile) {
@@ -213,9 +219,13 @@ export function createNotificationsExtension(): GeneratorExtension {
213
219
  type: 'NotificationTypeEntry[]',
214
220
  initializer: arrayLiteral(notificationClientTypeEntries, writeValue),
215
221
  },
222
+ {
223
+ name: 'entries',
224
+ initializer: callExpression(identifier('applyNotificationTypeOverridesToEntries'), [identifier('entriesRaw')]),
225
+ },
216
226
  {
217
227
  name: 'allTypes',
218
- initializer: methodCall(identifier('entriesRaw'), 'flatMap', [
228
+ initializer: methodCall(identifier('entries'), 'flatMap', [
219
229
  arrowFunction({
220
230
  parameters: ['entry'],
221
231
  body: propertyAccess(identifier('entry'), 'types'),
@@ -255,7 +265,7 @@ export function createNotificationsExtension(): GeneratorExtension {
255
265
  declarationKind: VariableDeclarationKind.Const,
256
266
  isExported: true,
257
267
  declarations: [
258
- { name: 'notificationClientTypeEntries', initializer: identifier('entriesRaw') },
268
+ { name: 'notificationClientTypeEntries', initializer: identifier('entries') },
259
269
  { name: 'notificationClientTypes', initializer: identifier('allTypes') },
260
270
  { name: 'notificationRenderers', initializer: identifier('renderers') },
261
271
  ],
@@ -925,8 +925,12 @@ function toLiteral(value: unknown): string {
925
925
  .replace(/\u2029/g, '\\u2029')
926
926
  }
927
927
 
928
- const GENERATED_MODULE_SPECIFIER_PREFIXES = ['@/', '@open-mercato/', '../../src/modules/', './'] as const
928
+ const GENERATED_MODULE_RELATIVE_PREFIXES = ['@/', '../../src/modules/', './'] as const
929
929
  const GENERATED_MODULE_SPECIFIER_SEGMENT = /^[A-Za-z0-9_.\-[\]()']+$/
930
+ // Lenient npm package name matcher. Accepts legacy uppercase packages and
931
+ // tilde-prefixed names, bans leading "." / "_", and reserves "@" for scope
932
+ // markers so the dispatcher below can split scope-vs-bare unambiguously.
933
+ const NPM_PACKAGE_NAME_SEGMENT = /^[A-Za-z0-9~][A-Za-z0-9_.~-]*$/
930
934
 
931
935
  function sanitizeGeneratedModuleSpecifierSegment(segment: string, importPath: string): string {
932
936
  const match = GENERATED_MODULE_SPECIFIER_SEGMENT.exec(segment)
@@ -936,22 +940,83 @@ function sanitizeGeneratedModuleSpecifierSegment(segment: string, importPath: st
936
940
  return match[0]
937
941
  }
938
942
 
939
- function sanitizeGeneratedModuleSpecifier(importPath: string): string {
940
- const prefix = GENERATED_MODULE_SPECIFIER_PREFIXES.find((candidate) => importPath.startsWith(candidate))
941
- if (!prefix) {
942
- throw new Error(`Unsafe generated module specifier prefix: ${importPath}`)
943
+ function sanitizeNpmPackageNameSegment(segment: string, importPath: string): void {
944
+ if (!NPM_PACKAGE_NAME_SEGMENT.test(segment) || segment === '.' || segment === '..') {
945
+ throw new Error(`Unsafe generated module specifier: ${importPath}`)
943
946
  }
947
+ }
944
948
 
945
- const suffix = importPath.slice(prefix.length)
946
- if (!suffix) {
949
+ function sanitizeGeneratedModuleSpecifier(importPath: string): string {
950
+ if (!importPath) {
947
951
  throw new Error(`Unsafe generated module specifier: ${importPath}`)
948
952
  }
949
953
 
950
- const segments = suffix
954
+ const relativePrefix = GENERATED_MODULE_RELATIVE_PREFIXES.find((candidate) =>
955
+ importPath.startsWith(candidate),
956
+ )
957
+ if (relativePrefix) {
958
+ const suffix = importPath.slice(relativePrefix.length)
959
+ if (!suffix) {
960
+ throw new Error(`Unsafe generated module specifier: ${importPath}`)
961
+ }
962
+ const segments = suffix
963
+ .split('/')
964
+ .map((segment) => sanitizeGeneratedModuleSpecifierSegment(segment, importPath))
965
+ return `${relativePrefix}${segments.join('/')}`
966
+ }
967
+
968
+ if (importPath.startsWith('@')) {
969
+ // Scoped npm package: @scope/name[/subpath]
970
+ const firstSlash = importPath.indexOf('/')
971
+ if (firstSlash < 2) {
972
+ throw new Error(`Unsafe generated module specifier prefix: ${importPath}`)
973
+ }
974
+ const scope = importPath.slice(1, firstSlash)
975
+ sanitizeNpmPackageNameSegment(scope, importPath)
976
+ const rest = importPath.slice(firstSlash + 1)
977
+ if (!rest) {
978
+ throw new Error(`Unsafe generated module specifier: ${importPath}`)
979
+ }
980
+ const secondSlash = rest.indexOf('/')
981
+ const name = secondSlash >= 0 ? rest.slice(0, secondSlash) : rest
982
+ sanitizeNpmPackageNameSegment(name, importPath)
983
+ const subpath = secondSlash >= 0 ? rest.slice(secondSlash + 1) : ''
984
+ if (!subpath) {
985
+ return `@${scope}/${name}`
986
+ }
987
+ const segments = subpath
988
+ .split('/')
989
+ .map((segment) => sanitizeGeneratedModuleSpecifierSegment(segment, importPath))
990
+ return `@${scope}/${name}/${segments.join('/')}`
991
+ }
992
+
993
+ // Bare npm package: name[/subpath]
994
+ const firstSlash = importPath.indexOf('/')
995
+ const name = firstSlash >= 0 ? importPath.slice(0, firstSlash) : importPath
996
+ sanitizeNpmPackageNameSegment(name, importPath)
997
+ const subpath = firstSlash >= 0 ? importPath.slice(firstSlash + 1) : ''
998
+ if (!subpath) {
999
+ return name
1000
+ }
1001
+ const segments = subpath
951
1002
  .split('/')
952
1003
  .map((segment) => sanitizeGeneratedModuleSpecifierSegment(segment, importPath))
953
-
954
- return `${prefix}${segments.join('/')}`
1004
+ return `${name}/${segments.join('/')}`
1005
+ }
1006
+
1007
+ // Defense in depth: when src/modules.ts registers a module from a third-party
1008
+ // npm scope (anything outside @app / @open-mercato/*), verify the resolved
1009
+ // pkgBase directory exists. If it doesn't, the package referenced via `from:`
1010
+ // is almost certainly not an Open Mercato module (or the id is wrong), and
1011
+ // silently emitting an empty module registration would hide the misconfig.
1012
+ function verifyThirdPartyModuleShape(entry: { id: string; from?: string }, pkgBase: string): void {
1013
+ const from = entry.from
1014
+ if (!from || from === '@app' || from.startsWith('@open-mercato/')) return
1015
+ if (fs.existsSync(pkgBase)) return
1016
+ throw new Error(
1017
+ `Module '${entry.id}' is registered with from: '${from}' but no Open Mercato module shape was found at '${pkgBase}'. ` +
1018
+ `Ensure the package ships a 'modules/${entry.id}' subtree (either under 'dist/modules/' or 'src/modules/'), or correct the 'from' value in src/modules.ts.`,
1019
+ )
955
1020
  }
956
1021
 
957
1022
  function buildImportStatement(importClause: string, importPath: string): string {
@@ -2502,6 +2567,9 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
2502
2567
  const legacySubscribersChecksumFile = path.join(outputDir, 'subscribers.generated.checksum')
2503
2568
 
2504
2569
  const enabled = resolver.loadEnabledModules()
2570
+ for (const entry of enabled) {
2571
+ verifyThirdPartyModuleShape(entry, resolver.getModulePaths(entry).pkgBase)
2572
+ }
2505
2573
  const extensions = loadGeneratorExtensions()
2506
2574
 
2507
2575
  // Pre-pass: collect generator plugins from each enabled module's generators.ts
@@ -3118,6 +3186,9 @@ export async function generateModuleRegistryApp(options: ModuleRegistryOptions):
3118
3186
  const enabledIdsChecksumFile = path.join(outputDir, 'enabled-module-ids.generated.checksum')
3119
3187
 
3120
3188
  const enabled = resolver.loadEnabledModules()
3189
+ for (const entry of enabled) {
3190
+ verifyThirdPartyModuleShape(entry, resolver.getModulePaths(entry).pkgBase)
3191
+ }
3121
3192
  const imports: string[] = []
3122
3193
  const moduleDecls: WriterFunction[] = []
3123
3194
  const importIdRef = { value: 0 }
@@ -3449,6 +3520,9 @@ export async function generateModuleRegistryCli(options: ModuleRegistryOptions):
3449
3520
  const legacyChecksumFile = path.join(outputDir, 'cli-modules.generated.checksum')
3450
3521
 
3451
3522
  const enabled = resolver.loadEnabledModules()
3523
+ for (const entry of enabled) {
3524
+ verifyThirdPartyModuleShape(entry, resolver.getModulePaths(entry).pkgBase)
3525
+ }
3452
3526
  const imports: string[] = []
3453
3527
  const moduleDecls: WriterFunction[] = []
3454
3528
  // Mutable ref so extracted helper functions can increment the shared counter