mfe-platform-cli 1.0.0

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 (3) hide show
  1. package/README.md +61 -0
  2. package/index.mjs +1065 -0
  3. package/package.json +29 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # mfe-platform-cli
2
+
3
+ Генератор воркспейса: host (shell), remote-модули (`services/*`), пакеты `@mfe-platform/shared` и `@mfe-platform/tracing`, манифест `mfe.platform.json`.
4
+
5
+ ## Установка
6
+
7
+ ```bash
8
+ npm install -g mfe-platform-cli
9
+ ```
10
+
11
+ Или без установки:
12
+
13
+ ```bash
14
+ npx mfe-platform-cli create workspace ./my-app --with-remote shop
15
+ ```
16
+
17
+ ## Рекомендуемый сценарий
18
+
19
+ 1. **Оболочка + первый микрофронт одной командой** (трассировка уже в `packages/tracing`):
20
+
21
+ ```bash
22
+ mfe-platform create workspace ./my-app --with-remote shop --remote-port 4181 --remote-title "Магазин"
23
+ cd my-app && npm install && npm run dev
24
+ ```
25
+
26
+ 2. **Второй и следующие микрофронты** (подключаются к тому же host):
27
+
28
+ ```bash
29
+ mfe-platform add remote ./my-app billing --port 4182 --title "Оплата"
30
+ cd my-app && npm install && npm run dev
31
+ ```
32
+
33
+ ## Команды
34
+
35
+ ```bash
36
+ mfe-platform create workspace <targetDir> [--host-name shell] [--host-port 4180] \
37
+ [--with-remote <name> [--remote-port 4181] [--remote-title "Заголовок"]]]
38
+ mfe-platform add remote <workspaceDir> <remoteName> [--port 4181] [--title "Title"]
39
+ ```
40
+
41
+ ## Локальная разработка (из клонированного репозитория)
42
+
43
+ ```bash
44
+ npm install
45
+ npm run platform -- create workspace ./tmp-workspace
46
+ ```
47
+
48
+ Общее описание монорепозитория и сценарии запуска — в корневом **`README.md`** репозитория `mfe-practice`.
49
+
50
+ ## Публикация в npm (для сопровождающих пакет)
51
+
52
+ Публикуйте **из этого каталога**, не из корня `mfe-practice`:
53
+
54
+ ```bash
55
+ cd tooling/mfe-platform
56
+ npm login
57
+ npm pack --dry-run
58
+ npm publish
59
+ ```
60
+
61
+ Перед первой публикацией обновите поле `repository.url` в `package.json` под ваш Git-репозиторий.
package/index.mjs ADDED
@@ -0,0 +1,1065 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ const [, , command, entity, ...argv] = process.argv
9
+
10
+ function fail(message) {
11
+ console.error(`mfe-platform: ${message}`)
12
+ process.exit(1)
13
+ }
14
+
15
+ function ensureDir(target) {
16
+ fs.mkdirSync(target, { recursive: true })
17
+ }
18
+
19
+ function writeFile(target, content) {
20
+ ensureDir(path.dirname(target))
21
+ fs.writeFileSync(target, content, 'utf8')
22
+ }
23
+
24
+ function readJson(target) {
25
+ return JSON.parse(fs.readFileSync(target, 'utf8'))
26
+ }
27
+
28
+ function writeJson(target, value) {
29
+ writeFile(target, `${JSON.stringify(value, null, 2)}\n`)
30
+ }
31
+
32
+ function parseArgs(args) {
33
+ const positionals = []
34
+ const options = {}
35
+
36
+ for (let i = 0; i < args.length; i += 1) {
37
+ const value = args[i]
38
+ if (!value.startsWith('--')) {
39
+ positionals.push(value)
40
+ continue
41
+ }
42
+
43
+ const key = value.slice(2)
44
+ const next = args[i + 1]
45
+ if (!next || next.startsWith('--')) {
46
+ options[key] = true
47
+ continue
48
+ }
49
+
50
+ options[key] = next
51
+ i += 1
52
+ }
53
+
54
+ return { positionals, options }
55
+ }
56
+
57
+ function toTitle(value) {
58
+ return value
59
+ .split(/[-_\s]+/)
60
+ .filter(Boolean)
61
+ .map((part) => part[0].toUpperCase() + part.slice(1))
62
+ .join(' ')
63
+ }
64
+
65
+ function workspacePackageName(workspaceDir) {
66
+ return path.basename(workspaceDir).toLowerCase().replace(/[^a-z0-9-]/g, '-')
67
+ }
68
+
69
+ function renderWorkspaceManifest({ hostName, hostPort, remotes }) {
70
+ return {
71
+ host: {
72
+ name: hostName,
73
+ title: `${toTitle(hostName)} Host`,
74
+ port: hostPort,
75
+ },
76
+ remotes,
77
+ packages: {
78
+ shared: '@mfe-platform/shared',
79
+ tracing: '@mfe-platform/tracing',
80
+ },
81
+ }
82
+ }
83
+
84
+ function renderRootPackageJson(workspaceDir) {
85
+ const resolved = path.resolve(workspaceDir)
86
+ const cliEntry = path.join(__dirname, 'index.mjs')
87
+ const relToCli = path.relative(resolved, cliEntry).split(path.sep).join('/')
88
+ const platformScript = `node ${relToCli}`
89
+
90
+ return {
91
+ name: workspacePackageName(workspaceDir),
92
+ private: true,
93
+ version: '1.0.0',
94
+ workspaces: ['host', 'services/*', 'packages/*'],
95
+ scripts: {
96
+ dev: 'node ./scripts/dev.mjs',
97
+ build: 'node ./scripts/build.mjs',
98
+ platform: platformScript,
99
+ 'mfe:add-remote:example': 'echo "Use the library CLI: npm run platform -- add remote ./TARGET name --port 4181"',
100
+ },
101
+ devDependencies: {
102
+ concurrently: '^9.2.1',
103
+ },
104
+ }
105
+ }
106
+
107
+ function renderDevScript() {
108
+ return `import { spawn } from 'node:child_process'
109
+ import { readFileSync } from 'node:fs'
110
+ import path from 'node:path'
111
+ import { fileURLToPath } from 'node:url'
112
+
113
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
114
+ const manifest = JSON.parse(readFileSync(path.join(rootDir, 'mfe.platform.json'), 'utf8'))
115
+
116
+ const commands = [
117
+ 'npm --prefix host run dev:app',
118
+ ...manifest.remotes.map(
119
+ (remote) => \`npm --prefix services/\${remote.name} run dev:remote\`,
120
+ ),
121
+ ]
122
+
123
+ // Do not use shell: true — the shell would split args and npm --prefix would be parsed as
124
+ // concurrently's own --prefix flag, breaking the logger (prev.replace is not a function).
125
+ const child = spawn('npx', ['concurrently', ...commands], {
126
+ cwd: rootDir,
127
+ stdio: 'inherit',
128
+ })
129
+
130
+ child.on('exit', (code) => process.exit(code ?? 0))
131
+ `
132
+ }
133
+
134
+ function renderBuildScript() {
135
+ return `import { execSync } from 'node:child_process'
136
+ import { readFileSync } from 'node:fs'
137
+ import path from 'node:path'
138
+ import { fileURLToPath } from 'node:url'
139
+
140
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
141
+ const manifest = JSON.parse(readFileSync(path.join(rootDir, 'mfe.platform.json'), 'utf8'))
142
+
143
+ execSync('npm --prefix host run build', { cwd: rootDir, stdio: 'inherit' })
144
+ for (const remote of manifest.remotes) {
145
+ execSync(\`npm --prefix services/\${remote.name} run build\`, { cwd: rootDir, stdio: 'inherit' })
146
+ }
147
+ `
148
+ }
149
+
150
+ function renderWorkspaceReadme() {
151
+ return `# MFE Platform Workspace
152
+
153
+ Сгенерированное workspace-приложение для host и набора remote-модулей.
154
+
155
+ ## Команды
156
+
157
+ \`npm install\`
158
+
159
+ \`npm run dev\` — из **корня воркспейса** поднять host и все remotes.
160
+
161
+ \`cd host && npm run dev\` — то же самое: скрипт \`dev\` в host запускает общий \`scripts/dev.mjs\` и поднимает Vite-оболочку (\`dev:app\`) и все \`services/*\`.
162
+
163
+ \`npm run build\` — собрать host и все remotes.
164
+
165
+ ## Манифест
166
+
167
+ Файл \`mfe.platform.json\` содержит конфигурацию host и список remote-модулей.
168
+ После генерации достаточно выполнить один раз \`npm install\` в корне workspace: npm workspaces установят зависимости для host, services и packages автоматически.
169
+
170
+ Рекомендуемый сценарий: \`mfe-platform create workspace ./app --with-remote shop\`, затем \`cd app && npm install && npm run dev\` — сразу поднимаются оболочка и первый микрофронт. Дальше: \`mfe-platform add remote ./app payments --port 4182\`. Порты по умолчанию (4180+), чтобы не пересекаться с демо в корне репозитория (4170–4172).
171
+
172
+ Host-файлы \`host/src/remotes.generated.ts\`, \`host/src/federation.d.ts\` и \`host/vite.config.ts\` обновляются через CLI.
173
+ `
174
+ }
175
+
176
+ function renderTracingPackageJson() {
177
+ return {
178
+ name: '@mfe-platform/tracing',
179
+ private: true,
180
+ version: '1.0.0',
181
+ type: 'module',
182
+ main: './src/index.ts',
183
+ types: './src/index.ts',
184
+ exports: {
185
+ '.': './src/index.ts',
186
+ './package.json': './package.json',
187
+ },
188
+ dependencies: {
189
+ '@opentelemetry/api': '^1.9.0',
190
+ '@opentelemetry/context-zone': '^2.6.0',
191
+ '@opentelemetry/resources': '^2.1.0',
192
+ '@opentelemetry/sdk-trace-base': '^2.6.0',
193
+ '@opentelemetry/sdk-trace-web': '^2.6.0',
194
+ },
195
+ peerDependencies: {
196
+ react: '>=18',
197
+ 'react-dom': '>=18',
198
+ },
199
+ devDependencies: {
200
+ typescript: '~5.9.3',
201
+ },
202
+ }
203
+ }
204
+
205
+ function renderTracingTsconfig() {
206
+ return `{
207
+ "compilerOptions": {
208
+ "target": "ES2020",
209
+ "module": "ESNext",
210
+ "moduleResolution": "bundler",
211
+ "strict": true,
212
+ "skipLibCheck": true,
213
+ "noEmit": true
214
+ },
215
+ "include": ["src"]
216
+ }
217
+ `
218
+ }
219
+
220
+ function renderTracingIndex() {
221
+ return `import { trace, SpanStatusCode } from '@opentelemetry/api'
222
+ import { resourceFromAttributes } from '@opentelemetry/resources'
223
+ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
224
+ import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'
225
+ import { ZoneContextManager } from '@opentelemetry/context-zone'
226
+
227
+ export type TraceEntry = {
228
+ timestamp: string
229
+ eventName: string
230
+ traceId: string
231
+ attributes: Record<string, string>
232
+ }
233
+
234
+ export type TracingOptions = {
235
+ serviceName?: string
236
+ onRecord?: (entry: TraceEntry) => void
237
+ }
238
+
239
+ let initialized = false
240
+ let activeServiceName = 'mfe-host'
241
+ let sink: ((entry: TraceEntry) => void) | undefined
242
+ const traceEntries: TraceEntry[] = []
243
+ const listeners = new Set<(entries: TraceEntry[]) => void>()
244
+
245
+ export function initTracing(options: TracingOptions = {}) {
246
+ if (initialized) return
247
+
248
+ activeServiceName = options.serviceName ?? activeServiceName
249
+ sink = options.onRecord
250
+
251
+ const provider = new WebTracerProvider({
252
+ resource: resourceFromAttributes({ 'service.name': activeServiceName }),
253
+ spanProcessors: [
254
+ new BatchSpanProcessor({
255
+ export(_spans, resultCallback) {
256
+ resultCallback({ code: 0 })
257
+ },
258
+ shutdown() {
259
+ return Promise.resolve()
260
+ },
261
+ }),
262
+ ],
263
+ })
264
+
265
+ provider.register({
266
+ contextManager: new ZoneContextManager(),
267
+ })
268
+
269
+ initialized = true
270
+ }
271
+
272
+ export function recordTraceEvent(eventName: string, attributes: Record<string, string> = {}) {
273
+ try {
274
+ const tracer = trace.getTracer(activeServiceName, '1.0.0')
275
+ const span = tracer.startSpan(eventName, { attributes })
276
+ span.setStatus({ code: SpanStatusCode.OK })
277
+
278
+ const entry: TraceEntry = {
279
+ timestamp: new Date().toISOString(),
280
+ eventName,
281
+ traceId: span.spanContext().traceId,
282
+ attributes,
283
+ }
284
+
285
+ span.end()
286
+ traceEntries.unshift(entry)
287
+ if (traceEntries.length > 20) traceEntries.pop()
288
+
289
+ listeners.forEach((listener) => listener([...traceEntries]))
290
+ sink?.(entry)
291
+ return entry
292
+ } catch {
293
+ return undefined
294
+ }
295
+ }
296
+
297
+ export function subscribeTraceEvents(listener: (entries: TraceEntry[]) => void) {
298
+ listeners.add(listener)
299
+ listener([...traceEntries])
300
+ return () => listeners.delete(listener)
301
+ }
302
+
303
+ export function getTraceEvents() {
304
+ return [...traceEntries]
305
+ }
306
+
307
+ export function getLastTraceIds() {
308
+ return traceEntries.map((entry) => entry.traceId)
309
+ }
310
+ `
311
+ }
312
+
313
+ function renderSharedPackageJson() {
314
+ return {
315
+ name: '@mfe-platform/shared',
316
+ private: true,
317
+ version: '1.0.0',
318
+ type: 'module',
319
+ main: './src/index.ts',
320
+ types: './src/index.ts',
321
+ exports: {
322
+ '.': './src/index.ts',
323
+ './package.json': './package.json',
324
+ },
325
+ dependencies: {
326
+ zustand: '^5.0.2',
327
+ },
328
+ peerDependencies: {
329
+ react: '>=18',
330
+ 'react-dom': '>=18',
331
+ },
332
+ devDependencies: {
333
+ typescript: '~5.9.3',
334
+ },
335
+ }
336
+ }
337
+
338
+ function renderSharedTsconfig() {
339
+ return renderTracingTsconfig()
340
+ }
341
+
342
+ function renderSharedIndex() {
343
+ return `import { create } from 'zustand'
344
+
345
+ export type ServiceSnapshot = {
346
+ key: string
347
+ displayName: string
348
+ actionCount: number
349
+ lastAction: string
350
+ updatedAt: string
351
+ }
352
+
353
+ export type PlatformStore = {
354
+ services: Record<string, ServiceSnapshot>
355
+ registerService: (key: string, displayName: string) => void
356
+ recordAction: (key: string, actionName: string) => void
357
+ resetService: (key: string) => void
358
+ }
359
+
360
+ export const usePlatformStore = create<PlatformStore>((set) => ({
361
+ services: {},
362
+ registerService: (key, displayName) =>
363
+ set((state) => ({
364
+ services: {
365
+ ...state.services,
366
+ [key]: state.services[key] ?? {
367
+ key,
368
+ displayName,
369
+ actionCount: 0,
370
+ lastAction: 'registered',
371
+ updatedAt: new Date().toISOString(),
372
+ },
373
+ },
374
+ })),
375
+ recordAction: (key, actionName) =>
376
+ set((state) => {
377
+ const current = state.services[key] ?? {
378
+ key,
379
+ displayName: key,
380
+ actionCount: 0,
381
+ lastAction: 'registered',
382
+ updatedAt: new Date().toISOString(),
383
+ }
384
+
385
+ return {
386
+ services: {
387
+ ...state.services,
388
+ [key]: {
389
+ ...current,
390
+ actionCount: current.actionCount + 1,
391
+ lastAction: actionName,
392
+ updatedAt: new Date().toISOString(),
393
+ },
394
+ },
395
+ }
396
+ }),
397
+ resetService: (key) =>
398
+ set((state) => ({
399
+ services: {
400
+ ...state.services,
401
+ [key]: {
402
+ ...(state.services[key] ?? {
403
+ key,
404
+ displayName: key,
405
+ }),
406
+ actionCount: 0,
407
+ lastAction: 'reset',
408
+ updatedAt: new Date().toISOString(),
409
+ },
410
+ },
411
+ })),
412
+ }))
413
+ `
414
+ }
415
+
416
+ function renderHostPackageJson() {
417
+ return {
418
+ name: 'host',
419
+ private: true,
420
+ version: '0.0.0',
421
+ type: 'module',
422
+ scripts: {
423
+ dev: 'node ../scripts/dev.mjs',
424
+ 'dev:app': 'vite',
425
+ build: 'vite build',
426
+ preview: 'vite preview',
427
+ },
428
+ dependencies: {
429
+ '@mfe-platform/shared': 'file:../packages/shared',
430
+ '@mfe-platform/tracing': 'file:../packages/tracing',
431
+ '@originjs/vite-plugin-federation': '^1.4.1',
432
+ react: '^19.2.0',
433
+ 'react-dom': '^19.2.0',
434
+ zustand: '^5.0.2',
435
+ },
436
+ devDependencies: {
437
+ '@types/react': '^19.2.2',
438
+ '@types/react-dom': '^19.2.2',
439
+ '@vitejs/plugin-react': '^6.0.0',
440
+ typescript: '~5.9.3',
441
+ vite: '^8.0.0',
442
+ },
443
+ }
444
+ }
445
+
446
+ function renderHostTsconfig() {
447
+ return `{
448
+ "compilerOptions": {
449
+ "target": "ES2020",
450
+ "module": "ESNext",
451
+ "moduleResolution": "bundler",
452
+ "jsx": "react-jsx",
453
+ "strict": true,
454
+ "skipLibCheck": true,
455
+ "noEmit": true,
456
+ "types": ["vite/client"]
457
+ },
458
+ "include": ["src", "vite.config.ts"]
459
+ }
460
+ `
461
+ }
462
+
463
+ function renderHostMain() {
464
+ return `import { StrictMode } from 'react'
465
+ import { createRoot } from 'react-dom/client'
466
+ import { initTracing } from '@mfe-platform/tracing'
467
+ import './index.css'
468
+ import App from './App'
469
+
470
+ initTracing({ serviceName: 'mfe-host' })
471
+
472
+ createRoot(document.getElementById('root')!).render(
473
+ <StrictMode>
474
+ <App />
475
+ </StrictMode>,
476
+ )
477
+ `
478
+ }
479
+
480
+ function renderHostApp() {
481
+ return `import { Suspense, useEffect, useMemo, useState } from 'react'
482
+ import { subscribeTraceEvents, type TraceEntry, recordTraceEvent } from '@mfe-platform/tracing'
483
+ import { usePlatformStore } from '@mfe-platform/shared'
484
+ import { remoteModules } from './remotes.generated'
485
+ import './App.css'
486
+
487
+ function App() {
488
+ const [activeKey, setActiveKey] = useState(remoteModules[0]?.key ?? '')
489
+ const [traceEntries, setTraceEntries] = useState<TraceEntry[]>([])
490
+ const servicesRecord = usePlatformStore((state) => state.services)
491
+ const services = useMemo(() => Object.values(servicesRecord), [servicesRecord])
492
+
493
+ useEffect(() => subscribeTraceEvents(setTraceEntries), [])
494
+
495
+ const activeRemote = useMemo(
496
+ () => remoteModules.find((remote) => remote.key === activeKey) ?? remoteModules[0],
497
+ [activeKey],
498
+ )
499
+
500
+ const ActiveComponent = activeRemote?.Component
501
+
502
+ const switchTab = (nextKey: string) => {
503
+ setActiveKey(nextKey)
504
+ recordTraceEvent('host:tab-switched', { 'mfe.target': nextKey })
505
+ }
506
+
507
+ return (
508
+ <main className="shell-layout">
509
+ <header className="shell-header">
510
+ <h1>MFE Platform Host</h1>
511
+ <p>Оболочка подключает удалённые модули и показывает общую наблюдаемость.</p>
512
+ </header>
513
+
514
+ {remoteModules.length > 0 ? (
515
+ <nav className="tabs">
516
+ {remoteModules.map((remote) => (
517
+ <button
518
+ key={remote.key}
519
+ className={remote.key === activeKey ? 'active' : ''}
520
+ onClick={() => switchTab(remote.key)}
521
+ >
522
+ {remote.title}
523
+ </button>
524
+ ))}
525
+ </nav>
526
+ ) : (
527
+ <section className="empty-state">
528
+ <p>Remote-модули пока не добавлены. Используйте CLI: add remote.</p>
529
+ </section>
530
+ )}
531
+
532
+ <section className="module-container">
533
+ {ActiveComponent ? (
534
+ <Suspense fallback={<p>Загрузка удалённого модуля...</p>}>
535
+ <ActiveComponent />
536
+ </Suspense>
537
+ ) : (
538
+ <p>Нет активного remote-модуля.</p>
539
+ )}
540
+ </section>
541
+
542
+ <section className="platform-panels">
543
+ <article className="panel-card">
544
+ <h2>Состояние сервисов</h2>
545
+ {services.length === 0 ? (
546
+ <p>Сервисы зарегистрируются после загрузки remote-модулей.</p>
547
+ ) : (
548
+ <ul>
549
+ {services.map((service) => (
550
+ <li key={service.key}>
551
+ <strong>{service.displayName}</strong>
552
+ <span>Действий: {service.actionCount}</span>
553
+ <span>Последнее: {service.lastAction}</span>
554
+ </li>
555
+ ))}
556
+ </ul>
557
+ )}
558
+ </article>
559
+
560
+ <article className="panel-card">
561
+ <h2>Trace-события</h2>
562
+ {traceEntries.length === 0 ? (
563
+ <p>Trace-панель обновится после действий в host или remote-модулях.</p>
564
+ ) : (
565
+ <ul>
566
+ {traceEntries.slice(0, 8).map((entry) => (
567
+ <li key={entry.timestamp + '-' + entry.eventName}>
568
+ <strong>{entry.eventName}</strong>
569
+ <span>{entry.traceId.slice(0, 16)}...</span>
570
+ </li>
571
+ ))}
572
+ </ul>
573
+ )}
574
+ </article>
575
+ </section>
576
+ </main>
577
+ )
578
+ }
579
+
580
+ export default App
581
+ `
582
+ }
583
+
584
+ function renderHostCss() {
585
+ return `.shell-layout {
586
+ max-width: 1120px;
587
+ margin: 0 auto;
588
+ padding: 32px 20px 60px;
589
+ font-family: Inter, Arial, sans-serif;
590
+ color: #132238;
591
+ }
592
+
593
+ .shell-header {
594
+ margin-bottom: 24px;
595
+ }
596
+
597
+ .tabs {
598
+ display: flex;
599
+ gap: 12px;
600
+ margin-bottom: 20px;
601
+ }
602
+
603
+ .tabs button {
604
+ border: 1px solid #b8c6d9;
605
+ background: #ffffff;
606
+ border-radius: 10px;
607
+ padding: 10px 16px;
608
+ cursor: pointer;
609
+ }
610
+
611
+ .tabs button.active {
612
+ background: #1f5eff;
613
+ color: white;
614
+ border-color: #1f5eff;
615
+ }
616
+
617
+ .module-container,
618
+ .panel-card,
619
+ .empty-state {
620
+ border: 1px solid #d9e3f0;
621
+ border-radius: 16px;
622
+ background: white;
623
+ padding: 20px;
624
+ box-shadow: 0 10px 32px rgba(17, 38, 68, 0.08);
625
+ }
626
+
627
+ .platform-panels {
628
+ display: grid;
629
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
630
+ gap: 16px;
631
+ margin-top: 20px;
632
+ }
633
+
634
+ .panel-card ul {
635
+ list-style: none;
636
+ padding: 0;
637
+ margin: 0;
638
+ display: grid;
639
+ gap: 10px;
640
+ }
641
+
642
+ .panel-card li {
643
+ display: grid;
644
+ gap: 4px;
645
+ }
646
+ `
647
+ }
648
+
649
+ function renderHostIndexCss() {
650
+ return `:root {
651
+ background: #f4f7fb;
652
+ }
653
+
654
+ body {
655
+ margin: 0;
656
+ }
657
+
658
+ button,
659
+ input {
660
+ font: inherit;
661
+ }
662
+ `
663
+ }
664
+
665
+ function renderHostHtml() {
666
+ return `<!doctype html>
667
+ <html lang="ru">
668
+ <head>
669
+ <meta charset="UTF-8" />
670
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
671
+ <title>MFE Platform Host</title>
672
+ </head>
673
+ <body>
674
+ <div id="root"></div>
675
+ <script type="module" src="/src/main.tsx"></script>
676
+ </body>
677
+ </html>
678
+ `
679
+ }
680
+
681
+ function renderHostViteConfig(manifest) {
682
+ const remotes = manifest.remotes
683
+ .map((remote) => ` ${remote.name}: 'http://localhost:${remote.port}/assets/remoteEntry.js',`)
684
+ .join('\n')
685
+
686
+ return `import path from 'node:path'
687
+ import { readFileSync } from 'node:fs'
688
+ import { defineConfig } from 'vite'
689
+ import react from '@vitejs/plugin-react'
690
+ import federation from '@originjs/vite-plugin-federation'
691
+
692
+ const manifest = JSON.parse(
693
+ readFileSync(new URL('../mfe.platform.json', import.meta.url), 'utf8'),
694
+ )
695
+
696
+ export default defineConfig({
697
+ resolve: {
698
+ dedupe: ['react', 'react-dom'],
699
+ },
700
+ plugins: [
701
+ react(),
702
+ federation({
703
+ name: manifest.host.name,
704
+ remotes: {
705
+ ${remotes}
706
+ },
707
+ shared: ['react', 'react-dom', 'zustand', '@mfe-platform/shared', '@mfe-platform/tracing'],
708
+ }),
709
+ ],
710
+ server: {
711
+ port: manifest.host.port,
712
+ strictPort: true,
713
+ fs: {
714
+ allow: [path.resolve(__dirname, '..'), path.resolve(__dirname, '../packages')],
715
+ },
716
+ },
717
+ build: {
718
+ target: 'esnext',
719
+ },
720
+ })
721
+ `
722
+ }
723
+
724
+ function renderHostRemotesGenerated(manifest) {
725
+ if (manifest.remotes.length === 0) {
726
+ return `export const remoteModules = [] as Array<{
727
+ key: string
728
+ title: string
729
+ Component: React.ComponentType
730
+ }>
731
+ `
732
+ }
733
+
734
+ const imports = [`import { lazy } from 'react'`]
735
+ const entries = manifest.remotes.map((remote) => {
736
+ const alias = `${remote.name}Remote`
737
+ imports.push(`const ${alias} = lazy(() => import('${remote.name}/RemoteApp'))`)
738
+ return ` { key: '${remote.name}', title: '${remote.title}', Component: ${alias} },`
739
+ })
740
+
741
+ return `${imports.join('\n')}
742
+
743
+ export const remoteModules = [
744
+ ${entries.join('\n')}
745
+ ]
746
+ `
747
+ }
748
+
749
+ function renderHostFederationDts(manifest) {
750
+ if (manifest.remotes.length === 0) {
751
+ return `export {}
752
+ `
753
+ }
754
+
755
+ return `${manifest.remotes
756
+ .map(
757
+ (remote) => `declare module '${remote.name}/RemoteApp' {
758
+ import { FC } from 'react'
759
+ const Component: FC
760
+ export default Component
761
+ }
762
+ `,
763
+ )
764
+ .join('\n')}`
765
+ }
766
+
767
+ function renderRemotePackageJson(name) {
768
+ return {
769
+ name,
770
+ private: true,
771
+ version: '0.0.0',
772
+ type: 'module',
773
+ scripts: {
774
+ dev: 'vite',
775
+ // Стабильная схема: полная сборка + preview. Watch оставляет неполные бандлы и ломает remoteEntry.
776
+ 'dev:remote': 'vite build && vite preview --strictPort',
777
+ build: 'vite build',
778
+ preview: 'vite preview',
779
+ },
780
+ dependencies: {
781
+ '@mfe-platform/shared': 'file:../../packages/shared',
782
+ '@mfe-platform/tracing': 'file:../../packages/tracing',
783
+ '@originjs/vite-plugin-federation': '^1.4.1',
784
+ react: '^19.2.0',
785
+ 'react-dom': '^19.2.0',
786
+ zustand: '^5.0.2',
787
+ },
788
+ devDependencies: {
789
+ '@types/react': '^19.2.2',
790
+ '@types/react-dom': '^19.2.2',
791
+ '@vitejs/plugin-react': '^6.0.0',
792
+ concurrently: '^9.2.1',
793
+ typescript: '~5.9.3',
794
+ vite: '^8.0.0',
795
+ },
796
+ }
797
+ }
798
+
799
+ function renderRemoteTsconfig() {
800
+ return renderHostTsconfig()
801
+ }
802
+
803
+ function renderRemoteViteConfig(name, port) {
804
+ return `import { defineConfig } from 'vite'
805
+ import react from '@vitejs/plugin-react'
806
+ import federation from '@originjs/vite-plugin-federation'
807
+
808
+ export default defineConfig({
809
+ resolve: {
810
+ dedupe: ['react', 'react-dom'],
811
+ },
812
+ plugins: [
813
+ react(),
814
+ federation({
815
+ name: '${name}',
816
+ filename: 'remoteEntry.js',
817
+ exposes: {
818
+ './RemoteApp': './src/RemoteApp.tsx',
819
+ },
820
+ shared: ['react', 'react-dom', 'zustand', '@mfe-platform/shared', '@mfe-platform/tracing'],
821
+ }),
822
+ ],
823
+ server: {
824
+ port: ${port},
825
+ strictPort: true,
826
+ },
827
+ preview: {
828
+ port: ${port},
829
+ strictPort: true,
830
+ cors: true,
831
+ },
832
+ build: {
833
+ target: 'esnext',
834
+ minify: false,
835
+ cssCodeSplit: false,
836
+ },
837
+ })
838
+ `
839
+ }
840
+
841
+ function renderRemoteMain() {
842
+ return `import { StrictMode } from 'react'
843
+ import { createRoot } from 'react-dom/client'
844
+ import './index.css'
845
+ import RemoteApp from './RemoteApp'
846
+
847
+ createRoot(document.getElementById('root')!).render(
848
+ <StrictMode>
849
+ <RemoteApp />
850
+ </StrictMode>,
851
+ )
852
+ `
853
+ }
854
+
855
+ function renderRemoteHtml(name) {
856
+ return `<!doctype html>
857
+ <html lang="ru">
858
+ <head>
859
+ <meta charset="UTF-8" />
860
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
861
+ <title>${toTitle(name)} Remote</title>
862
+ </head>
863
+ <body>
864
+ <div id="root"></div>
865
+ <script type="module" src="/src/main.tsx"></script>
866
+ </body>
867
+ </html>
868
+ `
869
+ }
870
+
871
+ function renderRemoteIndexCss() {
872
+ return `body {
873
+ margin: 0;
874
+ background: #f4f7fb;
875
+ font-family: Inter, Arial, sans-serif;
876
+ }
877
+ `
878
+ }
879
+
880
+ function renderRemoteCss() {
881
+ return `.remote-card {
882
+ border: 1px solid #d9e3f0;
883
+ border-radius: 16px;
884
+ background: white;
885
+ padding: 20px;
886
+ box-shadow: 0 10px 32px rgba(17, 38, 68, 0.08);
887
+ }
888
+
889
+ .remote-card button {
890
+ border: none;
891
+ background: #1f5eff;
892
+ color: white;
893
+ border-radius: 10px;
894
+ padding: 10px 14px;
895
+ cursor: pointer;
896
+ }
897
+ `
898
+ }
899
+
900
+ function renderRemoteApp(name, title) {
901
+ return `import { useEffect } from 'react'
902
+ import { usePlatformStore } from '@mfe-platform/shared'
903
+ import { recordTraceEvent } from '@mfe-platform/tracing'
904
+ import './remote.css'
905
+
906
+ const serviceKey = '${name}'
907
+ const displayName = '${title}'
908
+
909
+ export default function RemoteApp() {
910
+ const registerService = usePlatformStore((state) => state.registerService)
911
+ const recordAction = usePlatformStore((state) => state.recordAction)
912
+ const snapshot = usePlatformStore((state) => state.services[serviceKey])
913
+
914
+ useEffect(() => {
915
+ registerService(serviceKey, displayName)
916
+ recordTraceEvent('${name}:mounted', {
917
+ 'mfe.kind': 'remote',
918
+ 'mfe.service': serviceKey,
919
+ })
920
+ }, [registerService])
921
+
922
+ const handleAction = () => {
923
+ recordAction(serviceKey, 'business-action')
924
+ recordTraceEvent('${name}:business-action', {
925
+ 'mfe.kind': 'remote',
926
+ 'mfe.service': serviceKey,
927
+ })
928
+ }
929
+
930
+ return (
931
+ <section className="remote-card">
932
+ <h2>{displayName}</h2>
933
+ <p>
934
+ Этот remote-модуль создан через CLI платформы. Он автоматически подключён к host,
935
+ shared store и tracing API.
936
+ </p>
937
+ <p>Количество действий: {snapshot?.actionCount ?? 0}</p>
938
+ <p>Последнее действие: {snapshot?.lastAction ?? 'ещё нет'}</p>
939
+ <button onClick={handleAction}>Выполнить действие</button>
940
+ </section>
941
+ )
942
+ }
943
+ `
944
+ }
945
+
946
+ function createWorkspace(targetDir, options) {
947
+ const workspaceDir = path.resolve(targetDir)
948
+ ensureDir(workspaceDir)
949
+
950
+ const hostName = options['host-name'] || 'shell'
951
+ const hostPort = Number(options['host-port'] || 4180)
952
+ const manifest = renderWorkspaceManifest({ hostName, hostPort, remotes: [] })
953
+
954
+ writeJson(path.join(workspaceDir, 'mfe.platform.json'), manifest)
955
+ writeJson(path.join(workspaceDir, 'package.json'), renderRootPackageJson(workspaceDir))
956
+ writeFile(path.join(workspaceDir, 'scripts', 'dev.mjs'), renderDevScript())
957
+ writeFile(path.join(workspaceDir, 'scripts', 'build.mjs'), renderBuildScript())
958
+ writeFile(path.join(workspaceDir, 'README.md'), renderWorkspaceReadme())
959
+
960
+ writeJson(path.join(workspaceDir, 'packages', 'tracing', 'package.json'), renderTracingPackageJson())
961
+ writeFile(path.join(workspaceDir, 'packages', 'tracing', 'tsconfig.json'), renderTracingTsconfig())
962
+ writeFile(path.join(workspaceDir, 'packages', 'tracing', 'src', 'index.ts'), renderTracingIndex())
963
+
964
+ writeJson(path.join(workspaceDir, 'packages', 'shared', 'package.json'), renderSharedPackageJson())
965
+ writeFile(path.join(workspaceDir, 'packages', 'shared', 'tsconfig.json'), renderSharedTsconfig())
966
+ writeFile(path.join(workspaceDir, 'packages', 'shared', 'src', 'index.ts'), renderSharedIndex())
967
+
968
+ writeJson(path.join(workspaceDir, 'host', 'package.json'), renderHostPackageJson())
969
+ writeFile(path.join(workspaceDir, 'host', 'tsconfig.json'), renderHostTsconfig())
970
+ writeFile(path.join(workspaceDir, 'host', 'vite.config.ts'), renderHostViteConfig(manifest))
971
+ writeFile(path.join(workspaceDir, 'host', 'index.html'), renderHostHtml())
972
+ writeFile(path.join(workspaceDir, 'host', 'src', 'main.tsx'), renderHostMain())
973
+ writeFile(path.join(workspaceDir, 'host', 'src', 'App.tsx'), renderHostApp())
974
+ writeFile(path.join(workspaceDir, 'host', 'src', 'App.css'), renderHostCss())
975
+ writeFile(path.join(workspaceDir, 'host', 'src', 'index.css'), renderHostIndexCss())
976
+ writeFile(path.join(workspaceDir, 'host', 'src', 'remotes.generated.ts'), renderHostRemotesGenerated(manifest))
977
+ writeFile(path.join(workspaceDir, 'host', 'src', 'federation.d.ts'), renderHostFederationDts(manifest))
978
+
979
+ ensureDir(path.join(workspaceDir, 'services'))
980
+
981
+ console.log(`Workspace created at ${workspaceDir}`)
982
+
983
+ const firstRemote = options['with-remote']
984
+ if (firstRemote) {
985
+ const name = String(firstRemote)
986
+ addRemote(workspaceDir, name, {
987
+ port: options['remote-port'],
988
+ title: options['remote-title'] || toTitle(name),
989
+ silent: true,
990
+ })
991
+ console.log('')
992
+ console.log(`Готово: оболочка (host) и первый микрофронт «${name}» (трассировка уже в shared/tracing).`)
993
+ console.log(` cd ${workspaceDir}`)
994
+ console.log(` npm install`)
995
+ console.log(` npm run dev`)
996
+ } else {
997
+ console.log(`Next step: mfe-platform add remote ${workspaceDir} <имя> --port 4181`)
998
+ console.log(` или: mfe-platform create workspace ${targetDir} --with-remote <имя>`)
999
+ }
1000
+ }
1001
+
1002
+ function addRemote(workspaceDir, remoteName, options) {
1003
+ const rootDir = path.resolve(workspaceDir)
1004
+ const manifestPath = path.join(rootDir, 'mfe.platform.json')
1005
+ if (!fs.existsSync(manifestPath)) fail(`manifest not found: ${manifestPath}`)
1006
+
1007
+ const manifest = readJson(manifestPath)
1008
+ if (manifest.remotes.some((remote) => remote.name === remoteName)) {
1009
+ fail(`remote already exists: ${remoteName}`)
1010
+ }
1011
+
1012
+ const port = Number(options.port || 4181 + manifest.remotes.length)
1013
+ const title = options.title || toTitle(remoteName)
1014
+ manifest.remotes.push({ name: remoteName, title, port })
1015
+ writeJson(manifestPath, manifest)
1016
+
1017
+ const remoteDir = path.join(rootDir, 'services', remoteName)
1018
+ writeJson(path.join(remoteDir, 'package.json'), renderRemotePackageJson(remoteName))
1019
+ writeFile(path.join(remoteDir, 'tsconfig.json'), renderRemoteTsconfig())
1020
+ writeFile(path.join(remoteDir, 'vite.config.ts'), renderRemoteViteConfig(remoteName, port))
1021
+ writeFile(path.join(remoteDir, 'index.html'), renderRemoteHtml(remoteName))
1022
+ writeFile(path.join(remoteDir, 'src', 'main.tsx'), renderRemoteMain())
1023
+ writeFile(path.join(remoteDir, 'src', 'RemoteApp.tsx'), renderRemoteApp(remoteName, title))
1024
+ writeFile(path.join(remoteDir, 'src', 'remote.css'), renderRemoteCss())
1025
+ writeFile(path.join(remoteDir, 'src', 'index.css'), renderRemoteIndexCss())
1026
+
1027
+ writeFile(path.join(rootDir, 'host', 'vite.config.ts'), renderHostViteConfig(manifest))
1028
+ writeFile(path.join(rootDir, 'host', 'src', 'remotes.generated.ts'), renderHostRemotesGenerated(manifest))
1029
+ writeFile(path.join(rootDir, 'host', 'src', 'federation.d.ts'), renderHostFederationDts(manifest))
1030
+
1031
+ if (!options.silent) {
1032
+ console.log(`Remote '${remoteName}' added to ${rootDir}`)
1033
+ console.log(`Run inside workspace:`)
1034
+ console.log(` cd ${rootDir}`)
1035
+ console.log(` npm install`)
1036
+ console.log(` npm run dev`)
1037
+ }
1038
+ }
1039
+
1040
+ function printHelp() {
1041
+ console.log(`Usage:
1042
+ mfe-platform create workspace <targetDir> [--host-name shell] [--host-port 4180]
1043
+ [--with-remote <name> [--remote-port 4181] [--remote-title "Заголовок"]]]
1044
+ mfe-platform add remote <workspaceDir> <remoteName> [--port 4181] [--title Catalog]
1045
+
1046
+ Сценарий: create workspace с --with-remote → npm install → npm run dev;
1047
+ затем add remote для следующих микрофронтендов.
1048
+
1049
+ (из репозитория: npm run platform -- …)
1050
+ `)
1051
+ }
1052
+
1053
+ const { positionals, options } = parseArgs(argv)
1054
+
1055
+ if (command === 'create' && entity === 'workspace') {
1056
+ const [targetDir] = positionals
1057
+ if (!targetDir) fail('targetDir is required')
1058
+ createWorkspace(targetDir, options)
1059
+ } else if (command === 'add' && entity === 'remote') {
1060
+ const [workspaceDir, remoteName] = positionals
1061
+ if (!workspaceDir || !remoteName) fail('workspaceDir and remoteName are required')
1062
+ addRemote(workspaceDir, remoteName, options)
1063
+ } else {
1064
+ printHelp()
1065
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "mfe-platform-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI для генерации воркспейса микрофронтендов (Vite, Module Federation, shared store, tracing)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "",
8
+ "keywords": [
9
+ "mfe",
10
+ "micro-frontend",
11
+ "vite",
12
+ "module-federation",
13
+ "cli",
14
+ "scaffold"
15
+ ],
16
+ "bin": {
17
+ "mfe-platform": "index.mjs"
18
+ },
19
+ "files": [
20
+ "index.mjs",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }