From 7308719adda08390addbc417f2594721c4b798c0 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 4 May 2026 14:21:42 +0400 Subject: [PATCH 01/15] chore: Repository agent skills location updated --- {skills => .agents/skills}/commit-workflow/SKILL.md | 0 .../skills}/local-ci-simulation/SKILL.md | 0 .../skills}/local-ci-simulation/agents/openai.yaml | 0 .../skills}/sync-remote-host-registry/SKILL.md | 0 .../skills}/yarn-lock-conflict-resolution/SKILL.md | 0 AGENTS.md | 11 ++++++----- 6 files changed, 6 insertions(+), 5 deletions(-) rename {skills => .agents/skills}/commit-workflow/SKILL.md (100%) rename {skills => .agents/skills}/local-ci-simulation/SKILL.md (100%) rename {skills => .agents/skills}/local-ci-simulation/agents/openai.yaml (100%) rename {skills => .agents/skills}/sync-remote-host-registry/SKILL.md (100%) rename {skills => .agents/skills}/yarn-lock-conflict-resolution/SKILL.md (100%) diff --git a/skills/commit-workflow/SKILL.md b/.agents/skills/commit-workflow/SKILL.md similarity index 100% rename from skills/commit-workflow/SKILL.md rename to .agents/skills/commit-workflow/SKILL.md diff --git a/skills/local-ci-simulation/SKILL.md b/.agents/skills/local-ci-simulation/SKILL.md similarity index 100% rename from skills/local-ci-simulation/SKILL.md rename to .agents/skills/local-ci-simulation/SKILL.md diff --git a/skills/local-ci-simulation/agents/openai.yaml b/.agents/skills/local-ci-simulation/agents/openai.yaml similarity index 100% rename from skills/local-ci-simulation/agents/openai.yaml rename to .agents/skills/local-ci-simulation/agents/openai.yaml diff --git a/skills/sync-remote-host-registry/SKILL.md b/.agents/skills/sync-remote-host-registry/SKILL.md similarity index 100% rename from skills/sync-remote-host-registry/SKILL.md rename to .agents/skills/sync-remote-host-registry/SKILL.md diff --git a/skills/yarn-lock-conflict-resolution/SKILL.md b/.agents/skills/yarn-lock-conflict-resolution/SKILL.md similarity index 100% rename from skills/yarn-lock-conflict-resolution/SKILL.md rename to .agents/skills/yarn-lock-conflict-resolution/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 1180a22f..27cf448d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,15 +93,16 @@ yarn workspace @retailcrm/embed-ui-v1-components run storybook:build ``` ## Commit Workflow -- Before creating commits, you must read `skills/commit-workflow/SKILL.md` and follow its rules. +- Before creating commits, you must read `.agents/skills/commit-workflow/SKILL.md` and follow its rules. ## Skills -- Repository-local skills are available under `skills/`. +- Repository-local skills are available under `.agents/skills/`. - If a skill conflicts with this file, follow `AGENTS.md`. - Current local skills: - - `skills/commit-workflow/SKILL.md` - - `skills/sync-remote-host-registry/SKILL.md` - - `skills/yarn-lock-conflict-resolution/SKILL.md` + - `.agents/skills/commit-workflow/SKILL.md` + - `.agents/skills/local-ci-simulation/SKILL.md` + - `.agents/skills/sync-remote-host-registry/SKILL.md` + - `.agents/skills/yarn-lock-conflict-resolution/SKILL.md` ## Notes - Do not assume legacy rules from other repositories (especially `omnica`) apply here. From c357edd286248f430f50fe7181295408268d1378 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 4 May 2026 15:53:56 +0400 Subject: [PATCH 02/15] feat: Endpoint target metadata generation added --- .gitignore | 1 + meta/index.ts | 595 +----------------- package.json | 1 + packages/v1-endpoint/docs/README.md | 1 + packages/v1-endpoint/docs/targets.md | 53 +- packages/v1-endpoint/package.json | 5 +- packages/v1-endpoint/scripts/build.docs.ts | 101 +++ packages/v1-endpoint/scripts/build.meta.ts | 27 + .../src/common/targets.documentation.ts | 313 +++++++++ packages/v1-endpoint/src/common/targets.ts | 255 +++----- .../tests/factories/targets.test.ts | 15 + packages/v1-endpoint/tsconfig.json | 1 + scripts/build.meta.ts | 15 +- types/widget.d.ts | 142 +---- yarn.lock | 4 +- 15 files changed, 630 insertions(+), 899 deletions(-) create mode 100644 packages/v1-endpoint/scripts/build.docs.ts create mode 100644 packages/v1-endpoint/scripts/build.meta.ts create mode 100644 packages/v1-endpoint/src/common/targets.documentation.ts diff --git a/.gitignore b/.gitignore index 355e15f6..15304606 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ generated node_modules coverage artifacts/ +packages/v1-endpoint/docs/targets/ .codex .npmrc diff --git a/meta/index.ts b/meta/index.ts index ba9dfb2e..1e07858f 100644 --- a/meta/index.ts +++ b/meta/index.ts @@ -1,608 +1,27 @@ -import type { SchemaListByTarget } from '~types/widget' import type { TranslationList } from '@retailcrm/embed-ui-v1-types/doc' -import type { UnionToArray } from '@retailcrm/embed-ui-v1-types/scaffolding' -import { keysOf } from '@/utilities' - -export const targetListDocumentation: { - [Target in keyof SchemaListByTarget]: { - description: TranslationList; - location: TranslationList; - contexts: UnionToArray; - customContexts: string[]; - actions: string[]; - } -} = { - 'customer/card:phone': { - description: { - 'en-GB': 'Widget for customer phone list item', - 'es-ES': 'Widget para el elemento de la lista de teléfonos del cliente', - 'ru-RU': 'Виджет для элемента списка телефонов клиента', - }, - location: { - 'en-GB': 'Right after the phone number in the list', - 'es-ES': 'Justo después del número de teléfono en la lista', - 'ru-RU': 'Сразу после номера телефона в списке', - }, - contexts: [ - 'customer/card', - 'customer/card:phone', - 'user/current', - 'settings', - ], - customContexts: ['customer'], - actions: [], - }, - 'customer/card:communications.after': { - description: { - 'en-GB': 'Widget for enhancing the communication section in the left column of the customer page.', - 'es-ES': 'Widget para ampliar la sección de comunicación en la columna izquierda de la página del cliente.', - 'ru-RU': 'Виджет для дополнения секции коммуникаций в левой колонке страницы клиента.', - }, - location: { - 'en-GB': 'Right below the short summary, in the communication section', - 'es-ES': 'Justo debajo del resumen breve, en la sección de comunicación', - 'ru-RU': 'Сразу под краткой сводкой, в секции коммуникаций', - }, - contexts: [ - 'customer/card', - 'user/current', - 'settings', - ], - customContexts: ['customer'], - actions: [], - }, - 'customer/card:inWork.before': { - description: { - 'en-GB': 'Widget for the contact request item', - 'es-ES': 'Widget para el elemento de solicitud de contacto', - 'ru-RU': 'Виджет для элемента обращений', - }, - location: { - 'en-GB': 'At the beginning of the "In Progress" block in the client card', - 'es-ES': 'Al principio del bloque "En progreso" en la tarjeta del cliente', - 'ru-RU': 'В начале блока "В работе" в карточке клиента', - }, - contexts: [ - 'customer/card', - 'user/current', - 'settings', - ], - customContexts: ['customer'], - actions: [], - }, - 'customer/card:inWork.after': { - description: { - 'en-GB': 'Widget for the contact request item', - 'es-ES': 'Widget para el elemento de solicitud de contacto', - 'ru-RU': 'Виджет для элемента обращений', - }, - location: { - 'en-GB': 'At the end of the "In progress" block in the client card', - 'es-ES': 'Al final del bloque "En progreso" en la tarjeta del cliente', - 'ru-RU': 'В конце блока "В работе" в карточке клиента', - }, - contexts: [ - 'customer/card', - 'user/current', - 'settings', - ], - customContexts: ['customer'], - actions: [], - }, - 'order/card:common.before': { - description: { - 'en-GB': 'Widget for the section with common data', - 'es-ES': 'Widget para la sección con datos comunes', - 'ru-RU': 'Виджет для секции с основными данными', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:common.after': { - description: { - 'en-GB': 'Widget for the section with common data', - 'es-ES': 'Widget para la sección con datos comunes', - 'ru-RU': 'Виджет для секции с основными данными', - }, - location: { - 'en-GB': 'Section end, right under the input fields', - 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', - 'ru-RU': 'Конец секции, под полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:customer.before': { - description: { - 'en-GB': 'Widget for the section with customer data', - 'es-ES': 'Widget para la sección con datos del cliente', - 'ru-RU': 'Виджет для секции с данными клиента', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:customer.after': { - description: { - 'en-GB': 'Widget for the section with customer data', - 'es-ES': 'Widget para la sección con datos del cliente', - 'ru-RU': 'Виджет для секции с данными клиента', - }, - location: { - 'en-GB': 'Section end, right under the input fields', - 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', - 'ru-RU': 'Конец секции, под полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:customer.email': { - description: { - 'en-GB': 'Widget for customer email input field', - 'es-ES': 'Widget para el campo de entrada del correo electrónico del cliente', - 'ru-RU': 'Виджет для поля ввода email клиента', - }, - location: { - 'en-GB': 'Right after the input field', - 'es-ES': 'Justo después del campo de entrada', - 'ru-RU': 'Сразу после поля ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:customer.phone': { - description: { - 'en-GB': 'Widget for customer phone input field', - 'es-ES': 'Widget para el campo de entrada del teléfono del cliente', - 'ru-RU': 'Виджет для поля ввода телефона клиента', - }, - location: { - 'en-GB': 'Right after the input field', - 'es-ES': 'Justo después del campo de entrada', - 'ru-RU': 'Сразу после поля ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:list.before': { - description: { - 'en-GB': 'Widget for the list of ordered items', - 'es-ES': 'Widget para la lista de artículos pedidos', - 'ru-RU': 'Виджет для списка позиций заказа', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:list.after': { - description: { - 'en-GB': 'Widget for the list of ordered items', - 'es-ES': 'Widget para la lista de artículos pedidos', - 'ru-RU': 'Виджет для списка позиций заказа', - }, - location: { - 'en-GB': 'Section start, right under the list', - 'es-ES': 'Inicio de la sección, justo debajo de la lista', - 'ru-RU': 'Начало секции, под списком', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:store.before': { - description: { - 'en-GB': 'Widget for the section with warehouse data', - 'es-ES': 'Widget para la sección con datos del almacén', - 'ru-RU': 'Виджет для секции с данными склада', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:dimensions.before': { - description: { - 'en-GB': 'Widget for the section with dimensions and weight', - 'es-ES': 'Widget para la sección con dimensiones y peso', - 'ru-RU': 'Виджет для секции с данными габаритов и веса', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:delivery.before': { - description: { - 'en-GB': 'Widget for the section with delivery data', - 'es-ES': 'Widget para la sección con datos de entrega', - 'ru-RU': 'Виджет для секции с данными доставки', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:delivery.after': { - description: { - 'en-GB': 'Widget for the section with delivery data', - 'es-ES': 'Widget para la sección con datos de entrega', - 'ru-RU': 'Виджет для секции с данными доставки', - }, - location: { - 'en-GB': 'Section end, right under the input fields', - 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', - 'ru-RU': 'Конец секции, под полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:delivery.address': { - description: { - 'en-GB': 'Widget for delivery address input field', - 'es-ES': 'Widget para el campo de entrada de la dirección de entrega', - 'ru-RU': 'Виджет для поля ввода адреса доставки', - }, - location: { - 'en-GB': 'Right under the input field', - 'es-ES': 'Justo debajo del campo de entrada', - 'ru-RU': 'Под полем ввода адреса', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:payment.before': { - description: { - 'en-GB': 'Widget for the section with payment data', - 'es-ES': 'Widget para la sección con datos de pago', - 'ru-RU': 'Виджет для секции с данными по оплате', - }, - location: { - 'en-GB': 'Section start, right above the input fields', - 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', - 'ru-RU': 'Начало секции, над полями ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/card:comment.manager.before': { - description: { - 'en-GB': 'Widget for the block "Manager comment"', - 'es-ES': 'Widget para el bloque "Comentario del asesor"', - 'ru-RU': 'Виджет для блока "Комментарии оператора"', - }, - location: { - 'en-GB': 'Section start, right above the input field', - 'es-ES': 'Inicio de la sección, justo encima del campo de entrada', - 'ru-RU': 'Начало секции, над полем ввода', - }, - contexts: [ - 'order/card', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/mg:list.before': { - description: { - 'en-GB': 'Widget for the block "Order items"', - 'es-ES': 'Widget para el bloque "Artículos del pedido"', - 'ru-RU': 'Виджет для блока "Состав заказа"', - }, - location: { - 'en-GB': 'Section start, right above the list of order items', - 'es-ES': 'Inicio de la sección, justo encima de la lista de artículos del pedido', - 'ru-RU': 'Начало секции, над списком товарных позиций', - }, - contexts: [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/mg:list.after': { - description: { - 'en-GB': 'Widget for the block "Order items"', - 'es-ES': 'Widget para el bloque "Artículos del pedido"', - 'ru-RU': 'Виджет для блока "Состав заказа"', - }, - location: { - 'en-GB': 'Section end, right after the list of order items and before the discount, privilege selection, etc. input fields', - 'es-ES': 'Fin de la sección, justo después de la lista de artículos del pedido y antes de los campos de entrada de descuento, selección de privilegios, etc.', - 'ru-RU': 'Конец секции, сразу после списка товарных позиций и до полей ввода скидки, выбора привилегии и т.п.', - }, - contexts: [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/mg:delivery.before': { - description: { - 'en-GB': 'Widget for the block "Delivery"', - 'es-ES': 'Widget para el bloque "Entrega"', - 'ru-RU': 'Виджет для блока "Доставка"', - }, - location: { - 'en-GB': 'Section start, right above the input field', - 'es-ES': 'Inicio de la sección, justo encima del campo de entrada', - 'ru-RU': 'Начало секции, над полем ввода', - }, - contexts: [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/mg:delivery.after': { - description: { - 'en-GB': 'Widget for the block "Delivery"', - 'es-ES': 'Widget para el bloque "Entrega"', - 'ru-RU': 'Виджет для блока "Доставка"', - }, - location: { - 'en-GB': 'Section end, right under the input fields', - 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', - 'ru-RU': 'Конец секции, под полями ввода', - }, - contexts: [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/mg:payment.before': { - description: { - 'en-GB': 'Widget for the block "Payment"', - 'es-ES': 'Widget para el bloque "Pago"', - 'ru-RU': 'Виджет для блока "Оплата"', - }, - location: { - 'en-GB': 'Section start, right above the input field', - 'es-ES': 'Inicio de la sección, justo encima del campo de entrada', - 'ru-RU': 'Начало секции, над полем ввода', - }, - contexts: [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, - 'order/mg:payment.after': { - description: { - 'en-GB': 'Widget for the block "Payment"', - 'es-ES': 'Widget para el bloque "Pago"', - 'ru-RU': 'Виджет для блока "Оплата"', - }, - location: { - 'en-GB': 'Section end, after the list of payments, controls, and custom fields', - 'es-ES': 'Fin de la sección, después de la lista de pagos, controles y campos personalizados', - 'ru-RU': 'Конец секции, после списка оплат, контролов и пользовательских полей', - }, - contexts: [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ], - customContexts: [ - 'order', - ], - actions: [ - 'order/card', - ], - }, -} - -export const pageListDocumentation = [{ +export const pageListDocumentation: Array<{ + id: string; + description: TranslationList; +}> = [{ id: 'customer/card', description: { 'en-GB': 'Customer page', 'es-ES': 'Página del cliente', 'ru-RU': 'Страница клиента', - } as TranslationList, - targets: keysOf(targetListDocumentation).filter(target => target.startsWith('customer/card:')), + }, }, { id: 'order/card', description: { 'en-GB': 'Page with the order creation/editing form', 'es-ES': 'Página con el formulario de creación/edición de pedidos', 'ru-RU': 'Страница с формой создания/редактирования заказа', - } as TranslationList, - targets: keysOf(targetListDocumentation).filter(target => target.startsWith('order/card:')), + }, }, { id: 'order/mg', description: { 'en-GB': 'Page with the order creation/editing form in chats', 'es-ES': 'Página con el formulario de creación/edición de pedidos en los chats', 'ru-RU': 'Страница с формой создания/редактирования заказа в чатах', - } as TranslationList, - targets: keysOf(targetListDocumentation).filter(target => target.startsWith('order/mg:')), + }, }] diff --git a/package.json b/package.json index cb09e031..cddc5636 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@remote-ui/rpc": "^1.4.5", "@retailcrm/embed-ui-v1-components": "^0.9.21", "@retailcrm/embed-ui-v1-contexts": "^0.9.21", + "@retailcrm/embed-ui-v1-endpoint": "^0.9.21", "@retailcrm/embed-ui-v1-types": "^0.9.21" }, "devDependencies": { diff --git a/packages/v1-endpoint/docs/README.md b/packages/v1-endpoint/docs/README.md index c14881d9..b0fbab8f 100644 --- a/packages/v1-endpoint/docs/README.md +++ b/packages/v1-endpoint/docs/README.md @@ -13,6 +13,7 @@ ## Дополнительно - [`targets` и `defineTarget`](./targets.md) — типизированные цели для виджетов. +- [`targets/*.yml`](./targets/) — сгенерированные AI-friendly описания встроенных widget targets на английском. - [`menu-placements`](./menu-placements.md) — как описывать меню и пункты навигации, из которых открываются remote-страницы. - [`page-routes`](./page-routes.md) — как связывать page `code`, CRM route и `definePageRunner`. - [`layout`](./layout.md) — практический гайд по layout-паттернам страниц, шторок и модалок. diff --git a/packages/v1-endpoint/docs/targets.md b/packages/v1-endpoint/docs/targets.md index f0125bff..6b2059ab 100644 --- a/packages/v1-endpoint/docs/targets.md +++ b/packages/v1-endpoint/docs/targets.md @@ -7,7 +7,9 @@ - `targets` — словарь встроенных target-конфигураций. - `TargetName` — union всех ключей `targets`. -- `defineTarget(id, contexts)` — helper для объявления target с типами контекстов. +- `TargetList` — словарь `target -> SchemaList`, выведенный из `targets[target].contexts`. +- `defineTarget(id, config)` — helper для объявления target с типами контекстов, custom contexts и action scopes. + Также поддерживает альтернативную форму `defineTarget(id, contexts)`. ## Что такое `target` и `context` @@ -22,15 +24,6 @@ `target` не является данными заказа, клиента или пользователя. Это только место встраивания. Данные нужно брать из контекстов, которые привязаны к этому `target`. -## Примеры встроенных `target` - -| Target | Где встраивается | Доступные контексты | -| --- | --- | --- | -| `order/card:common.before` | Карточка заказа, перед блоком общей информации | `order/card`, `user/current`, `settings` | -| `order/card:customer.phone` | Карточка заказа, поле телефона клиента | `order/card`, `user/current`, `settings` | -| `customer/card:phone` | Карточка клиента, телефонный блок | `customer/card`, `customer/card:phone`, `user/current`, `settings` | -| `order/mg:list.before` | Карточка заказа, перед списком в multi-goods блоке | `order/card`, `order/card:settings`, `user/current`, `settings` | - ## Проверка контекстов для `target` ```ts @@ -60,6 +53,7 @@ const selectedTarget: TargetName = 'order/card:common.before' import type { TargetName } from '@retailcrm/embed-ui-v1-endpoint/common' import { useContext as useOrderContext } from '@retailcrm/embed-ui-v1-contexts/remote/order/card' +import { useContext as useOrderSettingsContext } from '@retailcrm/embed-ui-v1-contexts/remote/order/card-settings' import { useContext as useSettingsContext } from '@retailcrm/embed-ui-v1-contexts/remote/settings' import { useContext as useUserContext } from '@retailcrm/embed-ui-v1-contexts/remote/user/current' @@ -68,13 +62,14 @@ defineProps<{ }>() const order = useOrderContext() +const orderSettings = useOrderSettingsContext() const settings = useSettingsContext() const user = useUserContext() ``` Такой набор контекстов подходит для `order/card:common.before`, потому что этот `target` объявлен с -контекстами `order/card`, `user/current` и `settings`. +контекстами `order/card`, `order/card:settings`, `user/current` и `settings`. Для `customer/card:phone` набор импортов будет другим: @@ -85,18 +80,48 @@ import { useContext as useSettingsContext } from '@retailcrm/embed-ui-v1-context import { useContext as useUserContext } from '@retailcrm/embed-ui-v1-contexts/remote/user/current' ``` -## Пример `defineTarget` +## `defineTarget` в тестах + +CRM не предоставляет расширениям механизм регистрации произвольных widget targets. +В рабочем сценарии виджет монтируется только в заранее определённые платформой цели из `targets`. + +`defineTarget` нужен для объявления этих встроенных целей внутри пакета и для автотестов, +где требуется собрать изолированную target-конфигурацию без запуска CRM. ```ts import { defineTarget } from '@retailcrm/embed-ui-v1-endpoint/common' -const customTarget = defineTarget('order/card:custom.after', [ - 'order/card', +const testTarget = defineTarget('order/card:test.after', { + contexts: [ + 'order/card', + 'order/card:settings', + 'user/current', + 'settings', + ], + customContexts: ['order'], + actions: ['order/card'], +} as const) +``` + +Альтернативная форма с массивом контекстов подходит для target без custom contexts и action scopes. +В этом случае `customContexts` и `actions` будут пустыми: + +```ts +const testTarget = defineTarget('customer/card:test.after', [ + 'customer/card', 'user/current', 'settings', ] as const) ``` +## AI-friendly YAML profiles + +Для встроенных целей дополнительно генерируется каталог [`targets/*.yml`](./targets/). +Эти файлы предназначены для AI-ассистентов: они описывают target на английском, перечисляют +доступные contexts, custom contexts и action scopes, но не являются отдельным источником truth. +Обновляйте `targets.ts` и `targets.documentation.ts`, затем запускайте +`yarn workspace @retailcrm/embed-ui-v1-endpoint run build:docs`. + ## Практический совет Если раннер маппится по `target`, старайтесь использовать ключи из `TargetName`. diff --git a/packages/v1-endpoint/package.json b/packages/v1-endpoint/package.json index 8d6d9947..ca2a502b 100644 --- a/packages/v1-endpoint/package.json +++ b/packages/v1-endpoint/package.json @@ -71,8 +71,10 @@ "README.md" ], "scripts": { - "build": "yarn build:code", + "build": "yarn build:docs && yarn build:code && yarn build:meta", "build:code": "vite build -c ./vite.config.ts", + "build:docs": "npx tsx scripts/build.docs.ts", + "build:meta": "npx tsx scripts/build.meta.ts", "test:e2e": "yarn exec vitest --run --config ./vitest.config.playwright.ts", "test:playwright": "yarn exec vitest --run --config ./vitest.config.playwright.ts", "test": "vitest --run" @@ -97,6 +99,7 @@ "date-fns": "^4.1.0", "lodash.isequal": "^4.5.0", "playwright": "1.58.2", + "tsx": "^4.21.0", "vite": "^7.3.2", "vite-plugin-dts": "^4.5.4", "vitest": "4.1.3" diff --git a/packages/v1-endpoint/scripts/build.docs.ts b/packages/v1-endpoint/scripts/build.docs.ts new file mode 100644 index 00000000..2bc720fb --- /dev/null +++ b/packages/v1-endpoint/scripts/build.docs.ts @@ -0,0 +1,101 @@ +import type { TargetName } from '@/common/targets' + +import * as fs from 'node:fs' + +import { fileURLToPath } from 'node:url' + +import { dirname, join, resolve } from 'node:path' + +import { targets } from '@/common/targets' +import { targetsDocumentation } from '@/common/targets.documentation' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const docs = resolve(__dirname, '../docs/targets/') + +const targetNames = Object.keys(targets) as TargetName[] + +const pageLabels: Record = { + 'customer/card': 'customer card', + 'order/card': 'full order form', + 'order/mg': 'chat order form', +} + +const quote = (value: string): string => JSON.stringify(value) + +const paragraph = (value: string): string => `>\n ${value}` + +const list = (values: readonly string[], indent = ' '): string => values.length === 0 + ? ' []' + : `\n${values.map(value => `${indent}- ${quote(value)}`).join('\n')}` + +const fileNameOf = (target: string): string => `${target.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.yml` + +const pageOf = (target: string): string => target.split(':')[0] + +const areaOf = (target: string): string => target.split(':')[1] ?? '' + +const sentenceOf = (value: string): string => /[.!?]$/.test(value) ? value : `${value}.` + +const renderTarget = (target: TargetName): string => { + const config = targets[target] + const documentation = targetsDocumentation[target] + const page = pageOf(target) + const pageLabel = pageLabels[page] ?? page + const description = documentation.description['en-GB'] + const location = documentation.location['en-GB'] + + return `target: ${quote(target)} +summary: ${paragraph(`${sentenceOf(description)} It is placed in the ${pageLabel}: ${location}.`)} +language: en-GB +audience: ai + +public_import: + from: ${quote('@retailcrm/embed-ui-v1-endpoint/common')} + named: + - targets + - TargetName + +runner_import: + from: ${quote('@retailcrm/embed-ui-v1-endpoint/remote/widgets')} + named: + - defineWidgetRunner + - defineMultiRunner + +page: + id: ${quote(page)} + label: ${quote(pageLabel)} + area: ${quote(areaOf(target))} + +description: ${quote(description)} +location: ${quote(location)} + +target_config: + contexts:${list(config.contexts, ' ')} + custom_contexts:${list(config.customContexts, ' ')} + action_scopes:${list(config.actions, ' ')} + +use_when: + - ${quote(`Place a widget exactly at ${target}.`)} + - ${quote(`The widget belongs to the ${pageLabel} and should appear at this location: ${location}.`)} + - ${quote('The widget can work with the contexts listed in target_config.contexts.')} + +avoid_when: + - ${quote('The widget belongs to a different page, section, or field-level insertion point.')} + - ${quote('The widget requires contexts, custom contexts, or action scopes that are not listed in target_config.')} + +ai_notes: + - ${quote('Use the target id as the runner registration key.')} + - ${quote('Use targets[target].contexts as the source of truth for context availability.')} + - ${quote('Do not duplicate target context lists in generated widget code.')} +${target.startsWith('order/') + ? ` - ${quote('Order card and chat order form targets share the same order form data contract.')}\n` + : ''}` +} + +fs.rmSync(docs, { recursive: true, force: true }) +fs.mkdirSync(docs, { recursive: true }) + +for (const target of targetNames) { + fs.writeFileSync(join(docs, fileNameOf(target)), renderTarget(target)) +} diff --git a/packages/v1-endpoint/scripts/build.meta.ts b/packages/v1-endpoint/scripts/build.meta.ts new file mode 100644 index 00000000..0ac00c75 --- /dev/null +++ b/packages/v1-endpoint/scripts/build.meta.ts @@ -0,0 +1,27 @@ +import type { TargetName } from '@/common/targets' + +import * as fs from 'node:fs' + +import { fileURLToPath } from 'node:url' + +import { dirname, join, resolve } from 'node:path' + +import { targets } from '@/common/targets' +import { targetsDocumentation } from '@/common/targets.documentation' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const dist = resolve(__dirname, '../dist/') + +if (!fs.existsSync(dist)) { + fs.mkdirSync(dist) +} + +const targetNames = Object.keys(targets) as TargetName[] + +fs.writeFileSync(join(dist, 'meta.json'), JSON.stringify({ + targets: targetNames.map(target => ({ + ...targets[target], + ...targetsDocumentation[target], + })), +}, null, 2)) diff --git a/packages/v1-endpoint/src/common/targets.documentation.ts b/packages/v1-endpoint/src/common/targets.documentation.ts new file mode 100644 index 00000000..9d7c1d33 --- /dev/null +++ b/packages/v1-endpoint/src/common/targets.documentation.ts @@ -0,0 +1,313 @@ +import type { TranslationList } from '@retailcrm/embed-ui-v1-types/doc' + +import type { TargetName } from './targets' + +type TargetDocumentation = { + [Target in TargetName]: { + description: TranslationList; + location: TranslationList; + } +} + +export const targetsDocumentation = { + 'customer/card:phone': { + description: { + 'en-GB': 'Widget for customer phone list item', + 'es-ES': 'Widget para el elemento de la lista de teléfonos del cliente', + 'ru-RU': 'Виджет для элемента списка телефонов клиента', + }, + location: { + 'en-GB': 'Right after the phone number in the list', + 'es-ES': 'Justo después del número de teléfono en la lista', + 'ru-RU': 'Сразу после номера телефона в списке', + }, + }, + 'customer/card:communications.after': { + description: { + 'en-GB': 'Widget for enhancing the communication section in the left column of the customer page.', + 'es-ES': 'Widget para ampliar la sección de comunicación en la columna izquierda de la página del cliente.', + 'ru-RU': 'Виджет для дополнения секции коммуникаций в левой колонке страницы клиента.', + }, + location: { + 'en-GB': 'Right below the short summary, in the communication section', + 'es-ES': 'Justo debajo del resumen breve, en la sección de comunicación', + 'ru-RU': 'Сразу под краткой сводкой, в секции коммуникаций', + }, + }, + 'customer/card:inWork.before': { + description: { + 'en-GB': 'Widget for the contact request item', + 'es-ES': 'Widget para el elemento de solicitud de contacto', + 'ru-RU': 'Виджет для элемента обращений', + }, + location: { + 'en-GB': 'At the beginning of the "In Progress" block in the client card', + 'es-ES': 'Al principio del bloque "En progreso" en la tarjeta del cliente', + 'ru-RU': 'В начале блока "В работе" в карточке клиента', + }, + }, + 'customer/card:inWork.after': { + description: { + 'en-GB': 'Widget for the contact request item', + 'es-ES': 'Widget para el elemento de solicitud de contacto', + 'ru-RU': 'Виджет для элемента обращений', + }, + location: { + 'en-GB': 'At the end of the "In progress" block in the client card', + 'es-ES': 'Al final del bloque "En progreso" en la tarjeta del cliente', + 'ru-RU': 'В конце блока "В работе" в карточке клиента', + }, + }, + 'order/card:common.before': { + description: { + 'en-GB': 'Widget for the section with common data', + 'es-ES': 'Widget para la sección con datos comunes', + 'ru-RU': 'Виджет для секции с основными данными', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:common.after': { + description: { + 'en-GB': 'Widget for the section with common data', + 'es-ES': 'Widget para la sección con datos comunes', + 'ru-RU': 'Виджет для секции с основными данными', + }, + location: { + 'en-GB': 'Section end, right under the input fields', + 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', + 'ru-RU': 'Конец секции, под полями ввода', + }, + }, + 'order/card:customer.before': { + description: { + 'en-GB': 'Widget for the section with customer data', + 'es-ES': 'Widget para la sección con datos del cliente', + 'ru-RU': 'Виджет для секции с данными клиента', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:customer.after': { + description: { + 'en-GB': 'Widget for the section with customer data', + 'es-ES': 'Widget para la sección con datos del cliente', + 'ru-RU': 'Виджет для секции с данными клиента', + }, + location: { + 'en-GB': 'Section end, right under the input fields', + 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', + 'ru-RU': 'Конец секции, под полями ввода', + }, + }, + 'order/card:customer.email': { + description: { + 'en-GB': 'Widget for customer email input field', + 'es-ES': 'Widget para el campo de entrada del correo electrónico del cliente', + 'ru-RU': 'Виджет для поля ввода email клиента', + }, + location: { + 'en-GB': 'Right after the input field', + 'es-ES': 'Justo después del campo de entrada', + 'ru-RU': 'Сразу после поля ввода', + }, + }, + 'order/card:customer.phone': { + description: { + 'en-GB': 'Widget for customer phone input field', + 'es-ES': 'Widget para el campo de entrada del teléfono del cliente', + 'ru-RU': 'Виджет для поля ввода телефона клиента', + }, + location: { + 'en-GB': 'Right after the input field', + 'es-ES': 'Justo después del campo de entrada', + 'ru-RU': 'Сразу после поля ввода', + }, + }, + 'order/card:list.before': { + description: { + 'en-GB': 'Widget for the list of ordered items', + 'es-ES': 'Widget para la lista de artículos pedidos', + 'ru-RU': 'Виджет для списка позиций заказа', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:list.after': { + description: { + 'en-GB': 'Widget for the list of ordered items', + 'es-ES': 'Widget para la lista de artículos pedidos', + 'ru-RU': 'Виджет для списка позиций заказа', + }, + location: { + 'en-GB': 'Section start, right under the list', + 'es-ES': 'Inicio de la sección, justo debajo de la lista', + 'ru-RU': 'Начало секции, под списком', + }, + }, + 'order/card:store.before': { + description: { + 'en-GB': 'Widget for the section with warehouse data', + 'es-ES': 'Widget para la sección con datos del almacén', + 'ru-RU': 'Виджет для секции с данными склада', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:dimensions.before': { + description: { + 'en-GB': 'Widget for the section with dimensions and weight', + 'es-ES': 'Widget para la sección con dimensiones y peso', + 'ru-RU': 'Виджет для секции с данными габаритов и веса', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:delivery.before': { + description: { + 'en-GB': 'Widget for the section with delivery data', + 'es-ES': 'Widget para la sección con datos de entrega', + 'ru-RU': 'Виджет для секции с данными доставки', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:delivery.after': { + description: { + 'en-GB': 'Widget for the section with delivery data', + 'es-ES': 'Widget para la sección con datos de entrega', + 'ru-RU': 'Виджет для секции с данными доставки', + }, + location: { + 'en-GB': 'Section end, right under the input fields', + 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', + 'ru-RU': 'Конец секции, под полями ввода', + }, + }, + 'order/card:delivery.address': { + description: { + 'en-GB': 'Widget for delivery address input field', + 'es-ES': 'Widget para el campo de entrada de la dirección de entrega', + 'ru-RU': 'Виджет для поля ввода адреса доставки', + }, + location: { + 'en-GB': 'Right under the input field', + 'es-ES': 'Justo debajo del campo de entrada', + 'ru-RU': 'Под полем ввода адреса', + }, + }, + 'order/card:payment.before': { + description: { + 'en-GB': 'Widget for the section with payment data', + 'es-ES': 'Widget para la sección con datos de pago', + 'ru-RU': 'Виджет для секции с данными по оплате', + }, + location: { + 'en-GB': 'Section start, right above the input fields', + 'es-ES': 'Inicio de la sección, justo encima de los campos de entrada', + 'ru-RU': 'Начало секции, над полями ввода', + }, + }, + 'order/card:comment.manager.before': { + description: { + 'en-GB': 'Widget for the block "Manager comment"', + 'es-ES': 'Widget para el bloque "Comentario del asesor"', + 'ru-RU': 'Виджет для блока "Комментарии оператора"', + }, + location: { + 'en-GB': 'Section start, right above the input field', + 'es-ES': 'Inicio de la sección, justo encima del campo de entrada', + 'ru-RU': 'Начало секции, над полем ввода', + }, + }, + 'order/mg:list.before': { + description: { + 'en-GB': 'Widget for the block "Order items"', + 'es-ES': 'Widget para el bloque "Artículos del pedido"', + 'ru-RU': 'Виджет для блока "Состав заказа"', + }, + location: { + 'en-GB': 'Section start, right above the list of order items', + 'es-ES': 'Inicio de la sección, justo encima de la lista de artículos del pedido', + 'ru-RU': 'Начало секции, над списком товарных позиций', + }, + }, + 'order/mg:list.after': { + description: { + 'en-GB': 'Widget for the block "Order items"', + 'es-ES': 'Widget para el bloque "Artículos del pedido"', + 'ru-RU': 'Виджет для блока "Состав заказа"', + }, + location: { + 'en-GB': 'Section end, right after the list of order items and before the discount, privilege selection, etc. input fields', + 'es-ES': 'Fin de la sección, justo después de la lista de artículos del pedido y antes de los campos de entrada de descuento, selección de privilegios, etc.', + 'ru-RU': 'Конец секции, сразу после списка товарных позиций и до полей ввода скидки, выбора привилегии и т.п.', + }, + }, + 'order/mg:delivery.before': { + description: { + 'en-GB': 'Widget for the block "Delivery"', + 'es-ES': 'Widget para el bloque "Entrega"', + 'ru-RU': 'Виджет для блока "Доставка"', + }, + location: { + 'en-GB': 'Section start, right above the input field', + 'es-ES': 'Inicio de la sección, justo encima del campo de entrada', + 'ru-RU': 'Начало секции, над полем ввода', + }, + }, + 'order/mg:delivery.after': { + description: { + 'en-GB': 'Widget for the block "Delivery"', + 'es-ES': 'Widget para el bloque "Entrega"', + 'ru-RU': 'Виджет для блока "Доставка"', + }, + location: { + 'en-GB': 'Section end, right under the input fields', + 'es-ES': 'Fin de la sección, justo debajo de los campos de entrada', + 'ru-RU': 'Конец секции, под полями ввода', + }, + }, + 'order/mg:payment.before': { + description: { + 'en-GB': 'Widget for the block "Payment"', + 'es-ES': 'Widget para el bloque "Pago"', + 'ru-RU': 'Виджет для блока "Оплата"', + }, + location: { + 'en-GB': 'Section start, right above the input field', + 'es-ES': 'Inicio de la sección, justo encima del campo de entrada', + 'ru-RU': 'Начало секции, над полем ввода', + }, + }, + 'order/mg:payment.after': { + description: { + 'en-GB': 'Widget for the block "Payment"', + 'es-ES': 'Widget para el bloque "Pago"', + 'ru-RU': 'Виджет для блока "Оплата"', + }, + location: { + 'en-GB': 'Section end, after the list of payments, controls, and custom fields', + 'es-ES': 'Fin de la sección, después de la lista de pagos, controles y campos personalizados', + 'ru-RU': 'Конец секции, после списка оплат, контролов и пользовательских полей', + }, + }, +} satisfies TargetDocumentation diff --git a/packages/v1-endpoint/src/common/targets.ts b/packages/v1-endpoint/src/common/targets.ts index 535194bf..ab613230 100644 --- a/packages/v1-endpoint/src/common/targets.ts +++ b/packages/v1-endpoint/src/common/targets.ts @@ -3,176 +3,127 @@ import type { SchemaList } from '@retailcrm/embed-ui-v1-contexts/types' export type TargetDefinition< TId extends string = string, TContexts extends readonly (keyof SchemaList)[] = readonly (keyof SchemaList)[], + TCustomContexts extends readonly string[] = readonly string[], + TActions extends readonly string[] = readonly string[], > = { id: TId; contexts: TContexts; + customContexts: TCustomContexts; + actions: TActions; } -export const defineTarget = < - const TId extends string, - const TContexts extends readonly (keyof SchemaList)[], ->(id: TId, contexts: TContexts): TargetDefinition => ({ - id, - contexts, - }) +export type TargetConfig< + TContexts extends readonly (keyof SchemaList)[] = readonly (keyof SchemaList)[], + TCustomContexts extends readonly string[] = readonly string[], + TActions extends readonly string[] = readonly string[], +> = { + contexts: TContexts; + customContexts: TCustomContexts; + actions: TActions; +} -export const targets = { - 'customer/card:phone': defineTarget('customer/card:phone', [ - 'customer/card', - 'customer/card:phone', - 'user/current', - 'settings', - ]), +const isTargetConfig = ( + configOrContexts: readonly (keyof SchemaList)[] | TargetConfig +): configOrContexts is TargetConfig => !Array.isArray(configOrContexts) - 'customer/card:communications.after': defineTarget('customer/card:communications.after', [ - 'customer/card', - 'user/current', - 'settings', - ]), +export function defineTarget< + const TId extends string, + const TContexts extends readonly (keyof SchemaList)[], +>( + id: TId, + contexts: TContexts +): TargetDefinition - 'customer/card:inWork.before': defineTarget('customer/card:inWork.before', [ - 'customer/card', - 'user/current', - 'settings', - ]), +export function defineTarget< + const TId extends string, + const TContexts extends readonly (keyof SchemaList)[], + const TCustomContexts extends readonly string[], + const TActions extends readonly string[], +>( + id: TId, + config: TargetConfig +): TargetDefinition + +export function defineTarget( + id: string, + configOrContexts: + | readonly (keyof SchemaList)[] + | TargetConfig +): TargetDefinition { + if (isTargetConfig(configOrContexts)) { + return { + id, + ...configOrContexts, + } + } + + return { + id, + contexts: configOrContexts, + customContexts: [], + actions: [], + } +} - 'customer/card:inWork.after': defineTarget('customer/card:inWork.after', [ +const customerCardTarget = { + contexts: [ 'customer/card', 'user/current', 'settings', - ]), - - 'order/card:common.before': defineTarget('order/card:common.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:common.after': defineTarget('order/card:common.after', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:customer.before': defineTarget('order/card:customer.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:customer.after': defineTarget('order/card:customer.after', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:customer.email': defineTarget('order/card:customer.email', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:customer.phone': defineTarget('order/card:customer.phone', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:list.before': defineTarget('order/card:list.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:list.after': defineTarget('order/card:list.after', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:store.before': defineTarget('order/card:store.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:dimensions.before': defineTarget('order/card:dimensions.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:delivery.before': defineTarget('order/card:delivery.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:delivery.after': defineTarget('order/card:delivery.after', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:delivery.address': defineTarget('order/card:delivery.address', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:payment.before': defineTarget('order/card:payment.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/card:comment.manager.before': defineTarget('order/card:comment.manager.before', [ - 'order/card', - 'user/current', - 'settings', - ]), - - 'order/mg:list.before': defineTarget('order/mg:list.before', [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ]), - - 'order/mg:list.after': defineTarget('order/mg:list.after', [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ]), - - 'order/mg:delivery.before': defineTarget('order/mg:delivery.before', [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ]), - - 'order/mg:delivery.after': defineTarget('order/mg:delivery.after', [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ]), + ], + customContexts: ['customer'], + actions: [], +} as const - 'order/mg:payment.before': defineTarget('order/mg:payment.before', [ +const orderFormTarget = { + contexts: [ 'order/card', 'order/card:settings', 'user/current', 'settings', - ]), + ], + customContexts: ['order'], + actions: ['order/card'], +} as const - 'order/mg:payment.after': defineTarget('order/mg:payment.after', [ - 'order/card', - 'order/card:settings', - 'user/current', - 'settings', - ]), +export const targets = { + 'customer/card:phone': defineTarget('customer/card:phone', { + ...customerCardTarget, + contexts: [ + 'customer/card', + 'customer/card:phone', + 'user/current', + 'settings', + ], + }), + 'customer/card:communications.after': defineTarget('customer/card:communications.after', customerCardTarget), + 'customer/card:inWork.before': defineTarget('customer/card:inWork.before', customerCardTarget), + 'customer/card:inWork.after': defineTarget('customer/card:inWork.after', customerCardTarget), + 'order/card:common.before': defineTarget('order/card:common.before', orderFormTarget), + 'order/card:common.after': defineTarget('order/card:common.after', orderFormTarget), + 'order/card:customer.before': defineTarget('order/card:customer.before', orderFormTarget), + 'order/card:customer.after': defineTarget('order/card:customer.after', orderFormTarget), + 'order/card:customer.email': defineTarget('order/card:customer.email', orderFormTarget), + 'order/card:customer.phone': defineTarget('order/card:customer.phone', orderFormTarget), + 'order/card:list.before': defineTarget('order/card:list.before', orderFormTarget), + 'order/card:list.after': defineTarget('order/card:list.after', orderFormTarget), + 'order/card:store.before': defineTarget('order/card:store.before', orderFormTarget), + 'order/card:dimensions.before': defineTarget('order/card:dimensions.before', orderFormTarget), + 'order/card:delivery.before': defineTarget('order/card:delivery.before', orderFormTarget), + 'order/card:delivery.after': defineTarget('order/card:delivery.after', orderFormTarget), + 'order/card:delivery.address': defineTarget('order/card:delivery.address', orderFormTarget), + 'order/card:payment.before': defineTarget('order/card:payment.before', orderFormTarget), + 'order/card:comment.manager.before': defineTarget('order/card:comment.manager.before', orderFormTarget), + 'order/mg:list.before': defineTarget('order/mg:list.before', orderFormTarget), + 'order/mg:list.after': defineTarget('order/mg:list.after', orderFormTarget), + 'order/mg:delivery.before': defineTarget('order/mg:delivery.before', orderFormTarget), + 'order/mg:delivery.after': defineTarget('order/mg:delivery.after', orderFormTarget), + 'order/mg:payment.before': defineTarget('order/mg:payment.before', orderFormTarget), + 'order/mg:payment.after': defineTarget('order/mg:payment.after', orderFormTarget), } as const export type TargetName = keyof typeof targets + +export type TargetList = { + [Target in TargetName]: Pick +} diff --git a/packages/v1-endpoint/tests/factories/targets.test.ts b/packages/v1-endpoint/tests/factories/targets.test.ts index 54d7febc..c98aacaa 100644 --- a/packages/v1-endpoint/tests/factories/targets.test.ts +++ b/packages/v1-endpoint/tests/factories/targets.test.ts @@ -7,4 +7,19 @@ test('defineTarget returns typed target shape', () => { expect(target.id).toBe('order/card:common.before') expect(target.contexts).toEqual(['order/card', 'user/current', 'settings']) + expect(target.customContexts).toEqual([]) + expect(target.actions).toEqual([]) +}) + +test('defineTarget accepts target config shape', () => { + const target = defineTarget('order/card:common.before', { + contexts: ['order/card', 'order/card:settings', 'user/current', 'settings'] as const, + customContexts: ['order'] as const, + actions: ['order/card'] as const, + }) + + expect(target.id).toBe('order/card:common.before') + expect(target.contexts).toEqual(['order/card', 'order/card:settings', 'user/current', 'settings']) + expect(target.customContexts).toEqual(['order']) + expect(target.actions).toEqual(['order/card']) }) diff --git a/packages/v1-endpoint/tsconfig.json b/packages/v1-endpoint/tsconfig.json index 4f9578bd..c8dec5b2 100644 --- a/packages/v1-endpoint/tsconfig.json +++ b/packages/v1-endpoint/tsconfig.json @@ -32,6 +32,7 @@ "useDefineForClassFields": true }, "include": [ + "scripts/**/*.ts", "src/**/*.ts" ] } diff --git a/scripts/build.meta.ts b/scripts/build.meta.ts index d0837ec1..ad97b41d 100644 --- a/scripts/build.meta.ts +++ b/scripts/build.meta.ts @@ -5,10 +5,9 @@ import { fileURLToPath } from 'node:url' import { dirname, join, resolve } from 'node:path' import basic from '@retailcrm/embed-ui-v1-contexts/dist/meta.json' +import endpoint from '@retailcrm/embed-ui-v1-endpoint/dist/meta.json' -import { pageListDocumentation, targetListDocumentation } from '~meta' - -import { keysOf } from '@/utilities' +import { pageListDocumentation } from '~meta' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -20,9 +19,11 @@ if (!fs.existsSync(dist)) { fs.writeFileSync(join(dist, 'meta.json'), JSON.stringify({ ...basic, - targets: keysOf(targetListDocumentation).map(target => ({ - id: target, - ...targetListDocumentation[target], + targets: endpoint.targets, + pages: pageListDocumentation.map(page => ({ + ...page, + targets: endpoint.targets + .map(target => target.id) + .filter(target => target.startsWith(`${page.id}:`)), })), - pages: pageListDocumentation, }, null, 2)) diff --git a/types/widget.d.ts b/types/widget.d.ts index 3a79bb1d..2bfb023a 100644 --- a/types/widget.d.ts +++ b/types/widget.d.ts @@ -6,7 +6,10 @@ import type { Pinia } from 'pinia' import type { RemoteRoot, SchemaOf } from '@omnicajs/vue-remote/remote' -import type { SchemaList } from './context' +import type { + TargetList, + TargetName, +} from '@retailcrm/embed-ui-v1-endpoint/common/targets' export interface WidgetRunner { run( @@ -25,140 +28,7 @@ export interface WidgetEndpoint { release (): void; } -export type WidgetTarget = keyof SchemaListByTarget +export type WidgetTarget = TargetName export type SchemaListOf = SchemaListByTarget[T] -export type SchemaListByTarget = { - 'customer/card:phone': Pick; - 'customer/card:communications.after': Pick, - 'customer/card:inWork.before': Pick; - 'customer/card:inWork.after': Pick; - 'order/card:common.before': Pick; - 'order/card:common.after': Pick; - 'order/card:customer.before': Pick; - 'order/card:customer.after': Pick; - 'order/card:customer.email': Pick; - 'order/card:customer.phone': Pick; - 'order/card:list.before': Pick; - 'order/card:list.after': Pick; - 'order/card:store.before': Pick; - 'order/card:dimensions.before': Pick; - 'order/card:delivery.before': Pick; - 'order/card:delivery.after': Pick; - 'order/card:delivery.address': Pick; - 'order/card:payment.before': Pick; - 'order/card:comment.manager.before': Pick; - 'order/mg:list.before': Pick; - 'order/mg:list.after': Pick; - 'order/mg:delivery.before': Pick; - 'order/mg:delivery.after': Pick; - 'order/mg:payment.before': Pick; - 'order/mg:payment.after': Pick; -} +export type SchemaListByTarget = TargetList diff --git a/yarn.lock b/yarn.lock index 8fffc942..8c519b37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1347,7 +1347,7 @@ __metadata: languageName: unknown linkType: soft -"@retailcrm/embed-ui-v1-endpoint@workspace:packages/v1-endpoint": +"@retailcrm/embed-ui-v1-endpoint@npm:^0.9.21, @retailcrm/embed-ui-v1-endpoint@workspace:packages/v1-endpoint": version: 0.0.0-use.local resolution: "@retailcrm/embed-ui-v1-endpoint@workspace:packages/v1-endpoint" dependencies: @@ -1361,6 +1361,7 @@ __metadata: date-fns: "npm:^4.1.0" lodash.isequal: "npm:^4.5.0" playwright: "npm:1.58.2" + tsx: "npm:^4.21.0" vite: "npm:^7.3.2" vite-plugin-dts: "npm:^4.5.4" vitest: "npm:4.1.3" @@ -1407,6 +1408,7 @@ __metadata: "@remote-ui/rpc": "npm:^1.4.5" "@retailcrm/embed-ui-v1-components": "npm:^0.9.21" "@retailcrm/embed-ui-v1-contexts": "npm:^0.9.21" + "@retailcrm/embed-ui-v1-endpoint": "npm:^0.9.21" "@retailcrm/embed-ui-v1-testing": "npm:^0.9.21" "@retailcrm/embed-ui-v1-types": "npm:^0.9.21" "@types/git-semver-tags": "npm:^7.0.0" From 058f309704e18b86beedc3cb6b917c85b35fbb3a Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 4 May 2026 18:28:32 +0400 Subject: [PATCH 03/15] feat(v1-endpoint): MCP target resources and agent setup added --- packages/v1-endpoint/README.md | 39 ++ .../bin/embed-ui-v1-endpoint-mcp.mjs | 10 + .../v1-endpoint/bin/embed-ui-v1-endpoint.mjs | 189 ++++++ packages/v1-endpoint/docs/README.md | 1 + packages/v1-endpoint/package.json | 18 +- packages/v1-endpoint/src/mcp/server.ts | 127 ++++ packages/v1-endpoint/vite.config.mcp.ts | 55 ++ yarn.lock | 543 +++++++++++++++++- 8 files changed, 977 insertions(+), 5 deletions(-) create mode 100755 packages/v1-endpoint/bin/embed-ui-v1-endpoint-mcp.mjs create mode 100755 packages/v1-endpoint/bin/embed-ui-v1-endpoint.mjs create mode 100644 packages/v1-endpoint/src/mcp/server.ts create mode 100644 packages/v1-endpoint/vite.config.mcp.ts diff --git a/packages/v1-endpoint/README.md b/packages/v1-endpoint/README.md index 31333d20..515f7167 100644 --- a/packages/v1-endpoint/README.md +++ b/packages/v1-endpoint/README.md @@ -44,3 +44,42 @@ runEndpoint(runner) - [`menu-placements`](./docs/menu-placements.md) — как описывать меню и пункты навигации для remote-страниц. - [`page-routes`](./docs/page-routes.md) — как связывать page `code`, CRM route и `definePageRunner`. - [`layout`](./docs/layout.md) — как выбирать layout-паттерны страниц, `modal sidebar` и `modal window`, и из каких `v1-components` их собирать. + +## MCP для AI-ассистентов + +Пакет поставляет MCP-сервер с AI-friendly описаниями встроенных widget targets. +Сервер читает сгенерированные YAML-профили из `docs/targets/*.yml` и отдаёт их как MCP resources. + +Запуск через опубликованный пакет: + +```bash +npx -p @retailcrm/embed-ui-v1-endpoint embed-ui-v1-endpoint-mcp +``` + +Пример stdio-конфига для MCP-клиента: + +```json +{ + "command": "npx", + "args": ["-y", "-p", "@retailcrm/embed-ui-v1-endpoint", "embed-ui-v1-endpoint-mcp"] +} +``` + +Базовые resources: + +- `embed-ui-v1-endpoint://targets` — JSON-индекс всех target profiles. +- `embed-ui-v1-endpoint://targets/` — YAML-профиль конкретного target. + +## AI и инициализация `AGENTS.md` + +Чтобы агент понимал, когда использовать MCP-сервер пакета, можно добавить в +целевой проект секцию с инструкциями: + +```bash +npx @retailcrm/embed-ui-v1-endpoint init-agents +``` + +Если `AGENTS.md` ещё нет, команда создаст файл. Если файл уже есть, команда +допишет в конец английский блок для `@retailcrm/embed-ui-v1-endpoint`, если +такого блока там ещё нет. С `--force` можно обновить уже существующий блок +пакета. diff --git a/packages/v1-endpoint/bin/embed-ui-v1-endpoint-mcp.mjs b/packages/v1-endpoint/bin/embed-ui-v1-endpoint-mcp.mjs new file mode 100755 index 00000000..7ae9e21a --- /dev/null +++ b/packages/v1-endpoint/bin/embed-ui-v1-endpoint-mcp.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { runEndpointMcpServer } from '../dist/mcp/server.js' + +try { + await runEndpointMcpServer() +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/packages/v1-endpoint/bin/embed-ui-v1-endpoint.mjs b/packages/v1-endpoint/bin/embed-ui-v1-endpoint.mjs new file mode 100755 index 00000000..c87ab239 --- /dev/null +++ b/packages/v1-endpoint/bin/embed-ui-v1-endpoint.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const PACKAGE_NAME = '@retailcrm/embed-ui-v1-endpoint' +const DEFAULT_NEWLINE = '\n' +const AGENTS_SECTION_HEADER = '## @retailcrm/embed-ui-v1-endpoint' + +const HELP_TEXT = `Usage: + npx ${PACKAGE_NAME} init-agents [target] [options] + +Options: + -f, --force Replace existing package section in AGENTS.md + -h, --help Show this help + +Examples: + npx ${PACKAGE_NAME} init-agents + npx ${PACKAGE_NAME} init-agents ./my-project + npx ${PACKAGE_NAME} init-agents --force +` + +const parseArgs = (argv) => { + const options = { + command: null, + target: process.cwd(), + force: false, + } + + const positionals = [] + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index] + + if (argument === '-h' || argument === '--help') { + console.log(HELP_TEXT) + process.exit(0) + } + + if (argument === '-f' || argument === '--force') { + options.force = true + continue + } + + if (argument.startsWith('-')) { + throw new Error(`Unknown option: ${argument}`) + } + + positionals.push(argument) + } + + if (!positionals.length) { + throw new Error('Command is required') + } + + options.command = positionals[0] + + if (positionals.length >= 2) { + options.target = path.resolve(process.cwd(), positionals[1]) + } + + if (positionals.length > 2) { + throw new Error('Too many positional arguments') + } + + return options +} + +const createAgentsSection = () => { + return `${AGENTS_SECTION_HEADER} + +When working with \`${PACKAGE_NAME}\` in this project: + +1. Read \`./node_modules/${PACKAGE_NAME}/README.md\`. +2. Then read the relevant guide from \`./node_modules/${PACKAGE_NAME}/docs/README.md\`. +3. Use documented public entrypoints instead of package internals: + - \`${PACKAGE_NAME}/remote\` + - \`${PACKAGE_NAME}/common/targets\` +4. Do not import from \`${PACKAGE_NAME}/dist/*\`, source files, or repository-only paths. +5. When the task involves widget targets, target placement, target contexts, target metadata, or choosing a target, use the package MCP server if it is available. +6. First read \`embed-ui-v1-endpoint://targets\` to discover available target profiles. +7. Then read the relevant \`embed-ui-v1-endpoint://targets/\` resource before answering or changing code related to that target. +8. If MCP resources are not available, use the generated YAML profiles from \`./node_modules/${PACKAGE_NAME}/docs/targets/*.yml\` as the fallback source. +9. Prefer target profiles over guessing target placement, contexts, or semantic intent from names alone. + +Suggested MCP stdio server configuration: + +\`\`\`json +{ + "command": "npx", + "args": ["-y", "-p", "${PACKAGE_NAME}", "embed-ui-v1-endpoint-mcp"] +} +\`\`\` +` +} + +const createAgentsTemplate = () => { + return `# AGENTS.md + +${createAgentsSection()}` + DEFAULT_NEWLINE +} + +const hasPackageSection = (content) => content.includes(AGENTS_SECTION_HEADER) + +const appendSection = (content, section) => { + const trimmed = content.replace(/\s+$/u, '') + + if (!trimmed.length) { + return `${section}${DEFAULT_NEWLINE}` + } + + return `${trimmed}${DEFAULT_NEWLINE}${DEFAULT_NEWLINE}${section}${DEFAULT_NEWLINE}` +} + +const replaceSection = (content, section) => { + const escapedHeader = AGENTS_SECTION_HEADER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const sectionPattern = new RegExp(`${escapedHeader}[\\s\\S]*?(?=\\n##\\s|$)`, 'u') + + if (!sectionPattern.test(content)) { + return appendSection(content, section) + } + + return content + .replace(sectionPattern, section.trimEnd()) + .replace(/\s+$/u, '') + DEFAULT_NEWLINE +} + +const initAgents = (target, force) => { + if (!fs.existsSync(target)) { + throw new Error(`Target path does not exist: ${target}`) + } + + const stat = fs.statSync(target) + + if (!stat.isDirectory()) { + throw new Error(`Target path is not a directory: ${target}`) + } + + const agentsPath = path.join(target, 'AGENTS.md') + const section = createAgentsSection() + + if (!fs.existsSync(agentsPath)) { + fs.writeFileSync(agentsPath, createAgentsTemplate(), 'utf8') + + console.log(`AGENTS.md was created at ${agentsPath}`) + console.log('Next step: review it and adjust project-specific rules if needed.') + return + } + + const currentContent = fs.readFileSync(agentsPath, 'utf8') + + if (force) { + fs.writeFileSync(agentsPath, replaceSection(currentContent, section), 'utf8') + console.log(`AGENTS.md was updated at ${agentsPath}`) + console.log(`The ${PACKAGE_NAME} section was refreshed.`) + return + } + + if (hasPackageSection(currentContent)) { + console.log(`AGENTS.md already contains a ${PACKAGE_NAME} section at ${agentsPath}`) + console.log('Nothing was changed. Re-run with --force to refresh that section.') + return + } + + fs.writeFileSync(agentsPath, appendSection(currentContent, section), 'utf8') + + console.log(`AGENTS.md was updated at ${agentsPath}`) + console.log(`The ${PACKAGE_NAME} instructions were appended to the end of the file.`) +} + +const main = () => { + try { + const options = parseArgs(process.argv.slice(2)) + + if (options.command !== 'init-agents') { + throw new Error(`Unknown command: ${options.command}`) + } + + initAgents(options.target, options.force) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + console.error('') + console.error(HELP_TEXT) + process.exit(1) + } +} + +main() diff --git a/packages/v1-endpoint/docs/README.md b/packages/v1-endpoint/docs/README.md index b0fbab8f..abbffec9 100644 --- a/packages/v1-endpoint/docs/README.md +++ b/packages/v1-endpoint/docs/README.md @@ -14,6 +14,7 @@ - [`targets` и `defineTarget`](./targets.md) — типизированные цели для виджетов. - [`targets/*.yml`](./targets/) — сгенерированные AI-friendly описания встроенных widget targets на английском. +- MCP-сервер `embed-ui-v1-endpoint-mcp` — поставляемый stdio server, который отдаёт `targets/*.yml` как MCP resources. - [`menu-placements`](./menu-placements.md) — как описывать меню и пункты навигации, из которых открываются remote-страницы. - [`page-routes`](./page-routes.md) — как связывать page `code`, CRM route и `definePageRunner`. - [`layout`](./layout.md) — практический гайд по layout-паттернам страниц, шторок и модалок. diff --git a/packages/v1-endpoint/package.json b/packages/v1-endpoint/package.json index ca2a502b..871ef383 100644 --- a/packages/v1-endpoint/package.json +++ b/packages/v1-endpoint/package.json @@ -1,5 +1,9 @@ { "name": "@retailcrm/embed-ui-v1-endpoint", + "bin": { + "embed-ui-v1-endpoint": "./bin/embed-ui-v1-endpoint.mjs", + "embed-ui-v1-endpoint-mcp": "./bin/embed-ui-v1-endpoint-mcp.mjs" + }, "type": "module", "version": "0.9.21", "description": "Endpoint API for integrations in RetailCRM", @@ -25,6 +29,12 @@ "require": "./dist/common/targets.cjs", "default": "./dist/common/targets.js" }, + "./mcp": { + "types": "./dist/mcp/server.d.ts", + "import": "./dist/mcp/server.js", + "require": "./dist/mcp/server.cjs", + "default": "./dist/mcp/server.js" + }, "./remote": { "types": "./dist/remote.d.ts", "import": "./dist/remote.js", @@ -54,6 +64,9 @@ "common/targets": [ "./dist/common/targets.d.ts" ], + "mcp": [ + "./dist/mcp/server.d.ts" + ], "remote": [ "./dist/remote.d.ts" ], @@ -66,14 +79,16 @@ } }, "files": [ + "bin", "dist", "docs", "README.md" ], "scripts": { - "build": "yarn build:docs && yarn build:code && yarn build:meta", + "build": "yarn build:docs && yarn build:code && yarn build:mcp && yarn build:meta", "build:code": "vite build -c ./vite.config.ts", "build:docs": "npx tsx scripts/build.docs.ts", + "build:mcp": "vite build -c ./vite.config.mcp.ts", "build:meta": "npx tsx scripts/build.meta.ts", "test:e2e": "yarn exec vitest --run --config ./vitest.config.playwright.ts", "test:playwright": "yarn exec vitest --run --config ./vitest.config.playwright.ts", @@ -87,6 +102,7 @@ "vue": "^3.5" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@remote-ui/rpc": "^1.4.7", "@retailcrm/embed-ui-v1-components": "^0.9.21", "@retailcrm/embed-ui-v1-contexts": "^0.9.21", diff --git a/packages/v1-endpoint/src/mcp/server.ts b/packages/v1-endpoint/src/mcp/server.ts new file mode 100644 index 00000000..c9814e81 --- /dev/null +++ b/packages/v1-endpoint/src/mcp/server.ts @@ -0,0 +1,127 @@ +import * as fs from 'node:fs' + +import process from 'node:process' + +import { fileURLToPath } from 'node:url' + +import { dirname, join, resolve } from 'node:path' + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const targetDocs = join(packageRoot, 'docs/targets') +const packageJson = join(packageRoot, 'package.json') + +type TargetProfile = { + id: string; + file: string; + uri: string; + content: string; +} + +const targetUri = (target: string): string => + `embed-ui-v1-endpoint://targets/${encodeURIComponent(target)}` + +const readPackageVersion = (): string => { + const content = fs.readFileSync(packageJson, 'utf8') + + return JSON.parse(content).version +} + +const readTargetProfiles = (): TargetProfile[] => { + if (!fs.existsSync(targetDocs)) { + return [] + } + + return fs.readdirSync(targetDocs) + .filter(file => file.endsWith('.yml')) + .sort() + .map(file => { + const content = fs.readFileSync(join(targetDocs, file), 'utf8') + const target = content.match(/^target: "(.+)"$/m)?.[1] + + if (!target) { + throw new Error(`Target profile ${file} does not contain target field`) + } + + return { + id: target, + file, + uri: targetUri(target), + content, + } + }) +} + +const waitForCloseSignal = (): Promise => { + const keepAlive = setInterval(() => undefined, 2 ** 31 - 1) + + process.stdin.resume() + + return new Promise(resolve => { + const close = (): void => { + clearInterval(keepAlive) + resolve() + } + + process.once('SIGINT', close) + process.once('SIGTERM', close) + }) +} + +export const createEndpointMcpServer = (): McpServer => { + const server = new McpServer({ + name: '@retailcrm/embed-ui-v1-endpoint', + version: readPackageVersion(), + }) + + const targets = readTargetProfiles() + + server.registerResource('v1-endpoint targets index', 'embed-ui-v1-endpoint://targets', { + title: 'v1-endpoint widget targets index', + description: 'Machine-readable index of built-in widget targets provided by @retailcrm/embed-ui-v1-endpoint.', + mimeType: 'application/json', + }, uri => ({ + contents: [{ + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify({ + package: '@retailcrm/embed-ui-v1-endpoint', + resources: targets.map(target => ({ + target: target.id, + uri: target.uri, + file: `docs/targets/${target.file}`, + })), + }, null, 2), + }], + })) + + for (const target of targets) { + server.registerResource(`v1-endpoint target ${target.id}`, target.uri, { + title: `Target ${target.id}`, + description: `AI-friendly YAML profile for widget target ${target.id}.`, + mimeType: 'application/yaml', + annotations: { + audience: ['assistant'], + priority: 0.8, + }, + }, uri => ({ + contents: [{ + uri: uri.href, + mimeType: 'application/yaml', + text: target.content, + }], + })) + } + + return server +} + +export const runEndpointMcpServer = async (): Promise => { + const server = createEndpointMcpServer() + + await server.connect(new StdioServerTransport()) + await waitForCloseSignal() + await server.close() +} diff --git a/packages/v1-endpoint/vite.config.mcp.ts b/packages/v1-endpoint/vite.config.mcp.ts new file mode 100644 index 00000000..a5ac4534 --- /dev/null +++ b/packages/v1-endpoint/vite.config.mcp.ts @@ -0,0 +1,55 @@ +import { builtinModules } from 'node:module' +import * as path from 'node:path' + +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +import { dependencies, peerDependencies } from './package.json' + +const externalPackages = [ + ...Object.keys(peerDependencies ?? {}), + ...Object.keys(dependencies ?? {}), +] + +const builtinPackageNames = new Set([ + ...builtinModules, + ...builtinModules.map(module => `node:${module}`), +]) + +const isPackageExternal = (id: string): boolean => + builtinPackageNames.has(id) + || externalPackages.some(packageName => id === packageName || id.startsWith(`${packageName}/`)) + +export default defineConfig({ + plugins: [dts({ + tsconfigPath: path.resolve(__dirname, './tsconfig.json'), + entryRoot: path.resolve(__dirname, './src/mcp'), + include: [path.resolve(__dirname, './src/mcp')], + outDir: path.resolve(__dirname, './dist/mcp'), + insertTypesEntry: false, + staticImport: true, + })], + build: { + emptyOutDir: false, + lib: { + entry: { + server: path.resolve(__dirname, './src/mcp/server.ts'), + }, + formats: ['es', 'cjs'], + fileName: (format, name) => `${name}.${{ + es: 'js', + cjs: 'cjs', + }[format as 'es' | 'cjs']}`, + }, + rollupOptions: { + external: isPackageExternal, + output: { + exports: 'named', + dir: path.join(__dirname, '/dist/mcp'), + }, + }, + minify: false, + ssr: true, + target: 'node22', + }, +}) diff --git a/yarn.lock b/yarn.lock index 8c519b37..35717c5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -932,6 +932,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.14 + resolution: "@hono/node-server@npm:1.19.14" + peerDependencies: + hono: ^4 + checksum: 10/618dd95feeb3fd11ec8502e088879cd86529523788de19602edebd16892dd61899e73564d6e3d00875cc5a49488a908ddb2aa425d28f9cdeb7f22cfecabf022c + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -1151,6 +1160,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.29.0": + version: 1.29.0 + resolution: "@modelcontextprotocol/sdk@npm:1.29.0" + dependencies: + "@hono/node-server": "npm:^1.19.9" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10/ff551b97e06b661f95fec8fd34e112c446e69894a84a9979cdac369fb5de27f0a1a5c1f4e2a1f270cc60f93e54c28a8059a94ca51c3d528d2670ade874b244f9 + languageName: node + linkType: hard + "@modulify/git-toolkit@npm:^0.0.2": version: 0.0.2 resolution: "@modulify/git-toolkit@npm:0.0.2" @@ -1351,6 +1393,7 @@ __metadata: version: 0.0.0-use.local resolution: "@retailcrm/embed-ui-v1-endpoint@workspace:packages/v1-endpoint" dependencies: + "@modelcontextprotocol/sdk": "npm:^1.29.0" "@remote-ui/rpc": "npm:^1.4.7" "@retailcrm/embed-ui-v1-components": "npm:^0.9.21" "@retailcrm/embed-ui-v1-contexts": "npm:^0.9.21" @@ -3014,6 +3057,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: "npm:^3.0.0" + negotiator: "npm:^1.0.0" + checksum: 10/ea1343992b40b2bfb3a3113fa9c3c2f918ba0f9197ae565c48d3f84d44b174f6b1d5cd9989decd7655963eb03a272abc36968cc439c2907f999bd5ef8653d5a7 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -3076,7 +3129,7 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:~3.0.1": +"ajv-formats@npm:^3.0.1, ajv-formats@npm:~3.0.1": version: 3.0.1 resolution: "ajv-formats@npm:3.0.1" dependencies: @@ -3114,6 +3167,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.17.1": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/5ce59c0537f4c2aca9a758b412659ec70acb4d5dde971c10ecf21d2e3d799f99acdb4a08e1f5fb2e067c8542930398aae793bb996bb07d3feb81dae22fe2ada9 + languageName: node + linkType: hard + "ajv@npm:~8.18.0": version: 8.18.0 resolution: "ajv@npm:8.18.0" @@ -3395,6 +3460,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.2.2 + resolution: "body-parser@npm:2.2.2" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^1.0.5" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.0" + iconv-lite: "npm:^0.7.0" + on-finished: "npm:^2.4.1" + qs: "npm:^6.14.1" + raw-body: "npm:^3.0.1" + type-is: "npm:^2.0.1" + checksum: 10/69671f67d4d5ae5974593901a92d639757231da1725ed6de4d35e86cde9ce7650afdf1cd28df9b6f7892ea7f9eb03ccb30c70fe27d679275ae4cb4aae5ce1b21 + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -3463,6 +3545,13 @@ __metadata: languageName: node linkType: hard +"bytes@npm:^3.1.2, bytes@npm:~3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 + languageName: node + linkType: hard + "cacache@npm:^20.0.1": version: 20.0.4 resolution: "cacache@npm:20.0.4" @@ -3700,6 +3789,20 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:^1.0.0": + version: 1.1.0 + resolution: "content-disposition@npm:1.1.0" + checksum: 10/c4f65e3c001a4a8eb87d0d24c0f112abb139836fb13b8ea67276715e7dce09570ef666ba7848ee8b660d467e6588d030c8ed7e8d0128db6ca78a0800dcd8c7a8 + languageName: node + linkType: hard + +"content-type@npm:^1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10/585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662 + languageName: node + linkType: hard + "conventional-changelog-angular@npm:^8.0.0": version: 8.0.0 resolution: "conventional-changelog-angular@npm:8.0.0" @@ -3868,6 +3971,20 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 10/be44a3c9a56f3771aea3a8bd8ad8f0a8e2679bcb967478267f41a510b4eb5ec55085386ba79c706c4ac21605ca76f4251973444b90283e0eb3eeafe8a92c7708 + languageName: node + linkType: hard + +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f + languageName: node + linkType: hard + "copy-anything@npm:^2.0.1": version: 2.0.6 resolution: "copy-anything@npm:2.0.6" @@ -3877,7 +3994,17 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.6": +"cors@npm:^2.8.5": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: "npm:^4" + vary: "npm:^1" + checksum: 10/aa7174305b21ceb90f9c84f4eaa32f04432d333addbfdc0d1eb7310393c48902e5364aada5ac2f5d054528d63b3179238444475426fcb74e1e345077de485727 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -4147,6 +4274,13 @@ __metadata: languageName: node linkType: hard +"depd@npm:^2.0.0, depd@npm:~2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: 10/c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca + languageName: node + linkType: hard + "dequal@npm:^2.0.0": version: 2.0.3 resolution: "dequal@npm:2.0.3" @@ -4293,6 +4427,13 @@ __metadata: languageName: node linkType: hard +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 10/1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.263": version: 1.5.286 resolution: "electron-to-chromium@npm:1.5.286" @@ -4324,6 +4465,13 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:^2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: 10/abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.17.1": version: 5.17.1 resolution: "enhanced-resolve@npm:5.17.1" @@ -4682,6 +4830,13 @@ __metadata: languageName: node linkType: hard +"escape-html@npm:^1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 10/6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 + languageName: node + linkType: hard + "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -5014,6 +5169,29 @@ __metadata: languageName: node linkType: hard +"etag@npm:^1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 10/571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff + languageName: node + linkType: hard + +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.0.8 + resolution: "eventsource-parser@npm:3.0.8" + checksum: 10/286a84a7005e3e669e94dce0bb48f00acfda0d3973671ae2790e48a93f7b27a85b1828df8f3876c70afe4d577b6a7e4f8794cb596072585b584b4f207bc35c15 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10/e034915bc97068d1d38617951afd798e6776d6a3a78e36a7569c235b177c7afc2625c9fe82656f7341ab72c7eeecb3fd507b7f88e9328f2448872ff9c4742bb6 + languageName: node + linkType: hard + "expect-type@npm:^1.3.0": version: 1.3.0 resolution: "expect-type@npm:1.3.0" @@ -5028,6 +5206,53 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.4.1 + resolution: "express-rate-limit@npm:8.4.1" + dependencies: + ip-address: "npm:10.1.0" + peerDependencies: + express: ">= 4.11" + checksum: 10/2f979a261e6c95e31a62f2ce1cb9621566b0061706d0337091c8399617d6f09b28402ba4383aa3d72518374a7eec35be24f9703de59e563cc127ba14e2748a4d + languageName: node + linkType: hard + +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.1" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10/4aa545d89702ac83f645c77abda1b57bcabe288f0b380fb5580fac4e323ea0eb533005c8e666b4e19152fb16d4abf11ba87b22aa9a10857a0485cd86b94639bd + languageName: node + linkType: hard + "exsolve@npm:^1.0.7": version: 1.0.8 resolution: "exsolve@npm:1.0.8" @@ -5100,6 +5325,20 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + checksum: 10/f4ba75c23408d8f9d393c3e875b9452e84d68c925411a6e67b7efa678b0bed5075ef33def4bb65ed8e0dd37c92a3ea354bcbde07303cd4dc2550e12b95885067 + languageName: node + linkType: hard + "find-up-simple@npm:^1.0.0": version: 1.0.0 resolution: "find-up-simple@npm:1.0.0" @@ -5153,6 +5392,20 @@ __metadata: languageName: node linkType: hard +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: 10/29ba9fd347117144e97cbb8852baae5e8b2acb7d1b591ef85695ed96f5b933b1804a7fac4a15dd09ca7ac7d0cdc104410e8102aae2dd3faa570a797ba07adb81 + languageName: node + linkType: hard + +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 10/44e1468488363074641991c1340d2a10c5a6f6d7c353d89fd161c49d120c58ebf9890720f7584f509058385836e3ce50ddb60e9f017315a4ba8c6c3461813bfc + languageName: node + linkType: hard + "fs-extra@npm:~11.3.0": version: 11.3.2 resolution: "fs-extra@npm:11.3.2" @@ -5549,6 +5802,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.16 + resolution: "hono@npm:4.12.16" + checksum: 10/60ebaf89dc62820a9caf2b15dcc720d29037489c292bdd4e14e59ce3481978f08c1268ad1d179bc15b241e027717dad812ed37eb4bb3dd19589038be5da3ba6f + languageName: node + linkType: hard + "hosted-git-info@npm:^7.0.0": version: 7.0.2 resolution: "hosted-git-info@npm:7.0.2" @@ -5581,6 +5841,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10/9fe31bc0edf36566c87048aed1d3d0cbe03552564adc3541626a0613f542d753fbcb13bdfcec0a3a530dbe1714bb566c89d46244616b66bddd26ac413b06a207 + languageName: node + linkType: hard + "http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -5610,7 +5883,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.7.2": +"iconv-lite@npm:^0.7.0, iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": version: 0.7.2 resolution: "iconv-lite@npm:0.7.2" dependencies: @@ -5680,6 +5953,13 @@ __metadata: languageName: node linkType: hard +"inherits@npm:~2.0.4": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 + languageName: node + linkType: hard + "ini@npm:^1.3.4": version: 1.3.8 resolution: "ini@npm:1.3.8" @@ -5698,6 +5978,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:10.1.0": + version: 10.1.0 + resolution: "ip-address@npm:10.1.0" + checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -5708,6 +5995,13 @@ __metadata: languageName: node linkType: hard +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: 10/864d0cced0c0832700e9621913a6429ccdc67f37c1bd78fb8c6789fff35c9d167cb329134acad2290497a53336813ab4798d2794fd675d5eb33b5fdf0982b9ca + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -5915,6 +6209,13 @@ __metadata: languageName: node linkType: hard +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 10/0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a + languageName: node + linkType: hard + "is-regex@npm:^1.0.3, is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" @@ -6100,6 +6401,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.3 + resolution: "jose@npm:6.2.3" + checksum: 10/876974613c5ee988d43b65a34c96ce440dbf7706a2f07f465b8874af16ee532102e224459a7068d2c6ef044affe49690667d23ca12770c279804baec95a09608 + languageName: node + linkType: hard + "js-beautify@npm:^1.14.9": version: 1.15.1 resolution: "js-beautify@npm:1.15.1" @@ -6219,6 +6527,13 @@ __metadata: languageName: node linkType: hard +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 10/fa866d1fe91e3a94aa4fe007861475cd03dcaf47b719861cab171ef2f8598478007c634d29ae45de94ee34ddff4e13414c63ea5ff06c5b868b613142c699d511 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -6815,6 +7130,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: 10/a58dd60804df73c672942a7253ccc06815612326dc1c0827984b1a21704466d7cde351394f47649e56cf7415e6ee2e26e000e81b51b3eebb5a93540e8bf93cbd + languageName: node + linkType: hard + "meow@npm:^12.0.1": version: 12.1.1 resolution: "meow@npm:12.1.1" @@ -6829,6 +7151,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10/e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "micromark-core-commonmark@npm:^2.0.0": version: 2.0.2 resolution: "micromark-core-commonmark@npm:2.0.2" @@ -7158,6 +7487,22 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: 10/9e7834be3d66ae7f10eaa69215732c6d389692b194f876198dca79b2b90cbf96688d9d5d05ef7987b20f749b769b11c01766564264ea5f919c88b32a29011311 + languageName: node + linkType: hard + +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: "npm:^1.54.0" + checksum: 10/9db0ad31f5eff10ee8f848130779b7f2d056ddfdb6bda696cb69be68d486d33a3457b4f3f9bdeb60d0736edb471bd5a7c0a384375c011c51c889fd0d5c3b893e + languageName: node + linkType: hard + "mime@npm:^1.4.1": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -7460,7 +7805,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.1.1": +"object-assign@npm:^4, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: 10/fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -7537,6 +7882,24 @@ __metadata: languageName: node linkType: hard +"on-finished@npm:^2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: "npm:1.1.1" + checksum: 10/8e81472c5028125c8c39044ac4ab8ba51a7cdc19a9fbd4710f5d524a74c6d8c9ded4dd0eed83f28d3d33ac1d7a6a439ba948ccb765ac6ce87f30450a26bfe2ea + languageName: node + linkType: hard + +"once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10/cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68 + languageName: node + linkType: hard + "open@npm:^10.2.0": version: 10.2.0 resolution: "open@npm:10.2.0" @@ -7642,6 +8005,13 @@ __metadata: languageName: node linkType: hard +"parseurl@npm:^1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 10/407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 + languageName: node + linkType: hard + "path-browserify@npm:^1.0.1": version: 1.0.1 resolution: "path-browserify@npm:1.0.1" @@ -7690,6 +8060,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^8.0.0": + version: 8.4.2 + resolution: "path-to-regexp@npm:8.4.2" + checksum: 10/70fd2cbce0b962cbcf4d312af07818bfce2bae11c09cf3bd86be99c0e30168238a1a7b02b18b452e73f075897df04597d30d63e56da7be41eecfc37998693389 + languageName: node + linkType: hard + "pathe@npm:^2.0.1, pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" @@ -7758,6 +8135,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 10/51d11f68d5a78617cfb2e9c2706dadcc2cbe55ffb55b21d42a6ed848ac5159db2657bf6c966a5a414119aa839ceb64240afea35e9e1c06946b57606ed0b43789 + languageName: node + linkType: hard + "pkg-types@npm:^1.3.0": version: 1.3.1 resolution: "pkg-types@npm:1.3.1" @@ -7880,6 +8264,16 @@ __metadata: languageName: node linkType: hard +"proxy-addr@npm:^2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10/f24a0c80af0e75d31e3451398670d73406ec642914da11a2965b80b1898ca6f66a0e3e091a11a4327079b2b268795f6fa06691923fef91887215c3d0e8ea3f68 + languageName: node + linkType: hard + "prr@npm:~1.0.1": version: 1.0.1 resolution: "prr@npm:1.0.1" @@ -8021,6 +8415,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.14.0, qs@npm:^6.14.1": + version: 6.15.1 + resolution: "qs@npm:6.15.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac + languageName: node + linkType: hard + "quansync@npm:^0.2.11": version: 0.2.11 resolution: "quansync@npm:0.2.11" @@ -8028,6 +8431,25 @@ __metadata: languageName: node linkType: hard +"range-parser@npm:^1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 10/ce21ef2a2dd40506893157970dc76e835c78cf56437e26e19189c48d5291e7279314477b06ac38abd6a401b661a6840f7b03bd0b1249da9b691deeaa15872c26 + languageName: node + linkType: hard + +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10/4168c82157bd69175d5bd960e59b74e253e237b358213694946a427a6f750a18b8e150f036fed3421b3e83294b071a4e2bb01037a79ccacdac05360c63d3ebba + languageName: node + linkType: hard + "react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": version: 19.2.1 resolution: "react-dom@npm:19.2.1" @@ -8390,6 +8812,19 @@ __metadata: languageName: node linkType: hard +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + is-promise: "npm:^4.0.0" + parseurl: "npm:^1.3.3" + path-to-regexp: "npm:^8.0.0" + checksum: 10/8949bd1d3da5403cc024e2989fee58d7fda0f3ffe9f2dc5b8a192f295f400b3cde307b0b554f7d44851077640f36962ca469a766b3d57410d7d96245a7ba6c91 + languageName: node + linkType: hard + "run-applescript@npm:^7.0.0": version: 7.1.0 resolution: "run-applescript@npm:7.1.0" @@ -8515,6 +8950,37 @@ __metadata: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: "npm:^4.4.3" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.1" + mime-types: "npm:^3.0.2" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.2" + checksum: 10/274f842d69ccfa49d4940a85598c6825da58dee6cb8ea33b08d5bd3988e6a82267c4d7c32b23d0e4706aad076ee95b1edfa13f859877db9b589829019397e355 + languageName: node + linkType: hard + +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + parseurl: "npm:^1.3.3" + send: "npm:^1.2.0" + checksum: 10/71500fe80cc7163fec04e4297de7591ad1cb682d137fc030e7a53e57040fda5187e8082a9c1b2ef37f1d3f9c27c9a94d4ba61806ebc28938ba4a7c8947c9f71e + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -8552,6 +9018,13 @@ __metadata: languageName: node linkType: hard +"setprototypeof@npm:~1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: 10/fde1630422502fbbc19e6844346778f99d449986b2f9cdcceb8326730d2f3d9964dbcb03c02aaadaefffecd0f2c063315ebea8b3ad895914bf1afc1747fc172e + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -8747,6 +9220,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "std-env@npm:^4.0.0-rc.1": version: 4.0.0 resolution: "std-env@npm:4.0.0" @@ -9049,6 +9529,13 @@ __metadata: languageName: node linkType: hard +"toidentifier@npm:~1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 10/952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 + languageName: node + linkType: hard + "token-stream@npm:1.0.0": version: 1.0.0 resolution: "token-stream@npm:1.0.0" @@ -9189,6 +9676,17 @@ __metadata: languageName: node linkType: hard +"type-is@npm:^2.0.1": + version: 2.0.1 + resolution: "type-is@npm:2.0.1" + dependencies: + content-type: "npm:^1.0.5" + media-typer: "npm:^1.1.0" + mime-types: "npm:^3.0.0" + checksum: 10/bacdb23c872dacb7bd40fbd9095e6b2fca2895eedbb689160c05534d7d4810a7f4b3fd1ae87e96133c505958f6d602967a68db5ff577b85dd6be76eaa75d58af + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.3": version: 1.0.3 resolution: "typed-array-buffer@npm:1.0.3" @@ -9389,6 +9887,13 @@ __metadata: languageName: node linkType: hard +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 10/4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 + languageName: node + linkType: hard + "unplugin@npm:^2.3.5": version: 2.3.11 resolution: "unplugin@npm:2.3.11" @@ -9459,6 +9964,13 @@ __metadata: languageName: node linkType: hard +"vary@npm:^1, vary@npm:^1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: 10/31389debef15a480849b8331b220782230b9815a8e0dbb7b9a8369559aed2e9a7800cd904d4371ea74f4c3527db456dc8e7ac5befce5f0d289014dbdf47b2242 + languageName: node + linkType: hard + "vfile-message@npm:^4.0.0": version: 4.0.2 resolution: "vfile-message@npm:4.0.2" @@ -10226,6 +10738,13 @@ __metadata: languageName: node linkType: hard +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10/159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5 + languageName: node + linkType: hard + "ws@npm:^8.18.0, ws@npm:^8.18.3": version: 8.18.3 resolution: "ws@npm:8.18.3" @@ -10343,6 +10862,22 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.25.1": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665 + languageName: node + linkType: hard + +"zod@npm:^3.25 || ^4.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From 42491df9f173dfe5f3e3dafbd480980a0408ef09 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 4 May 2026 19:16:03 +0400 Subject: [PATCH 04/15] docs(v1-endpoint): Documentation terminology refined --- packages/v1-endpoint/README.md | 22 ++++---- packages/v1-endpoint/docs/README.md | 12 ++--- packages/v1-endpoint/docs/create-endpoint.md | 18 +++---- .../v1-endpoint/docs/define-page-runner.md | 6 +-- .../v1-endpoint/docs/define-widget-runner.md | 4 +- packages/v1-endpoint/docs/layout.md | 51 +++++-------------- packages/v1-endpoint/docs/menu-placements.md | 24 ++++----- packages/v1-endpoint/docs/page-routes.md | 26 +++++----- packages/v1-endpoint/docs/run-endpoint.md | 8 +-- packages/v1-endpoint/docs/targets.md | 6 +-- 10 files changed, 74 insertions(+), 103 deletions(-) diff --git a/packages/v1-endpoint/README.md b/packages/v1-endpoint/README.md index 515f7167..e1082299 100644 --- a/packages/v1-endpoint/README.md +++ b/packages/v1-endpoint/README.md @@ -2,9 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@retailcrm/embed-ui-v1-endpoint.svg)](https://www.npmjs.com/package/@retailcrm/embed-ui-v1-endpoint) -`@retailcrm/embed-ui-v1-endpoint` помогает поднять endpoint для удалённых -виджетов и страниц в RetailCRM: принять вызовы host-части, смонтировать Vue -приложение в удалённый root и корректно освобождать ресурсы. +`@retailcrm/embed-ui-v1-endpoint` предоставляет endpoint API для встраиваемых +виджетов и страниц в RetailCRM: обработку вызовов host-части, монтирование Vue-приложения +в endpoint root и освобождение ресурсов. Пакет покрывает два сценария: @@ -28,22 +28,22 @@ const runner = defineRunner({ runEndpoint(runner) ``` -Для продакшен-использования обычно достаточно `runEndpoint(...)` в worker entry. +Для продакшен-использования обычно достаточно `runEndpoint(...)` в точке входа веб-воркера. Если нужен более низкоуровневый контроль транспорта — используйте `createEndpoint(...)`. -## Продвинутые гайды +## Документация Подробная документация по методам находится в каталоге [`docs/`](./docs/README.md): - [`defineRunner`](./docs/define-runner.md) — как объединить page и widget runners в один endpoint runner. -- [`definePageRunner`](./docs/define-page-runner.md) — как запускать remote-страницы по `code`. -- [`defineWidgetRunner`](./docs/define-widget-runner.md) — как запускать remote-виджеты по `target`. +- [`definePageRunner`](./docs/define-page-runner.md) — как запускать встраиваемые страницы по `code`. +- [`defineWidgetRunner`](./docs/define-widget-runner.md) — как запускать встраиваемые виджеты по `target`. - [`createEndpoint`](./docs/create-endpoint.md) — как вручную создать endpoint с transport и messenger. -- [`runEndpoint`](./docs/run-endpoint.md) — как поднять endpoint в worker entry одной строкой. +- [`runEndpoint`](./docs/run-endpoint.md) — как поднять endpoint в точке входа веб-воркера одной строкой. - [`targets` и `defineTarget`](./docs/targets.md) — как типизировать цели виджетов и маршрутизировать их по target. -- [`menu-placements`](./docs/menu-placements.md) — как описывать меню и пункты навигации для remote-страниц. -- [`page-routes`](./docs/page-routes.md) — как связывать page `code`, CRM route и `definePageRunner`. -- [`layout`](./docs/layout.md) — как выбирать layout-паттерны страниц, `modal sidebar` и `modal window`, и из каких `v1-components` их собирать. +- [`menu-placements`](./docs/menu-placements.md) — как описывать меню и пункты навигации для встраиваемых страниц. +- [`page-routes`](./docs/page-routes.md) — как связывать page `code`, CRM-маршрут и `definePageRunner`. +- [`layout`](./docs/layout.md) — как выбирать паттерны компоновки страниц, `modal sidebar` и `modal window`, и из каких `v1-components` их собирать. ## MCP для AI-ассистентов diff --git a/packages/v1-endpoint/docs/README.md b/packages/v1-endpoint/docs/README.md index abbffec9..8b33b8e2 100644 --- a/packages/v1-endpoint/docs/README.md +++ b/packages/v1-endpoint/docs/README.md @@ -1,12 +1,12 @@ # Документация `@retailcrm/embed-ui-v1-endpoint` -В этом каталоге собраны продвинутые гайды по публичному API `v1-endpoint`. +В этом каталоге собраны практические гайды по публичному API `v1-endpoint`. ## По методам - [`defineRunner`](./define-runner.md) — объединение page/widget runners в один endpoint runner. -- [`definePageRunner`](./define-page-runner.md) — запуск remote-страниц по `code`. -- [`defineWidgetRunner`](./define-widget-runner.md) — запуск remote-виджетов по `target`. +- [`definePageRunner`](./define-page-runner.md) — запуск встраиваемых страниц по `code`. +- [`defineWidgetRunner`](./define-widget-runner.md) — запуск встраиваемых виджетов по `target`. - [`createEndpoint`](./create-endpoint.md) — ручное создание endpoint с transport/messenger. - [`runEndpoint`](./run-endpoint.md) — запуск endpoint в worker одной строкой. @@ -15,6 +15,6 @@ - [`targets` и `defineTarget`](./targets.md) — типизированные цели для виджетов. - [`targets/*.yml`](./targets/) — сгенерированные AI-friendly описания встроенных widget targets на английском. - MCP-сервер `embed-ui-v1-endpoint-mcp` — поставляемый stdio server, который отдаёт `targets/*.yml` как MCP resources. -- [`menu-placements`](./menu-placements.md) — как описывать меню и пункты навигации, из которых открываются remote-страницы. -- [`page-routes`](./page-routes.md) — как связывать page `code`, CRM route и `definePageRunner`. -- [`layout`](./layout.md) — практический гайд по layout-паттернам страниц, шторок и модалок. +- [`menu-placements`](./menu-placements.md) — как описывать меню и пункты навигации, из которых открываются встраиваемые страницы. +- [`page-routes`](./page-routes.md) — как связывать page `code`, CRM-маршрут и `definePageRunner`. +- [`layout`](./layout.md) — практический гайд по паттернам компоновки страниц, шторок и модалок. diff --git a/packages/v1-endpoint/docs/create-endpoint.md b/packages/v1-endpoint/docs/create-endpoint.md index 4807f5e6..0e1b4af5 100644 --- a/packages/v1-endpoint/docs/create-endpoint.md +++ b/packages/v1-endpoint/docs/create-endpoint.md @@ -1,7 +1,7 @@ # `createEndpoint` -`createEndpoint` связывает runner и transport (messenger), после чего -экспортирует endpoint API (`run`, `release`, `reset`) через `@remote-ui/rpc`. +`createEndpoint` связывает runner и transport (`messenger`) и экспортирует +endpoint API (`run`, `release`, `reset`) через `@remote-ui/rpc`. ## Сигнатура @@ -15,15 +15,15 @@ createEndpoint( ): Endpoint ``` -## Что делает под капотом +## Поведение При `run(...)`: -1. сбрасывает предыдущий mount для того же `id` (widget) или `code` (page), -2. поднимает remote root (`mountEndpointRoot`), +1. сбрасывает предыдущее монтирование для того же `id` (widget) или `code` (page), +2. поднимает endpoint root (`mountEndpointRoot`), 3. создаёт `pinia` и инжектит endpoint/context accessors, 4. вызывает нужный runner (`page.run` или `widget.run`), -5. сохраняет destroy-функцию в registry. +5. сохраняет destroy-функцию в реестре. При `release(...)`: @@ -33,7 +33,7 @@ createEndpoint( - вызывает destroy для всех активных page/widget инстансов. -## Пример (низкоуровневый) +## Пример ```ts import { defineRunner, createEndpoint } from '@retailcrm/embed-ui-v1-endpoint/remote' @@ -43,12 +43,10 @@ const runner = defineRunner({ widgets: [MyWidgetRoot], }) -// messenger зависит от среды исполнения createEndpoint(runner, self as unknown as MessageEndpoint) ``` ## Когда нужен именно `createEndpoint` - Нужна кастомная интеграция transport-слоя. -- Вы сами контролируете, где и как создаётся `MessageEndpoint`. -- Нужно использовать endpoint не через стандартный worker-entry сценарий. +- Нужно использовать endpoint не через стандартную точку входа веб-воркера. diff --git a/packages/v1-endpoint/docs/define-page-runner.md b/packages/v1-endpoint/docs/define-page-runner.md index 954de576..23bc7496 100644 --- a/packages/v1-endpoint/docs/define-page-runner.md +++ b/packages/v1-endpoint/docs/define-page-runner.md @@ -1,6 +1,6 @@ # `definePageRunner` -`definePageRunner` создаёт runner для remote-страниц. +`definePageRunner` создаёт runner для встраиваемых страниц. При запуске в компонент пробрасывается проп `code`. ## Перегрузки @@ -58,5 +58,5 @@ const pageRunner = definePageRunner(PageRoot, async (app, pinia) => { Читайте также: -- [`page-routes`](./page-routes.md) — как связать page `code`, CRM route и компонент страницы. -- [`menu-placements`](./menu-placements.md) — как описывать пункты меню, которые открывают remote-страницы. +- [`page-routes`](./page-routes.md) — как связать page `code`, CRM-маршрут и компонент страницы. +- [`menu-placements`](./menu-placements.md) — как описывать пункты меню, которые открывают встраиваемые страницы. diff --git a/packages/v1-endpoint/docs/define-widget-runner.md b/packages/v1-endpoint/docs/define-widget-runner.md index cfd3b87f..da72c8ed 100644 --- a/packages/v1-endpoint/docs/define-widget-runner.md +++ b/packages/v1-endpoint/docs/define-widget-runner.md @@ -1,6 +1,6 @@ # `defineWidgetRunner` -`defineWidgetRunner` создаёт runner для remote-виджетов. +`defineWidgetRunner` создаёт runner для встраиваемых виджетов. При запуске в компонент пробрасывается проп `target`. ## Перегрузки @@ -47,7 +47,7 @@ const widgetRunner = defineWidgetRunner({ ## `beforeMount` -`beforeMount` работает аналогично page-раннеру: выполняется перед mount и после подключения `pinia`. +`beforeMount` работает аналогично page-раннеру: выполняется перед монтированием и после подключения `pinia`. ```ts const widgetRunner = defineWidgetRunner(WidgetRoot, async (app, pinia) => { diff --git a/packages/v1-endpoint/docs/layout.md b/packages/v1-endpoint/docs/layout.md index 4b2736d1..9eb75ece 100644 --- a/packages/v1-endpoint/docs/layout.md +++ b/packages/v1-endpoint/docs/layout.md @@ -1,14 +1,9 @@ -# Гайд по layout для страниц и связанных экранов +# Компоновка страниц и связанных экранов -Этот документ собирает в одном месте практические правила по тому, как проектировать -страницы для `@retailcrm/embed-ui-v1-endpoint`: списки, карточки, страницы с несколькими -колонками, страницы с collapse-блоками, а также случаи, когда вместо полноценной страницы -следует использовать `modal sidebar` или `modal window`. +Практические правила для встраиваемых страниц `@retailcrm/embed-ui-v1-endpoint`: списков, +карточек, многоколоночных страниц, collapse-страниц, `modal sidebar` и `modal window`. -Это не API-справка по `v1-endpoint`, а UX/layout guide для людей, которые собирают remote-page -через `definePageRunner(...)` и хотят держаться общего визуального языка CRM. - -## Базовая ментальная модель +## Структура страницы Обычная страница в CRM чаще всего состоит из таких зон: @@ -18,19 +13,14 @@ 4. Основной контент на белых подложках. 5. Опционально: нижняя панель сохранения. -Если экран перестаёт быть удобной полноценной страницей, его не следует усложнять бесконечно; -в этом случае его следует переводить в другой паттерн: `modal sidebar` или `modal window`. +Если сценарий перестаёт быть удобной полноценной страницей, используйте другой паттерн: +`modal sidebar` или `modal window`. ## Термины -В этом документе используются следующие термины: - - `modal sidebar` — боковая выезжающая панель. В разговорной речи может называться “шторкой”. - `modal window` — всплывающее модальное окно. В разговорной речи может называться “модалкой”. -- `страница` — основной route-level экран, который занимает центральную рабочую область CRM. - -Далее по тексту предпочтительно используются термины `modal sidebar` и `modal window`, потому что -они напрямую соотносятся с компонентами `UiModalSidebar`, `UiModalWindow` и `UiModalWindowSurface`. +- `страница` — основной экран уровня маршрута, который занимает центральную рабочую область CRM. ## Общие правила @@ -246,10 +236,6 @@ - Дополнительная информация, которую не хочется разворачивать в отдельную страницу. - Сценарии вроде задач, уведомлений, нового обращения, редактирования шага. -### Размеры - -- Часто используется одна из двух ширин: `720px` или `416px`. - ### Основные части 1. Закреплённый `header`. @@ -275,7 +261,8 @@ - Контент в двух колонках на разных подложках. - Громоздкие, большие и сложные интерфейсы. -Если экран фактически превращается в полноценную страницу или большую предметную область, `modal sidebar` уже не является подходящим выбором. +Если экран превращается в полноценную страницу или большую предметную область, `modal sidebar` +уже не подходит. ## 5. Когда вместо страницы нужен `modal window` @@ -283,12 +270,7 @@ - Для отображения большой вспомогательной таблицы “по месту”. - Для дополнительных настроек, когда `modal sidebar` уже не хватает. -- Для сценариев просмотра или выбора, не заслуживающих отдельного route-level экрана. - -### Размеры - -- Обычный `modal window` шириной около `960px`. -- Или полноэкранный режим с внешними отступами по краям. +- Для сценариев просмотра или выбора, не заслуживающих отдельного экрана уровня маршрута. ### Основные части @@ -328,9 +310,9 @@ - требуется быстро завершить отдельный шаг без перехода на полноценную страницу; - `modal sidebar` уже тесен, но полноценная страница всё ещё избыточна. -## Сопоставление с `embed-ui` +## Компонентная карта -Ниже не жёсткий contract, а практичное соответствие layout-паттернов библиотечным примитивам: +Соответствие паттернов компоновки публичным компонентам: - заголовок страницы: `UiPageHeader`; - верхние actions: `UiButton`, `UiToolbarButton`, `UiToolbarLink`; @@ -341,9 +323,7 @@ - `modal sidebar`: `UiModalSidebar`; - `modal window`: `UiModalWindow` и `UiModalWindowSurface`. -## Чеклист перед реализацией страницы - -Перед тем как собирать новый page runner, полезно ответить на несколько вопросов: +## Чеклист перед реализацией 1. Это действительно страница, а не `modal sidebar` и не `modal window`? 2. Это список, карточка, карточка с колонками или collapse-настройки? @@ -352,12 +332,9 @@ 5. Не перегружен ли экран количеством primary-действий? 6. Держатся ли отступы и расстояния сетки `4px`? -Если на эти вопросы нет уверенного ответа, сначала следует выбрать подходящий layout-паттерн, -а уже потом собирать компоненты и wiring через `v1-endpoint`. - --- -Примечание: все упоминаемые в этом документе компоненты вида `Ui*` относятся к пакету +Все упоминаемые компоненты вида `Ui*` относятся к пакету `@retailcrm/embed-ui-v1-components`. Термины `modal sidebar`, `modal window`, tabs, collapse-группы, таблицы, поля и другие layout-примитивы в этом гайде привязаны именно к публичным компонентам из `v1-components`, а не к произвольной внутренней терминологии проекта. diff --git a/packages/v1-endpoint/docs/menu-placements.md b/packages/v1-endpoint/docs/menu-placements.md index cef559cf..46aa4307 100644 --- a/packages/v1-endpoint/docs/menu-placements.md +++ b/packages/v1-endpoint/docs/menu-placements.md @@ -1,9 +1,8 @@ # Меню и точки входа страниц -Этот справочник описывает, как документировать меню и пункты навигации, из которых запускаются -remote-страницы расширения. +Справочник для описания меню и пунктов навигации, из которых запускаются встраиваемые страницы расширения. -Важно: `v1-endpoint` не экспортирует типизированный registry CRM-меню. Пакет отвечает за запуск +`v1-endpoint` не экспортирует типизированный реестр CRM-меню. Пакет отвечает за запуск страницы по `code`, а список меню, подпунктов и их видимость задаются на стороне host/manifest конкретного расширения. @@ -11,13 +10,13 @@ remote-страницы расширения. - `menu placement` — зона CRM, куда host добавляет пункт навигации. - `menu item` — конкретный пункт меню, который видит пользователь. -- `page code` — стабильный код remote-страницы, который host передаёт в `definePageRunner`. -- `route` — маршрут CRM или route name, через который host открывает страницу. +- `page code` — стабильный код встраиваемой страницы, который host передаёт в `definePageRunner`. +- `route` — маршрут CRM или имя маршрута, через который host открывает страницу. -`menu item` не равен `target`. Меню открывает полноценную remote-страницу по `code`, а `target` +`menu item` не равен `target`. Меню открывает полноценную встраиваемую страницу по `code`, а `target` используется для виджетов, которые встраиваются внутрь уже существующей CRM-страницы. -## Что нужно фиксировать в справочнике проекта +## Что фиксировать в справочнике проекта Для каждого пункта меню указывайте: @@ -26,14 +25,13 @@ remote-страницы расширения. | `placement` | Зона CRM, где находится пункт меню. | | `item code` | Стабильный код пункта меню в host/manifest. | | `label` | Пользовательское название пункта меню. | -| `page code` | Код remote-страницы, который получит `definePageRunner`. | -| `route` | Имя или путь CRM-маршрута, если пункт открывается через host routing. | +| `page code` | Код встраиваемой страницы, который получит `definePageRunner`. | +| `route` | Имя или путь CRM-маршрута, если пункт открывается через маршрутизацию host-части. | | `visibility` | Условия показа: права, настройки, тариф, доступность фичи. | ## Пример справочника меню -Это пример формата. Конкретные `placement`, `item code` и `route` нужно брать из host/manifest -расширения. +Конкретные `placement`, `item code` и `route` нужно брать из host/manifest расширения. | Placement | Item code | Label | Page code | Route | Когда использовать | | --- | --- | --- | --- | --- | --- | @@ -64,8 +62,8 @@ const pageRunner = definePageRunner({ Читайте также: -- [`page-routes`](./page-routes.md) — как описывать page `code` и CRM route. -- [`definePageRunner`](./define-page-runner.md) — как remote-страница получает `code`. +- [`page-routes`](./page-routes.md) — как описывать page `code` и CRM-маршрут. +- [`definePageRunner`](./define-page-runner.md) — как встраиваемая страница получает `code`. - [`targets`](./targets.md) — точки встраивания виджетов, не пунктов меню. - [`layout`](./layout.md) — когда делать полноценную страницу, а когда `modal sidebar` или `modal window`. - [`UiMenuItem`](../../v1-components/docs/profiles/UiMenuItem.yml) — компонент строки меню внутри UI расширения. diff --git a/packages/v1-endpoint/docs/page-routes.md b/packages/v1-endpoint/docs/page-routes.md index 9ec79d2a..445ec3ff 100644 --- a/packages/v1-endpoint/docs/page-routes.md +++ b/packages/v1-endpoint/docs/page-routes.md @@ -1,16 +1,15 @@ -# Роуты страниц +# Маршруты страниц -Remote-страницы в `v1-endpoint` запускаются по `code`. Этот `code` приходит от host и пробрасывается -в компонент через `definePageRunner`. +Встраиваемые страницы в `v1-endpoint` запускаются по `code`. Host передаёт `code`, а +`definePageRunner` пробрасывает его в компонент. -`v1-endpoint` не задаёт фиксированный список page routes. Список страниц и CRM-маршрутов принадлежит +`v1-endpoint` не задаёт фиксированный список маршрутов страниц. Список страниц и CRM-маршрутов принадлежит host/manifest конкретного расширения, поэтому его нужно хранить в справочнике проекта рядом с кодом расширения. ## Что такое `page code` -`page code` — стабильный идентификатор remote-страницы внутри расширения. Он отвечает на вопрос: -«какую страницу расширения нужно смонтировать?». +`page code` — стабильный идентификатор встраиваемой страницы внутри расширения. Пример: @@ -37,11 +36,10 @@ defineProps<{ ## Что такое `route` -`route` — CRM-маршрут или route name, через который host открывает страницу. В remote-коде route -может использоваться для переходов через `HostApi.goTo(route, params)`. +`route` — CRM-маршрут или имя маршрута, через который host открывает страницу. В коде расширения +route может использоваться для переходов через `HostApi.goTo(route, params)`. -Данные Symfony JS router доступны в контексте `settings` в поле `system.routing`. Это полезно, -когда нужно проверить доступные route names или собрать ссылку тем же routing data, что отдаёт host. +Данные Symfony JS router доступны в контексте `settings` в поле `system.routing`. ```ts import { useContext as useSettingsContext } from '@retailcrm/embed-ui-v1-contexts/remote/settings' @@ -51,9 +49,9 @@ const settings = useSettingsContext() console.log(settings['system.routing'].routes) ``` -## Пример справочника роутов страниц +## Пример справочника маршрутов страниц -Это пример формата. Конкретные `code`, `route` и параметры нужно брать из host/manifest расширения. +Конкретные `code`, `route` и параметры нужно брать из host/manifest расширения. | Page code | Route | Params | Открывается из | Компонент | | --- | --- | --- | --- | --- | @@ -61,7 +59,7 @@ console.log(settings['system.routing'].routes) | `integration-settings` | `embed.page.integration_settings` | `{}` | `settings / integration-settings` | `IntegrationSettingsPage.vue` | | `customer-tools` | `embed.page.customer_tools` | `{ customerId }` | `customer/card:actions / customer-tools` | `CustomerToolsPage.vue` | -## Переход на CRM route +## Переход на CRM-маршрут Если странице нужен переход на другой CRM-маршрут, используйте host API, а не прямую сборку URL. Способ получения `HostApi` зависит от host-интеграции проекта, но сам публичный контракт выглядит так: @@ -76,7 +74,7 @@ const openSettings = (host: HostApi) => { Читайте также: -- [`menu-placements`](./menu-placements.md) — как связать пункт меню, page `code` и route. +- [`menu-placements`](./menu-placements.md) — как связать пункт меню, page `code` и маршрут. - [`definePageRunner`](./define-page-runner.md) — как page `code` попадает в компонент. - [`settings context`](../../v1-contexts/docs/ru/CONCEPT.md) — общий принцип работы контекстов. - [`HostApi`](../../v1-types/host.d.ts) — публичный тип host API с `goTo`. diff --git a/packages/v1-endpoint/docs/run-endpoint.md b/packages/v1-endpoint/docs/run-endpoint.md index cc637ecb..0992b334 100644 --- a/packages/v1-endpoint/docs/run-endpoint.md +++ b/packages/v1-endpoint/docs/run-endpoint.md @@ -1,6 +1,6 @@ # `runEndpoint` -`runEndpoint` — shortcut для worker entry: +`runEndpoint` — краткая форма для точки входа веб-воркера: он вызывает `createEndpoint(runner, self as Worker)`. ## Сигнатура @@ -28,10 +28,10 @@ runEndpoint(runner) ## Когда использовать -- Почти всегда, если endpoint запускается как web worker. +- Почти всегда, если endpoint запускается как веб-воркер. - Когда не нужен ручной контроль над messenger/transport. ## Когда лучше `createEndpoint` -- Когда transport создаётся не от `self` worker. -- Когда у вас кастомный runtime/bridge между host и remote. +- Когда transport создаётся не от `self` веб-воркера. +- Когда у вас кастомная среда выполнения или bridge между host и remote. diff --git a/packages/v1-endpoint/docs/targets.md b/packages/v1-endpoint/docs/targets.md index 6b2059ab..9d246364 100644 --- a/packages/v1-endpoint/docs/targets.md +++ b/packages/v1-endpoint/docs/targets.md @@ -14,7 +14,7 @@ ## Что такое `target` и `context` `target` — это идентификатор места встраивания виджета в интерфейсе CRM. Он отвечает на вопрос: -«куда CRM сейчас монтирует remote-виджет?». Например, `order/card:common.before` означает конкретную +«куда CRM сейчас монтирует встраиваемый виджет?». Например, `order/card:common.before` означает конкретную точку встраивания на карточке заказа. `context` — это набор реактивных данных, доступных виджету в этом месте. Он отвечает на вопрос: @@ -129,8 +129,8 @@ const testTarget = defineTarget('customer/card:test.after', [ Читайте также: -- [`defineWidgetRunner`](./define-widget-runner.md) — как `target` попадает в remote-компонент. +- [`defineWidgetRunner`](./define-widget-runner.md) — как `target` попадает в компонент встраиваемого виджета. - [`menu-placements`](./menu-placements.md) — чем пункты меню для страниц отличаются от widget `target`. -- [`page-routes`](./page-routes.md) — как описывать page `code` и CRM route. +- [`page-routes`](./page-routes.md) — как описывать page `code` и CRM-маршрут. - [`CONCEPT`](../../v1-contexts/docs/ru/CONCEPT.md) — общий принцип работы контекстов. - [`CUSTOM`](../../v1-contexts/docs/ru/CUSTOM.md) — пользовательский контекст для custom fields. From f632718ac41de82c630f58fcbe21da5c9b853d22 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 4 May 2026 19:55:35 +0400 Subject: [PATCH 05/15] chore: Endpoint package bin metadata locked --- yarn.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/yarn.lock b/yarn.lock index 35717c5a..4a128719 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1414,6 +1414,9 @@ __metadata: "@retailcrm/embed-ui-v1-types": ^0.9.21 pinia: ^2.2 vue: ^3.5 + bin: + embed-ui-v1-endpoint: ./bin/embed-ui-v1-endpoint.mjs + embed-ui-v1-endpoint-mcp: ./bin/embed-ui-v1-endpoint-mcp.mjs languageName: unknown linkType: soft From 5055a3d3102e91828a21dd74d5efc722300e57bb Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 6 May 2026 16:56:18 +0400 Subject: [PATCH 06/15] feat: Embed UI initialization CLI added --- .github/workflows/release.yml | 3 + .github/workflows/tests.yml | 6 + .gitignore | 1 + .npmignore | 1 + bin/embed-ui-update.mjs | 617 ------------- eslint.config.js | 1 + package.json | 12 +- src/cmd/embed-ui/agents.ts | 135 +++ src/cmd/embed-ui/args.ts | 298 +++++++ src/cmd/embed-ui/filesystem.ts | 121 +++ src/cmd/embed-ui/index.ts | 826 ++++++++++++++++++ src/cmd/embed-ui/package-json.ts | 174 ++++ src/cmd/embed-ui/packages.ts | 261 ++++++ src/cmd/embed-ui/report.ts | 112 +++ src/cmd/embed-ui/types.ts | 50 ++ ...bed-ui-update.test.ts => embed-ui.test.ts} | 109 ++- vite.config.bin.ts | 28 + yarn.lock | 2 +- 18 files changed, 2131 insertions(+), 626 deletions(-) delete mode 100755 bin/embed-ui-update.mjs create mode 100644 src/cmd/embed-ui/agents.ts create mode 100644 src/cmd/embed-ui/args.ts create mode 100644 src/cmd/embed-ui/filesystem.ts create mode 100755 src/cmd/embed-ui/index.ts create mode 100644 src/cmd/embed-ui/package-json.ts create mode 100644 src/cmd/embed-ui/packages.ts create mode 100644 src/cmd/embed-ui/report.ts create mode 100644 src/cmd/embed-ui/types.ts rename tests/{embed-ui-update.test.ts => embed-ui.test.ts} (51%) create mode 100644 vite.config.bin.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63bd5037..c2201962 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,9 @@ jobs: - name: Build worktree run: yarn workspaces foreach -A --topological-dev run build + - name: Ensure root bin is generated + run: test -s bin/embed-ui.mjs + - name: Run eslint run: yarn eslint diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 360c88c1..9f0380c4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,9 @@ jobs: - name: Build worktree run: yarn workspaces foreach -A --topological-dev run build + - name: Ensure root bin is generated + run: test -s bin/embed-ui.mjs + - name: Run eslint run: yarn eslint @@ -67,6 +70,9 @@ jobs: - name: Build worktree run: yarn workspaces foreach -A --topological-dev run build + - name: Ensure root bin is generated + run: test -s bin/embed-ui.mjs + - name: Run tests run: yarn test diff --git a/.gitignore b/.gitignore index 15304606..4661d029 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ generated node_modules coverage artifacts/ +bin/ packages/v1-endpoint/docs/targets/ .codex diff --git a/.npmignore b/.npmignore index 5db7c059..b22d3580 100644 --- a/.npmignore +++ b/.npmignore @@ -23,6 +23,7 @@ eslint.config.js tsconfig.json tsconfig.test.json vite.config.ts +vite.config.bin.ts vitest.config.ts yarn.lock diff --git a/bin/embed-ui-update.mjs b/bin/embed-ui-update.mjs deleted file mode 100755 index 20469ed9..00000000 --- a/bin/embed-ui-update.mjs +++ /dev/null @@ -1,617 +0,0 @@ -#!/usr/bin/env node - -import { createInterface } from 'node:readline/promises' -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import process from 'node:process' - -export const ROOT_PACKAGE = '@retailcrm/embed-ui' -export const TARGET_SECTIONS = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', -] - -export const INSTALLABLE_PACKAGES = [ - { - id: 'embed-ui', - name: ROOT_PACKAGE, - section: 'dependencies', - description: 'Базовый пакет с общим API и согласованными v1-зависимостями.', - }, - { - id: 'components', - name: '@retailcrm/embed-ui-v1-components', - section: 'dependencies', - description: 'UI-компоненты для host/remote приложений.', - }, - { - id: 'contexts', - name: '@retailcrm/embed-ui-v1-contexts', - section: 'dependencies', - description: 'Реактивные контексты RetailCRM JS API.', - }, - { - id: 'types', - name: '@retailcrm/embed-ui-v1-types', - section: 'dependencies', - description: 'Базовые type declarations для RetailCRM JS API.', - }, - { - id: 'testing', - name: '@retailcrm/embed-ui-v1-testing', - section: 'devDependencies', - description: 'Вспомогательные утилиты и типы для тестов интеграций.', - }, - { - id: 'endpoint', - name: '@retailcrm/embed-ui-v1-endpoint', - section: 'dependencies', - description: 'Endpoint API для интеграций в RetailCRM.', - }, -] - -const DEFAULT_INDENT = ' ' -const DEFAULT_NEWLINE = '\n' -const SKIP_DIRECTORIES = new Set([ - '.git', - '.hg', - '.svn', - '.yarn', - 'node_modules', - 'dist', - 'build', - 'coverage', -]) - -const HELP_TEXT = `Usage: - npx @retailcrm/embed-ui [target] [version] [options] - -Options: - -t, --target Target path (default: current directory) - -v, --version Target version. If omitted, latest npm version is used - --exact Use exact version instead of range - --dry-run Show changes without writing package.json - --add Add selected embed-ui packages into one package.json - --packages Comma-separated package ids or names for --add - -h, --help Show this help - -Examples: - npx @retailcrm/embed-ui - npx @retailcrm/embed-ui --version 0.9.11 - npx @retailcrm/embed-ui ./my-project 0.9.11 - npx @retailcrm/embed-ui --target ./my-project --dry-run - npx @retailcrm/embed-ui --add - npx @retailcrm/embed-ui --add --packages components,contexts -` - -const isSemverLike = (value) => /^v?\d+\.\d+\.\d+/.test(value) -const stripLeadingV = (value) => value.replace(/^v/, '') - -const parsePackageList = (value) => - value - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) - -export const parseArgs = (argv) => { - const options = { - target: process.cwd(), - version: null, - dryRun: false, - exact: false, - add: false, - packages: null, - } - - const positionals = [] - - for (let index = 0; index < argv.length; index++) { - const argument = argv[index] - - if (argument === '-h' || argument === '--help') { - console.log(HELP_TEXT) - process.exit(0) - } - - if (argument === '-t' || argument === '--target') { - const value = argv[index + 1] - if (!value) { - throw new Error('Option --target requires a value') - } - - options.target = path.resolve(process.cwd(), value) - index++ - continue - } - - if (argument === '-v' || argument === '--version') { - const value = argv[index + 1] - if (!value) { - throw new Error('Option --version requires a value') - } - - options.version = stripLeadingV(value) - index++ - continue - } - - if (argument === '--packages') { - const value = argv[index + 1] - if (!value) { - throw new Error('Option --packages requires a value') - } - - options.packages = parsePackageList(value) - index++ - continue - } - - if (argument === '--dry-run') { - options.dryRun = true - continue - } - - if (argument === '--exact') { - options.exact = true - continue - } - - if (argument === '--add') { - options.add = true - continue - } - - if (argument.startsWith('-')) { - throw new Error(`Unknown option: ${argument}`) - } - - positionals.push(argument) - } - - if (positionals.length > 2) { - throw new Error('Too many positional arguments') - } - - if (positionals.length >= 1) { - const first = positionals[0] - if (!options.version && isSemverLike(first)) { - options.version = stripLeadingV(first) - } else { - options.target = path.resolve(process.cwd(), first) - } - } - - if (positionals.length === 2) { - if (options.version) { - throw new Error('Version is already specified') - } - - options.version = stripLeadingV(positionals[1]) - } - - if (options.packages && !options.add) { - throw new Error('Option --packages can only be used together with --add') - } - - return options -} - -export const resolveLatestVersion = () => { - const output = execFileSync( - 'npm', - ['view', ROOT_PACKAGE, 'version'], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - } - ).trim() - - if (!output) { - throw new Error(`Cannot resolve latest version for ${ROOT_PACKAGE}`) - } - - return output -} - -export const isTargetPackage = (name) => - name === ROOT_PACKAGE || name.startsWith(`${ROOT_PACKAGE}-`) - -const createRange = (version, exact) => exact ? version : `^${version}` - -export const formatRange = (currentRange, nextVersion, exact) => { - if (exact) { - return nextVersion - } - - if (currentRange.startsWith('workspace:')) { - return currentRange - } - - if (currentRange.startsWith('~')) { - return `~${nextVersion}` - } - - if (currentRange.startsWith('^')) { - return `^${nextVersion}` - } - - return `^${nextVersion}` -} - -export const detectFormatting = (source) => { - const newline = source.includes('\r\n') ? '\r\n' : DEFAULT_NEWLINE - const indentMatch = source.match(/\n([ \t]+)"/) - - return { - indent: indentMatch?.[1] ?? DEFAULT_INDENT, - newline, - trailingNewline: source.endsWith('\n') || source.endsWith('\r\n'), - } -} - -export const serializePackageJson = (packageJson, formatting) => { - const serialized = JSON.stringify(packageJson, null, formatting.indent) - .replace(/\n/g, formatting.newline) - - return formatting.trailingNewline - ? `${serialized}${formatting.newline}` - : serialized -} - -const ensureDirectoryExists = (targetPath) => { - if (!fs.existsSync(targetPath)) { - throw new Error(`Path not found: ${targetPath}`) - } - - const stat = fs.statSync(targetPath) - if (!stat.isDirectory()) { - throw new Error(`Target is not a directory: ${targetPath}`) - } -} - -export const resolvePackageJsonPath = (targetPath) => { - if (path.basename(targetPath) === 'package.json') { - if (!fs.existsSync(targetPath)) { - throw new Error(`package.json not found: ${targetPath}`) - } - - return targetPath - } - - const packageJsonPath = path.resolve(targetPath, 'package.json') - - if (!fs.existsSync(packageJsonPath)) { - throw new Error(`package.json not found: ${packageJsonPath}`) - } - - return packageJsonPath -} - -export const collectPackageJsonPaths = (targetPath) => { - const resolvedTarget = path.resolve(targetPath) - - if (!fs.existsSync(resolvedTarget)) { - throw new Error(`Path not found: ${resolvedTarget}`) - } - - if (path.basename(resolvedTarget) === 'package.json') { - return [resolvedTarget] - } - - ensureDirectoryExists(resolvedTarget) - - const packageJsonPaths = [] - - const visit = (directoryPath) => { - const packageJsonPath = path.join(directoryPath, 'package.json') - if (fs.existsSync(packageJsonPath) && fs.statSync(packageJsonPath).isFile()) { - packageJsonPaths.push(packageJsonPath) - } - - for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.isSymbolicLink()) { - continue - } - - if (SKIP_DIRECTORIES.has(entry.name)) { - continue - } - - visit(path.join(directoryPath, entry.name)) - } - } - - visit(resolvedTarget) - - return packageJsonPaths.sort() -} - -const findDependencySection = (packageJson, packageName) => { - for (const section of TARGET_SECTIONS) { - const dependencyMap = packageJson[section] - - if (dependencyMap && typeof dependencyMap === 'object' && packageName in dependencyMap) { - return section - } - } - - return null -} - -export const updatePackageJson = (packageJson, version, exact) => { - const updates = [] - - for (const section of TARGET_SECTIONS) { - const dependencyMap = packageJson[section] - - if (!dependencyMap || typeof dependencyMap !== 'object') { - continue - } - - for (const [name, currentRange] of Object.entries(dependencyMap)) { - if (!isTargetPackage(name) || typeof currentRange !== 'string') { - continue - } - - const nextRange = formatRange(currentRange, version, exact) - if (nextRange === currentRange) { - continue - } - - dependencyMap[name] = nextRange - updates.push({ - type: 'update', - section, - name, - currentRange, - nextRange, - }) - } - } - - return updates -} - -export const resolveInstallPackages = (tokens) => { - const selectedPackages = [] - const seen = new Set() - - for (const token of tokens) { - const normalized = token.trim() - if (!normalized) { - continue - } - - const numericIndex = Number(normalized) - const selectedPackage = - Number.isInteger(numericIndex) && numericIndex >= 1 && numericIndex <= INSTALLABLE_PACKAGES.length - ? INSTALLABLE_PACKAGES[numericIndex - 1] - : INSTALLABLE_PACKAGES.find((entry) => entry.id === normalized || entry.name === normalized) - - if (!selectedPackage) { - const supported = INSTALLABLE_PACKAGES - .map((entry, index) => `${index + 1}/${entry.id}/${entry.name}`) - .join(', ') - - throw new Error(`Unknown add target "${normalized}". Supported values: ${supported}`) - } - - if (seen.has(selectedPackage.name)) { - continue - } - - seen.add(selectedPackage.name) - selectedPackages.push(selectedPackage) - } - - return selectedPackages -} - -export const installPackages = (packageJson, packages, version, exact) => { - const updates = [] - - for (const selectedPackage of packages) { - const section = findDependencySection(packageJson, selectedPackage.name) ?? selectedPackage.section - const dependencyMap = packageJson[section] ?? {} - - if (!(section in packageJson)) { - packageJson[section] = dependencyMap - } - - const currentRange = dependencyMap[selectedPackage.name] - const nextRange = typeof currentRange === 'string' - ? formatRange(currentRange, version, exact) - : createRange(version, exact) - - if (currentRange === nextRange) { - continue - } - - dependencyMap[selectedPackage.name] = nextRange - updates.push({ - type: typeof currentRange === 'string' ? 'update' : 'install', - section, - name: selectedPackage.name, - currentRange: typeof currentRange === 'string' ? currentRange : null, - nextRange, - }) - } - - return updates -} - -export const promptForInstallSelection = async (packageJson) => { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error('Interactive add mode requires a TTY. Use --packages to select packages explicitly.') - } - - console.log('Выберите пакеты для установки в текущий package.json:') - for (const [index, selectedPackage] of INSTALLABLE_PACKAGES.entries()) { - const currentSection = findDependencySection(packageJson, selectedPackage.name) - const installedHint = currentSection ? ` Уже есть в ${currentSection}.` : '' - - console.log(` ${index + 1}. ${selectedPackage.name} (${selectedPackage.id})`) - console.log(` ${selectedPackage.description} Раздел по умолчанию: ${selectedPackage.section}.${installedHint}`) - } - - const readline = createInterface({ - input: process.stdin, - output: process.stdout, - }) - - try { - while (true) { - const answer = await readline.question( - 'Введите номера, ids или имена пакетов через запятую (например: 1,3 или components,types): ' - ) - - const tokens = parsePackageList(answer) - if (tokens.length === 0) { - return [] - } - - try { - return resolveInstallPackages(tokens) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(message) - } - } - } finally { - readline.close() - } -} - -const readPackageJson = (packageJsonPath) => { - const source = fs.readFileSync(packageJsonPath, 'utf8') - - return { - formatting: detectFormatting(source), - packageJson: JSON.parse(source), - } -} - -const writePackageJson = (packageJsonPath, packageJson, formatting) => { - fs.writeFileSync(packageJsonPath, serializePackageJson(packageJson, formatting), 'utf8') -} - -const printChanges = (changes) => { - for (const change of changes) { - const prefix = change.type === 'install' - ? `${change.section}: ${change.name} -> ${change.nextRange}` - : `${change.section}: ${change.name} ${change.currentRange} -> ${change.nextRange}` - - console.log(` ${prefix}`) - } -} - -export const runUpdate = (options) => { - const version = options.version ?? resolveLatestVersion() - const packageJsonPaths = collectPackageJsonPaths(options.target) - const reports = [] - - for (const packageJsonPath of packageJsonPaths) { - const { formatting, packageJson } = readPackageJson(packageJsonPath) - const updates = updatePackageJson(packageJson, version, options.exact) - - if (updates.length === 0) { - continue - } - - if (!options.dryRun) { - writePackageJson(packageJsonPath, packageJson, formatting) - } - - reports.push({ packageJsonPath, updates }) - } - - if (reports.length === 0) { - console.log(`No ${ROOT_PACKAGE}* dependencies found or changed under ${path.resolve(options.target)}`) - return - } - - const totalUpdates = reports.reduce((sum, report) => sum + report.updates.length, 0) - - console.log(`Resolved version: ${version}`) - for (const report of reports) { - console.log(report.packageJsonPath) - printChanges(report.updates) - } - - if (options.dryRun) { - console.log('Dry run enabled, package.json files were not modified') - return - } - - console.log( - `Updated ${totalUpdates} dependency entries in ${reports.length} package.json file(s) under ${path.resolve(options.target)}` - ) -} - -export const runAdd = async (options) => { - const version = options.version ?? resolveLatestVersion() - const packageJsonPath = resolvePackageJsonPath(path.resolve(options.target)) - const { formatting, packageJson } = readPackageJson(packageJsonPath) - const selectedPackages = options.packages - ? resolveInstallPackages(options.packages) - : await promptForInstallSelection(packageJson) - - if (selectedPackages.length === 0) { - console.log('Nothing selected, package.json was not modified') - return - } - - const updates = installPackages(packageJson, selectedPackages, version, options.exact) - - if (updates.length === 0) { - console.log(`Selected packages are already installed with matching ranges in ${packageJsonPath}`) - return - } - - console.log(`Resolved version: ${version}`) - console.log(packageJsonPath) - printChanges(updates) - - if (options.dryRun) { - console.log('Dry run enabled, package.json was not modified') - return - } - - writePackageJson(packageJsonPath, packageJson, formatting) - console.log(`Installed ${updates.length} package entries in ${packageJsonPath}`) -} - -export const main = async (argv = process.argv.slice(2)) => { - const options = parseArgs(argv) - - if (options.add) { - await runAdd(options) - return - } - - runUpdate(options) -} - -const isExecutedDirectly = () => { - const entryPath = process.argv[1] - - if (!entryPath) { - return false - } - - return pathToFileURL(path.resolve(entryPath)).href === import.meta.url -} - -if (isExecutedDirectly()) { - try { - await main() - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(message) - process.exit(1) - } -} diff --git a/eslint.config.js b/eslint.config.js index 43f2e950..cb927145 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -172,5 +172,6 @@ export default defineConfig([ }, { ignores: ['dist/*'] }, { ignores: ['**/dist/*'] }, + { ignores: ['bin/embed-ui.mjs'] }, { ignores: ['packages/**/generated/**'] }, ]) diff --git a/package.json b/package.json index cddc5636..4ca3d6d2 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,16 @@ "./dist/*": "./dist/*", "./types/*": "./types/*" }, - "bin": "./bin/embed-ui-update.mjs", + "bin": "./bin/embed-ui.mjs", "types": "index.d.ts", "workspaces": [ "packages/*" ], "scripts": { "build": "yarn build:code && yarn build:meta", - "build:code": "vite build", + "build:code": "yarn build:lib && yarn build:bin", + "build:lib": "vite build", + "build:bin": "vite build -c vite.config.bin.ts", "build:meta": "npx tsx scripts/build.meta.ts", "eslint": "eslint .", "test": "vitest --run", @@ -45,7 +47,8 @@ "@retailcrm/embed-ui-v1-components": "^0.9.21", "@retailcrm/embed-ui-v1-contexts": "^0.9.21", "@retailcrm/embed-ui-v1-endpoint": "^0.9.21", - "@retailcrm/embed-ui-v1-types": "^0.9.21" + "@retailcrm/embed-ui-v1-types": "^0.9.21", + "yargs": "^17.7.2" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", @@ -85,8 +88,7 @@ "vite-plugin-dts": "^4.5.4", "vitest": "^4.1.3", "vue": "^3.5.32", - "vue-eslint-parser": "^10.4.0", - "yargs": "^17.7.2" + "vue-eslint-parser": "^10.4.0" }, "resolutions": { "handlebars": "^4.7.9" diff --git a/src/cmd/embed-ui/agents.ts b/src/cmd/embed-ui/agents.ts new file mode 100644 index 00000000..72056d97 --- /dev/null +++ b/src/cmd/embed-ui/agents.ts @@ -0,0 +1,135 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' +import type { InstallablePackage } from './types' + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +import { DEFAULT_NEWLINE } from './package-json' + +const ROOT_AGENTS_SECTION_HEADER = '## @retailcrm/embed-ui' + +const createRootAgentsSection = (): string => `${ROOT_AGENTS_SECTION_HEADER} + +When working with RetailCRM embedded UI in this project: + +1. Use documented public package entrypoints instead of package internals. +2. Read package README files from \`./node_modules/@retailcrm/embed-ui*\` before changing integration code. +3. Prefer package-provided \`AGENTS.md\`, MCP resources, YAML profiles, and docs over guessing from source names. +4. Keep widget targets, page codes, contexts, and host API contracts aligned with package documentation. +` + +const appendOrReplaceSection = ( + content: string, + header: string, + section: string, + force: boolean +): string | null => { + const escapedHeader = header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const sectionPattern = new RegExp(`${escapedHeader}[\\s\\S]*?(?=\\n##\\s|$)`, 'u') + + if (sectionPattern.test(content)) { + if (!force) { + return null + } + + return content.replace(sectionPattern, section.trimEnd()).replace(/\s+$/u, '') + DEFAULT_NEWLINE + } + + const trimmed = content.replace(/\s+$/u, '') + + return `${trimmed}${trimmed ? `${DEFAULT_NEWLINE}${DEFAULT_NEWLINE}` : ''}${section}${DEFAULT_NEWLINE}` +} + +const updateRootAgents = (cwd: string, options: InitOptions, changes: InitChanges): void => { + const agentsPath = path.join(cwd, 'AGENTS.md') + const section = createRootAgentsSection() + const content = fs.existsSync(agentsPath) + ? fs.readFileSync(agentsPath, 'utf8') + : '# AGENTS.md\n' + const nextContent = appendOrReplaceSection(content, ROOT_AGENTS_SECTION_HEADER, section, options.force || options.forceAgents) + + if (nextContent === null) { + changes.skipped.push(`${agentsPath} already contains ${ROOT_AGENTS_SECTION_HEADER}`) + return + } + + if (!options.dryRun) { + fs.writeFileSync(agentsPath, nextContent, 'utf8') + } + + changes.agents.push(`update ${agentsPath}`) +} + +const runInitAgentsHook = ( + packageName: string, + binName: string, + cwd: string, + options: InitOptions, + changes: InitChanges +): void => { + const args: string[] = [ + '-y', + '-p', + packageName, + binName, + 'init-agents', + cwd, + ] + + if (options.force || options.forceAgents) { + args.push('--force') + } + + changes.hooks.push(`npx ${args.join(' ')}`) + + if (options.dryRun) { + return + } + + execFileSync('npx', args, { + cwd, + stdio: 'inherit', + }) +} + +export const applyInitAgents = ( + cwd: string, + selectedPackages: InstallablePackage[], + options: InitOptions, + changes: InitChanges +): void => { + if (options.noAgents) { + return + } + + updateRootAgents(cwd, options, changes) + + const selectedIds = new Set(selectedPackages.map((entry) => entry.id)) + + if (selectedIds.has('components')) { + runInitAgentsHook( + '@retailcrm/embed-ui-v1-components', + 'embed-ui-v1-components', + cwd, + options, + changes + ) + } + + if (selectedIds.has('endpoint')) { + if (options.noMcp) { + changes.warnings.push('Skipping v1-endpoint init-agents because it currently includes MCP instructions') + return + } + + runInitAgentsHook( + '@retailcrm/embed-ui-v1-endpoint', + 'embed-ui-v1-endpoint', + cwd, + options, + changes + ) + } +} diff --git a/src/cmd/embed-ui/args.ts b/src/cmd/embed-ui/args.ts new file mode 100644 index 00000000..e5161bc0 --- /dev/null +++ b/src/cmd/embed-ui/args.ts @@ -0,0 +1,298 @@ +import path from 'node:path' +import process from 'node:process' + +import yargs from 'yargs' + +export const PACKAGE_MANAGERS = ['yarn', 'npm', 'pnpm', 'bun'] as const + +export type PackageManager = typeof PACKAGE_MANAGERS[number] + +export interface UpdateOptions { + command: 'update'; + target: string; + version: string | null; + dryRun: boolean; + exact: boolean; + add: boolean; + packages: string[] | null; +} + +export interface InitOptions { + command: 'init'; + cwd: string; + target: string | null; + version: string | null; + dryRun: boolean; + exact: boolean; + packages: string[] | null; + with: string[] | null; + packageManager: PackageManager | null; + noInstall: boolean; + force: boolean; + forceFiles: boolean; + noDirs: boolean; + dirs: string[] | null; + srcDir: string | null; + noTemplate: boolean; + template: string; + pageCode: string; + widgetTarget: string; + noAgents: boolean; + forceAgents: boolean; + agentsOnly: boolean; + noMcp: boolean; + forceMcp: boolean; +} + +export type CliOptions = InitOptions | UpdateOptions + +export const HELP_TEXT = `Usage: + npx @retailcrm/embed-ui [target] [version] [options] + npx @retailcrm/embed-ui init [target] [options] + +Options: + -t, --target Target path (default: current directory) + -v, --version Target version. If omitted, latest npm version is used + --exact Use exact version instead of range + --dry-run Show changes without writing package.json + --add Add selected embed-ui packages into one package.json + --packages Comma-separated package ids or names for --add/init + --cwd Project working directory for init + --package-manager Package manager for init installs + --no-install Do not run package manager install in init mode + --no-agents Do not create or update AGENTS.md in init mode + --no-mcp Do not add package MCP instructions in init mode + -h, --help Show this help + +Examples: + npx @retailcrm/embed-ui + npx @retailcrm/embed-ui --version 0.9.11 + npx @retailcrm/embed-ui ./my-project 0.9.11 + npx @retailcrm/embed-ui --target ./my-project --dry-run + npx @retailcrm/embed-ui --add + npx @retailcrm/embed-ui --add --packages components,contexts + npx @retailcrm/embed-ui init ./web --package-manager yarn +` + +const isSemverLike = (value: string): boolean => /^v?\d+\.\d+\.\d+/.test(value) +const stripLeadingV = (value: string): string => value.replace(/^v/, '') + +export const parsePackageList = (value: string): string[] => + value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + +export const parseInitArgs = (argv: string[]): InitOptions => { + const normalizedArgv = argv.map((argument) => { + if (argument === '--no-dirs') { + return '--no-dirs-enabled' + } + + if (argument === '--no-template') { + return '--no-template-enabled' + } + + return argument + }) + const parsed = yargs(normalizedArgv) + .scriptName('embed-ui') + .usage('Usage: $0 init [target] [options]') + .help(false) + .version(false) + .exitProcess(false) + .strictOptions() + .parserConfiguration({ + 'camel-case-expansion': true, + 'boolean-negation': true, + }) + .option('cwd', { + type: 'string', + default: process.cwd(), + describe: 'Project working directory', + }) + .option('help', { + alias: 'h', + type: 'boolean', + }) + .option('target', { + alias: 't', + type: 'string', + describe: 'Frontend target directory relative to cwd', + }) + .option('version', { + alias: 'v', + type: 'string', + coerce: stripLeadingV, + describe: 'Target package version', + }) + .option('packages', { + type: 'string', + coerce: parsePackageList, + describe: 'Comma-separated init package ids or names', + }) + .option('with', { + type: 'string', + coerce: parsePackageList, + describe: 'Additional published package ids or names', + }) + .option('package-manager', { + type: 'string', + choices: PACKAGE_MANAGERS, + describe: 'Package manager used for install', + }) + .option('dirs', { + type: 'string', + coerce: parsePackageList, + describe: 'Comma-separated directory presets', + }) + .option('src-dir', { + type: 'string', + describe: 'Frontend source root relative to cwd', + }) + .option('template', { + type: 'string', + default: 'order-card', + describe: 'Starter template name', + }) + .option('page-code', { + type: 'string', + default: 'settings', + describe: 'Starter embedded page code', + }) + .option('widget-target', { + type: 'string', + default: 'order/card:common.after', + describe: 'Starter widget target', + }) + .option('dry-run', { type: 'boolean', default: false }) + .option('exact', { type: 'boolean', default: false }) + .option('install', { type: 'boolean', default: true }) + .option('force', { type: 'boolean', default: false }) + .option('force-files', { type: 'boolean', default: false }) + .option('dirs-enabled', { type: 'boolean', default: true }) + .option('template-enabled', { type: 'boolean', default: true }) + .option('agents', { type: 'boolean', default: true }) + .option('force-agents', { type: 'boolean', default: false }) + .option('agents-only', { type: 'boolean', default: false }) + .option('mcp', { type: 'boolean', default: true }) + .option('force-mcp', { type: 'boolean', default: false }) + .parseSync() + + if (parsed.help || parsed.h) { + console.log(HELP_TEXT) + process.exit(0) + } + + const positionals = parsed._.map(String) + if (positionals.length > 1) { + throw new Error('Too many positional arguments') + } + + return { + command: 'init', + cwd: path.resolve(process.cwd(), parsed.cwd), + target: parsed.target ?? positionals[0] ?? null, + version: parsed.version ?? null, + dryRun: parsed.dryRun, + exact: parsed.exact, + packages: parsed.packages ?? null, + with: parsed.with ?? null, + packageManager: parsed.packageManager ?? null, + noInstall: !parsed.install, + force: parsed.force, + forceFiles: parsed.forceFiles, + noDirs: !parsed.dirsEnabled, + dirs: parsed.dirs ?? null, + srcDir: parsed.srcDir ?? null, + noTemplate: !parsed.templateEnabled, + template: parsed.template, + pageCode: parsed.pageCode, + widgetTarget: parsed.widgetTarget, + noAgents: !parsed.agents, + forceAgents: parsed.forceAgents, + agentsOnly: parsed.agentsOnly, + noMcp: !parsed.mcp, + forceMcp: parsed.forceMcp, + } +} + +export const parseArgs = (argv: string[]): CliOptions => { + if (argv[0] === 'init') { + return parseInitArgs(argv.slice(1)) + } + + const parsed = yargs(argv) + .scriptName('embed-ui') + .usage('Usage: $0 [target] [version] [options]') + .help(false) + .version(false) + .exitProcess(false) + .strictOptions() + .option('target', { + alias: 't', + type: 'string', + default: process.cwd(), + }) + .option('help', { + alias: 'h', + type: 'boolean', + }) + .option('version', { + alias: 'v', + type: 'string', + coerce: stripLeadingV, + }) + .option('dry-run', { type: 'boolean', default: false }) + .option('exact', { type: 'boolean', default: false }) + .option('add', { type: 'boolean', default: false }) + .option('packages', { + type: 'string', + coerce: parsePackageList, + }) + .parseSync() + + if (parsed.help || parsed.h) { + console.log(HELP_TEXT) + process.exit(0) + } + + const positionals = parsed._.map(String) + + if (positionals.length > 2) { + throw new Error('Too many positional arguments') + } + + const options: UpdateOptions = { + command: 'update', + target: path.resolve(process.cwd(), parsed.target), + version: parsed.version ?? null, + dryRun: parsed.dryRun, + exact: parsed.exact, + add: parsed.add, + packages: parsed.packages ?? null, + } + + if (positionals.length >= 1) { + const first = positionals[0] + if (!options.version && isSemverLike(first)) { + options.version = stripLeadingV(first) + } else { + options.target = path.resolve(process.cwd(), first) + } + } + + if (positionals.length === 2) { + if (options.version) { + throw new Error('Version is already specified') + } + + options.version = stripLeadingV(positionals[1]) + } + + if (options.packages && !options.add) { + throw new Error('Option --packages can only be used together with --add') + } + + return options +} diff --git a/src/cmd/embed-ui/filesystem.ts b/src/cmd/embed-ui/filesystem.ts new file mode 100644 index 00000000..11b9e4b0 --- /dev/null +++ b/src/cmd/embed-ui/filesystem.ts @@ -0,0 +1,121 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' + +import fs from 'node:fs' +import path from 'node:path' + +const SKIP_DIRECTORIES = new Set([ + '.git', + '.hg', + '.svn', + '.yarn', + 'node_modules', + 'dist', + 'build', + 'coverage', +]) + +const ensureDirectoryExists = (targetPath: string): void => { + if (!fs.existsSync(targetPath)) { + throw new Error(`Path not found: ${targetPath}`) + } + + const stat = fs.statSync(targetPath) + if (!stat.isDirectory()) { + throw new Error(`Target is not a directory: ${targetPath}`) + } +} + +export const resolvePackageJsonPath = (targetPath: string): string => { + if (path.basename(targetPath) === 'package.json') { + if (!fs.existsSync(targetPath)) { + throw new Error(`package.json not found: ${targetPath}`) + } + + return targetPath + } + + const packageJsonPath = path.resolve(targetPath, 'package.json') + + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`package.json not found: ${packageJsonPath}`) + } + + return packageJsonPath +} + +export const collectPackageJsonPaths = (targetPath: string): string[] => { + const resolvedTarget = path.resolve(targetPath) + + if (!fs.existsSync(resolvedTarget)) { + throw new Error(`Path not found: ${resolvedTarget}`) + } + + if (path.basename(resolvedTarget) === 'package.json') { + return [resolvedTarget] + } + + ensureDirectoryExists(resolvedTarget) + + const packageJsonPaths: string[] = [] + + const visit = (directoryPath: string): void => { + const packageJsonPath = path.join(directoryPath, 'package.json') + if (fs.existsSync(packageJsonPath) && fs.statSync(packageJsonPath).isFile()) { + packageJsonPaths.push(packageJsonPath) + } + + for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue + } + + if (SKIP_DIRECTORIES.has(entry.name)) { + continue + } + + visit(path.join(directoryPath, entry.name)) + } + } + + visit(resolvedTarget) + + return packageJsonPaths.sort() +} + +export const writeFileIfAllowed = ( + filePath: string, + content: string, + options: InitOptions, + changes: InitChanges +): boolean => { + if (fs.existsSync(filePath) && !options.forceFiles && !options.force) { + changes.warnings.push(`${filePath} already exists and will not be overwritten`) + return false + } + + if (!options.dryRun) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content, 'utf8') + } + + changes.files.push(filePath) + return true +} + +export const ensureDirectory = (directoryPath: string, options: InitOptions, changes: InitChanges): void => { + if (fs.existsSync(directoryPath)) { + if (!fs.statSync(directoryPath).isDirectory()) { + throw new Error(`Target path is not a directory: ${directoryPath}`) + } + + changes.skipped.push(`${directoryPath} already exists`) + return + } + + if (!options.dryRun) { + fs.mkdirSync(directoryPath, { recursive: true }) + } + + changes.directories.push(directoryPath) +} diff --git a/src/cmd/embed-ui/index.ts b/src/cmd/embed-ui/index.ts new file mode 100755 index 00000000..b46916d3 --- /dev/null +++ b/src/cmd/embed-ui/index.ts @@ -0,0 +1,826 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' +import type { InstallablePackage, PackageChange } from './types' +import type { PackageManager, UpdateOptions } from './args' + +import { createInterface } from 'node:readline/promises' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +import { applyInitAgents } from './agents' +import { collectPackageJsonPaths } from './filesystem' +import { createInitChanges } from './report' +import { createRange } from './packages' +import { DEFAULT_NEWLINE } from './package-json' +import { ensureDirectory } from './filesystem' +import { INIT_DEV_DEPENDENCIES, INIT_RUNTIME_DEPENDENCIES } from './package-json' +import { installPackages } from './packages' +import { PACKAGE_MANAGERS, parseArgs, parseInitArgs } from './args' +import { printChanges, printInitReport } from './report' +import { promptForInstallSelection } from './packages' +import { readOrCreatePackageJson, readPackageJson } from './package-json' +import { resolveInstallPackages, resolveLatestVersion } from './packages' +import { resolvePackageJsonPath } from './filesystem' +import { ROOT_PACKAGE } from './packages' +import { setDependency, setMissingScript } from './package-json' +import { updatePackageJson } from './packages' +import { writeFileIfAllowed } from './filesystem' +import { writePackageJson } from './package-json' + +export type { CliOptions, InitOptions, UpdateOptions } from './args' +export { parseArgs, parseInitArgs } + +const DEFAULT_INIT_PACKAGE_IDS = ['embed-ui', 'components', 'contexts', 'types', 'endpoint'] +const DEFAULT_INIT_DIRS = ['endpoint', 'pages', 'widgets', 'shared', 'i18n'] + +const detectPackageManagerByLockfile = (cwd: string): PackageManager | null => { + const knownLockfiles = [ + { packageManager: 'yarn', file: 'yarn.lock' }, + { packageManager: 'npm', file: 'package-lock.json' }, + { packageManager: 'pnpm', file: 'pnpm-lock.yaml' }, + { packageManager: 'bun', file: 'bun.lockb' }, + ] satisfies Array<{ packageManager: PackageManager; file: string }> + const lockfiles = knownLockfiles.filter(({ file }) => fs.existsSync(path.join(cwd, file))) + + return lockfiles.length === 1 ? lockfiles[0].packageManager : null +} + +const resolvePackageManagerVersion = (packageManager: PackageManager): string | null => { + try { + return execFileSync(packageManager, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return null + } +} + +const promptForPackageManager = async (): Promise => { + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + try { + while (true) { + const answer = await readline.question('Choose package manager (yarn/npm/pnpm/bun): ') + if (PACKAGE_MANAGERS.includes(answer as PackageManager)) { + return answer as PackageManager + } + + console.error(`Unknown package manager: ${answer}`) + } + } finally { + readline.close() + } +} + +const resolvePackageManager = async ( + cwd: string, + explicitPackageManager: PackageManager | null +): Promise => { + if (explicitPackageManager) { + if (!PACKAGE_MANAGERS.includes(explicitPackageManager)) { + throw new Error(`Unknown package manager: ${explicitPackageManager}`) + } + + return explicitPackageManager + } + + const packageManager = detectPackageManagerByLockfile(cwd) + if (packageManager) { + return packageManager + } + + if (process.stdin.isTTY && process.stdout.isTTY) { + return promptForPackageManager() + } + + return 'npm' +} + +const resolveInitPackages = ( + tokens: string[] | null, + extraTokens: string[] | null +): InstallablePackage[] => { + const packageIds = tokens ?? [...DEFAULT_INIT_PACKAGE_IDS, ...(extraTokens ?? [])] + const packages = resolveInstallPackages(packageIds) + + for (const selectedPackage of packages) { + if (selectedPackage.id === 'testing') { + throw new Error('@retailcrm/embed-ui-v1-testing is not published for public init yet') + } + } + + return packages +} + +const resolveInitCwd = (options: InitOptions): string => { + const cwd = path.resolve(options.cwd) + + if (!fs.existsSync(cwd)) { + throw new Error(`cwd does not exist: ${cwd}`) + } + + if (!fs.statSync(cwd).isDirectory()) { + throw new Error(`cwd is not a directory: ${cwd}`) + } + + return cwd +} + +const resolveInitSourceRoot = (cwd: string, options: InitOptions): string => { + if (options.srcDir) { + return path.resolve(cwd, options.srcDir) + } + + if (options.target) { + return path.resolve(cwd, options.target) + } + + const srcPath = path.join(cwd, 'src') + if (!fs.existsSync(srcPath)) { + return srcPath + } + + return path.join(cwd, 'web') +} + +const toPosixRelative = (from: string, to: string): string => { + const relativePath = path.relative(from, to) || '.' + + return relativePath.split(path.sep).join('/') +} + +const quoteJsString = (value: string): string => `'${value.replace(/\\/gu, '\\\\').replace(/'/gu, '\\\'')}'` + +const createEnvDts = () => `/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, unknown> + export default component +} +` + +const createTsConfig = (cwd: string, sourceRoot: string): string => { + const sourceRootRelative = toPosixRelative(cwd, sourceRoot) + + return `${JSON.stringify({ + compilerOptions: { + target: 'ES2022', + useDefineForClassFields: true, + module: 'ESNext', + moduleResolution: 'Bundler', + strict: true, + jsx: 'preserve', + resolveJsonModule: true, + isolatedModules: true, + skipLibCheck: true, + allowSyntheticDefaultImports: true, + esModuleInterop: true, + baseUrl: '.', + paths: { + '@/*': [`${sourceRootRelative}/*`], + }, + }, + include: [ + `${sourceRootRelative}/**/*`, + 'env.d.ts', + ], + vueCompilerOptions: { + plugins: ['@omnicajs/vue-remote/tooling'], + }, + }, null, 2)}${DEFAULT_NEWLINE}` +} + +const createViteConfig = (cwd: string, sourceRoot: string): string => { + const entryPath = toPosixRelative(cwd, path.join(sourceRoot, 'endpoint/endpoint.worker.ts')) + + return `import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite' + +const root = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [vue()], + build: { + rollupOptions: { + input: path.resolve(root, ${JSON.stringify(entryPath)}), + }, + }, +}) +` +} + +const createEslintConfig = () => `import { defineConfig } from 'eslint/config' + +import globals from 'globals' + +import pluginDependencies from '@omnicajs/eslint-plugin-dependencies' +import pluginJs from '@eslint/js' +import pluginTs from 'typescript-eslint' +import pluginVue from 'eslint-plugin-vue' + +const staticTranslationKeysRule = { + meta: { + type: 'problem', + docs: { + description: 'Require static vue-i18n translation keys', + }, + messages: { + dynamicKey: 'Translation keys must be static string literals.', + }, + schema: [], + }, + create (context) { + const i18nFunctions = new Set(['$t', '$te']) + const i18nObjects = new Set(['i18n']) + + const unwrap = (node) => node?.type === 'ChainExpression' ? node.expression : node + const isStaticKey = (node) => { + const unwrapped = unwrap(node) + + return (unwrapped?.type === 'Literal' && typeof unwrapped.value === 'string') + || (unwrapped?.type === 'TemplateLiteral' && unwrapped.expressions.length === 0) + } + const isUseI18nCall = (node) => unwrap(node)?.type === 'CallExpression' + && unwrap(unwrap(node).callee)?.type === 'Identifier' + && unwrap(unwrap(node).callee).name === 'useI18n' + const registerUseI18nBinding = (node) => { + if (!isUseI18nCall(node.init)) { + return + } + + if (node.id.type === 'Identifier') { + i18nObjects.add(node.id.name) + return + } + + if (node.id.type !== 'ObjectPattern') { + return + } + + for (const property of node.id.properties) { + if (property.type !== 'Property') { + continue + } + + const key = property.key.type === 'Identifier' ? property.key.name : property.key.value + const value = property.value.type === 'Identifier' ? property.value.name : null + + if ((key === 't' || key === 'te') && value) { + i18nFunctions.add(value) + } + } + } + const isTranslationCallee = (callee) => { + const unwrapped = unwrap(callee) + + if (unwrapped?.type === 'Identifier') { + return i18nFunctions.has(unwrapped.name) + } + + if (unwrapped?.type !== 'MemberExpression') { + return false + } + + const object = unwrap(unwrapped.object) + const property = unwrap(unwrapped.property) + const propertyName = property?.type === 'Identifier' ? property.name : property?.value + + if (!['$t', '$te', 't', 'te'].includes(propertyName)) { + return false + } + + return object?.type === 'ThisExpression' + || (object?.type === 'Identifier' && i18nObjects.has(object.name)) + } + + return { + VariableDeclarator: registerUseI18nBinding, + CallExpression (node) { + if (!isTranslationCallee(node.callee)) { + return + } + + if (!isStaticKey(node.arguments[0])) { + context.report({ node: node.arguments[0] ?? node, messageId: 'dynamicKey' }) + } + }, + } + }, +} + +export default defineConfig([ + { files: ['**/*.{js,mjs,cjs,ts,vue}'] }, + { + plugins: { + dependencies: pluginDependencies, + 'retailcrm-init': { + rules: { + 'static-translation-keys': staticTranslationKeysRule, + }, + }, + }, + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + 'comma-dangle': ['error', 'always-multiline'], + 'eqeqeq': ['error', 'always'], + 'indent': ['error', 2, { SwitchCase: 1 }], + 'no-debugger': 'error', + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }], + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'quotes': ['error', 'single'], + 'semi': ['error', 'never'], + + '@typescript-eslint/consistent-type-imports': ['error', { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', + }], + + 'dependencies/import-style': ['error', { + maxSingleLineLength: 90, + maxSingleLineSpecifiers: 3, + }], + 'dependencies/separate-type-imports': 'error', + 'dependencies/separate-type-partitions': 'error', + 'dependencies/sort-named-imports': ['error', { + type: 'alphabetical', + ignoreAlias: true, + }], + 'dependencies/sort-imports': ['error', { + type: 'alphabetical', + imports: { + orderBy: 'alias', + splitDeclarations: true, + }, + groups: [ + 'side-effect-style', + 'side-effect', + ['type-import', 'type-external', 'type-internal', 'type-parent', 'type-sibling', 'type-index'], + 'builtin', + 'value-external', + 'value-internal', + ['value-parent', 'value-sibling'], + 'index', + 'unknown', + ], + newlinesInside: 1, + }], + + 'retailcrm-init/static-translation-keys': 'error', + }, + }, + pluginJs.configs.recommended, + ...pluginTs.configs.recommended, + ...pluginVue.configs['flat/essential'], + { + files: ['**/*.vue'], + languageOptions: { + parserOptions: { parser: pluginTs.parser }, + }, + rules: { + 'vue/attributes-order': 'error', + 'vue/component-definition-name-casing': ['error', 'PascalCase'], + 'vue/component-name-in-template-casing': ['error', 'PascalCase'], + 'vue/html-self-closing': ['error', { + html: { + component: 'always', + normal: 'always', + void: 'always', + }, + math: 'always', + svg: 'always', + }], + 'vue/max-attributes-per-line': ['error', { + singleline: 4, + multiline: { max: 1 }, + }], + }, + }, + { ignores: ['dist/**', 'coverage/**'] }, +]) +` + +const createEndpointWorker = (options: InitOptions): string => `import type { App } from 'vue' + +import { + definePageRunner, + defineRunner, + defineWidgetRunner, + runEndpoint, +} from '@retailcrm/embed-ui-v1-endpoint/remote' + +import { i18n } from '../i18n' +import SettingsPage from '../pages/SettingsPage.vue' +import OrderCommonAfterWidget from '../widgets/OrderCommonAfterWidget.vue' + +const setupApp = (app: App) => { + app.use(i18n) +} + +const runner = defineRunner({ + pages: [{ + ${quoteJsString(options.pageCode)}: definePageRunner(SettingsPage, setupApp), + }], + widgets: [{ + ${quoteJsString(options.widgetTarget)}: defineWidgetRunner(OrderCommonAfterWidget, setupApp), + }], +}) + +runEndpoint(runner) +` + +const createI18nIndex = (): string => `import { createI18n } from 'vue-i18n' + +import enGB from './locales/en-GB.json' +import esES from './locales/es-ES.json' +import ruRU from './locales/ru-RU.json' + +const messages = { + 'en-GB': enGB, + 'es-ES': esES, + 'ru-RU': ruRU, +} as const + +export type Locale = keyof typeof messages +export type MessageSchema = typeof enGB + +export const i18n = createI18n<[MessageSchema], Locale>({ + legacy: false, + locale: 'ru-RU', + fallbackLocale: 'en-GB', + messages, +}) +` + +const createSettingsPage = (): string => ` + + +` + +const createOrderWidget = (): string => ` + + +` + +const createMessages = (locale: 'en-GB' | 'es-ES' | 'ru-RU'): string => `${JSON.stringify({ + settings: { + title: locale === 'ru-RU' ? 'Настройки расширения' : locale === 'es-ES' ? 'Configuracion de la extension' : 'Extension settings', + description: locale === 'ru-RU' + ? 'Здесь можно подготовить настройки встроенного интерфейса.' + : locale === 'es-ES' + ? 'Aqui puede preparar la configuracion de la interfaz integrada.' + : 'Prepare embedded interface settings here.', + }, + orderCommonAfter: { + title: locale === 'ru-RU' ? 'Виджет заказа' : locale === 'es-ES' ? 'Widget del pedido' : 'Order widget', + description: locale === 'ru-RU' + ? 'Стартовый виджет для формы заказа.' + : locale === 'es-ES' + ? 'Widget inicial para el formulario del pedido.' + : 'Starter widget for the order form.', + }, +}, null, 2)}${DEFAULT_NEWLINE}` + +export const runUpdate = (options: UpdateOptions): void => { + const version = options.version ?? resolveLatestVersion() + const packageJsonPaths = collectPackageJsonPaths(options.target) + const reports: Array<{ packageJsonPath: string; updates: PackageChange[] }> = [] + + for (const packageJsonPath of packageJsonPaths) { + const { formatting, packageJson } = readPackageJson(packageJsonPath) + const updates = updatePackageJson(packageJson, version, options.exact) + + if (updates.length === 0) { + continue + } + + if (!options.dryRun) { + writePackageJson(packageJsonPath, packageJson, formatting) + } + + reports.push({ packageJsonPath, updates }) + } + + if (reports.length === 0) { + console.log(`No ${ROOT_PACKAGE}* dependencies found or changed under ${path.resolve(options.target)}`) + return + } + + const totalUpdates = reports.reduce((sum, report) => sum + report.updates.length, 0) + + console.log(`Resolved version: ${version}`) + for (const report of reports) { + console.log(report.packageJsonPath) + printChanges(report.updates) + } + + if (options.dryRun) { + console.log('Dry run enabled, package.json files were not modified') + return + } + + console.log( + `Updated ${totalUpdates} dependency entries in ${reports.length} package.json file(s) under ${path.resolve(options.target)}` + ) +} + +export const runAdd = async (options: UpdateOptions): Promise => { + const version = options.version ?? resolveLatestVersion() + const packageJsonPath = resolvePackageJsonPath(path.resolve(options.target)) + const { formatting, packageJson } = readPackageJson(packageJsonPath) + const selectedPackages = options.packages + ? resolveInstallPackages(options.packages) + : await promptForInstallSelection(packageJson) + + if (selectedPackages.length === 0) { + console.log('Nothing selected, package.json was not modified') + return + } + + const updates = installPackages(packageJson, selectedPackages, version, options.exact) + + if (updates.length === 0) { + console.log(`Selected packages are already installed with matching ranges in ${packageJsonPath}`) + return + } + + console.log(`Resolved version: ${version}`) + console.log(packageJsonPath) + printChanges(updates) + + if (options.dryRun) { + console.log('Dry run enabled, package.json was not modified') + return + } + + writePackageJson(packageJsonPath, packageJson, formatting) + console.log(`Installed ${updates.length} package entries in ${packageJsonPath}`) +} + +const applyInitPackageJson = ( + cwd: string, + selectedPackages: InstallablePackage[], + version: string, + packageManager: PackageManager, + options: InitOptions, + changes: InitChanges +): string => { + const packageJsonPath = path.join(cwd, 'package.json') + const { + created, + formatting, + packageJson, + } = readOrCreatePackageJson(packageJsonPath) + + if (!packageJson.type) { + packageJson.type = 'module' + changes.packageJson.push({ type: 'field', name: 'type', nextRange: 'module' }) + } else if (packageJson.type !== 'module') { + changes.warnings.push('package.json already has type field and it is not "module"') + } + + setMissingScript(packageJson, 'build', 'vite build', changes) + setMissingScript(packageJson, 'lint', 'eslint .', changes) + setMissingScript(packageJson, 'lint:fix', 'eslint --fix .', changes) + + for (const selectedPackage of selectedPackages) { + setDependency( + packageJson, + selectedPackage.section, + selectedPackage.name, + createRange(version, options.exact), + changes + ) + } + + for (const dependency of INIT_RUNTIME_DEPENDENCIES) { + setDependency(packageJson, 'dependencies', dependency.name, dependency.range, changes) + } + + for (const dependency of INIT_DEV_DEPENDENCIES) { + setDependency(packageJson, 'devDependencies', dependency.name, dependency.range, changes) + } + + if (!packageJson.packageManager && options.packageManager) { + const packageManagerVersion = resolvePackageManagerVersion(packageManager) + + if (packageManagerVersion) { + const nextPackageManager = `${packageManager}@${packageManagerVersion}` + + packageJson.packageManager = nextPackageManager + changes.packageJson.push({ + type: 'field', + name: 'packageManager', + nextRange: nextPackageManager, + }) + } else { + changes.warnings.push(`Cannot resolve ${packageManager} version; packageManager field was not written`) + } + } + + if (!options.dryRun && (created || changes.packageJson.length > 0)) { + writePackageJson(packageJsonPath, packageJson, formatting) + } + + return packageJsonPath +} + +const applyInitDirectories = (sourceRoot: string, options: InitOptions, changes: InitChanges): void => { + if (options.noDirs || options.agentsOnly) { + return + } + + const dirs = options.dirs ?? DEFAULT_INIT_DIRS + const unknownDir = dirs.find((dir) => ![...DEFAULT_INIT_DIRS, 'src', 'tests'].includes(dir)) + if (unknownDir) { + throw new Error(`Unknown directory preset: ${unknownDir}`) + } + + ensureDirectory(sourceRoot, options, changes) + + for (const dir of dirs) { + if (dir === 'src') { + continue + } + + ensureDirectory(path.join(sourceRoot, dir), options, changes) + } + + if (dirs.includes('i18n')) { + ensureDirectory(path.join(sourceRoot, 'i18n/locales'), options, changes) + } +} + +const applyInitConfigs = ( + cwd: string, + sourceRoot: string, + options: InitOptions, + changes: InitChanges +): void => { + if (options.agentsOnly) { + return + } + + writeFileIfAllowed(path.join(cwd, 'tsconfig.json'), createTsConfig(cwd, sourceRoot), options, changes) + writeFileIfAllowed(path.join(cwd, 'vite.config.ts'), createViteConfig(cwd, sourceRoot), options, changes) + writeFileIfAllowed(path.join(cwd, 'env.d.ts'), createEnvDts(), options, changes) + writeFileIfAllowed(path.join(cwd, 'eslint.config.js'), createEslintConfig(), options, changes) +} + +const applyInitTemplate = (sourceRoot: string, options: InitOptions, changes: InitChanges): void => { + if (options.noTemplate || options.agentsOnly) { + return + } + + if (options.template !== 'order-card') { + throw new Error(`Unknown template: ${options.template}`) + } + + writeFileIfAllowed( + path.join(sourceRoot, 'endpoint/endpoint.worker.ts'), + createEndpointWorker(options), + options, + changes + ) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/index.ts'), createI18nIndex(), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/en-GB.json'), createMessages('en-GB'), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/es-ES.json'), createMessages('es-ES'), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/ru-RU.json'), createMessages('ru-RU'), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'pages/SettingsPage.vue'), createSettingsPage(), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'widgets/OrderCommonAfterWidget.vue'), createOrderWidget(), options, changes) +} + +const runInstall = ( + cwd: string, + packageManager: PackageManager, + options: InitOptions, + changes: InitChanges, + packageJsonChanged: boolean +): void => { + if (options.noInstall || options.agentsOnly) { + return + } + + if (!packageJsonChanged && !options.force) { + changes.skipped.push('install skipped because package.json was not changed') + return + } + + const args = ['install'] + changes.install = `${packageManager} ${args.join(' ')}` + + if (options.dryRun) { + return + } + + execFileSync(packageManager, args, { + cwd, + stdio: 'inherit', + }) +} + +export const runInit = async (options: InitOptions): Promise => { + const cwd = resolveInitCwd(options) + const sourceRoot = resolveInitSourceRoot(cwd, options) + + if (fs.existsSync(sourceRoot) && !fs.statSync(sourceRoot).isDirectory()) { + throw new Error(`Target path is not a directory: ${sourceRoot}`) + } + + const selectedPackages = resolveInitPackages(options.packages, options.with) + const version = options.agentsOnly + ? options.version ?? 'not used' + : options.version ?? resolveLatestVersion() + const packageManager = options.agentsOnly + ? options.packageManager ?? detectPackageManagerByLockfile(cwd) ?? 'npm' + : await resolvePackageManager(cwd, options.packageManager) + const changes = createInitChanges() + + let packageJsonPath: string | null = null + if (!options.agentsOnly) { + packageJsonPath = applyInitPackageJson(cwd, selectedPackages, version, packageManager, options, changes) + applyInitDirectories(sourceRoot, options, changes) + applyInitConfigs(cwd, sourceRoot, options, changes) + applyInitTemplate(sourceRoot, options, changes) + } + + applyInitAgents(cwd, selectedPackages, options, changes) + runInstall(cwd, packageManager, options, changes, Boolean(packageJsonPath && changes.packageJson.length > 0)) + printInitReport(cwd, sourceRoot, version, packageManager, changes, options) +} + +export const main = async (argv: string[] = process.argv.slice(2)): Promise => { + const options = parseArgs(argv) + + if (options.command === 'init') { + await runInit(options) + return + } + + if (options.add) { + await runAdd(options) + return + } + + runUpdate(options) +} + +const isExecutedDirectly = (): boolean => { + const entryPath = process.argv[1] + + if (!entryPath) { + return false + } + + return pathToFileURL(path.resolve(entryPath)).href === import.meta.url +} + +if (isExecutedDirectly()) { + try { + await main() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exit(1) + } +} diff --git a/src/cmd/embed-ui/package-json.ts b/src/cmd/embed-ui/package-json.ts new file mode 100644 index 00000000..0eb90aa5 --- /dev/null +++ b/src/cmd/embed-ui/package-json.ts @@ -0,0 +1,174 @@ +import type { + DependencySection, + Formatting, + InitChanges, + PackageJson, +} from './types' + +import fs from 'node:fs' + +import { findDependencySection, formatRange, isTargetPackage } from './packages' + +export const DEFAULT_INDENT = ' ' +export const DEFAULT_NEWLINE = '\n' + +export const INIT_RUNTIME_DEPENDENCIES: Array<{ name: string; range: string }> = [ + { name: '@omnicajs/vue-remote', range: '^0.2.23' }, + { name: 'pinia', range: '^2.2' }, + { name: 'vue', range: '^3.5' }, + { name: 'vue-i18n', range: '^11' }, +] + +export const INIT_DEV_DEPENDENCIES: Array<{ name: string; range: string }> = [ + { name: '@eslint/js', range: '^9' }, + { name: '@omnicajs/eslint-plugin-dependencies', range: '^0.0' }, + { name: '@types/node', range: '^22' }, + { name: '@vitejs/plugin-vue', range: '^6' }, + { name: '@vue/language-server', range: '^3' }, + { name: 'eslint', range: '^9' }, + { name: 'eslint-plugin-vue', range: '^10' }, + { name: 'globals', range: '^16' }, + { name: 'typescript', range: '^5' }, + { name: 'typescript-eslint', range: '^8' }, + { name: 'vite', range: '^7' }, + { name: 'vue-eslint-parser', range: '^10' }, +] + +export const detectFormatting = (source: string): Formatting => { + const newline = source.includes('\r\n') ? '\r\n' : DEFAULT_NEWLINE + const indentMatch = source.match(/\n([ \t]+)"/) + + return { + indent: indentMatch?.[1] ?? DEFAULT_INDENT, + newline, + trailingNewline: source.endsWith('\n') || source.endsWith('\r\n'), + } +} + +export const serializePackageJson = (packageJson: PackageJson, formatting: Formatting): string => { + const serialized = JSON.stringify(packageJson, null, formatting.indent) + .replace(/\n/g, formatting.newline) + + return formatting.trailingNewline + ? `${serialized}${formatting.newline}` + : serialized +} + +export const readPackageJson = (packageJsonPath: string): { formatting: Formatting; packageJson: PackageJson } => { + const source = fs.readFileSync(packageJsonPath, 'utf8') + + return { + formatting: detectFormatting(source), + packageJson: JSON.parse(source), + } +} + +export const writePackageJson = (packageJsonPath: string, packageJson: PackageJson, formatting: Formatting): void => { + fs.writeFileSync(packageJsonPath, serializePackageJson(packageJson, formatting), 'utf8') +} + +export const readOrCreatePackageJson = ( + packageJsonPath: string +): { created: boolean; formatting: Formatting; packageJson: PackageJson } => { + if (fs.existsSync(packageJsonPath)) { + return { + created: false, + ...readPackageJson(packageJsonPath), + } + } + + return { + created: true, + formatting: { + indent: DEFAULT_INDENT, + newline: DEFAULT_NEWLINE, + trailingNewline: true, + }, + packageJson: { + name: 'retailcrm-extension-frontend', + private: true, + type: 'module', + scripts: { + build: 'vite build', + lint: 'eslint .', + 'lint:fix': 'eslint --fix .', + }, + dependencies: {}, + devDependencies: {}, + }, + } +} + +const ensureObjectField = (object: PackageJson, field: string): PackageJson => { + if (!object[field] || typeof object[field] !== 'object' || Array.isArray(object[field])) { + object[field] = {} + } + + return object[field] as PackageJson +} + +export const setMissingScript = ( + packageJson: PackageJson, + name: string, + command: string, + changes: InitChanges +): void => { + const scripts = ensureObjectField(packageJson, 'scripts') + + if (scripts[name] === command) { + return + } + + if (typeof scripts[name] === 'string') { + changes.warnings.push(`script "${name}" already exists and will not be overwritten`) + return + } + + scripts[name] = command + + changes.packageJson.push({ + type: 'script', + name, + nextRange: command, + }) +} + +export const setDependency = ( + packageJson: PackageJson, + section: DependencySection, + name: string, + range: string, + changes: InitChanges +): void => { + const currentSection = findDependencySection(packageJson, name) + + if (currentSection && currentSection !== section) { + changes.warnings.push(`${name} already exists in ${currentSection}; expected ${section}`) + return + } + + const dependencyMap = ensureObjectField(packageJson, section) + const currentRange = dependencyMap[name] + + if (currentRange === range) { + return + } + + if (typeof currentRange === 'string' && !isTargetPackage(name)) { + changes.warnings.push(`${name} already exists with range ${currentRange}; expected ${range}`) + return + } + + dependencyMap[name] = typeof currentRange === 'string' + ? formatRange(currentRange, range.replace(/^[~^]/u, ''), range === range.replace(/^[~^]/u, '')) + : range + const nextRange = String(dependencyMap[name]) + + changes.packageJson.push({ + type: typeof currentRange === 'string' ? 'update' : 'install', + section, + name, + currentRange: typeof currentRange === 'string' ? currentRange : null, + nextRange, + }) +} diff --git a/src/cmd/embed-ui/packages.ts b/src/cmd/embed-ui/packages.ts new file mode 100644 index 00000000..1b5b8edb --- /dev/null +++ b/src/cmd/embed-ui/packages.ts @@ -0,0 +1,261 @@ +import type { + DependencySection, + InstallablePackage, + PackageChange, + PackageJson, +} from './types' + +import { createInterface } from 'node:readline/promises' +import { execFileSync } from 'node:child_process' +import process from 'node:process' + +import { parsePackageList } from './args' +import { TARGET_SECTIONS } from './types' + +export const ROOT_PACKAGE = '@retailcrm/embed-ui' + +export const INSTALLABLE_PACKAGES: InstallablePackage[] = [ + { + id: 'embed-ui', + name: ROOT_PACKAGE, + section: 'dependencies', + description: 'Базовый пакет с общим API и согласованными v1-зависимостями.', + }, + { + id: 'components', + name: '@retailcrm/embed-ui-v1-components', + section: 'dependencies', + description: 'UI-компоненты для host/remote приложений.', + }, + { + id: 'contexts', + name: '@retailcrm/embed-ui-v1-contexts', + section: 'dependencies', + description: 'Реактивные контексты RetailCRM JS API.', + }, + { + id: 'types', + name: '@retailcrm/embed-ui-v1-types', + section: 'dependencies', + description: 'Базовые type declarations для RetailCRM JS API.', + }, + { + id: 'testing', + name: '@retailcrm/embed-ui-v1-testing', + section: 'devDependencies', + description: 'Вспомогательные утилиты и типы для тестов интеграций.', + }, + { + id: 'endpoint', + name: '@retailcrm/embed-ui-v1-endpoint', + section: 'dependencies', + description: 'Endpoint API для интеграций в RetailCRM.', + }, +] + +export const resolveLatestVersion = (): string => { + const output = execFileSync( + 'npm', + ['view', ROOT_PACKAGE, 'version'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + } + ).trim() + + if (!output) { + throw new Error(`Cannot resolve latest version for ${ROOT_PACKAGE}`) + } + + return output +} + +export const isTargetPackage = (name: string): boolean => + name === ROOT_PACKAGE || name.startsWith(`${ROOT_PACKAGE}-`) + +export const createRange = (version: string, exact: boolean): string => exact ? version : `^${version}` + +export const formatRange = (currentRange: string, nextVersion: string, exact: boolean): string => { + if (exact) { + return nextVersion + } + + if (currentRange.startsWith('workspace:')) { + return currentRange + } + + if (currentRange.startsWith('~')) { + return `~${nextVersion}` + } + + if (currentRange.startsWith('^')) { + return `^${nextVersion}` + } + + return `^${nextVersion}` +} + +export const findDependencySection = (packageJson: PackageJson, packageName: string): DependencySection | null => { + for (const section of TARGET_SECTIONS) { + const dependencyMap = packageJson[section] as Record | undefined + + if (dependencyMap && typeof dependencyMap === 'object' && packageName in dependencyMap) { + return section + } + } + + return null +} + +export const updatePackageJson = ( + packageJson: PackageJson, + version: string, + exact: boolean +): PackageChange[] => { + const updates: PackageChange[] = [] + + for (const section of TARGET_SECTIONS) { + const dependencyMap = packageJson[section] as Record | undefined + + if (!dependencyMap || typeof dependencyMap !== 'object') { + continue + } + + for (const [name, currentRange] of Object.entries(dependencyMap)) { + if (!isTargetPackage(name) || typeof currentRange !== 'string') { + continue + } + + const nextRange = formatRange(currentRange, version, exact) + if (nextRange === currentRange) { + continue + } + + dependencyMap[name] = nextRange + updates.push({ + type: 'update', + section, + name, + currentRange, + nextRange, + }) + } + } + + return updates +} + +export const resolveInstallPackages = (tokens: string[]): InstallablePackage[] => { + const selectedPackages: InstallablePackage[] = [] + const seen = new Set() + + for (const token of tokens) { + const normalized = token.trim() + if (!normalized) { + continue + } + + const numericIndex = Number(normalized) + const selectedPackage = + Number.isInteger(numericIndex) && numericIndex >= 1 && numericIndex <= INSTALLABLE_PACKAGES.length + ? INSTALLABLE_PACKAGES[numericIndex - 1] + : INSTALLABLE_PACKAGES.find((entry) => entry.id === normalized || entry.name === normalized) + + if (!selectedPackage) { + const supported = INSTALLABLE_PACKAGES + .map((entry, index) => `${index + 1}/${entry.id}/${entry.name}`) + .join(', ') + + throw new Error(`Unknown add target "${normalized}". Supported values: ${supported}`) + } + + if (seen.has(selectedPackage.name)) { + continue + } + + seen.add(selectedPackage.name) + selectedPackages.push(selectedPackage) + } + + return selectedPackages +} + +export const installPackages = ( + packageJson: PackageJson, + packages: InstallablePackage[], + version: string, + exact: boolean +): PackageChange[] => { + const updates: PackageChange[] = [] + + for (const selectedPackage of packages) { + const section = findDependencySection(packageJson, selectedPackage.name) ?? selectedPackage.section + const dependencyMap = (packageJson[section] ?? {}) as Record + + if (!(section in packageJson)) { + packageJson[section] = dependencyMap + } + + const currentRange = dependencyMap[selectedPackage.name] + const nextRange = typeof currentRange === 'string' + ? formatRange(currentRange, version, exact) + : createRange(version, exact) + + if (currentRange === nextRange) { + continue + } + + dependencyMap[selectedPackage.name] = nextRange + updates.push({ + type: typeof currentRange === 'string' ? 'update' : 'install', + section, + name: selectedPackage.name, + currentRange: typeof currentRange === 'string' ? currentRange : null, + nextRange, + }) + } + + return updates +} + +export const promptForInstallSelection = async (packageJson: PackageJson): Promise => { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('Interactive add mode requires a TTY. Use --packages to select packages explicitly.') + } + + console.log('Выберите пакеты для установки в текущий package.json:') + for (const [index, selectedPackage] of INSTALLABLE_PACKAGES.entries()) { + const currentSection = findDependencySection(packageJson, selectedPackage.name) + const installedHint = currentSection ? ` Уже есть в ${currentSection}.` : '' + + console.log(` ${index + 1}. ${selectedPackage.name} (${selectedPackage.id})`) + console.log(` ${selectedPackage.description} Раздел по умолчанию: ${selectedPackage.section}.${installedHint}`) + } + + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + try { + while (true) { + const answer = await readline.question( + 'Введите номера, ids или имена пакетов через запятую (например: 1,3 или components,types): ' + ) + + const tokens = parsePackageList(answer) + if (tokens.length === 0) { + return [] + } + + try { + return resolveInstallPackages(tokens) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + } + } + } finally { + readline.close() + } +} diff --git a/src/cmd/embed-ui/report.ts b/src/cmd/embed-ui/report.ts new file mode 100644 index 00000000..bc72b8c3 --- /dev/null +++ b/src/cmd/embed-ui/report.ts @@ -0,0 +1,112 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' +import type { PackageChange } from './types' +import type { PackageManager } from './args' + +export const createInitChanges = (): InitChanges => ({ + packageJson: [], + directories: [], + files: [], + agents: [], + hooks: [], + install: null, + skipped: [], + warnings: [], +}) + +export const printChanges = (changes: PackageChange[]): void => { + for (const change of changes) { + let prefix: string + + if (change.type === 'script') { + prefix = `scripts: ${change.name} -> ${change.nextRange}` + } else if (change.type === 'field') { + prefix = `${change.name} -> ${change.nextRange}` + } else { + prefix = change.type === 'install' + ? `${change.section}: ${change.name} -> ${change.nextRange}` + : `${change.section}: ${change.name} ${change.currentRange} -> ${change.nextRange}` + } + + console.log(` ${prefix}`) + } +} + +export const printInitReport = ( + cwd: string, + sourceRoot: string, + version: string, + packageManager: PackageManager, + changes: InitChanges, + options: InitOptions +): void => { + console.log(`CWD: ${cwd}`) + console.log(`Target: ${sourceRoot}`) + console.log(`Resolved version: ${version}`) + console.log(`Package manager: ${packageManager}`) + + if (changes.packageJson.length > 0) { + console.log('') + console.log('package.json') + printChanges(changes.packageJson) + } + + if (changes.directories.length > 0) { + console.log('') + console.log('directories') + for (const directoryPath of changes.directories) { + console.log(` create ${directoryPath}`) + } + } + + if (changes.files.length > 0) { + console.log('') + console.log('files') + for (const filePath of changes.files) { + console.log(` create ${filePath}`) + } + } + + if (changes.agents.length > 0) { + console.log('') + console.log('AGENTS.md') + for (const agentChange of changes.agents) { + console.log(` ${agentChange}`) + } + } + + if (changes.hooks.length > 0) { + console.log('') + console.log('package hooks') + for (const hook of changes.hooks) { + console.log(` ${options.dryRun ? 'would run' : 'ran'} ${hook}`) + } + } + + if (changes.install) { + console.log('') + console.log('install') + console.log(` ${changes.install}`) + } + + if (changes.skipped.length > 0) { + console.log('') + console.log('skipped') + for (const skipped of changes.skipped) { + console.log(` ${skipped}`) + } + } + + if (changes.warnings.length > 0) { + console.log('') + console.log('warnings') + for (const warning of changes.warnings) { + console.log(` ${warning}`) + } + } + + if (options.dryRun) { + console.log('') + console.log('Dry run enabled, no files were modified.') + } +} diff --git a/src/cmd/embed-ui/types.ts b/src/cmd/embed-ui/types.ts new file mode 100644 index 00000000..35f44bf0 --- /dev/null +++ b/src/cmd/embed-ui/types.ts @@ -0,0 +1,50 @@ +import type { InitOptions } from './args' + +export const TARGET_SECTIONS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const + +export type DependencySection = typeof TARGET_SECTIONS[number] +export type PackageJson = Record + +export interface Formatting { + indent: string; + newline: string; + trailingNewline: boolean; +} + +export interface InstallablePackage { + id: string; + name: string; + section: DependencySection; + description: string; +} + +export interface PackageChange { + type: 'field' | 'install' | 'script' | 'update'; + section?: DependencySection; + name: string; + currentRange?: string | null; + nextRange: string; +} + +export interface InitChanges { + packageJson: PackageChange[]; + directories: string[]; + files: string[]; + agents: string[]; + hooks: string[]; + install: string | null; + skipped: string[]; + warnings: string[]; +} + +export type InitFileWriter = ( + filePath: string, + content: string, + options: InitOptions, + changes: InitChanges +) => boolean diff --git a/tests/embed-ui-update.test.ts b/tests/embed-ui.test.ts similarity index 51% rename from tests/embed-ui-update.test.ts rename to tests/embed-ui.test.ts index ec0444eb..86873b2e 100644 --- a/tests/embed-ui-update.test.ts +++ b/tests/embed-ui.test.ts @@ -12,16 +12,22 @@ import { vi, } from 'vitest' -import { parseArgs, runAdd, runUpdate } from '../bin/embed-ui-update.mjs' +import { + parseArgs, + parseInitArgs, + runAdd, + runInit, + runUpdate, +} from '../src/cmd/embed-ui' -const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'embed-ui-update-')) +const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'embed-ui-')) const writeFile = (filePath: string, content: string) => { fs.mkdirSync(path.dirname(filePath), { recursive: true }) fs.writeFileSync(filePath, content, 'utf8') } -describe('embed-ui update CLI', () => { +describe('embed-ui CLI', () => { afterEach(() => { vi.restoreAllMocks() }) @@ -65,6 +71,7 @@ describe('embed-ui update CLI', () => { vi.spyOn(console, 'log').mockImplementation(() => undefined) runUpdate({ + command: 'update', target: tempDir, version: '1.2.3', dryRun: false, @@ -102,6 +109,7 @@ describe('embed-ui update CLI', () => { vi.spyOn(console, 'log').mockImplementation(() => undefined) await runAdd({ + command: 'update', target: tempDir, version: '2.0.0', dryRun: false, @@ -134,4 +142,99 @@ describe('embed-ui update CLI', () => { 'Option --packages can only be used together with --add' ) }) + + test('parseArgs supports init command with cwd and frontend target', () => { + const options = parseArgs([ + 'init', + './web', + '--cwd', + '/tmp/module', + '--package-manager', + 'yarn', + '--no-install', + '--no-agents', + ]) + + expect(options.command).toBe('init') + if (options.command !== 'init') { + throw new Error('Expected init options') + } + + expect(options.target).toBe('./web') + expect(options.cwd).toBe('/tmp/module') + expect(options.packageManager).toBe('yarn') + expect(options.noInstall).toBe(true) + expect(options.noAgents).toBe(true) + }) + + test('parseInitArgs rejects testing package in init mode', async () => { + const tempDir = createTempDir() + + await expect(runInit({ + ...parseInitArgs(['--cwd', tempDir, '--packages', 'testing', '--no-install', '--no-agents']), + version: '1.2.3', + })).rejects.toThrow('@retailcrm/embed-ui-v1-testing is not published for public init yet') + }) + + test('init mode creates package.json, configs and starter template without install', async () => { + const tempDir = createTempDir() + + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await runInit({ + ...parseInitArgs([ + './web', + '--cwd', + tempDir, + '--package-manager', + 'npm', + '--no-install', + '--no-agents', + ]), + version: '1.2.3', + }) + + const packageJson = JSON.parse(fs.readFileSync(path.join(tempDir, 'package.json'), 'utf8')) + + expect(packageJson.type).toBe('module') + expect(packageJson.scripts).toMatchObject({ + build: 'vite build', + lint: 'eslint .', + 'lint:fix': 'eslint --fix .', + }) + expect(packageJson.dependencies).toMatchObject({ + '@retailcrm/embed-ui': '^1.2.3', + '@retailcrm/embed-ui-v1-components': '^1.2.3', + '@retailcrm/embed-ui-v1-contexts': '^1.2.3', + '@retailcrm/embed-ui-v1-endpoint': '^1.2.3', + '@retailcrm/embed-ui-v1-types': '^1.2.3', + '@omnicajs/vue-remote': '^0.2.23', + pinia: '^2.2', + vue: '^3.5', + 'vue-i18n': '^11', + }) + expect(packageJson.devDependencies).toMatchObject({ + '@eslint/js': '^9', + '@omnicajs/eslint-plugin-dependencies': '^0.0', + '@types/node': '^22', + '@vitejs/plugin-vue': '^6', + '@vue/language-server': '^3', + eslint: '^9', + 'eslint-plugin-vue': '^10', + globals: '^16', + typescript: '^5', + 'typescript-eslint': '^8', + vite: '^7', + 'vue-eslint-parser': '^10', + }) + + expect(fs.existsSync(path.join(tempDir, 'tsconfig.json'))).toBe(true) + expect(fs.readFileSync(path.join(tempDir, 'tsconfig.json'), 'utf8')).toContain('"resolveJsonModule": true') + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain('static-translation-keys') + expect(fs.readFileSync(path.join(tempDir, 'web/i18n/index.ts'), 'utf8')).toContain('./locales/en-GB.json') + expect(fs.existsSync(path.join(tempDir, 'web/i18n/locales/ru-RU.json'))).toBe(true) + expect(fs.readFileSync(path.join(tempDir, 'web/endpoint/endpoint.worker.ts'), 'utf8')).toContain( + '\'order/card:common.after\': defineWidgetRunner(OrderCommonAfterWidget, setupApp)' + ) + }) }) diff --git a/vite.config.bin.ts b/vite.config.bin.ts new file mode 100644 index 00000000..4fef7358 --- /dev/null +++ b/vite.config.bin.ts @@ -0,0 +1,28 @@ +import { builtinModules } from 'node:module' +import { resolve } from 'node:path' + +import { defineConfig } from 'vite' + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map(moduleName => `node:${moduleName}`), +]) + +export default defineConfig({ + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, './src/cmd/embed-ui/index.ts'), + fileName: () => 'embed-ui.mjs', + formats: ['es'], + }, + minify: false, + outDir: resolve(__dirname, './bin'), + rollupOptions: { + external: id => nodeBuiltins.has(id) || id === 'yargs', + output: { + banner: '#!/usr/bin/env node', + }, + }, + }, +}) diff --git a/yarn.lock b/yarn.lock index 4a128719..43d82c2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1494,7 +1494,7 @@ __metadata: pinia: ^2.2 vue: ^3.5 bin: - embed-ui: ./bin/embed-ui-update.mjs + embed-ui: ./bin/embed-ui.mjs languageName: unknown linkType: soft From 2d9ef93ca095e4566d6f3410eb57250629457fe3 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 6 May 2026 17:11:40 +0400 Subject: [PATCH 07/15] fix: Generated init project lint issues corrected --- src/cmd/embed-ui/index.ts | 692 ++++++++++++++++++++++++------- src/cmd/embed-ui/package-json.ts | 28 +- tests/embed-ui.test.ts | 82 +++- 3 files changed, 633 insertions(+), 169 deletions(-) diff --git a/src/cmd/embed-ui/index.ts b/src/cmd/embed-ui/index.ts index b46916d3..c568d6d6 100755 --- a/src/cmd/embed-ui/index.ts +++ b/src/cmd/embed-ui/index.ts @@ -9,6 +9,7 @@ import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' +import { randomUUID } from 'node:crypto' import { applyInitAgents } from './agents' import { collectPackageJsonPaths } from './filesystem' @@ -160,6 +161,13 @@ const quoteJsString = (value: string): string => `'${value.replace(/\\/gu, '\\\\ const createEnvDts = () => `/// +declare module '*.svg' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, unknown> + export default component +} + declare module '*.vue' { import type { DefineComponent } from 'vue' @@ -201,27 +209,51 @@ const createTsConfig = (cwd: string, sourceRoot: string): string => { const createViteConfig = (cwd: string, sourceRoot: string): string => { const entryPath = toPosixRelative(cwd, path.join(sourceRoot, 'endpoint/endpoint.worker.ts')) + const sourceRootPath = toPosixRelative(cwd, sourceRoot) - return `import path from 'node:path' -import { fileURLToPath } from 'node:url' + return `import { fileURLToPath } from 'node:url' + +import path from 'node:path' -import vue from '@vitejs/plugin-vue' import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +import vueI18n from '@intlify/unplugin-vue-i18n/vite' + +import svgLoader from 'vite-svg-loader' + const root = path.dirname(fileURLToPath(import.meta.url)) export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + svgLoader({ + defaultImport: 'component', + }), + vueI18n({ + defaultSFCLang: 'json', + include: path.resolve(root, '${toPosixRelative(cwd, path.join(sourceRoot, 'i18n/locales'))}/**/*.{json,json5,yaml,yml}'), + }), + ], + resolve: { + alias: { + '@': path.resolve(root, ${quoteJsString(sourceRootPath)}), + }, + }, build: { rollupOptions: { - input: path.resolve(root, ${JSON.stringify(entryPath)}), + input: path.resolve(root, ${quoteJsString(entryPath)}), }, }, }) ` } -const createEslintConfig = () => `import { defineConfig } from 'eslint/config' +const createEslintConfig = (cwd: string, sourceRoot: string): string => { + const localeDirPattern = `./${toPosixRelative(cwd, path.join(sourceRoot, 'i18n/locales'))}/*.{json,json5,yaml,yml}` + + return `import { defineConfig } from 'eslint/config' import globals from 'globals' @@ -229,108 +261,31 @@ import pluginDependencies from '@omnicajs/eslint-plugin-dependencies' import pluginJs from '@eslint/js' import pluginTs from 'typescript-eslint' import pluginVue from 'eslint-plugin-vue' - -const staticTranslationKeysRule = { - meta: { - type: 'problem', - docs: { - description: 'Require static vue-i18n translation keys', - }, - messages: { - dynamicKey: 'Translation keys must be static string literals.', - }, - schema: [], - }, - create (context) { - const i18nFunctions = new Set(['$t', '$te']) - const i18nObjects = new Set(['i18n']) - - const unwrap = (node) => node?.type === 'ChainExpression' ? node.expression : node - const isStaticKey = (node) => { - const unwrapped = unwrap(node) - - return (unwrapped?.type === 'Literal' && typeof unwrapped.value === 'string') - || (unwrapped?.type === 'TemplateLiteral' && unwrapped.expressions.length === 0) - } - const isUseI18nCall = (node) => unwrap(node)?.type === 'CallExpression' - && unwrap(unwrap(node).callee)?.type === 'Identifier' - && unwrap(unwrap(node).callee).name === 'useI18n' - const registerUseI18nBinding = (node) => { - if (!isUseI18nCall(node.init)) { - return - } - - if (node.id.type === 'Identifier') { - i18nObjects.add(node.id.name) - return - } - - if (node.id.type !== 'ObjectPattern') { - return - } - - for (const property of node.id.properties) { - if (property.type !== 'Property') { - continue - } - - const key = property.key.type === 'Identifier' ? property.key.name : property.key.value - const value = property.value.type === 'Identifier' ? property.value.name : null - - if ((key === 't' || key === 'te') && value) { - i18nFunctions.add(value) - } - } - } - const isTranslationCallee = (callee) => { - const unwrapped = unwrap(callee) - - if (unwrapped?.type === 'Identifier') { - return i18nFunctions.has(unwrapped.name) - } - - if (unwrapped?.type !== 'MemberExpression') { - return false - } - - const object = unwrap(unwrapped.object) - const property = unwrap(unwrapped.property) - const propertyName = property?.type === 'Identifier' ? property.name : property?.value - - if (!['$t', '$te', 't', 'te'].includes(propertyName)) { - return false - } - - return object?.type === 'ThisExpression' - || (object?.type === 'Identifier' && i18nObjects.has(object.name)) - } - - return { - VariableDeclarator: registerUseI18nBinding, - CallExpression (node) { - if (!isTranslationCallee(node.callee)) { - return - } - - if (!isStaticKey(node.arguments[0])) { - context.report({ node: node.arguments[0] ?? node, messageId: 'dynamicKey' }) - } - }, - } - }, -} +import pluginVueI18n from '@intlify/eslint-plugin-vue-i18n' export default defineConfig([ { files: ['**/*.{js,mjs,cjs,ts,vue}'] }, { - plugins: { - dependencies: pluginDependencies, - 'retailcrm-init': { - rules: { - 'static-translation-keys': staticTranslationKeysRule, + settings: { + 'vue-i18n': { + localeDir: { + pattern: '${localeDirPattern}', + localeKey: 'file', }, + messageSyntaxVersion: '^11.0.0', }, }, + }, + pluginJs.configs.recommended, + ...pluginTs.configs.recommended, + ...pluginVue.configs['flat/essential'], + ...pluginVueI18n.configs.recommended, + { + files: ['**/*.{js,mjs,cjs,ts,vue}'], + plugins: { + '@intlify/vue-i18n': pluginVueI18n, + dependencies: pluginDependencies, + }, languageOptions: { globals: { ...globals.browser, @@ -353,6 +308,21 @@ export default defineConfig([ fixStyle: 'separate-type-imports', }], + '@intlify/vue-i18n/key-format-style': ['error', 'camelCase', { + allowArray: true, + }], + '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'error', + '@intlify/vue-i18n/no-missing-keys': 'error', + '@intlify/vue-i18n/no-missing-keys-in-other-locales': 'error', + '@intlify/vue-i18n/no-raw-text': ['warn', { + ignorePattern: '^[-–—~+#:()&=×%/\\\\d\\\\s\\u00A0\\n,.<>•]+$', + ignoreText: ['API', 'CRM', ''], + }], + '@intlify/vue-i18n/no-unknown-locale': 'error', + '@intlify/vue-i18n/no-unused-keys': 'error', + '@intlify/vue-i18n/sfc-locale-attr': 'error', + 'dependencies/import-style': ['error', { maxSingleLineLength: 90, maxSingleLineSpecifiers: 3, @@ -372,23 +342,43 @@ export default defineConfig([ groups: [ 'side-effect-style', 'side-effect', - ['type-import', 'type-external', 'type-internal', 'type-parent', 'type-sibling', 'type-index'], + [ + 'type-import', + 'type-external', + 'type-vue-components', + 'type-internal', + 'type-parent', + 'type-sibling', + 'type-index', + ], 'builtin', 'value-external', + 'value-vue-components', 'value-internal', ['value-parent', 'value-sibling'], 'index', + 'ts-equals-import', 'unknown', ], + customGroups: [{ + groupName: 'type-vue-components', + selector: 'type', + elementNamePattern: ['\\\\.(svg|vue)$'], + }, { + groupName: 'value-vue-components', + elementNamePattern: ['\\\\.(svg|vue)$'], + }], newlinesInside: 1, + partitions: { + orderBy: 'type-first', + splitBy: { + comments: false, + newlines: true, + }, + }, }], - - 'retailcrm-init/static-translation-keys': 'error', }, }, - pluginJs.configs.recommended, - ...pluginTs.configs.recommended, - ...pluginVue.configs['flat/essential'], { files: ['**/*.vue'], languageOptions: { @@ -416,22 +406,43 @@ export default defineConfig([ { ignores: ['dist/**', 'coverage/**'] }, ]) ` +} const createEndpointWorker = (options: InitOptions): string => `import type { App } from 'vue' +import { watch } from 'vue' + +import { useField } from '@retailcrm/embed-ui' + import { definePageRunner, defineRunner, defineWidgetRunner, runEndpoint, } from '@retailcrm/embed-ui-v1-endpoint/remote' +import { + useContext as useSettingsContext, +} from '@retailcrm/embed-ui-v1-contexts/remote/settings' -import { i18n } from '../i18n' -import SettingsPage from '../pages/SettingsPage.vue' import OrderCommonAfterWidget from '../widgets/OrderCommonAfterWidget.vue' -const setupApp = (app: App) => { +import SettingsPage from '../pages/SettingsPage.vue' + +import { i18n } from '../i18n' + +const setupApp = async (app: App) => { app.use(i18n) + + const settings = useSettingsContext() + await settings.initialize() + + const locale = useField(settings, 'system.locale') + + i18n.global.locale.value = locale.value + + watch(locale, value => { + i18n.global.locale.value = value + }) } const runner = defineRunner({ @@ -449,7 +460,9 @@ runEndpoint(runner) const createI18nIndex = (): string => `import { createI18n } from 'vue-i18n' import enGB from './locales/en-GB.json' + import esES from './locales/es-ES.json' + import ruRU from './locales/ru-RU.json' const messages = { @@ -469,58 +482,445 @@ export const i18n = createI18n<[MessageSchema], Locale>({ }) ` -const createSettingsPage = (): string => ` + + + + +{ + "title": "Extension settings", + "description": "Prepare embedded interface settings here." +} + + + +{ + "title": "Configuracion de la extension", + "description": "Aqui puede preparar la configuracion de la interfaz integrada." +} + + + +{ + "title": "Настройки расширения", + "description": "Здесь можно подготовить настройки встроенного интерфейса." +} + ` -const createOrderWidget = (): string => ` + + + + +{ + "title": "Order widget", + "description": "Starter widget for the order form." +} + + + +{ + "title": "Widget del pedido", + "description": "Widget inicial para el formulario del pedido." +} + + + +{ + "title": "Виджет заказа", + "description": "Стартовый виджет для формы заказа." +} + ` -const createMessages = (locale: 'en-GB' | 'es-ES' | 'ru-RU'): string => `${JSON.stringify({ - settings: { - title: locale === 'ru-RU' ? 'Настройки расширения' : locale === 'es-ES' ? 'Configuracion de la extension' : 'Extension settings', - description: locale === 'ru-RU' - ? 'Здесь можно подготовить настройки встроенного интерфейса.' - : locale === 'es-ES' - ? 'Aqui puede preparar la configuracion de la interfaz integrada.' - : 'Prepare embedded interface settings here.', - }, - orderCommonAfter: { - title: locale === 'ru-RU' ? 'Виджет заказа' : locale === 'es-ES' ? 'Widget del pedido' : 'Order widget', - description: locale === 'ru-RU' - ? 'Стартовый виджет для формы заказа.' - : locale === 'es-ES' - ? 'Widget inicial para el formulario del pedido.' - : 'Starter widget for the order form.', - }, +const createMessages = (): string => `${JSON.stringify({}, null, 2)}${DEFAULT_NEWLINE}` + +const createExtensionConfig = (options: InitOptions): string => `${JSON.stringify({ + code: 'retailcrm-extension-frontend', + name: 'RetailCRM Extension Frontend', + uuid: randomUUID(), + version: '1.0.0', + targets: [options.widgetTarget], + pages: [options.pageCode], + stylesheet: true, + entrypointType: 'script', + runner: 'worker', }, null, 2)}${DEFAULT_NEWLINE}` +const createExtensionIcon = (): string => ` + + + +` + +const createPublishScript = (): string => `#!/usr/bin/env node + +import { fileURLToPath } from 'node:url' + +import fs from 'node:fs' + +import path from 'node:path' + +import { spawnSync } from 'node:child_process' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(scriptDir, '..') +const args = new Set(process.argv.slice(2)) +const archiveOnly = args.has('--archive-only') + +const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +const loadEnvFile = (filePath) => { + if (!fs.existsSync(filePath)) { + return + } + + for (const line of fs.readFileSync(filePath, 'utf8').split(/\\r?\\n/u)) { + const trimmed = line.trim() + + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + + if (separatorIndex === -1) { + continue + } + + const key = trimmed.slice(0, separatorIndex).trim() + let value = trimmed.slice(separatorIndex + 1).trim() + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\\'') && value.endsWith('\\''))) { + value = value.slice(1, -1) + } + + process.env[key] ??= value + } +} + +const assertNonEmptyString = (value, field) => { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error('Field "' + field + '" must be a non-empty string') + } + + return value +} + +const assertStringArray = (value, field) => { + if (value === undefined) { + return [] + } + + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { + throw new Error('Field "' + field + '" must be an array of non-empty strings') + } + + return value +} + +const listFiles = (directoryPath, basePath = directoryPath) => { + const result = [] + + for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { + const entryPath = path.join(directoryPath, entry.name) + const relativePath = path.relative(basePath, entryPath).split(path.sep).join('/') + + if (entry.isDirectory()) { + result.push(...listFiles(entryPath, basePath)) + continue + } + + if (entry.isFile()) { + result.push(relativePath) + } + } + + return result +} + +const normalizeManifestPath = (value) => typeof value === 'string' && value.startsWith('./') + ? value.slice(2) + : value + +const pickBuildArtifacts = (distDir, code) => { + const files = listFiles(distDir) + .filter(file => file !== 'manifest.json') + .filter(file => file !== code + '.zip') + .filter(file => !file.endsWith('.map')) + + const viteManifestPath = path.join(distDir, '.vite/manifest.json') + + if (fs.existsSync(viteManifestPath)) { + const viteManifest = readJsonFile(viteManifestPath) + const entries = Object.values(viteManifest) + const entry = entries.find(item => item && item.isEntry) ?? entries[0] + + if (entry?.file) { + return { + files, + scriptFile: normalizeManifestPath(entry.file), + styleFile: Array.isArray(entry.css) ? normalizeManifestPath(entry.css[0]) : null, + } + } + } + + return { + files, + scriptFile: files.find(file => file.endsWith('.js')) ?? null, + styleFile: files.find(file => file.endsWith('.css')) ?? null, + } +} + +const zipExtension = (distDir, code, extensionManifest, files) => { + const archivePath = path.join(distDir, 'extension.zip') + const manifestPath = path.join(distDir, 'manifest.json') + const previousManifest = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath) : null + + fs.writeFileSync(manifestPath, JSON.stringify(extensionManifest), 'utf8') + + try { + const zipResult = spawnSync('zip', ['-rFS', archivePath, ...files, 'manifest.json'], { + cwd: distDir, + stdio: 'inherit', + }) + + if (zipResult.error) { + throw new Error('Zip command failed: ' + zipResult.error.message) + } + + if (zipResult.status !== 0) { + throw new Error('Zip archive creation failed') + } + } finally { + if (previousManifest) { + fs.writeFileSync(manifestPath, previousManifest) + } else { + fs.rmSync(manifestPath, { force: true }) + } + } + + return archivePath +} + +loadEnvFile(path.join(projectRoot, '.env')) + +const configPath = path.join(projectRoot, 'extensionrc.json') + +if (!fs.existsSync(configPath)) { + console.error('Config not found: ' + configPath) + process.exit(1) +} + +let config + +try { + config = readJsonFile(configPath) +} catch (error) { + console.error('Cannot read extensionrc.json:', error) + process.exit(1) +} + +try { + const code = config.code ?? 'retailcrm-extension-frontend' + const uuid = assertNonEmptyString(config.uuid, 'uuid') + const version = assertNonEmptyString(config.version, 'version') + const targets = assertStringArray(config.targets, 'targets') + const pages = assertStringArray(config.pages, 'pages') + const runner = config.runner ?? 'worker' + + if (targets.length === 0 && pages.length === 0) { + throw new Error('Specify at least one target or page in extensionrc.json') + } + + const distDir = path.join(projectRoot, 'dist') + + if (!fs.existsSync(distDir)) { + throw new Error('Build directory not found: ' + distDir) + } + + const { files, scriptFile, styleFile } = pickBuildArtifacts(distDir, code) + + if (!scriptFile) { + throw new Error('Missing JS build artifact. Run npm run build before publishing.') + } + + const extensionManifest = { + code, + version, + entrypoint: scriptFile, + scripts: [scriptFile], + runner, + } + + if (targets.length > 0) { + extensionManifest.targets = targets + } + + if (pages.length > 0) { + extensionManifest.pages = pages + } + + if (styleFile) { + extensionManifest.stylesheet = styleFile + } + + const archivePath = zipExtension(distDir, code, extensionManifest, files) + + console.log('Archive created: ' + archivePath) + + if (archiveOnly) { + process.exit(0) + } + + const crmHost = process.env.CRM_API_HOST + const crmKey = process.env.CRM_API_KEY + const baseUrl = config.baseUrl || process.env.MODULE_URL || process.env.EXTENSION_BASE_URL + + if (!crmHost || !crmKey) { + throw new Error('Missing CRM_API_HOST or CRM_API_KEY in .env') + } + + if (!baseUrl) { + throw new Error('Missing MODULE_URL or EXTENSION_BASE_URL in .env or baseUrl in extensionrc.json') + } + + const embedJs = { + entrypoint: config.entrypoint || '/extension/' + uuid + '/script', + runner, + } + + if (targets.length > 0) { + embedJs.targets = targets + } + + if (pages.length > 0) { + embedJs.pages = pages + } + + if (styleFile && config.stylesheet !== false) { + embedJs.stylesheet = typeof config.stylesheet === 'string' + ? config.stylesheet + : '/extension/' + uuid + '/stylesheet' + } + + const integrationModule = { + code, + integrationCode: code, + active: true, + name: config.name || code, + clientId: config.clientId || 'client-id-xxx', + baseUrl, + integrations: { + embedJs, + }, + } + + const form = new FormData() + form.append('integrationModule', JSON.stringify(integrationModule)) + + const response = await fetch(new URL('/api/v5/integration-modules/' + code + '/edit', crmHost), { + method: 'POST', + headers: { + 'X-Api-Key': crmKey, + }, + body: form, + }) + + const text = await response.text() + + if (!response.ok) { + console.error('Request failed: ' + response.status + ' ' + response.statusText) + console.error(text) + process.exit(1) + } + + console.log(text) +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} +` + export const runUpdate = (options: UpdateOptions): void => { const version = options.version ?? resolveLatestVersion() const packageJsonPaths = collectPackageJsonPaths(options.target) @@ -622,6 +1022,7 @@ const applyInitPackageJson = ( setMissingScript(packageJson, 'build', 'vite build', changes) setMissingScript(packageJson, 'lint', 'eslint .', changes) setMissingScript(packageJson, 'lint:fix', 'eslint --fix .', changes) + setMissingScript(packageJson, 'publish-extension', 'node scripts/publish-extension.mjs', changes) for (const selectedPackage of selectedPackages) { setDependency( @@ -704,10 +1105,10 @@ const applyInitConfigs = ( writeFileIfAllowed(path.join(cwd, 'tsconfig.json'), createTsConfig(cwd, sourceRoot), options, changes) writeFileIfAllowed(path.join(cwd, 'vite.config.ts'), createViteConfig(cwd, sourceRoot), options, changes) writeFileIfAllowed(path.join(cwd, 'env.d.ts'), createEnvDts(), options, changes) - writeFileIfAllowed(path.join(cwd, 'eslint.config.js'), createEslintConfig(), options, changes) + writeFileIfAllowed(path.join(cwd, 'eslint.config.js'), createEslintConfig(cwd, sourceRoot), options, changes) } -const applyInitTemplate = (sourceRoot: string, options: InitOptions, changes: InitChanges): void => { +const applyInitTemplate = (cwd: string, sourceRoot: string, options: InitOptions, changes: InitChanges): void => { if (options.noTemplate || options.agentsOnly) { return } @@ -722,12 +1123,15 @@ const applyInitTemplate = (sourceRoot: string, options: InitOptions, changes: In options, changes ) + writeFileIfAllowed(path.join(sourceRoot, 'shared/assets/extension.svg'), createExtensionIcon(), options, changes) writeFileIfAllowed(path.join(sourceRoot, 'i18n/index.ts'), createI18nIndex(), options, changes) - writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/en-GB.json'), createMessages('en-GB'), options, changes) - writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/es-ES.json'), createMessages('es-ES'), options, changes) - writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/ru-RU.json'), createMessages('ru-RU'), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/en-GB.json'), createMessages(), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/es-ES.json'), createMessages(), options, changes) + writeFileIfAllowed(path.join(sourceRoot, 'i18n/locales/ru-RU.json'), createMessages(), options, changes) writeFileIfAllowed(path.join(sourceRoot, 'pages/SettingsPage.vue'), createSettingsPage(), options, changes) writeFileIfAllowed(path.join(sourceRoot, 'widgets/OrderCommonAfterWidget.vue'), createOrderWidget(), options, changes) + writeFileIfAllowed(path.join(cwd, 'extensionrc.json'), createExtensionConfig(options), options, changes) + writeFileIfAllowed(path.join(cwd, 'scripts/publish-extension.mjs'), createPublishScript(), options, changes) } const runInstall = ( @@ -781,7 +1185,7 @@ export const runInit = async (options: InitOptions): Promise => { packageJsonPath = applyInitPackageJson(cwd, selectedPackages, version, packageManager, options, changes) applyInitDirectories(sourceRoot, options, changes) applyInitConfigs(cwd, sourceRoot, options, changes) - applyInitTemplate(sourceRoot, options, changes) + applyInitTemplate(cwd, sourceRoot, options, changes) } applyInitAgents(cwd, selectedPackages, options, changes) diff --git a/src/cmd/embed-ui/package-json.ts b/src/cmd/embed-ui/package-json.ts index 0eb90aa5..a899f6f6 100644 --- a/src/cmd/embed-ui/package-json.ts +++ b/src/cmd/embed-ui/package-json.ts @@ -20,18 +20,22 @@ export const INIT_RUNTIME_DEPENDENCIES: Array<{ name: string; range: string }> = ] export const INIT_DEV_DEPENDENCIES: Array<{ name: string; range: string }> = [ - { name: '@eslint/js', range: '^9' }, - { name: '@omnicajs/eslint-plugin-dependencies', range: '^0.0' }, - { name: '@types/node', range: '^22' }, - { name: '@vitejs/plugin-vue', range: '^6' }, - { name: '@vue/language-server', range: '^3' }, - { name: 'eslint', range: '^9' }, - { name: 'eslint-plugin-vue', range: '^10' }, - { name: 'globals', range: '^16' }, - { name: 'typescript', range: '^5' }, - { name: 'typescript-eslint', range: '^8' }, - { name: 'vite', range: '^7' }, - { name: 'vue-eslint-parser', range: '^10' }, + { name: '@eslint/js', range: '^9.39' }, + { name: '@intlify/eslint-plugin-vue-i18n', range: '~4.3.0' }, + { name: '@intlify/unplugin-vue-i18n', range: '^11.1' }, + { name: '@omnicajs/eslint-plugin-dependencies', range: '^0.0.2' }, + { name: '@types/node', range: '^22.19' }, + { name: '@vitejs/plugin-vue', range: '^6.0' }, + { name: '@vue/language-server', range: '^3.2' }, + { name: 'eslint', range: '^9.39' }, + { name: 'eslint-plugin-vue', range: '^10.9' }, + { name: 'globals', range: '^16.5' }, + { name: 'less', range: '^4.6' }, + { name: 'typescript', range: '^5.9' }, + { name: 'typescript-eslint', range: '^8.59' }, + { name: 'vite', range: '^7.3' }, + { name: 'vite-svg-loader', range: '^5.1' }, + { name: 'vue-eslint-parser', range: '^10.4' }, ] export const detectFormatting = (source: string): Formatting => { diff --git a/tests/embed-ui.test.ts b/tests/embed-ui.test.ts index 86873b2e..ecb58881 100644 --- a/tests/embed-ui.test.ts +++ b/tests/embed-ui.test.ts @@ -201,6 +201,7 @@ describe('embed-ui CLI', () => { build: 'vite build', lint: 'eslint .', 'lint:fix': 'eslint --fix .', + 'publish-extension': 'node scripts/publish-extension.mjs', }) expect(packageJson.dependencies).toMatchObject({ '@retailcrm/embed-ui': '^1.2.3', @@ -214,27 +215,82 @@ describe('embed-ui CLI', () => { 'vue-i18n': '^11', }) expect(packageJson.devDependencies).toMatchObject({ - '@eslint/js': '^9', - '@omnicajs/eslint-plugin-dependencies': '^0.0', - '@types/node': '^22', - '@vitejs/plugin-vue': '^6', - '@vue/language-server': '^3', - eslint: '^9', - 'eslint-plugin-vue': '^10', - globals: '^16', - typescript: '^5', - 'typescript-eslint': '^8', - vite: '^7', - 'vue-eslint-parser': '^10', + '@eslint/js': '^9.39', + '@intlify/eslint-plugin-vue-i18n': '~4.3.0', + '@intlify/unplugin-vue-i18n': '^11.1', + '@omnicajs/eslint-plugin-dependencies': '^0.0.2', + '@types/node': '^22.19', + '@vitejs/plugin-vue': '^6.0', + '@vue/language-server': '^3.2', + eslint: '^9.39', + 'eslint-plugin-vue': '^10.9', + globals: '^16.5', + less: '^4.6', + typescript: '^5.9', + 'typescript-eslint': '^8.59', + vite: '^7.3', + 'vite-svg-loader': '^5.1', + 'vue-eslint-parser': '^10.4', }) expect(fs.existsSync(path.join(tempDir, 'tsconfig.json'))).toBe(true) expect(fs.readFileSync(path.join(tempDir, 'tsconfig.json'), 'utf8')).toContain('"resolveJsonModule": true') - expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain('static-translation-keys') + expect(fs.readFileSync(path.join(tempDir, 'env.d.ts'), 'utf8')).toContain('declare module \'*.svg\'') + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain( + '@intlify/vue-i18n/no-dynamic-keys' + ) + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain( + 'pluginVueI18n.configs.recommended' + ) + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain('value-vue-components') + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain('partitions: {') + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain( + '@intlify/unplugin-vue-i18n/vite' + ) + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('vite-svg-loader') + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('defaultImport: \'component\'') + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('vueI18n({') + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain( + '\'@\': path.resolve(root, \'web\')' + ) expect(fs.readFileSync(path.join(tempDir, 'web/i18n/index.ts'), 'utf8')).toContain('./locales/en-GB.json') expect(fs.existsSync(path.join(tempDir, 'web/i18n/locales/ru-RU.json'))).toBe(true) expect(fs.readFileSync(path.join(tempDir, 'web/endpoint/endpoint.worker.ts'), 'utf8')).toContain( '\'order/card:common.after\': defineWidgetRunner(OrderCommonAfterWidget, setupApp)' ) + expect(fs.readFileSync(path.join(tempDir, 'web/endpoint/endpoint.worker.ts'), 'utf8')).toContain( + 'const settings = useSettingsContext()' + ) + expect(fs.readFileSync(path.join(tempDir, 'web/endpoint/endpoint.worker.ts'), 'utf8')).toContain( + 'i18n.global.locale.value = locale.value' + ) + expect(fs.readFileSync(path.join(tempDir, 'web/pages/SettingsPage.vue'), 'utf8')).toContain( + ' - - - - - - -{ - "title": "Extension settings", - "description": "Prepare embedded interface settings here." -} - - - -{ - "title": "Configuracion de la extension", - "description": "Aqui puede preparar la configuracion de la interfaz integrada." -} - - - -{ - "title": "Настройки расширения", - "description": "Здесь можно подготовить настройки встроенного интерфейса." -} - -` - -const createOrderWidget = (): string => ` - - - - - - -{ - "title": "Order widget", - "description": "Starter widget for the order form." -} - - - -{ - "title": "Widget del pedido", - "description": "Widget inicial para el formulario del pedido." -} - - - -{ - "title": "Виджет заказа", - "description": "Стартовый виджет для формы заказа." -} - -` - -const createMessages = (): string => `${JSON.stringify({}, null, 2)}${DEFAULT_NEWLINE}` - -const createExtensionConfig = (options: InitOptions): string => `${JSON.stringify({ - code: 'retailcrm-extension-frontend', - name: 'RetailCRM Extension Frontend', - uuid: randomUUID(), - version: '1.0.0', - targets: [options.widgetTarget], - pages: [options.pageCode], - stylesheet: true, - entrypointType: 'script', - runner: 'worker', -}, null, 2)}${DEFAULT_NEWLINE}` - -const createExtensionIcon = (): string => ` - - - -` - -const createPublishScript = (): string => `#!/usr/bin/env node - -import { fileURLToPath } from 'node:url' - -import fs from 'node:fs' - -import path from 'node:path' - -import { spawnSync } from 'node:child_process' - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)) -const projectRoot = path.resolve(scriptDir, '..') -const args = new Set(process.argv.slice(2)) -const archiveOnly = args.has('--archive-only') - -const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) - -const loadEnvFile = (filePath) => { - if (!fs.existsSync(filePath)) { - return - } - - for (const line of fs.readFileSync(filePath, 'utf8').split(/\\r?\\n/u)) { - const trimmed = line.trim() - - if (!trimmed || trimmed.startsWith('#')) { - continue - } - - const separatorIndex = trimmed.indexOf('=') - - if (separatorIndex === -1) { - continue - } - - const key = trimmed.slice(0, separatorIndex).trim() - let value = trimmed.slice(separatorIndex + 1).trim() - - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\\'') && value.endsWith('\\''))) { - value = value.slice(1, -1) - } - - process.env[key] ??= value - } -} - -const assertNonEmptyString = (value, field) => { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error('Field "' + field + '" must be a non-empty string') - } - - return value -} - -const assertStringArray = (value, field) => { - if (value === undefined) { - return [] - } - - if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { - throw new Error('Field "' + field + '" must be an array of non-empty strings') - } - - return value -} - -const listFiles = (directoryPath, basePath = directoryPath) => { - const result = [] - - for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { - const entryPath = path.join(directoryPath, entry.name) - const relativePath = path.relative(basePath, entryPath).split(path.sep).join('/') - - if (entry.isDirectory()) { - result.push(...listFiles(entryPath, basePath)) - continue - } - - if (entry.isFile()) { - result.push(relativePath) - } - } - - return result -} - -const normalizeManifestPath = (value) => typeof value === 'string' && value.startsWith('./') - ? value.slice(2) - : value - -const pickBuildArtifacts = (distDir, code) => { - const files = listFiles(distDir) - .filter(file => file !== 'manifest.json') - .filter(file => file !== code + '.zip') - .filter(file => !file.endsWith('.map')) - - const viteManifestPath = path.join(distDir, '.vite/manifest.json') - - if (fs.existsSync(viteManifestPath)) { - const viteManifest = readJsonFile(viteManifestPath) - const entries = Object.values(viteManifest) - const entry = entries.find(item => item && item.isEntry) ?? entries[0] - - if (entry?.file) { - return { - files, - scriptFile: normalizeManifestPath(entry.file), - styleFile: Array.isArray(entry.css) ? normalizeManifestPath(entry.css[0]) : null, - } - } - } - - return { - files, - scriptFile: files.find(file => file.endsWith('.js')) ?? null, - styleFile: files.find(file => file.endsWith('.css')) ?? null, - } -} - -const zipExtension = (distDir, code, extensionManifest, files) => { - const archivePath = path.join(distDir, 'extension.zip') - const manifestPath = path.join(distDir, 'manifest.json') - const previousManifest = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath) : null - - fs.writeFileSync(manifestPath, JSON.stringify(extensionManifest), 'utf8') - - try { - const zipResult = spawnSync('zip', ['-rFS', archivePath, ...files, 'manifest.json'], { - cwd: distDir, - stdio: 'inherit', - }) - - if (zipResult.error) { - throw new Error('Zip command failed: ' + zipResult.error.message) - } - - if (zipResult.status !== 0) { - throw new Error('Zip archive creation failed') - } - } finally { - if (previousManifest) { - fs.writeFileSync(manifestPath, previousManifest) - } else { - fs.rmSync(manifestPath, { force: true }) - } - } - - return archivePath -} - -loadEnvFile(path.join(projectRoot, '.env')) - -const configPath = path.join(projectRoot, 'extensionrc.json') - -if (!fs.existsSync(configPath)) { - console.error('Config not found: ' + configPath) - process.exit(1) -} - -let config - -try { - config = readJsonFile(configPath) -} catch (error) { - console.error('Cannot read extensionrc.json:', error) - process.exit(1) -} - -try { - const code = config.code ?? 'retailcrm-extension-frontend' - const uuid = assertNonEmptyString(config.uuid, 'uuid') - const version = assertNonEmptyString(config.version, 'version') - const targets = assertStringArray(config.targets, 'targets') - const pages = assertStringArray(config.pages, 'pages') - const runner = config.runner ?? 'worker' - - if (targets.length === 0 && pages.length === 0) { - throw new Error('Specify at least one target or page in extensionrc.json') - } - - const distDir = path.join(projectRoot, 'dist') - - if (!fs.existsSync(distDir)) { - throw new Error('Build directory not found: ' + distDir) - } - - const { files, scriptFile, styleFile } = pickBuildArtifacts(distDir, code) - - if (!scriptFile) { - throw new Error('Missing JS build artifact. Run npm run build before publishing.') - } - - const extensionManifest = { - code, - version, - entrypoint: scriptFile, - scripts: [scriptFile], - runner, - } - - if (targets.length > 0) { - extensionManifest.targets = targets - } - - if (pages.length > 0) { - extensionManifest.pages = pages - } - - if (styleFile) { - extensionManifest.stylesheet = styleFile - } - - const archivePath = zipExtension(distDir, code, extensionManifest, files) - - console.log('Archive created: ' + archivePath) - - if (archiveOnly) { - process.exit(0) - } - - const crmHost = process.env.CRM_API_HOST - const crmKey = process.env.CRM_API_KEY - const baseUrl = config.baseUrl || process.env.MODULE_URL || process.env.EXTENSION_BASE_URL - - if (!crmHost || !crmKey) { - throw new Error('Missing CRM_API_HOST or CRM_API_KEY in .env') - } - - if (!baseUrl) { - throw new Error('Missing MODULE_URL or EXTENSION_BASE_URL in .env or baseUrl in extensionrc.json') - } - - const embedJs = { - entrypoint: config.entrypoint || '/extension/' + uuid + '/script', - runner, - } - - if (targets.length > 0) { - embedJs.targets = targets - } - - if (pages.length > 0) { - embedJs.pages = pages - } - - if (styleFile && config.stylesheet !== false) { - embedJs.stylesheet = typeof config.stylesheet === 'string' - ? config.stylesheet - : '/extension/' + uuid + '/stylesheet' - } - - const integrationModule = { - code, - integrationCode: code, - active: true, - name: config.name || code, - clientId: config.clientId || 'client-id-xxx', - baseUrl, - integrations: { - embedJs, - }, - } - - const form = new FormData() - form.append('integrationModule', JSON.stringify(integrationModule)) - - const response = await fetch(new URL('/api/v5/integration-modules/' + code + '/edit', crmHost), { - method: 'POST', - headers: { - 'X-Api-Key': crmKey, - }, - body: form, - }) - - const text = await response.text() - - if (!response.ok) { - console.error('Request failed: ' + response.status + ' ' + response.statusText) - console.error(text) - process.exit(1) - } - - console.log(text) -} catch (error) { - console.error(error instanceof Error ? error.message : error) - process.exit(1) -} -` - export const runUpdate = (options: UpdateOptions): void => { const version = options.version ?? resolveLatestVersion() const packageJsonPaths = collectPackageJsonPaths(options.target) @@ -1020,8 +266,8 @@ const applyInitPackageJson = ( } setMissingScript(packageJson, 'build', 'vite build', changes) - setMissingScript(packageJson, 'lint', 'eslint .', changes) - setMissingScript(packageJson, 'lint:fix', 'eslint --fix .', changes) + setMissingScript(packageJson, 'eslint', 'eslint .', changes) + setMissingScript(packageJson, 'eslint:fix', 'eslint --fix .', changes) setMissingScript(packageJson, 'publish-extension', 'node scripts/publish-extension.mjs', changes) for (const selectedPackage of selectedPackages) { @@ -1030,16 +276,22 @@ const applyInitPackageJson = ( selectedPackage.section, selectedPackage.name, createRange(version, options.exact), - changes + changes, + options ) } for (const dependency of INIT_RUNTIME_DEPENDENCIES) { - setDependency(packageJson, 'dependencies', dependency.name, dependency.range, changes) + if (dependency.name === I18N_RUNTIME_DEPENDENCY && hasExistingDependency(packageJson, dependency.name)) { + changes.skipped.push(`${dependency.name} already exists; i18n dependency setup skipped to avoid conflicts with existing project configuration`) + continue + } + + setDependency(packageJson, 'dependencies', dependency.name, dependency.range, changes, options) } for (const dependency of INIT_DEV_DEPENDENCIES) { - setDependency(packageJson, 'devDependencies', dependency.name, dependency.range, changes) + setDependency(packageJson, 'devDependencies', dependency.name, dependency.range, changes, options) } if (!packageJson.packageManager && options.packageManager) { @@ -1108,7 +360,13 @@ const applyInitConfigs = ( writeFileIfAllowed(path.join(cwd, 'eslint.config.js'), createEslintConfig(cwd, sourceRoot), options, changes) } -const applyInitTemplate = (cwd: string, sourceRoot: string, options: InitOptions, changes: InitChanges): void => { +const applyInitTemplate = ( + cwd: string, + sourceRoot: string, + packageManager: PackageManager, + options: InitOptions, + changes: InitChanges +): void => { if (options.noTemplate || options.agentsOnly) { return } @@ -1132,6 +390,7 @@ const applyInitTemplate = (cwd: string, sourceRoot: string, options: InitOptions writeFileIfAllowed(path.join(sourceRoot, 'widgets/OrderCommonAfterWidget.vue'), createOrderWidget(), options, changes) writeFileIfAllowed(path.join(cwd, 'extensionrc.json'), createExtensionConfig(options), options, changes) writeFileIfAllowed(path.join(cwd, 'scripts/publish-extension.mjs'), createPublishScript(), options, changes) + writeFileIfAllowed(path.join(cwd, 'README.md'), createReadme(cwd, sourceRoot, options, packageManager), options, changes) } const runInstall = ( @@ -1180,15 +439,18 @@ export const runInit = async (options: InitOptions): Promise => { : await resolvePackageManager(cwd, options.packageManager) const changes = createInitChanges() + applyInitPreflight(cwd, sourceRoot, packageManager, selectedPackages, version, options, changes) + let packageJsonPath: string | null = null if (!options.agentsOnly) { packageJsonPath = applyInitPackageJson(cwd, selectedPackages, version, packageManager, options, changes) applyInitDirectories(sourceRoot, options, changes) applyInitConfigs(cwd, sourceRoot, options, changes) - applyInitTemplate(cwd, sourceRoot, options, changes) + applyInitTemplate(cwd, sourceRoot, packageManager, options, changes) + applyInitPackageConfigHooks(cwd, selectedPackages, packageManager, options, changes) } - applyInitAgents(cwd, selectedPackages, options, changes) + applyInitAgents(cwd, selectedPackages, packageManager, options, changes) runInstall(cwd, packageManager, options, changes, Boolean(packageJsonPath && changes.packageJson.length > 0)) printInitReport(cwd, sourceRoot, version, packageManager, changes, options) } diff --git a/src/cmd/embed-ui/package-hook-runner.ts b/src/cmd/embed-ui/package-hook-runner.ts new file mode 100644 index 00000000..f8ae83f3 --- /dev/null +++ b/src/cmd/embed-ui/package-hook-runner.ts @@ -0,0 +1,142 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' +import type { InstallablePackageHook } from './types' +import type { PackageManager } from './args' + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +interface ResolvedHookCommand { + command: string; + args: string[]; + display: string; + source: 'local' | 'transient'; +} + +const resolveLocalBinPath = (cwd: string, binName: string): string | null => { + const binPath = path.join(cwd, 'node_modules', '.bin', process.platform === 'win32' ? `${binName}.cmd` : binName) + + return fs.existsSync(binPath) ? binPath : null +} + +const hasLocalPackage = (cwd: string, packageName: string): boolean => + fs.existsSync(path.join(cwd, 'node_modules', packageName, 'package.json')) + +const resolveDownloadCommand = ( + packageName: string, + binName: string, + packageManager: PackageManager, + args: string[] +): ResolvedHookCommand => { + if (packageManager === 'yarn') { + const commandArgs = ['dlx', '-p', packageName, binName, ...args] + + return { + command: 'yarn', + args: commandArgs, + display: `yarn ${commandArgs.join(' ')}`, + source: 'transient', + } + } + + if (packageManager === 'pnpm') { + const commandArgs = ['dlx', '--package', packageName, binName, ...args] + + return { + command: 'pnpm', + args: commandArgs, + display: `pnpm ${commandArgs.join(' ')}`, + source: 'transient', + } + } + + if (packageManager === 'bun') { + const commandArgs = ['x', '--package', packageName, binName, ...args] + + return { + command: 'bun', + args: commandArgs, + display: `bun ${commandArgs.join(' ')}`, + source: 'transient', + } + } + + const commandArgs = ['exec', '--yes', '--package', packageName, '--', binName, ...args] + + return { + command: 'npm', + args: commandArgs, + display: `npm ${commandArgs.join(' ')}`, + source: 'transient', + } +} + +export const resolvePackageHookCommand = ( + cwd: string, + packageName: string, + binName: string, + packageManager: PackageManager, + args: string[] +): ResolvedHookCommand => { + const localBinPath = resolveLocalBinPath(cwd, binName) + + if (localBinPath) { + return { + command: localBinPath, + args, + display: `${localBinPath} ${args.join(' ')}`, + source: 'local', + } + } + + if (hasLocalPackage(cwd, packageName)) { + throw new Error( + `${packageName} is installed, but ${binName} was not found in node_modules/.bin. ` + + 'Reinstall dependencies or check the package bin metadata.' + ) + } + + return resolveDownloadCommand(packageName, binName, packageManager, args) +} + +const getExecErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) { + return error.message + } + + return String(error) +} + +export const runPackageHookCommand = ( + cwd: string, + packageName: string, + binName: string, + packageManager: PackageManager, + args: string[], + failureMode: InstallablePackageHook['failureMode'], + options: InitOptions, + changes: InitChanges +): void => { + const command = resolvePackageHookCommand(cwd, packageName, binName, packageManager, args) + + changes.hooks.push(command.display) + + if (options.dryRun) { + return + } + + try { + execFileSync(command.command, command.args, { + cwd, + stdio: 'inherit', + }) + } catch (error) { + if (command.source === 'transient' && failureMode === 'advisory') { + changes.warnings.push(`Package hook ${command.display} was skipped: ${getExecErrorMessage(error)}`) + return + } + + throw error + } +} diff --git a/src/cmd/embed-ui/package-hooks.ts b/src/cmd/embed-ui/package-hooks.ts new file mode 100644 index 00000000..bd2912c6 --- /dev/null +++ b/src/cmd/embed-ui/package-hooks.ts @@ -0,0 +1,47 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' +import type { InstallablePackage } from './types' +import type { PackageManager } from './args' + +import { runPackageHookCommand } from './package-hook-runner' + +export const applyInitPackageConfigHooks = ( + cwd: string, + selectedPackages: InstallablePackage[], + packageManager: PackageManager, + options: InitOptions, + changes: InitChanges +): void => { + for (const selectedPackage of selectedPackages) { + for (const hook of selectedPackage.hooks ?? []) { + if (hook.type !== 'config') { + continue + } + + if (hook.requiresMcp && options.noMcp) { + continue + } + + const args = [hook.command, cwd] + + if (hook.requiresMcp && (options.force || options.forceMcp)) { + args.push('--force') + } + + if (hook.requiresMcp && options.mcpClientConfigs?.length) { + args.push('--mcp-client-configs', options.mcpClientConfigs.join(',')) + } + + runPackageHookCommand( + cwd, + selectedPackage.name, + hook.binName, + packageManager, + args, + hook.failureMode, + options, + changes + ) + } + } +} diff --git a/src/cmd/embed-ui/package-json.ts b/src/cmd/embed-ui/package-json.ts index a899f6f6..d3a8a8bb 100644 --- a/src/cmd/embed-ui/package-json.ts +++ b/src/cmd/embed-ui/package-json.ts @@ -38,6 +38,11 @@ export const INIT_DEV_DEPENDENCIES: Array<{ name: string; range: string }> = [ { name: 'vue-eslint-parser', range: '^10.4' }, ] +export const I18N_RUNTIME_DEPENDENCY = 'vue-i18n' + +export const hasExistingDependency = (packageJson: PackageJson, name: string): boolean => + findDependencySection(packageJson, name) !== null + export const detectFormatting = (source: string): Formatting => { const newline = source.includes('\r\n') ? '\r\n' : DEFAULT_NEWLINE const indentMatch = source.match(/\n([ \t]+)"/) @@ -94,8 +99,8 @@ export const readOrCreatePackageJson = ( type: 'module', scripts: { build: 'vite build', - lint: 'eslint .', - 'lint:fix': 'eslint --fix .', + eslint: 'eslint .', + 'eslint:fix': 'eslint --fix .', }, dependencies: {}, devDependencies: {}, @@ -111,6 +116,19 @@ const ensureObjectField = (object: PackageJson, field: string): PackageJson => { return object[field] as PackageJson } +const resolveRangeMajor = (range: string): number | null => { + const match = range.match(/\d+/u) + + return match ? Number(match[0]) : null +} + +const isCompatibleRange = (currentRange: string, expectedRange: string): boolean => { + const currentMajor = resolveRangeMajor(currentRange) + const expectedMajor = resolveRangeMajor(expectedRange) + + return currentMajor !== null && expectedMajor !== null && currentMajor === expectedMajor +} + export const setMissingScript = ( packageJson: PackageJson, name: string, @@ -142,13 +160,35 @@ export const setDependency = ( section: DependencySection, name: string, range: string, - changes: InitChanges + changes: InitChanges, + options: { + fixSections?: boolean; + forceDeps?: boolean; + } = {} ): void => { - const currentSection = findDependencySection(packageJson, name) + let currentSection = findDependencySection(packageJson, name) if (currentSection && currentSection !== section) { - changes.warnings.push(`${name} already exists in ${currentSection}; expected ${section}`) - return + if (options.fixSections) { + const previousSection = currentSection + const currentDependencyMap = packageJson[currentSection] as Record + const nextDependencyMap = ensureObjectField(packageJson, section) + + nextDependencyMap[name] = currentDependencyMap[name] + delete currentDependencyMap[name] + currentSection = section + + changes.packageJson.push({ + type: 'update', + section, + name, + currentRange: `in ${previousSection}`, + nextRange: `move to ${section}`, + }) + } else { + changes.warnings.push(`${name} already exists in ${currentSection}; expected ${section}. Use --fix-sections to move it.`) + return + } } const dependencyMap = ensureObjectField(packageJson, section) @@ -159,8 +199,14 @@ export const setDependency = ( } if (typeof currentRange === 'string' && !isTargetPackage(name)) { - changes.warnings.push(`${name} already exists with range ${currentRange}; expected ${range}`) - return + if (isCompatibleRange(currentRange, range) && !options.forceDeps) { + return + } + + if (!options.forceDeps) { + changes.warnings.push(`${name} has range ${currentRange}; expected compatible ${range}. Use --force-deps to replace it.`) + return + } } dependencyMap[name] = typeof currentRange === 'string' diff --git a/src/cmd/embed-ui/packages.ts b/src/cmd/embed-ui/packages.ts index 1b5b8edb..61cd69d2 100644 --- a/src/cmd/embed-ui/packages.ts +++ b/src/cmd/embed-ui/packages.ts @@ -26,6 +26,14 @@ export const INSTALLABLE_PACKAGES: InstallablePackage[] = [ name: '@retailcrm/embed-ui-v1-components', section: 'dependencies', description: 'UI-компоненты для host/remote приложений.', + hooks: [ + { + type: 'agents', + binName: 'embed-ui-v1-components', + command: 'init-agents', + failureMode: 'advisory', + }, + ], }, { id: 'contexts', @@ -50,6 +58,22 @@ export const INSTALLABLE_PACKAGES: InstallablePackage[] = [ name: '@retailcrm/embed-ui-v1-endpoint', section: 'dependencies', description: 'Endpoint API для интеграций в RetailCRM.', + hooks: [ + { + type: 'agents', + binName: 'embed-ui-v1-endpoint', + command: 'init-agents', + failureMode: 'advisory', + requiresMcp: true, + }, + { + type: 'config', + binName: 'embed-ui-v1-endpoint', + command: 'init-config', + failureMode: 'advisory', + requiresMcp: true, + }, + ], }, ] diff --git a/src/cmd/embed-ui/preflight.ts b/src/cmd/embed-ui/preflight.ts new file mode 100644 index 00000000..3b8b147f --- /dev/null +++ b/src/cmd/embed-ui/preflight.ts @@ -0,0 +1,211 @@ +import type { InitChanges } from './types' +import type { InitOptions } from './args' +import type { InstallablePackage, PackageJson } from './types' +import type { PackageManager } from './args' + +import fs from 'node:fs' +import path from 'node:path' + +import { findDependencySection } from './packages' +import { + hasExistingDependency, + I18N_RUNTIME_DEPENDENCY, + INIT_DEV_DEPENDENCIES, + INIT_RUNTIME_DEPENDENCIES, + readPackageJson, +} from './package-json' + +const LOCKFILES: Array<{ file: string; packageManager: PackageManager }> = [ + { file: 'yarn.lock', packageManager: 'yarn' }, + { file: 'package-lock.json', packageManager: 'npm' }, + { file: 'pnpm-lock.yaml', packageManager: 'pnpm' }, + { file: 'bun.lockb', packageManager: 'bun' }, +] + +const CONFIG_FILES = [ + 'tsconfig.json', + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mts', + 'eslint.config.js', + 'eslint.config.mjs', + 'env.d.ts', +] as const + +const SCRIPT_NAMES = ['build', 'dev', 'eslint', 'eslint:fix', 'lint', 'test'] as const + +const hasEnabledMcpConfigHook = (selectedPackages: InstallablePackage[], options: InitOptions): boolean => + !options.noMcp && selectedPackages.some((selectedPackage) => + selectedPackage.hooks?.some((hook) => hook.type === 'config' && hook.requiresMcp) ?? false + ) + +const resolveRangeMajor = (range: string): number | null => { + const match = range.match(/\d+/u) + + return match ? Number(match[0]) : null +} + +const describePathState = (cwd: string, relativePath: string): string => { + const targetPath = path.join(cwd, relativePath) + + if (!fs.existsSync(targetPath)) { + return `${relativePath}: missing` + } + + return `${relativePath}: found` +} + +const readExistingPackageJson = ( + packageJsonPath: string, + changes: InitChanges +): PackageJson | null => { + if (!fs.existsSync(packageJsonPath)) { + changes.preflight.push('package.json: missing; it will be created') + return null + } + + const { packageJson } = readPackageJson(packageJsonPath) + changes.preflight.push('package.json: found') + + return packageJson +} + +const analyzeDependency = ( + packageJson: PackageJson, + name: string, + expectedRange: string, + expectedSection: string, + options: InitOptions, + changes: InitChanges +): void => { + const section = findDependencySection(packageJson, name) + + if (!section) { + return + } + + const dependencyMap = packageJson[section] + const currentRange = typeof dependencyMap === 'object' && dependencyMap + ? (dependencyMap as Record)[name] + : null + + if (section !== expectedSection) { + if (options.fixSections) { + changes.preflight.push(`${name}: will move from ${section} to ${expectedSection}`) + } else { + changes.warnings.push(`${name} already exists in ${section}; expected ${expectedSection}. Use --fix-sections to move it.`) + } + } + + if (typeof currentRange !== 'string') { + return + } + + const currentMajor = resolveRangeMajor(currentRange) + const expectedMajor = resolveRangeMajor(expectedRange) + + if (currentMajor !== null && expectedMajor !== null && currentMajor !== expectedMajor) { + if (options.forceDeps) { + changes.preflight.push(`${name}: will replace ${currentRange} with ${expectedRange}`) + } else { + changes.warnings.push(`${name} has range ${currentRange}; expected compatible ${expectedRange}. Use --force-deps to replace it.`) + } + } +} + +const analyzePackageJson = ( + packageJson: PackageJson, + selectedPackages: InstallablePackage[], + version: string, + options: InitOptions, + changes: InitChanges +): void => { + if (packageJson.type === 'module') { + changes.preflight.push('package.json type: module') + } else if (packageJson.type) { + changes.warnings.push(`package.json already has type "${String(packageJson.type)}"; expected "module"`) + } else { + changes.preflight.push('package.json type: missing; "module" will be added') + } + + const scripts = packageJson.scripts + if (scripts && typeof scripts === 'object' && !Array.isArray(scripts)) { + for (const scriptName of SCRIPT_NAMES) { + if (scriptName in scripts) { + changes.preflight.push(`script ${scriptName}: found`) + } + } + } + + const selectedRange = options.exact ? version : `^${version}` + for (const selectedPackage of selectedPackages) { + analyzeDependency(packageJson, selectedPackage.name, selectedRange, selectedPackage.section, options, changes) + } + + for (const dependency of INIT_RUNTIME_DEPENDENCIES) { + if (dependency.name === I18N_RUNTIME_DEPENDENCY && hasExistingDependency(packageJson, dependency.name)) { + changes.preflight.push(`${dependency.name}: found; i18n dependency setup will be skipped to avoid conflicts with existing project configuration`) + continue + } + + analyzeDependency(packageJson, dependency.name, dependency.range, 'dependencies', options, changes) + } + + for (const dependency of INIT_DEV_DEPENDENCIES) { + analyzeDependency(packageJson, dependency.name, dependency.range, 'devDependencies', options, changes) + } +} + +export const applyInitPreflight = ( + cwd: string, + sourceRoot: string, + packageManager: PackageManager, + selectedPackages: InstallablePackage[], + version: string, + options: InitOptions, + changes: InitChanges +): void => { + if (options.agentsOnly) { + changes.preflight.push('agents-only mode: package.json, configs, and template files are skipped') + return + } + + changes.preflight.push(`source root: ${path.relative(cwd, sourceRoot) || '.'}`) + + const existingLockfiles = LOCKFILES.filter(({ file }) => fs.existsSync(path.join(cwd, file))) + if (existingLockfiles.length === 0) { + changes.preflight.push(`lockfile: none; using ${packageManager}`) + } else { + changes.preflight.push(`lockfile: ${existingLockfiles.map(({ file }) => file).join(', ')}; using ${packageManager}`) + } + + if (existingLockfiles.length > 1) { + changes.warnings.push(`Multiple lockfiles found: ${existingLockfiles.map(({ file }) => file).join(', ')}`) + } + + if (!options.target && !options.srcDir && fs.existsSync(path.join(cwd, 'src')) && path.basename(sourceRoot) === 'web') { + changes.warnings.push('src/ already exists; generated frontend source root resolved to web/') + } + + changes.preflight.push(describePathState(cwd, 'src')) + changes.preflight.push(describePathState(cwd, 'web')) + + for (const configFile of CONFIG_FILES) { + if (fs.existsSync(path.join(cwd, configFile))) { + changes.preflight.push(`${configFile}: found; generated config will be skipped unless --force-files is used`) + } + } + + if (hasEnabledMcpConfigHook(selectedPackages, options)) { + changes.preflight.push('v1-endpoint init-config: enabled') + + if (options.mcpClientConfigs?.length) { + changes.preflight.push(`MCP client configs requested: ${options.mcpClientConfigs.join(', ')}`) + } + } + + const packageJson = readExistingPackageJson(path.join(cwd, 'package.json'), changes) + if (packageJson) { + analyzePackageJson(packageJson, selectedPackages, version, options, changes) + } +} diff --git a/src/cmd/embed-ui/report.ts b/src/cmd/embed-ui/report.ts index bc72b8c3..d9149853 100644 --- a/src/cmd/embed-ui/report.ts +++ b/src/cmd/embed-ui/report.ts @@ -4,10 +4,12 @@ import type { PackageChange } from './types' import type { PackageManager } from './args' export const createInitChanges = (): InitChanges => ({ + preflight: [], packageJson: [], directories: [], files: [], agents: [], + mcp: [], hooks: [], install: null, skipped: [], @@ -45,6 +47,14 @@ export const printInitReport = ( console.log(`Resolved version: ${version}`) console.log(`Package manager: ${packageManager}`) + if (changes.preflight.length > 0) { + console.log('') + console.log('preflight') + for (const item of changes.preflight) { + console.log(` ${item}`) + } + } + if (changes.packageJson.length > 0) { console.log('') console.log('package.json') @@ -75,6 +85,14 @@ export const printInitReport = ( } } + if (changes.mcp.length > 0) { + console.log('') + console.log('MCP') + for (const mcpChange of changes.mcp) { + console.log(` ${mcpChange}`) + } + } + if (changes.hooks.length > 0) { console.log('') console.log('package hooks') @@ -100,7 +118,7 @@ export const printInitReport = ( if (changes.warnings.length > 0) { console.log('') console.log('warnings') - for (const warning of changes.warnings) { + for (const warning of new Set(changes.warnings)) { console.log(` ${warning}`) } } diff --git a/src/cmd/embed-ui/templates.ts b/src/cmd/embed-ui/templates.ts new file mode 100644 index 00000000..b225d872 --- /dev/null +++ b/src/cmd/embed-ui/templates.ts @@ -0,0 +1,173 @@ +import type { InitOptions } from './args' +import type { PackageManager } from './args' + +import path from 'node:path' + +import { randomUUID } from 'node:crypto' + +import endpointWorkerTemplate from './templates/endpoint.worker.ts.txt?raw' +import envDtsTemplate from './templates/env.d.ts.txt?raw' +import eslintConfigTemplate from './templates/eslint.config.js.txt?raw' +import extensionIconTemplate from './templates/extension.svg.txt?raw' +import i18nIndexTemplate from './templates/i18n-index.ts.txt?raw' +import orderWidgetTemplate from './templates/OrderCommonAfterWidget.vue.txt?raw' +import publishScriptTemplate from './templates/publish-extension.mjs.txt?raw' +import readmeEnGBTemplate from './templates/README.en-GB.md.txt?raw' +import readmeEsESTemplate from './templates/README.es-ES.md.txt?raw' +import readmeRuRUTemplate from './templates/README.ru-RU.md.txt?raw' +import settingsPageTemplate from './templates/SettingsPage.vue.txt?raw' +import tsConfigTemplate from './templates/tsconfig.json.txt?raw' +import viteConfigTemplate from './templates/vite.config.ts.txt?raw' + +import { DEFAULT_NEWLINE } from './package-json' + +const toPosixRelative = (from: string, to: string): string => { + const relativePath = path.relative(from, to) || '.' + + return relativePath.split(path.sep).join('/') +} + +const quoteJsString = (value: string): string => `'${value.replace(/\\/gu, '\\\\').replace(/'/gu, '\\\'')}'` + +const replaceTemplateVars = (template: string, vars: Record): string => { + let content = template + + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`__${key}__`, value) + } + + return content +} + +const readmeTemplates = { + 'en-GB': readmeEnGBTemplate, + 'es-ES': readmeEsESTemplate, + 'ru-RU': readmeRuRUTemplate, +} as const + +type ReadmeLocale = keyof typeof readmeTemplates + +const normalizeLocale = (value: string | undefined): ReadmeLocale | null => { + const normalized = value?.split('.')[0].replace('_', '-').toLowerCase() + + if (!normalized) { + return null + } + + if (normalized.startsWith('ru')) { + return 'ru-RU' + } + + if (normalized.startsWith('es')) { + return 'es-ES' + } + + if (normalized.startsWith('en')) { + return 'en-GB' + } + + return null +} + +const detectReadmeLocale = (): ReadmeLocale => { + const envCandidates = [ + process.env.LANGUAGE?.split(':')[0], + process.env.LC_ALL, + process.env.LC_MESSAGES, + process.env.LANG, + ] + + for (const candidate of envCandidates) { + const locale = normalizeLocale(candidate) + if (locale) { + return locale + } + } + + return 'en-GB' +} + +const createPackageManagerRunCommand = (packageManager: PackageManager): string => { + if (packageManager === 'npm') { + return 'npm run' + } + + if (packageManager === 'bun') { + return 'bun run' + } + + return packageManager +} + +export const createEnvDts = (): string => envDtsTemplate + +export const createTsConfig = (cwd: string, sourceRoot: string): string => replaceTemplateVars( + tsConfigTemplate, + { + SOURCE_ROOT_RELATIVE: toPosixRelative(cwd, sourceRoot), + } +) + +export const createViteConfig = (cwd: string, sourceRoot: string): string => replaceTemplateVars( + viteConfigTemplate, + { + ENTRY_PATH: quoteJsString(toPosixRelative(cwd, path.join(sourceRoot, 'endpoint/endpoint.worker.ts'))), + LOCALE_INCLUDE_PATTERN: toPosixRelative(cwd, path.join(sourceRoot, 'i18n/locales')), + SOURCE_ROOT_PATH: quoteJsString(toPosixRelative(cwd, sourceRoot)), + } +) + +export const createEslintConfig = (cwd: string, sourceRoot: string): string => replaceTemplateVars( + eslintConfigTemplate, + { + LOCALE_DIR_PATTERN: `./${toPosixRelative(cwd, path.join(sourceRoot, 'i18n/locales'))}/*.{json,json5,yaml,yml}`, + } +) + +export const createEndpointWorker = (options: InitOptions): string => replaceTemplateVars( + endpointWorkerTemplate, + { + PAGE_CODE: quoteJsString(options.pageCode), + WIDGET_TARGET: quoteJsString(options.widgetTarget), + } +) + +export const createI18nIndex = (): string => i18nIndexTemplate + +export const createSettingsPage = (): string => settingsPageTemplate + +export const createOrderWidget = (): string => orderWidgetTemplate + +export const createMessages = (): string => `${JSON.stringify({}, null, 2)}${DEFAULT_NEWLINE}` + +export const createExtensionConfig = (options: InitOptions): string => `${JSON.stringify({ + code: 'retailcrm-extension-frontend', + name: 'RetailCRM Extension Frontend', + uuid: randomUUID(), + version: '1.0.0', + targets: [options.widgetTarget], + pages: [options.pageCode], + stylesheet: true, + entrypointType: 'script', + runner: 'worker', +}, null, 2)}${DEFAULT_NEWLINE}` + +export const createExtensionIcon = (): string => extensionIconTemplate + +export const createPublishScript = (): string => publishScriptTemplate + +export const createReadme = ( + cwd: string, + sourceRoot: string, + options: InitOptions, + packageManager: PackageManager +): string => replaceTemplateVars( + readmeTemplates[detectReadmeLocale()], + { + PACKAGE_MANAGER: packageManager, + PACKAGE_MANAGER_RUN: createPackageManagerRunCommand(packageManager), + PAGE_CODE: options.pageCode, + SOURCE_ROOT: toPosixRelative(cwd, sourceRoot), + WIDGET_TARGET: options.widgetTarget, + } +) diff --git a/src/cmd/embed-ui/templates/OrderCommonAfterWidget.vue.txt b/src/cmd/embed-ui/templates/OrderCommonAfterWidget.vue.txt new file mode 100644 index 00000000..43bd2373 --- /dev/null +++ b/src/cmd/embed-ui/templates/OrderCommonAfterWidget.vue.txt @@ -0,0 +1,272 @@ + + + + + +{ + "cancel": "Cancel", + "close": "Close", + "createTask": "Create task after save", + "followUpNote": "Follow-up note", + "openSidebar": "Prepare follow-up", + "openWindow": "Quick action", + "sampleCustomer": "Sample customer", + "sampleCustomerValue": "Maria Garcia, paid order", + "sampleTarget": "Current target", + "save": "Save", + "saveDraft": "Save draft", + "sidebarLead": "Use this sidebar as a compact place for real order controls.", + "sidebarTitle": "Order follow-up", + "windowLead": "Use a modal window for a focused confirmation or a short blocking action.", + "windowTitle": "Quick order action" +} + + + +{ + "cancel": "Cancelar", + "close": "Cerrar", + "createTask": "Crear tarea al guardar", + "followUpNote": "Nota de seguimiento", + "openSidebar": "Preparar seguimiento", + "openWindow": "Accion rapida", + "sampleCustomer": "Cliente de ejemplo", + "sampleCustomerValue": "Maria Garcia, pedido pagado", + "sampleTarget": "Target actual", + "save": "Guardar", + "saveDraft": "Guardar borrador", + "sidebarLead": "Use esta barra lateral como lugar compacto para controles reales del pedido.", + "sidebarTitle": "Seguimiento del pedido", + "windowLead": "Use una ventana modal para una confirmacion enfocada o una accion bloqueante breve.", + "windowTitle": "Accion rapida del pedido" +} + + + +{ + "cancel": "Отменить", + "close": "Закрыть", + "createTask": "Создать задачу после сохранения", + "followUpNote": "Заметка для связи", + "openSidebar": "Подготовить связь", + "openWindow": "Быстрое действие", + "sampleCustomer": "Демонстрационный клиент", + "sampleCustomerValue": "Мария Гарсия, оплаченный заказ", + "sampleTarget": "Текущая цель", + "save": "Сохранить", + "saveDraft": "Сохранить черновик", + "sidebarLead": "Используйте эту боковую панель как компактное место для реальных контролов заказа.", + "sidebarTitle": "Связь по заказу", + "windowLead": "Используйте модальное окно для точечного подтверждения или короткого блокирующего действия.", + "windowTitle": "Быстрое действие с заказом" +} + + + diff --git a/src/cmd/embed-ui/templates/README.en-GB.md.txt b/src/cmd/embed-ui/templates/README.en-GB.md.txt new file mode 100644 index 00000000..1b8ffbae --- /dev/null +++ b/src/cmd/embed-ui/templates/README.en-GB.md.txt @@ -0,0 +1,62 @@ +# RetailCRM extension frontend + +This project was generated by `embed-ui init`. + +## What Was Added + +- `package.json` with scripts for Vite build, ESLint, and extension publishing. +- `extensionrc.json` with the generated extension manifest source. +- `__SOURCE_ROOT__/endpoint/endpoint.worker.ts` with `defineRunner`, one page runner, and one widget runner. +- `__SOURCE_ROOT__/pages/SettingsPage.vue` as a starter settings page. +- `__SOURCE_ROOT__/widgets/OrderCommonAfterWidget.vue` as a starter order widget. +- `__SOURCE_ROOT__/i18n/` with shared JSON message files. +- `scripts/publish-extension.mjs` for creating `dist/extension.zip` and publishing the integration module through RetailCRM API. +- `AGENTS.md` when agent instructions were enabled during init. + +## Replace Generic Values + +Review these generated placeholders before using the project in a real integration: + +- Extension code in `extensionrc.json`: `retailcrm-extension-frontend`. +- Extension name in `extensionrc.json`: `RetailCRM Extension Frontend`. +- Page code: `__PAGE_CODE__`. +- Widget target: `__WIDGET_TARGET__`. +- Sample controls and fake data in `__SOURCE_ROOT__/pages/SettingsPage.vue`. +- Sample toolbar actions and fake order data in `__SOURCE_ROOT__/widgets/OrderCommonAfterWidget.vue`. +- Shared messages in `__SOURCE_ROOT__/i18n/locales/*.json`. + +The generated page and widget are intentionally generic. Keep the structure you need, but replace the sample labels, fields, and fake data with real product behavior. + +## Vue File Names + +`SettingsPage.vue` and `OrderCommonAfterWidget.vue` are generic starter names. In product code, rename Vue files after the role they play in the extension, and update imports in `__SOURCE_ROOT__/endpoint/endpoint.worker.ts`. + +Examples from RetailCRM extension examples: + +- `ReturnsPage.vue` is a full returns-management page. +- `TasksPage.vue` is a task list/workspace page. +- `SummaryPage.vue` is a summary dashboard page. +- `RecordToCalendlyWidget.vue` is a focused widget for one scenario. + +Use the same idea for your code: `LoyaltySettingsPage.vue`, `OrderNotesWidget.vue`, `PaymentStatusSidebar.vue`, or another name that describes the real scenario. + +## Commands + +```bash +__PACKAGE_MANAGER__ install +__PACKAGE_MANAGER_RUN__ eslint +__PACKAGE_MANAGER_RUN__ build +__PACKAGE_MANAGER_RUN__ publish-extension -- --archive-only +``` + +## Publishing + +Create `.env` in the project root when you want `publish-extension` to update RetailCRM: + +```dotenv +CRM_API_HOST=https://example.retailcrm.pro +CRM_API_KEY=your-api-key +MODULE_URL=https://example.com +``` + +Run `__PACKAGE_MANAGER_RUN__ build` before publishing. The archive-only mode creates `dist/extension.zip` without sending API requests. diff --git a/src/cmd/embed-ui/templates/README.es-ES.md.txt b/src/cmd/embed-ui/templates/README.es-ES.md.txt new file mode 100644 index 00000000..d923c93b --- /dev/null +++ b/src/cmd/embed-ui/templates/README.es-ES.md.txt @@ -0,0 +1,62 @@ +# Frontend de extension RetailCRM + +Este proyecto fue generado por `embed-ui init`. + +## Que Se Agrego + +- `package.json` con scripts para Vite build, ESLint y publicacion de la extension. +- `extensionrc.json` con la fuente del manifiesto de la extension. +- `__SOURCE_ROOT__/endpoint/endpoint.worker.ts` con `defineRunner`, un runner de pagina y un runner de widget. +- `__SOURCE_ROOT__/pages/SettingsPage.vue` como pagina inicial de configuracion. +- `__SOURCE_ROOT__/widgets/OrderCommonAfterWidget.vue` como widget inicial del pedido. +- `__SOURCE_ROOT__/i18n/` con archivos JSON de mensajes compartidos. +- `scripts/publish-extension.mjs` para crear `dist/extension.zip` y publicar el modulo de integracion por RetailCRM API. +- `AGENTS.md` si las instrucciones para agentes estaban activadas durante init. + +## Sustituya Los Valores Genericos + +Revise estos valores generados antes de usar el proyecto en una integracion real: + +- Codigo de extension en `extensionrc.json`: `retailcrm-extension-frontend`. +- Nombre de extension en `extensionrc.json`: `RetailCRM Extension Frontend`. +- Codigo de pagina: `__PAGE_CODE__`. +- Target del widget: `__WIDGET_TARGET__`. +- Controles de ejemplo y datos ficticios en `__SOURCE_ROOT__/pages/SettingsPage.vue`. +- Acciones toolbar de ejemplo y datos ficticios del pedido en `__SOURCE_ROOT__/widgets/OrderCommonAfterWidget.vue`. +- Mensajes compartidos en `__SOURCE_ROOT__/i18n/locales/*.json`. + +La pagina y el widget generados son intencionalmente genericos. Mantenga la estructura que necesite, pero sustituya etiquetas, campos y datos ficticios por comportamiento real del producto. + +## Nombres De Archivos Vue + +`SettingsPage.vue` y `OrderCommonAfterWidget.vue` son nombres iniciales genericos. En codigo de producto, renombre los archivos Vue segun la funcion que cumplen en la extension y actualice los imports en `__SOURCE_ROOT__/endpoint/endpoint.worker.ts`. + +Ejemplos del repositorio de extensiones RetailCRM: + +- `ReturnsPage.vue` es una pagina completa de gestion de devoluciones. +- `TasksPage.vue` es una pagina de lista o espacio de trabajo de tareas. +- `SummaryPage.vue` es una pagina de resumen o dashboard. +- `RecordToCalendlyWidget.vue` es un widget enfocado en un escenario. + +Use la misma idea para su codigo: `LoyaltySettingsPage.vue`, `OrderNotesWidget.vue`, `PaymentStatusSidebar.vue` u otro nombre que describa el escenario real. + +## Comandos + +```bash +__PACKAGE_MANAGER__ install +__PACKAGE_MANAGER_RUN__ eslint +__PACKAGE_MANAGER_RUN__ build +__PACKAGE_MANAGER_RUN__ publish-extension -- --archive-only +``` + +## Publicacion + +Cree `.env` en la raiz del proyecto cuando quiera que `publish-extension` actualice RetailCRM: + +```dotenv +CRM_API_HOST=https://example.retailcrm.pro +CRM_API_KEY=your-api-key +MODULE_URL=https://example.com +``` + +Ejecute `__PACKAGE_MANAGER_RUN__ build` antes de publicar. El modo archive-only crea `dist/extension.zip` sin enviar peticiones API. diff --git a/src/cmd/embed-ui/templates/README.ru-RU.md.txt b/src/cmd/embed-ui/templates/README.ru-RU.md.txt new file mode 100644 index 00000000..abffca1e --- /dev/null +++ b/src/cmd/embed-ui/templates/README.ru-RU.md.txt @@ -0,0 +1,62 @@ +# Фронтенд расширения RetailCRM + +Проект создан командой `embed-ui init`. + +## Что Добавлено + +- `package.json` со скриптами для сборки Vite, ESLint и публикации расширения. +- `extensionrc.json` с исходным описанием манифеста расширения. +- `__SOURCE_ROOT__/endpoint/endpoint.worker.ts` с `defineRunner`, одним runner страницы и одним runner виджета. +- `__SOURCE_ROOT__/pages/SettingsPage.vue` как стартовая страница настроек. +- `__SOURCE_ROOT__/widgets/OrderCommonAfterWidget.vue` как стартовый виджет заказа. +- `__SOURCE_ROOT__/i18n/` с общими JSON-файлами переводов. +- `scripts/publish-extension.mjs` для создания `dist/extension.zip` и публикации интеграционного модуля через RetailCRM API. +- `AGENTS.md`, если при инициализации были включены инструкции для агентов. + +## Замените Generic Значения + +Перед использованием проекта в реальной интеграции проверьте сгенерированные общие значения: + +- Код расширения в `extensionrc.json`: `retailcrm-extension-frontend`. +- Название расширения в `extensionrc.json`: `RetailCRM Extension Frontend`. +- Код страницы: `__PAGE_CODE__`. +- Цель виджета: `__WIDGET_TARGET__`. +- Демонстрационные контролы и ненастоящие данные в `__SOURCE_ROOT__/pages/SettingsPage.vue`. +- Демонстрационные toolbar-действия и ненастоящие данные заказа в `__SOURCE_ROOT__/widgets/OrderCommonAfterWidget.vue`. +- Общие сообщения в `__SOURCE_ROOT__/i18n/locales/*.json`. + +Сгенерированные страница и виджет намеренно сделаны универсальными. Оставьте нужную структуру, но замените примерные подписи, поля и ненастоящие данные на реальное поведение продукта. + +## Имена Vue-Файлов + +`SettingsPage.vue` и `OrderCommonAfterWidget.vue` — универсальные стартовые имена. В продуктовом коде переименуйте Vue-файлы по роли, которую они выполняют в расширении, и обновите импорты в `__SOURCE_ROOT__/endpoint/endpoint.worker.ts`. + +Примеры из репозитория расширений RetailCRM: + +- `ReturnsPage.vue` — полноценная страница управления возвратами. +- `TasksPage.vue` — страница списка задач или рабочей области задач. +- `SummaryPage.vue` — страница сводки или дашборда. +- `RecordToCalendlyWidget.vue` — сфокусированный виджет под один сценарий. + +Используйте тот же принцип: `LoyaltySettingsPage.vue`, `OrderNotesWidget.vue`, `PaymentStatusSidebar.vue` или другое имя, которое описывает реальный сценарий. + +## Команды + +```bash +__PACKAGE_MANAGER__ install +__PACKAGE_MANAGER_RUN__ eslint +__PACKAGE_MANAGER_RUN__ build +__PACKAGE_MANAGER_RUN__ publish-extension -- --archive-only +``` + +## Публикация + +Создайте `.env` в корне проекта, когда потребуется обновлять RetailCRM через `publish-extension`: + +```dotenv +CRM_API_HOST=https://example.retailcrm.pro +CRM_API_KEY=your-api-key +MODULE_URL=https://example.com +``` + +Перед публикацией выполните `__PACKAGE_MANAGER_RUN__ build`. Режим archive-only создает `dist/extension.zip` без API-запросов. diff --git a/src/cmd/embed-ui/templates/SettingsPage.vue.txt b/src/cmd/embed-ui/templates/SettingsPage.vue.txt new file mode 100644 index 00000000..51160d76 --- /dev/null +++ b/src/cmd/embed-ui/templates/SettingsPage.vue.txt @@ -0,0 +1,303 @@ + + + + + +{ + "assistantName": "Assistant name", + "assistantNameHint": "Use this value as a starter label for your real setting.", + "enabled": "Enable order prompts", + "hint": "Hint", + "note": "Default note", + "owner": "Responsible team", + "ownerDelivery": "Delivery team", + "ownerSales": "Sales team", + "save": "Save", + "saved": "Settings saved", + "subtitle": "Starter configuration page", + "title": "Extension settings", + "workspaceDescription": "Replace these sample controls with settings that drive your embedded page and order widget.", + "workspaceTitle": "Order workflow defaults" +} + + + +{ + "assistantName": "Nombre del asistente", + "assistantNameHint": "Use este valor como etiqueta inicial para su ajuste real.", + "enabled": "Activar sugerencias del pedido", + "hint": "Ayuda", + "note": "Nota predeterminada", + "owner": "Equipo responsable", + "ownerDelivery": "Equipo de entrega", + "ownerSales": "Equipo comercial", + "save": "Guardar", + "saved": "Configuracion guardada", + "subtitle": "Pagina inicial de configuracion", + "title": "Configuracion de la extension", + "workspaceDescription": "Sustituya estos controles de ejemplo por ajustes que controlen su pagina integrada y el widget del pedido.", + "workspaceTitle": "Valores iniciales del proceso de pedidos" +} + + + +{ + "assistantName": "Название помощника", + "assistantNameHint": "Используйте это значение как стартовую подпись для реальной настройки.", + "enabled": "Включить подсказки в заказе", + "hint": "Подсказка", + "note": "Заметка по умолчанию", + "owner": "Ответственная команда", + "ownerDelivery": "Команда доставки", + "ownerSales": "Отдел продаж", + "save": "Сохранить", + "saved": "Настройки сохранены", + "subtitle": "Стартовая страница настроек", + "title": "Настройки расширения", + "workspaceDescription": "Замените эти демонстрационные контролы настройками, которые управляют встроенной страницей и виджетом заказа.", + "workspaceTitle": "Настройки сценария заказа" +} + + + diff --git a/src/cmd/embed-ui/templates/endpoint.worker.ts.txt b/src/cmd/embed-ui/templates/endpoint.worker.ts.txt new file mode 100644 index 00000000..d0c3d2af --- /dev/null +++ b/src/cmd/embed-ui/templates/endpoint.worker.ts.txt @@ -0,0 +1,47 @@ +import type { App } from 'vue' + +import { watch } from 'vue' + +import { useField } from '@retailcrm/embed-ui' + +import { + definePageRunner, + defineRunner, + defineWidgetRunner, + runEndpoint, +} from '@retailcrm/embed-ui-v1-endpoint/remote' +import { + useContext as useSettingsContext, +} from '@retailcrm/embed-ui-v1-contexts/remote/settings' + +import OrderCommonAfterWidget from '../widgets/OrderCommonAfterWidget.vue' + +import SettingsPage from '../pages/SettingsPage.vue' + +import { i18n } from '../i18n' + +const setupApp = async (app: App) => { + app.use(i18n) + + const settings = useSettingsContext() + await settings.initialize() + + const locale = useField(settings, 'system.locale') + + i18n.global.locale.value = locale.value + + watch(locale, value => { + i18n.global.locale.value = value + }) +} + +const runner = defineRunner({ + pages: [{ + __PAGE_CODE__: definePageRunner(SettingsPage, setupApp), + }], + widgets: [{ + __WIDGET_TARGET__: defineWidgetRunner(OrderCommonAfterWidget, setupApp), + }], +}) + +runEndpoint(runner) diff --git a/src/cmd/embed-ui/templates/env.d.ts.txt b/src/cmd/embed-ui/templates/env.d.ts.txt new file mode 100644 index 00000000..20287646 --- /dev/null +++ b/src/cmd/embed-ui/templates/env.d.ts.txt @@ -0,0 +1,15 @@ +/// + +declare module '*.svg' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, unknown> + export default component +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/src/cmd/embed-ui/templates/eslint.config.js.txt b/src/cmd/embed-ui/templates/eslint.config.js.txt new file mode 100644 index 00000000..91dabd73 --- /dev/null +++ b/src/cmd/embed-ui/templates/eslint.config.js.txt @@ -0,0 +1,167 @@ +import { defineConfig } from 'eslint/config' + +import globals from 'globals' + +import pluginDependencies from '@omnicajs/eslint-plugin-dependencies' +import pluginJs from '@eslint/js' +import pluginTs from 'typescript-eslint' +import pluginVue from 'eslint-plugin-vue' +import pluginVueI18n from '@intlify/eslint-plugin-vue-i18n' + +export default defineConfig([ + { files: ['**/*.{js,mjs,cjs,ts,vue}'] }, + { + settings: { + 'vue-i18n': { + localeDir: { + pattern: '__LOCALE_DIR_PATTERN__', + localeKey: 'file', + }, + messageSyntaxVersion: '^11.0.0', + }, + }, + }, + pluginJs.configs.recommended, + ...pluginTs.configs.recommended, + ...pluginVue.configs['flat/essential'], + ...pluginVueI18n.configs.recommended, + { + files: ['**/*.{js,mjs,cjs,ts,vue}'], + plugins: { + '@intlify/vue-i18n': pluginVueI18n, + dependencies: pluginDependencies, + }, + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + 'comma-dangle': ['error', 'always-multiline'], + 'eqeqeq': ['error', 'always'], + 'indent': ['error', 2, { SwitchCase: 1 }], + 'no-debugger': 'error', + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }], + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'quotes': ['error', 'single'], + 'semi': ['error', 'never'], + + '@typescript-eslint/consistent-type-imports': ['error', { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', + }], + + '@intlify/vue-i18n/key-format-style': ['error', 'camelCase', { + allowArray: true, + }], + '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'error', + '@intlify/vue-i18n/no-missing-keys': 'error', + '@intlify/vue-i18n/no-missing-keys-in-other-locales': 'error', + '@intlify/vue-i18n/no-raw-text': ['warn', { + ignorePattern: '^[-–—~+#:()&=×%/\\d\\s\\u00A0\\n,.<>•]+$', + ignoreText: ['API', 'CRM', ''], + }], + '@intlify/vue-i18n/no-unknown-locale': 'error', + '@intlify/vue-i18n/no-unused-keys': 'error', + '@intlify/vue-i18n/sfc-locale-attr': 'error', + + 'dependencies/import-style': ['error', { + maxSingleLineLength: 90, + maxSingleLineSpecifiers: 3, + }], + 'dependencies/separate-type-imports': 'error', + 'dependencies/separate-type-partitions': 'error', + 'dependencies/sort-named-imports': ['error', { + type: 'alphabetical', + ignoreAlias: true, + }], + 'dependencies/sort-imports': ['error', { + type: 'alphabetical', + imports: { + orderBy: 'alias', + splitDeclarations: true, + }, + groups: [ + 'side-effect-style', + 'side-effect', + [ + 'type-import', + 'type-external', + 'type-vue-components', + 'type-internal', + 'type-parent', + 'type-sibling', + 'type-index', + ], + 'builtin', + 'value-external', + 'value-vue-components', + 'value-internal', + ['value-parent', 'value-sibling'], + 'index', + 'ts-equals-import', + 'unknown', + ], + customGroups: [{ + groupName: 'type-vue-components', + selector: 'type', + elementNamePattern: ['\\.(svg|vue)$'], + }, { + groupName: 'value-vue-components', + elementNamePattern: ['\\.(svg|vue)$'], + }], + newlinesInside: 1, + partitions: { + orderBy: 'type-first', + splitBy: { + comments: false, + newlines: true, + }, + }, + }], + }, + }, + { + files: ['**/*.vue'], + languageOptions: { + parserOptions: { parser: pluginTs.parser }, + }, + rules: { + 'vue/block-order': ['error', { + order: ['template', 'script', 'i18n', 'style'], + }], + 'vue/attributes-order': 'error', + 'vue/component-definition-name-casing': ['error', 'PascalCase'], + 'vue/component-name-in-template-casing': ['error', 'PascalCase'], + 'vue/html-indent': ['error', 4, { + attribute: 1, + baseIndent: 1, + closeBracket: 0, + switchCase: 1, + }], + 'vue/html-self-closing': ['error', { + html: { + component: 'always', + normal: 'always', + void: 'always', + }, + math: 'always', + svg: 'always', + }], + 'vue/max-attributes-per-line': ['error', { + singleline: 4, + multiline: { max: 1 }, + }], + 'vue/script-indent': ['error', 2, { + baseIndent: 0, + switchCase: 1, + }], + + 'indent': 'off', + }, + }, + { ignores: ['dist/**', 'coverage/**'] }, +]) diff --git a/src/cmd/embed-ui/templates/extension.svg.txt b/src/cmd/embed-ui/templates/extension.svg.txt new file mode 100644 index 00000000..b99ee947 --- /dev/null +++ b/src/cmd/embed-ui/templates/extension.svg.txt @@ -0,0 +1,4 @@ + + + + diff --git a/src/cmd/embed-ui/templates/i18n-index.ts.txt b/src/cmd/embed-ui/templates/i18n-index.ts.txt new file mode 100644 index 00000000..7c2ace13 --- /dev/null +++ b/src/cmd/embed-ui/templates/i18n-index.ts.txt @@ -0,0 +1,21 @@ +import { createI18n } from 'vue-i18n' + +import enGB from './locales/en-GB.json' +import esES from './locales/es-ES.json' +import ruRU from './locales/ru-RU.json' + +const messages = { + 'en-GB': enGB, + 'es-ES': esES, + 'ru-RU': ruRU, +} as const + +export type Locale = keyof typeof messages +export type MessageSchema = typeof enGB + +export const i18n = createI18n<[MessageSchema], Locale>({ + legacy: false, + locale: 'ru-RU', + fallbackLocale: 'en-GB', + messages, +}) diff --git a/src/cmd/embed-ui/templates/publish-extension.mjs.txt b/src/cmd/embed-ui/templates/publish-extension.mjs.txt new file mode 100644 index 00000000..7476fb9d --- /dev/null +++ b/src/cmd/embed-ui/templates/publish-extension.mjs.txt @@ -0,0 +1,287 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'node:url' + +import fs from 'node:fs' + +import path from 'node:path' + +import { spawnSync } from 'node:child_process' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(scriptDir, '..') +const args = new Set(process.argv.slice(2)) +const archiveOnly = args.has('--archive-only') + +const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +const loadEnvFile = (filePath) => { + if (!fs.existsSync(filePath)) { + return + } + + for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/u)) { + const trimmed = line.trim() + + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + + if (separatorIndex === -1) { + continue + } + + const key = trimmed.slice(0, separatorIndex).trim() + let value = trimmed.slice(separatorIndex + 1).trim() + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { + value = value.slice(1, -1) + } + + process.env[key] ??= value + } +} + +const assertNonEmptyString = (value, field) => { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error('Field "' + field + '" must be a non-empty string') + } + + return value +} + +const assertStringArray = (value, field) => { + if (value === undefined) { + return [] + } + + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { + throw new Error('Field "' + field + '" must be an array of non-empty strings') + } + + return value +} + +const listFiles = (directoryPath, basePath = directoryPath) => { + const result = [] + + for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { + const entryPath = path.join(directoryPath, entry.name) + const relativePath = path.relative(basePath, entryPath).split(path.sep).join('/') + + if (entry.isDirectory()) { + result.push(...listFiles(entryPath, basePath)) + continue + } + + if (entry.isFile()) { + result.push(relativePath) + } + } + + return result +} + +const normalizeManifestPath = (value) => typeof value === 'string' && value.startsWith('./') + ? value.slice(2) + : value + +const pickBuildArtifacts = (distDir, code) => { + const files = listFiles(distDir) + .filter(file => file !== 'manifest.json') + .filter(file => file !== code + '.zip') + .filter(file => !file.endsWith('.map')) + + const viteManifestPath = path.join(distDir, '.vite/manifest.json') + + if (fs.existsSync(viteManifestPath)) { + const viteManifest = readJsonFile(viteManifestPath) + const entries = Object.values(viteManifest) + const entry = entries.find(item => item && item.isEntry) ?? entries[0] + + if (entry?.file) { + return { + files, + scriptFile: normalizeManifestPath(entry.file), + styleFile: Array.isArray(entry.css) ? normalizeManifestPath(entry.css[0]) : null, + } + } + } + + return { + files, + scriptFile: files.find(file => file.endsWith('.js')) ?? null, + styleFile: files.find(file => file.endsWith('.css')) ?? null, + } +} + +const zipExtension = (distDir, code, extensionManifest, files) => { + const archivePath = path.join(distDir, 'extension.zip') + const manifestPath = path.join(distDir, 'manifest.json') + const previousManifest = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath) : null + + fs.writeFileSync(manifestPath, JSON.stringify(extensionManifest), 'utf8') + + try { + const zipResult = spawnSync('zip', ['-rFS', archivePath, ...files, 'manifest.json'], { + cwd: distDir, + stdio: 'inherit', + }) + + if (zipResult.error) { + throw new Error('Zip command failed: ' + zipResult.error.message) + } + + if (zipResult.status !== 0) { + throw new Error('Zip archive creation failed') + } + } finally { + if (previousManifest) { + fs.writeFileSync(manifestPath, previousManifest) + } else { + fs.rmSync(manifestPath, { force: true }) + } + } + + return archivePath +} + +loadEnvFile(path.join(projectRoot, '.env')) + +const configPath = path.join(projectRoot, 'extensionrc.json') + +if (!fs.existsSync(configPath)) { + console.error('Config not found: ' + configPath) + process.exit(1) +} + +let config + +try { + config = readJsonFile(configPath) +} catch (error) { + console.error('Cannot read extensionrc.json:', error) + process.exit(1) +} + +try { + const code = config.code ?? 'retailcrm-extension-frontend' + const uuid = assertNonEmptyString(config.uuid, 'uuid') + const version = assertNonEmptyString(config.version, 'version') + const targets = assertStringArray(config.targets, 'targets') + const pages = assertStringArray(config.pages, 'pages') + const runner = config.runner ?? 'worker' + + if (targets.length === 0 && pages.length === 0) { + throw new Error('Specify at least one target or page in extensionrc.json') + } + + const distDir = path.join(projectRoot, 'dist') + + if (!fs.existsSync(distDir)) { + throw new Error('Build directory not found: ' + distDir) + } + + const { files, scriptFile, styleFile } = pickBuildArtifacts(distDir, code) + + if (!scriptFile) { + throw new Error('Missing JS build artifact. Run npm run build before publishing.') + } + + const extensionManifest = { + code, + version, + entrypoint: scriptFile, + scripts: [scriptFile], + runner, + } + + if (targets.length > 0) { + extensionManifest.targets = targets + } + + if (pages.length > 0) { + extensionManifest.pages = pages + } + + if (styleFile) { + extensionManifest.stylesheet = styleFile + } + + const archivePath = zipExtension(distDir, code, extensionManifest, files) + + console.log('Archive created: ' + archivePath) + + if (archiveOnly) { + process.exit(0) + } + + const crmHost = process.env.CRM_API_HOST + const crmKey = process.env.CRM_API_KEY + const baseUrl = config.baseUrl || process.env.MODULE_URL || process.env.EXTENSION_BASE_URL + + if (!crmHost || !crmKey) { + throw new Error('Missing CRM_API_HOST or CRM_API_KEY in .env') + } + + if (!baseUrl) { + throw new Error('Missing MODULE_URL or EXTENSION_BASE_URL in .env or baseUrl in extensionrc.json') + } + + const embedJs = { + entrypoint: config.entrypoint || '/extension/' + uuid + '/script', + runner, + } + + if (targets.length > 0) { + embedJs.targets = targets + } + + if (pages.length > 0) { + embedJs.pages = pages + } + + if (styleFile && config.stylesheet !== false) { + embedJs.stylesheet = typeof config.stylesheet === 'string' + ? config.stylesheet + : '/extension/' + uuid + '/stylesheet' + } + + const integrationModule = { + code, + integrationCode: code, + active: true, + name: config.name || code, + clientId: config.clientId || 'client-id-xxx', + baseUrl, + integrations: { + embedJs, + }, + } + + const form = new FormData() + form.append('integrationModule', JSON.stringify(integrationModule)) + + const response = await fetch(new URL('/api/v5/integration-modules/' + code + '/edit', crmHost), { + method: 'POST', + headers: { + 'X-Api-Key': crmKey, + }, + body: form, + }) + + const text = await response.text() + + if (!response.ok) { + console.error('Request failed: ' + response.status + ' ' + response.statusText) + console.error(text) + process.exit(1) + } + + console.log(text) +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/src/cmd/embed-ui/templates/tsconfig.json.txt b/src/cmd/embed-ui/templates/tsconfig.json.txt new file mode 100644 index 00000000..fc3be2e9 --- /dev/null +++ b/src/cmd/embed-ui/templates/tsconfig.json.txt @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "__SOURCE_ROOT_RELATIVE__/*" + ] + } + }, + "include": [ + "__SOURCE_ROOT_RELATIVE__/**/*", + "env.d.ts" + ], + "vueCompilerOptions": { + "plugins": [ + "@omnicajs/vue-remote/tooling" + ] + } +} diff --git a/src/cmd/embed-ui/templates/vite.config.ts.txt b/src/cmd/embed-ui/templates/vite.config.ts.txt new file mode 100644 index 00000000..b9269006 --- /dev/null +++ b/src/cmd/embed-ui/templates/vite.config.ts.txt @@ -0,0 +1,38 @@ +import { fileURLToPath } from 'node:url' + +import path from 'node:path' + +import { defineConfig } from 'vite' + +import vue from '@vitejs/plugin-vue' +import vueRemoteVitePlugin from '@omnicajs/vue-remote/vite-plugin' + +import vueI18n from '@intlify/unplugin-vue-i18n/vite' + +import svgLoader from 'vite-svg-loader' + +const root = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [ + svgLoader({ + defaultImport: 'component', + }), + vueRemoteVitePlugin(), + vue(), + vueI18n({ + defaultSFCLang: 'json', + include: path.resolve(root, '__LOCALE_INCLUDE_PATTERN__/**/*.{json,json5,yaml,yml}'), + }), + ], + resolve: { + alias: { + '@': path.resolve(root, __SOURCE_ROOT_PATH__), + }, + }, + build: { + rollupOptions: { + input: path.resolve(root, __ENTRY_PATH__), + }, + }, +}) diff --git a/src/cmd/embed-ui/types.ts b/src/cmd/embed-ui/types.ts index 35f44bf0..b541c495 100644 --- a/src/cmd/embed-ui/types.ts +++ b/src/cmd/embed-ui/types.ts @@ -21,6 +21,15 @@ export interface InstallablePackage { name: string; section: DependencySection; description: string; + hooks?: InstallablePackageHook[]; +} + +export interface InstallablePackageHook { + type: 'agents' | 'config'; + binName: string; + command: 'init-agents' | 'init-config'; + failureMode: 'advisory' | 'required'; + requiresMcp?: boolean; } export interface PackageChange { @@ -32,10 +41,12 @@ export interface PackageChange { } export interface InitChanges { + preflight: string[]; packageJson: PackageChange[]; directories: string[]; files: string[]; agents: string[]; + mcp: string[]; hooks: string[]; install: string | null; skipped: string[]; diff --git a/src/shims-raw.d.ts b/src/shims-raw.d.ts new file mode 100644 index 00000000..57d00c0d --- /dev/null +++ b/src/shims-raw.d.ts @@ -0,0 +1,5 @@ +declare module '*?raw' { + const content: string + + export default content +} diff --git a/tests/embed-ui.test.ts b/tests/embed-ui.test.ts index ecb58881..ff8f248b 100644 --- a/tests/embed-ui.test.ts +++ b/tests/embed-ui.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -12,13 +13,9 @@ import { vi, } from 'vitest' -import { - parseArgs, - parseInitArgs, - runAdd, - runInit, - runUpdate, -} from '../src/cmd/embed-ui' +import { parseArgs, parseInitArgs } from '../src/cmd/embed-ui' +import { resolvePackageHookCommand } from '../src/cmd/embed-ui/package-hook-runner' +import { runAdd, runInit, runUpdate } from '../src/cmd/embed-ui' const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'embed-ui-')) @@ -27,9 +24,13 @@ const writeFile = (filePath: string, content: string) => { fs.writeFileSync(filePath, content, 'utf8') } +const readJsonFile = (filePath: string): T => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as T + describe('embed-ui CLI', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllEnvs() }) test('updates package.json files in the whole subtree and preserves indentation', () => { @@ -167,6 +168,41 @@ describe('embed-ui CLI', () => { expect(options.noAgents).toBe(true) }) + test('parseArgs supports init dependency conflict controls', () => { + const options = parseArgs([ + 'init', + '--force-deps', + '--fix-sections', + '--no-install', + '--no-agents', + ]) + + expect(options.command).toBe('init') + if (options.command !== 'init') { + throw new Error('Expected init options') + } + + expect(options.forceDeps).toBe(true) + expect(options.fixSections).toBe(true) + }) + + test('parseArgs supports opt-in MCP client configs', () => { + const options = parseArgs([ + 'init', + '--mcp-client-configs', + 'cursor,vscode', + '--no-install', + '--no-agents', + ]) + + expect(options.command).toBe('init') + if (options.command !== 'init') { + throw new Error('Expected init options') + } + + expect(options.mcpClientConfigs).toEqual(['cursor', 'vscode']) + }) + test('parseInitArgs rejects testing package in init mode', async () => { const tempDir = createTempDir() @@ -180,6 +216,7 @@ describe('embed-ui CLI', () => { const tempDir = createTempDir() vi.spyOn(console, 'log').mockImplementation(() => undefined) + vi.stubEnv('LANG', 'ru_RU.UTF-8') await runInit({ ...parseInitArgs([ @@ -190,6 +227,7 @@ describe('embed-ui CLI', () => { 'npm', '--no-install', '--no-agents', + '--no-mcp', ]), version: '1.2.3', }) @@ -199,8 +237,8 @@ describe('embed-ui CLI', () => { expect(packageJson.type).toBe('module') expect(packageJson.scripts).toMatchObject({ build: 'vite build', - lint: 'eslint .', - 'lint:fix': 'eslint --fix .', + eslint: 'eslint .', + 'eslint:fix': 'eslint --fix .', 'publish-extension': 'node scripts/publish-extension.mjs', }) expect(packageJson.dependencies).toMatchObject({ @@ -242,13 +280,26 @@ describe('embed-ui CLI', () => { expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain( 'pluginVueI18n.configs.recommended' ) + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain( + 'order: [\'template\', \'script\', \'i18n\', \'style\']' + ) + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain( + '\'vue/html-indent\': [\'error\', 4' + ) + expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain( + '\'vue/script-indent\': [\'error\', 2' + ) expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain('value-vue-components') expect(fs.readFileSync(path.join(tempDir, 'eslint.config.js'), 'utf8')).toContain('partitions: {') expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain( '@intlify/unplugin-vue-i18n/vite' ) + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain( + '@omnicajs/vue-remote/vite-plugin' + ) expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('vite-svg-loader') expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('defaultImport: \'component\'') + expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('vueRemoteVitePlugin()') expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain('vueI18n({') expect(fs.readFileSync(path.join(tempDir, 'vite.config.ts'), 'utf8')).toContain( '\'@\': path.resolve(root, \'web\')' @@ -264,27 +315,85 @@ describe('embed-ui CLI', () => { expect(fs.readFileSync(path.join(tempDir, 'web/endpoint/endpoint.worker.ts'), 'utf8')).toContain( 'i18n.global.locale.value = locale.value' ) - expect(fs.readFileSync(path.join(tempDir, 'web/pages/SettingsPage.vue'), 'utf8')).toContain( - '