Dee Wan CMS

Dee Wan CMS (ดีหวาน 🇹🇭) — мультиарендная многоязычная headless CMS, которая работает целиком на edge-инфраструктуре Cloudflare: Hono Worker поверх D1 для API и админка на SvelteKit в Pages.
Прежде всего это CMS: моделируйте контент, создавайте его, публикуйте, управляйте им. От других headless CMS её отличает то, что она может передать вам в итоге: Generate standalone backend превращает сайт в принадлежащий владельцу репозиторий с нормализованной схемой Prisma, проверяемыми миграциями, которые только дополняются, сгенерированными маршрутами с привычной семантикой моделей и запросов Prisma, собственной базой данных, развёртыванием и учётными данными — и без зависимости от Dee Wan во время выполнения. Код, принадлежащий разработчику, переживает повторную генерацию, а полное отсоединение от Dee Wan — поддерживаемый исход, а не запасной выход.
Сопутствующие сильные стороны: нативное развёртывание на Cloudflare, привычная модель разработки с Prisma, работа с несколькими сайтами и несколькими языками, независимые репозитории и инфраструктура, безопасная эволюция схемы. Импорт/экспорт, необязательный редакционный процесс, помощь ИИ и инструменты миграции портфеля сайтов — вспомогательные возможности; ни одна из них не определяет продукт.
Две поверхности доставки, и это не одно и то же. Публичный API CMS — живая поверхность публикации, он обновляется в момент публикации контента. Сгенерированный бэкенд — независимый релиз схемы и контента: последующие изменения в CMS доходят до него только через явную повторную генерацию и развёртывание. Он не синхронизируется непрерывно.
Pre-MVP: кодовая база 1.0 — не выпущенный продукт. Локальное покрытие значительное, но рубежи приёмки на реальном Cloudflare, финальные браузерные/визуальные рубежи и рубеж миграции четырёх сайтов ещё не пройдены. См. релизные рубежи (
SPEC.md, in the repository) и границы продукта 1.0.
Типы контента не зашиты в код. Вы определяете модели контента и их поля в UI конструктора моделей, а CMS хранит их как EAV-данные, поэтому добавление типа «Recipe» с нужными вам полями не требует ни кода, ни миграции.
Настройка базы данных начинается с одной начальной миграции Prisma и её сгенерированного зеркала для D1. Начальная загрузка арендатора содержит текущую схему для каждого сайта. Будущие релизы добавляют миграции. См. инструкции по миграциям базы данных.
Что она делает
Заголовок раздела «Что она делает»-
Конструктор моделей — определяйте модели контента и поля (текст, форматированный текст, число, логическое значение, выбор, дата, медиа, JSON, связь, компонент) через UI. Перед применением изменения сначала строится diff, а правки, которые сделали бы существующий контент недействительным, помечаются до выполнения.
-
Несколько сайтов — одно развёртывание обслуживает много сайтов. Каждый запрос админки несёт
X-Site-Id, а слой данных принудительно подставляетsite_idв каждый запрос; API отказывается угадывать арендатора, чтобы не рисковать чтением чужих данных. -
Многоязычность — настраивайте языки в UI; контент хранится отдельно для каждого языка.
-
Версионирование и публикация — ревизии контента с публикацией и снятием с публикации. Редакционная проверка — необязательный режим для каждого сайта, по умолчанию выключенный: новый сайт публикует прямо из черновика, и никакие элементы управления проверкой не отображаются, пока кто-нибудь не включит её в Settings → General.
-
Помощь ИИ — генерация и перевод текстов, а также генерация изображений через серверный прокси. Ключи провайдеров никогда не попадают в браузер.
-
Медиа — загрузка в R2 или Cloudflare Images с автоматическим преобразованием в webp/avif.
-
Аутентификация — собственные сессии: одноразовая ссылка активации (claim link) создаёт первого администратора, пароли задают их владельцы, а каждый запрос несёт сессию из базы данных. Cloudflare Access — необязательное усиление периметра, по умолчанию выключенное. См. SPEC-AUTH.md.
-
Удалённые плагины — плагин — это отдельно развёрнутый удалённый сервис. Он не выполняет никакого кода внутри Dee Wan: никакой модуль не загружается, никакой JavaScript не попадает в UI админки, никакая привязка базы данных не выдаётся. Он говорит по одному версионированному протоколу HTTP, в рамках гранта для конкретного сайта, выбранного вручную в Settings → Plugins, и может читать узкую сводку контента и запрашивать одну условную публикацию или снятие с публикации. Публикация по расписанию — это плагин Scheduling, а не функция ядра.
Документация для разработчиков: написание плагина · протокол v1 · безопасность · тестирование. Машиночитаемые схемы манифеста и протокола генерируются из определений, по которым маршруты выполняют валидацию. Спецификация платформы — SPEC-PLUGINS.md; эталонный плагин —
plugins/scheduling/.
Встроенная роль author ограничена собственными записями, включая операции процесса, восстановления и публикации.
Правки группы переводов используют владельца основного экземпляра. Полное описание поведения и примечания о миграции — в
SPEC-ACCESS.md §4.4.
Архитектура
Заголовок раздела «Архитектура»| Компонент | Стек |
|---|---|
| UI админки | SvelteKit (SPA, ssr=false), shadcn-svelte, TipTap — в Cloudflare Pages |
| API | Hono на Cloudflare Worker |
| База данных | D1, доступ через Prisma (@prisma/adapter-d1) |
| Медиа | R2 или Cloudflare Images |
| Аутентификация | Сессии Better Auth; Cloudflare Access — необязательно, как периметр |
| Инференс | DeepInfra |
Репозиторий — это npm workspace. Общие библиотеки лежат в packages/ как git
submodules — клонируйте с --recurse-submodules.
backend/ Hono Worker: API, auth, scoped CRUD, AI proxy, Prisma schemafrontend/ SvelteKit adminpackages/ schema-parser, prisma-guard, prisma-rbac, eav-to-prisma (submodules)plugins/ remote plugins built against protocol v1 (Scheduling); not workspacesscripts/ setup, deploy, migrate, health, teardownУстановка
Заголовок раздела «Установка»Попробуйте локально, без аккаунта Cloudflare
Заголовок раздела «Попробуйте локально, без аккаунта Cloudflare»Всё, что ниже этого раздела, создаёт реальные ресурсы Cloudflare. Этот раздел — нет: он запускает весь продукт с локальным файлом D1 на вашей машине, без аккаунта, без API-токена и без DNS.
node scripts/install-dependencies.mjs # locked tree, scripts off, audited, then rebuiltnpm run local:migrate # apply the schema to the local D1 mirrornpm run local:api # admin Worker on http://localhost:8787 (leave running)npm run local:seed # the fixture rig, then a corpus that looks like real sitesnpm run local:admin # admin UI on http://localhost:5173 (leave running)local:seed выводит только что созданные данные для входа — admin@localhost.test, пароль
local-dev-password-1234. Команда идемпотентна: запустите её снова, и она сообщит, что уже существует, а не
создаст вторую копию.
Она заполняет две вещи, и если вам нужна только одна из них, это отдельные команды:
| Команда | Что создаёт |
|---|---|
npm run local:seed:fixture |
Local Dev Site: первый администратор, модель Article, три черновика и одна демонстрационная страница (Page). Именно на ней проверяются e2e-спецификации с реальным бэкендом, поэтому её контент не меняется. |
npm run local:seed:corpus |
Два сайта, которые выглядят как настоящие, — Meridian Review, журнал с архивом за восемнадцать месяцев, и Fern & Ash, небольшой сайт студии. |
Корпус разворачивает поставляемый пресет Blog System (Post, Article, Category, Keyword и шесть компонентов), так что модели — те же, что пользователь получает из конструктора моделей, а не схема, придуманная для заполнения. Он записывает около пятидесяти элементов в состояниях «опубликовано», «черновик», «на проверке», «одобрено» и «запланировано», связывает категории, ключевые слова и связанные статьи, загружает изображения с alt-текстом на обеих локалях и переводит две статьи на немецкий. Оба сайта вымышлены и сообщают об этом; ни одна реальная организация, человек или здание в них не фигурирует.
Даты привязаны к самой ранней метке времени создания двух сайтов корпуса в той же локальной цели D1. Свежий корпус помещает свой самый новый элемент за пять дней до этого дня и сохраняет заданные интервалы на обоих сайтах. Последующие перезапуски и восстановление недостающих элементов используют ту же привязку; они не сдвигают существующий контент вперёд. Инициализация меток времени продолжается после прерванного прохода и не трогает последующие метки обновления/версии, когда экземпляр уже инициализирован. Существующие заданные значения статей и постов сохраняются; недостающие связи всё ещё могут быть восстановлены. Нечитаемые или несовпадающие привязки сайтов останавливают запуск. Это не означает изоляции от одновременных ручных правок.
Изображения берутся из backend/scripts/fixtures/media/, если в этом каталоге что-то есть: положите туда фотографии,
и корпус их использует. Если там ничего нет, генерируются детерминированные двухцветные заглушки, которые
честно являются заглушками, а не фотографиями.
Затем в UI админки откройте Content, отредактируйте одну из созданных статей и опубликуйте её.



Чтобы прочитать опубликованное так, как это сделал бы веб-сайт:
npm run local:public # public read Worker on http://127.0.0.1:8788 (leave running)curl -H "Host: localhost" http://127.0.0.1:8788/v1/articleЗаголовок Host — не украшение. Публичный Worker определяет сайт по Host и
ни по чему другому, поэтому запрос без него получает 404 — это то же правило, по которому работает развёртывание.
Публичный кеш выключен, пока вы его не включите. Развёртывание очищает тег edge-кеша при
публикации, для чего нужны CF_PURGE_ZONE_ID и CF_PURGE_API_TOKEN с Zone → Cache Purge. У
инсталляции по умолчанию нет ни того, ни другого, поэтому она поставляется с PUBLIC_CACHE_MAX_AGE=0, и публичный Worker
отвечает Cache-Control: no-store: ничего не сохраняется и ничего не читается, поэтому публикация, обновление,
снятие с публикации или удаление видны при следующем чтении. Ноль выключает кеш в обоих направлениях
намеренно: запись переживает настройку, которая её сохранила, поэтому Worker, который перестал писать, но продолжил
читать, после перенастройки на остановку отдавал бы устаревшие ответы из предыдущего срока жизни.
no-store, а не max-age=0 — намеренно. Browser Cache TTL зоны переписывает директиву для браузера
всякий раз, когда значение источника меньше этой настройки, поэтому max-age=0 оставляет edge
таким, как скажет зона, — четыре часа на зоне с настройкой по умолчанию, измерено при источнике,
запрашивающем шестьдесят секунд. Увеличение PUBLIC_CACHE_MAX_AGE включает edge-кеш и эту
настройку зоны для браузеров: очистка по тегу очищает Cloudflare и никогда не может очистить чей-либо браузер, поэтому
установите Browser Cache TTL зоны в Respect Existing Headers, прежде чем на это полагаться.
Дальше Settings → Data импортирует и экспортирует контент, а Generate standalone
backend на панели — это путь graduation. Генерация бэкенда записывает проект; для его развёртывания
нужен аккаунт.
Эта локальная среда использует backend/wrangler.dev.jsonc, который отслеживается в репозитории и не содержит ни учётных данных, ни
идентификатора аккаунта. Она никогда не обращается к Cloudflare.
Требования
Заголовок раздела «Требования»- Аккаунт Cloudflare (Workers, D1, Pages и R2 или Cloudflare Images)
- Node.js 22.14+ или 24.10+ — только чётные ветки (в репозитории есть
.nvmrc;nvm useего подхватывает) - npm ровно той версии, которую объявляет
package.json#packageManager(11.16.0), — это npm, поставляемый с Node из.nvmrc. Устанавливайте зависимости командойnode scripts/install-dependencies.mjs, никогда не голымnpm installилиnpm ci: она отказывает при любом другом npm, распаковывает зафиксированное дерево с отключёнными скриптами, отказывает для каждого lifecycle-скрипта зависимости, о которомpackage.json#allowScriptsне принимает решения по точной версии, и только после этого запускаетnpm rebuildдля одобренных - API-ключ DeepInfra, если вам нужны функции ИИ
Cloudflare Access (Zero Trust) не требуется. Установка спрашивает, включать ли его как дополнительный периметр; отказ — вариант по умолчанию — оставляет сессионную аутентификацию способом входа.
Быстрый старт
Заголовок раздела «Быстрый старт»Клонируйте репозиторий, затем запустите установщик админской CMS. Доставке публичного сайта нужна дополнительная настройка из следующего раздела.
Полное руководство по установке — через веб-интерфейс и из командной строки — см. в разделе Установка Dee Wan.
git clone --recurse-submodules https://github.com/your-username/dee-wan-cms.gitcd dee-wan-cmsnode scripts/install-dependencies.mjs
npm run setup # provisions D1 and media storage; offers DNS and optional Accessnpm run deploy # builds and deploys the admin worker and admin UInpm run health # checks the deployment answersЗатем откройте ссылку активации, которую вывел setup, и задайте пароль. Это создаст
суперадминистратора, и вы внутри. Ссылка одноразовая и истекает через 15 минут; если
вы её упустили, снова запустите npm run setup — истёкшая ссылка считается незавершённым шагом, поэтому
запуск выпустит новую, пока инсталляцию никто не активировал.
setup предлагает применить схему базы данных в рамках запуска (по умолчанию — да), поэтому
свежей установке не нужен отдельный шаг миграции.
Свежему клону не нужны ручные шаги сборки. packages/*/dist и клиент Prisma —
результаты сборки, поэтому в клоне нет ни того, ни другого, а setup, deploy и
migrate:d1 каждый собирают недостающее, прежде чем делать что-либо ещё.
npm run doctor — необязательная диагностика только для чтения. На свежем клоне она сообщает об отсутствующих
сгенерированных пакетах и клиенте Prisma и завершается с ненулевым кодом. Запускайте её после setup или сначала соберите эти результаты
командой npm run build:packages && npm run prisma:generate.
Отдача опубликованного контента (публичный Worker чтения)
Заголовок раздела «Отдача опубликованного контента (публичный Worker чтения)»Опубликованный контент отдаёт отдельный публичный Worker чтения со своим
backend/wrangler.public.jsonc.
npm run setup записывает этот файл за вас. Он делает это в момент, когда все нужные значения
определены — аккаунт, зона и идентификатор базы данных, — и никогда не перезаписывает существующий файл. Если
какое-либо из них невозможно вывести, setup отказывает с указанием причины, а не угадывает маршрут или идентификатор
базы данных, и сообщает об этом; в этом случае вы готовите файл вручную:
cp backend/wrangler.public.example.jsonc backend/wrangler.public.jsonc# edit backend/wrangler.public.jsonc — account id, D1 id, routenpm run deploydeploy отказывается работать, когда этого файла нет, потому что развёртывание без публичного Worker отдаёт
опубликованный контент в никуда и не должно приниматься за завершённое. Инсталляция, которой
действительно нужен только UI админки, говорит об этом явно:
npm run deploy -- --admin-only— это разворачивает админскую часть и отмечает запуск как admin-only, а не как завершённый.
Развёртывание альтернативной инсталляции. --config выбирает конфиг wrangler админского Worker; у публичного
Worker свой конфиг, и одно имя файла не подразумевает другое. Поэтому нестандартный --config
требует --public-config:
npm run deploy -- --config wrangler.v3.jsonc --public-config wrangler.v3.public.jsoncБез него развёртывание отказывает, чтобы не связать ваш альтернативный админский Worker с публичным Worker
инсталляции по умолчанию. Оба конфига читаются, и их значения account_id сравниваются до того, как что-либо
будет развёрнуто.
Учётные данные
Заголовок раздела «Учётные данные»setup нужен API-токен Cloudflare. Он запрашивает его, если вы его не
задали, и выводит точный список прав доступа с причиной для каждого:
export CLOUDFLARE_API_TOKEN=… # optional — skips the promptexport CLOUDFLARE_ACCOUNT_ID=… # optional — skips the account pickernpm run doctor выводит тот же список в строке с исправлением, когда токена нет
или у него недостаточно прав. Оба берутся из одного источника (scripts/cloudflare-token-scopes.ts),
поэтому токен, собранный по запросу doctor, — это токен, который принимает setup.
Больше ничему не нужна переменная окружения. Секреты приложения задаются через
wrangler secret put (ниже), никогда не в файле.
Настройка и создание ресурсов
Заголовок раздела «Настройка и создание ресурсов»npm run setup интерактивен. Он создаёт базу данных D1 и бакет для медиа,
предлагает создать DNS-записи, настраивает приложение Cloudflare Access
только если вы на это согласитесь, затем записывает backend/wrangler.jsonc.
wrangler.jsonc — это файл конфигурации; wrangler.toml не существует.
Секреты
Заголовок раздела «Секреты»Секреты никогда не хранятся в wrangler.jsonc или в базе данных. Задавайте их через
wrangler secret put из каталога backend/:
npx wrangler secret put DEEPINFRA_API_KEY # AI text + image generationnpx wrangler secret put CF_IMAGES_API_TOKEN # only if STORAGE_PROVIDER=cf-imagesnpx wrangler secret put CF_PURGE_API_TOKEN # only if caching public reads; see PUBLIC_CACHE_MAX_AGEDeepInfra — единственный поддерживаемый провайдер инференса. Чат, изображения и
движок структурированной генерации работают через него на одном ключе. UI админки никогда
не обращается к провайдеру напрямую — всё идёт через /api/ai/* на Worker,
который хранит ключ и применяет почасовую квоту для каждого сайта.
Миграции
Заголовок раздела «Миграции»Свежей установке ничего из этого не нужно — setup применяет схему. Это для
последующих случаев, когда схема меняется:
npm run migrate:d1 # apply pending migrations to the deployed databasenpm run migrate:d1:local # …to the local mirrornpm run migrate:sync # regenerate backend/migrations-wrangler/ from Prismanpm run migrate:check # verify the two are in step (CI)
npm run migrate:d1:local -- --create-migration # author one from schema.prismaПрименение и создание миграций разделены. Без --create-migration эти команды
только применяют то, что уже существует, — они не напишут миграцию из разошедшегося
schema.prisma, а именно так DROP TABLE незаметно попадает в историю.
Миграции — это миграции Prisma, воспроизводимые в D1 из
backend/migrations-wrangler/. schema.sql не существует. prisma/migrations/ —
источник истины, а каталог wrangler генерируется из него: напишите
миграцию Prisma, затем запустите migrate:sync и закоммитьте обе половины.
Импорт и экспорт контента
Заголовок раздела «Импорт и экспорт контента»Обе функции находятся в админке в Settings → Data — без терминала и без правки JSON.
- Импорт: архив экспорта Strapi (
.tar/.tar.gz, проверено на Strapi 4.25.9 и 5.51.1) или копия сайта Dee Wan (.json), экспортированная из другой инсталляции этой CMS. Мастер изучает архив, показывает найденное, сопоставляет типы и языки, проверяет план, затем выполняет его. Ничего не перезаписывается: импорт создаёт контент или пропускает его, и всё задание можно отменить. - Экспорт: Download site copy записывает один файл, содержащий модели, поля, языки, процессы, настройки и текущий контент этого сайта, опубликованную версию там, где она отличается, и точные байты каждого изображения, включая более раннюю копию, которую всё ещё показывает перенесённая версия. Если какие-либо из этих байтов невозможно прочитать точно из R2 или Cloudflare Images, скачивание отклоняется с указанием причины, вместо того чтобы записать частичную копию. Именно этот файл читает обратно импортёр.
- Таблицы обновляют уже существующие строки. Этот путь начинается со списка контента для одной модели, а не с этого мастера.
Мастер, запущенный локально на архиве-фикстуре Strapi 5
(backend/test/fixtures/strapi/v5/archive/) в новый сайт:





Импорту архивов нужен IMPORT_BUCKET. npm run setup предлагает создать его,
а npm run doctor сообщает, включён ли он; всё остальное работает
без него.
Статус честно описан в SPEC-IMPORT.md: реализовано в рабочем дереве, пока не завершено для 1.0.
Локальный запуск
Заголовок раздела «Локальный запуск»npm run local:api # admin Worker on http://localhost:8787, tracked dev confignpm run local:admin # admin UI on http://localhost:5173npm run local:public # public read Worker on http://127.0.0.1:8788local:api — это wrangler dev -c wrangler.dev.jsonc, отслеживаемый локальный конфиг, который есть в свежем
клоне. backend/wrangler.jsonc записывается setup и находится в gitignore, поэтому ничто в локальном процессе
от него не зависит; чтобы вместо этого запустить установленную конфигурацию, используйте npm run dev:install --workspace backend.
local:admin передаёт PUBLIC_BACKEND_URL в командной строке, поэтому фронтенду не нужен .env для
этого пути. Развёртывание по-прежнему читает его из frontend/.env.
Развёртывание
Заголовок раздела «Развёртывание»npm run deploy # backend Worker + frontend Pages project (+ public worker when configured)npm run health # post-deploy checkshealth выполняет одни и те же проверки с Access и без него; при включённом Access он
также проверяет периметр с помощью пары сервисных токенов.
В конце установка выводит одноразовый URL активации. Откройте его, чтобы задать пароль и создать суперадминистратора, — см. ниже.
Cloudflare Access больше никого не создаёт. Он аутентифицирует пользователей, которые уже
существуют (это то, что нужно на период миграции), а идентичность Access без
аккаунта получает user_not_provisioned. BOOTSTRAP_ADMIN_EMAIL указывает адрес,
к которому привязана ссылка активации; сам по себе он ничего не предоставляет.
Вход и восстановление доступа
Заголовок раздела «Вход и восстановление доступа»Регистрация закрыта. Нет ни публичной регистрации, ни публичной формы «забыли пароль» — аккаунт существует потому, что его создал кто-то с соответствующими полномочиями. Пароли задают их владельцы через одноразовую ссылку; их никогда не выбирает тот, кто отправил приглашение.
Каждая ссылка ниже одноразовая, истекает через 15 минут и показывается ровно один раз. Хранится только её хеш, поэтому ничто не может вывести её снова; если вы потеряли ссылку, выпустите новую — это аннулирует первую. Минимальная длина пароля — 12 символов, это проверяет сервер.
Активация новой инсталляции
Заголовок раздела «Активация новой инсталляции»npm run setup выводит URL активации в конце свежей установки. Откройте его, задайте
пароль, и аккаунт суперадминистратора будет создан. Адрес, которому он принадлежит, берётся
из BOOTSTRAP_ADMIN_EMAIL, который должен указывать ровно один адрес.
Если ссылка истекла до того, как вы ею воспользовались, снова запустите установщик (npm run setup
или npm run install:cms -- resume). Истёкшая ссылка активации считается незавершённым шагом,
поэтому запуск выпустит новую, пока инсталляцию никто не активировал.
npm run claim:link этого не делает: он возвращает существующий аккаунт суперадминистратора
его владельцу и отказывает, если суперадминистратора нет.
Кто-то забыл пароль
Заголовок раздела «Кто-то забыл пароль»Суперадминистратор создаёт ссылку из списка People и передаёт её любым удобным способом — в чате, по телефону, лично. Ничего не отправляется по почте, потому что в инсталляции по умолчанию не настроен почтовый провайдер.
Последний суперадминистратор потерял доступ
Заголовок раздела «Последний суперадминистратор потерял доступ»Это единственный случай, который никто другой не может исправить, поэтому нужны учётные данные развёртывания:
npm run claim:link -- --remote --email locked-out@example.comКоманда отказывает, если этот адрес — не единственный активный суперадминистратор. Если их двое, второй должен вместо этого выпустить ссылку из списка People — этому пути вообще не нужен доступ к Cloudflare.
Разработка
Заголовок раздела «Разработка»cd backend && npm test # Vitest, against a local SQLite databasecd frontend && npm test # Vitestcd frontend && npx svelte-check # type checknpm run submodules:check # fail if a submodule has uncommitted workВсе команды в одной таблице
Заголовок раздела «Все команды в одной таблице»| Команда | Что делает |
|---|---|
npm run local:migrate |
Только локально, без учётных данных. Применяет схему к локальному зеркалу D1 через backend/wrangler.dev.jsonc. Читает --local раньше любых учётных данных, поэтому работает на машине, которая никогда не видела токена Cloudflare. |
npm run local:api |
Админский Worker на отслеживаемом локальном конфиге. Ничто из того, к чему он обращается, не покидает машину. |
npm run local:public |
Публичный Worker чтения поверх того же локального D1, из временного конфига вне репозитория. Сайт определяется через -H "Host: localhost". |
npm run local:seed |
Фикстура плюс реалистичный корпус, затем выводит данные для входа. Идемпотентна. |
npm run local:seed:fixture |
Только фикстура: первый администратор, один сайт, модель Article, три черновика и демонстрационная страница (Page). На ней проверяются e2e-спецификации с реальным бэкендом. |
npm run local:seed:corpus |
Только корпус: два вымышленных сайта с контентом реалистичной формы, медиа, переводами и задним числом проставленными метками времени. Требует, чтобы администратор из фикстуры уже существовал. |
npm run local:admin |
UI админки поверх локального Worker, с PUBLIC_BACKEND_URL, переданным в командной строке. |
npm run test:boot |
Запускает оба Worker под зафиксированной версией workerd и завершается ошибкой, если любой из них отказывает. Единственная проверка, которая видит недопустимый экспорт точки входа. |
npm run doctor |
Предварительная проверка. Ничего не меняет, называет, чего не хватает и как это исправить. |
npm run setup |
Создаёт ресурсы Cloudflare, предлагает применить схему, выводит ссылку активации. |
npm run plan |
Оператор, только чтение. Выводит каждый шаг, который выполнила бы установка, и состояние, в котором каждый из них уже находится, выводя всё это из конфига и журнала (ledger) на диске. Не обращается к сети, ничего не создаёт, ничего не стоит. Добавьте -- --json, чтобы получить машиночитаемый план с planVersion. |
npm run install:cms |
Оператор, изменяющая. Установщик, управляемый глаголами: install:cms plan, apply, resume, doctor, verify, teardown. apply и resume создают платные ресурсы Cloudflare; остальные глаголы — нет. Коды выхода различают «не завершено», «сломано» и «нет учётных данных», чтобы CI мог ветвиться по ним. |
npm run install:web |
Оператор, изменяющая. Тот же установщик с браузерным интерфейсом, отдаваемым с localhost на этой машине, чтобы токен Cloudflare оставался там, где он уже хранится. Закреплён за одним вызывающим одноразовым секретом. Мастер из четырёх шагов — Site (где он будет жить), Cloudflare (подключение, выбор аккаунта, план базы данных D1), Administrator (единственный адрес, к которому привязана ссылка активации), Install (обзор запланированных адресов и того, что будет создано, с именами инфраструктуры за одним раскрывающимся блоком «Customize infrastructure»). Continue сохраняет каждый шаг; инфраструктура импорта выключена, если её не выбрать. Записывает ваши решения в backend/wrangler.jsonc, не нарушая его комментарии, запрашивает у Cloudflare ровно тот доступ, который нужен этим настройкам, и отказывается запускать установку с настройками, которые вы не проверили. Использует тот же каталог шагов, который выводит plan. |
npm run install:web:dry-run |
Проверяющий, без изменений. Тот же сервер и та же страница, что у install:web, для проверки UI/UX, но каждый внешний эффект заменён: конфиг и журнал хранятся во временной папке, удаляемой по Ctrl-C, обнаружение аккаунтов и запуск установки заскриптованы, а «Create administrator» открывает локальное уведомление вместо настроенного адреса админки. Вход в Cloudflare завершается, когда вы открываете ссылку для входа, выведенную при старте (она показывает настоящую страницу «Connected»), или сам по себе с --connect-ms <n>; не нажимайте Authorise — эта кнопка открывает Cloudflare с идентификатором клиента-заглушкой. Запуск записывает настоящие ключи журнала, поэтому план и лог меняются так же, как при настоящей установке. --outcome succeeded|incomplete|failed определяет только первый запуск (failed останавливается на проверке работоспособности, incomplete — перед ссылкой активации), после чего повтор или продолжение завершаются успешно. Также --accounts one|several|none|unreadable, --cloudflare oauth|unavailable, --step-ms, --credential-seconds (60 и меньше означает немедленное истечение, потому что установщик отбрасывает учётные данные на 60 секунд раньше). Порт 8978, если не задан DEEWAN_INSTALL_PORT. |
npm run docs:api |
Контрибьютор, только локальные файлы. Перегенерирует docs/PUBLIC-API.md из backend/src/public/api-docs.ts. Записывает один файл в репозитории и больше ничего; CI проверяет закоммиченную копию, а не переписывает её. |
npm run docs:plugins |
Контрибьютор, только локальные файлы. Перегенерирует docs/plugins/protocol-v1.md и две JSON Schema в docs/plugins/schemas/ из backend/src/lib/plugins/. Записывает три файла в репозитории и больше ничего. backend/test/plugin-docs.test.ts проверяет закоммиченные копии и выполняет каждый пример из документации плагинов против настоящих маршрутов, поэтому расхождение приводит к падению теста, а не вводит автора плагина в заблуждение. |
npm run provision:site -- --site <id|slug> |
Оператор, изменяющая. Даёт одному сайту собственную базу данных D1 за восемь шагов с записью в журнал: create → migrate → seed → bind → deploy → preflight → activate → smoke. preflight проверяет то, для чего не нужен маршрутизатор, — оба развёрнутых Worker привязывают именно этот UUID базы данных, прочитанный из аккаунта, а не из этой рабочей копии, и арендатор отвечает на прямой запрос, — потому что registry.ts отказывает сайту, чья привязка не active, и запрос, сделанный до активации, измерял бы этот отказ. smoke выполняет настоящие запросы, когда активация сделала их обслуживаемыми. Создаёт платный ресурс и повторно разворачивает оба Worker. Добавьте --plan, чтобы вывести последовательность и то, что уже сделано, — только чтение, хотя, в отличие от npm run plan, она ЧИТАЕТ управляющую базу данных по сети, — или --repair, чтобы перепроверить шаги, которые журнал считает завершёнными; восстановление никогда не перезапускает create, migrate или seed для арендатора, который уже в работе, а восстановление, чьи проверки не смогли ответить, отказывает, ничего не запуская, вместо того чтобы сообщить об успехе по строкам, которые никто не перепроверил. Сайт, однажды завершивший создание ресурсов, записывается навсегда, и никакой последующий сбой не может удалить его базу данных. Безопасно запускать дважды; неизвестные аргументы отклоняются. Задайте DEE_WAN_ADMIN_COOKIE, чтобы smoke мог доказать маршрутизацию на стороне админки; без него запуск останавливается, а не заявляет о проверке, которую не выполнил. Если предыдущий запуск упал, удерживая аренду (lease), восстановите её с помощью --declare-dead <owner> --declared-by <who> --evidence <what> — все три части, потому что восстановление, которое никто не может атрибутировать, никто не сможет и проверить. |
curl -H "X-Site-Id: <id>" -b "<session cookie>" <admin>/api/models/public-client.ts |
Выдаёт TypeScript-клиент без зависимостей для публичного API чтения этого сайта — конверт ответа, тип для каждой модели, объединение локалей, типы запросов, чьи ключи relation/tag — настоящие поля связей модели, и методы, названные по моделям сайта. Только чтение. Приватная модель включается, но её методы требуют API-токена сайта; неизвестный тип поля выдаётся как unknown, никогда не any. Перегенерируйте после изменения модели — никто не отслеживает расхождение. |
npm run acceptance -- plan |
Выводит путь приёмки из десяти этапов — doctor → plan → install → claim-link → provision-site → publish-read → public-query-matrix → tenant-upgrade → health → teardown-dry — с рубежом из SPEC.md, для которого каждый этап служит доказательством. Ничего не запускает и не обращается ни к какому аккаунту. |
npm run acceptance -- run --account <id> --confirm-disposable |
Владелец развёртывания, изменяющая, ТОЛЬКО ДЛЯ ОДНОРАЗОВЫХ АККАУНТОВ. Создаёт реальные платные ресурсы и оставляет их — последний этап — это пробный прогон teardown, так что удалять их потом вам. Проходит путь по порядку на реальном аккаунте Cloudflare и записывает журнал в .dee-wan/acceptance-<account>.json: команду, код выхода, момент и цель для каждого этапа. Журнал привязан к ОТПЕЧАТКУ ЦЕЛИ, снятому по содержимому, а не по именам: аккаунт, сайт приёмки, email администратора, оба URL проверки, дайджест каждой конфигурации, которую читает путь (включая backend/wrangler.jsonc, который provision:site и tenant:upgrade читают независимо от --config и который находится в gitignore, так что его путь ничего не говорит о содержимом), дайджест каждого проиндексированного blob, дайджест всего, что рабочее дерево содержит сверх индекса, и точные рекурсивные закрепления submodule. Продолжение, цель которого сдвинулась, отклоняется с указанием изменившихся полей, потому что девять зелёных этапов на четырёх разных целях не описывают никакого состояния мира, которое когда-либо работало. --restart отбрасывает такой журнал и начинает новый путь; --allow-dirty требуется, чтобы записывать доказательства из рабочего дерева, которое не совпадает со своим коммитом. Останавливается на первом сбое — путь, продолжающийся после сбоя, измеряет состояние, которое никто не описывал. Этап, для которого не хватает обязательного окружения, ОТКЛОНЯЕТСЯ до того, как что-либо будет создано, и запуск НЕ записывает журнал вообще — раньше отклонённый запуск записывал строку skipped с отметкой того самого пустого окружения, которое только что отклонил, и следующий правильно настроенный запуск читал это как расхождение. Повторный запуск повторяет неудавшиеся этапы и пропускает пройденные; --from <leg> перезапускает начиная с этого этапа и отклоняется, если такого этапа нет. Каждый этап закреплён за указанным аккаунтом, поэтому ни один не может попасть в другой. Код выхода 0 означает, что ЖУРНАЛ фиксирует каждый этап как пройденный, а не «всё, что пытался сделать этот запуск, удалось»; частичный запуск завершается с кодом 2 и называет, что осталось. Нужны DEEWAN_ACCEPTANCE_ADMIN_EMAIL, DEEWAN_ACCEPTANCE_SITE_ID, DEE_WAN_ADMIN_COOKIE, значения VERIFY_* и DEEWAN_PUBLIC_HOST / DEEWAN_PUBLIC_STRANGER_HOST для матрицы фильтров. Запускающий скрипт — не доказательство; доказательство — журнал, который он оставляет. |
npm run public:query-probe -- --confirm-disposable --site <id> --admin <url> --public <url> --host <domain> --stranger-host <domain> |
Владелец развёртывания, изменяющая, ТОЛЬКО ДЛЯ ОДНОРАЗОВЫХ АККАУНТОВ. Матрица фильтров R4.1: запасная локаль, строгая локаль, равенство по связи, равенство по тегу через поставляемый путь json_each, многостраничный обход по курсору, исключение неопубликованного и изоляция чужого хоста — каждое утверждение проверяется по набору результатов, а не по коду статуса, и каждое отклоняет попадание в edge-кеш как доказательство. Сначала она читает собственную политику локалей сайта, заимствует её для строгой проверки и восстанавливает именно эту политику — а также удаляет каждую созданную модель и каждый экземпляр — в finally, где каждый шаг выполняется независимо, так что выброшенное исключение при удалении не может остановить восстановление. Успешное создание, в теле ответа которого не было id, восстанавливается по собственному уникальному slug запуска, а если это невозможно, о нём сообщается как об оставшемся ресурсе. Она никогда не создаёт язык: вторая локаль уже должна существовать. Сбой очистки или восстановления не записывает артефакт. Она также читает журнал аудита сайта, в который записи только добавляются (GET /api/sites/:id/audit, поэтому админской сессии нужно audit:read), до чтения сайта и повторно после очистки, и артефакт называет строки, которые там оставили её собственные применения моделей, удаления моделей и записи настроек. Любая другая административная запись, затрагивающая цель, которую меняет запуск, — настройки сайта, которые восстановление перезаписало бы, или созданную им модель, — проваливает запуск без артефакта, так же как ожидаемая строка, которая отсутствует или так и не зафиксировалась. Маршрут отдаёт только 200 самых свежих сырых строк без курсора, поэтому запуск, чьё первое проаудированное действие выходит за их пределы, отказывает. Каждый сбой называет свой вид (precondition, fixture, matrix, cleanup, audit) и что он означает для сайта. Никогда не направляйте её на продакшн, клиентскую, общую или любую другую не одноразовую инфраструктуру. Она предназначена для явно разрешённого одноразового развёртывания и ни для чего другого — она создаёт, публикует и удаляет реальный контент и на время работы меняет политику локалей сайта. Ни одного такого запуска не было: внешних доказательств пока нет. |
| Аналитика D1 и Workers | Доказательства ёмкости и производительности (R4.2, R4.7, R4.9). Никакой инструмент в репозитории их не измеряет. После обычного импорта и обычного трафика на развёртывании читайте собственные данные Cloudflare: npx wrangler d1 insights <database> и наборы данных GraphQL Analytics API d1StorageAdaptiveGroups / d1AnalyticsAdaptiveGroups для размера D1, числа строк и задержки запросов, а также метрики Workers в панели для процессорного времени и задержки запросов. См. доказательства производительности (docs/PERFORMANCE-EVIDENCE.md, in the repository). |
npm run acceptance -- report --account <id> |
Выводит этот журнал с указанием того, что каждый пройденный этап НЕ доказывает. Этап, который не запускался, так и отмечается, а не выглядит как отсутствующий-и-в-порядке. Ничто здесь не отмечает релизный рубеж — человек читает это и принимает решение. |
npm run prisma:drift |
Проверяет, что схема Prisma по-прежнему описывает то, что строят миграции. Для защитных SQL-таблиц и таблиц, существующих только в сыром D1, есть явный список разрешённых. Любое другое различие — расхождение. |
npm run tenant:schema |
Контрибьютор, только локальные файлы. Перегенерирует backend/tenant-migrations/ — схему, которую получает только что созданная база данных сайта, — из отслеживаемых миграций и lib/tenancy/placement.ts. Записывает один файл в репозитории. |
npm run tenant:schema:check |
Проверяет, что закоммиченная схема арендатора по-прежнему совпадает с миграциями (CI). Миграция, добавляющая таблицу, проваливает эту проверку, пока кто-нибудь её не разместит. |
npm run tenant:upgrade |
Оператор, изменяющая. Применяет закоммиченные дельты арендаторов к каждой АКТИВНОЙ базе данных сайта, которая их ещё не видела, с записью в журнал по каждому сайту через fanOutMigrations. Добавьте --plan, чтобы спросить каждого арендатора, чего ему не хватает, ничего не записывая, --site <id> — для одного сайта, или --declare-dead <owner> --declared-by <who> --evidence <what> — именно эту последовательность из трёх флагов, — чтобы восстановить аренду, которую удерживает упавший исполнитель. То, что арендатор содержит, определяет СОБСТВЕННЫЙ журнал дельт каждого арендатора, а не управляющий журнал: дельта, чей файл был изменён после применения, отклоняется, арендатор с дельтой, которую эта сборка не поставляет, отклоняется, а дельта, записанная до появления дайджестов, отмечается как unverifiable, а не как актуальная. --site с id, который неизвестен, не активен или не имеет идентификатора базы данных, завершается с ненулевым кодом, а не сообщает, что делать нечего. Файл начальной загрузки покрывает НОВЫЕ базы данных; эта команда подтягивает существующие. |
npm run deploy |
Собирает и разворачивает админский Worker и UI, а также парный публичный Worker чтения. --config выбирает админский конфиг wrangler, а --public-config — его публичный аналог; нестандартный --config требует его, и откат к публичному конфигу по умолчанию никогда не выполняется. --admin-only разворачивает без публичного Worker и отмечает запуск как admin-only. --skip-migration-check обходит проверку ожидающих/непроверяемых миграций, которая иначе отказывает. |
npm run health |
Проверки после развёртывания по развёрнутым URL. |
npm run probe |
Анонимная проверка доступности только для чтения — без учётных данных, ничего не трогает. |
npm run verify |
Проверяет одноразовое развёртывание от начала до конца — создаёт сайт, модель и контент. Никогда не направляйте её на продакшн; там используйте --read-only. |
npm run migrate:d1 |
Применяет ожидающие миграции к развёрнутой базе данных (:local — для зеркала). |
npm run migrate:sync |
Перегенерирует зеркало миграций wrangler из Prisma (migrate:check проверяет). |
npm run claim:link |
Выпускает одноразовую ссылку для задания пароля для одного адреса. |
npm run revoke:sessions |
Аварийная мера: завершает сессии всех пользователей развёртывания. |
npm run graduate |
Перегенерирует статический артефакт одного сайта (graduate:all — для всех сайтов). Чем затем владеет владелец и как он это расширяет: Расширение вашего бэкенда. |
npm run graduate:all |
Запускает сборку артефакта graduation для каждого сайта. Ничего не разворачивает. |
npm run test:e2e |
Запускает набор Cypress против dev-сервера, которым он сам управляет (frontend). |
npm run teardown |
Удаляет то, что создал setup. По умолчанию — пробный прогон. |
npm run prisma:generate |
Перегенерирует клиент Prisma после правки схемы. |
npm run contract:embed |
Контрибьютор, только локальные файлы. Перегенерирует backend/src/lib/graduate/site-contract-embedded.ts из backend/src/lib/page-contract/. Генератор graduation собирается в бандл так, чтобы ничего не читать во время выполнения, поэтому эти байты коммитятся как строковые литералы, а не встраиваются шагом сборки, — именно это сохраняет воспроизводимость вендоренного бандла простым npx esbuild. Запускайте после правки чего-либо в page-contract/; тест сравнивает обе версии побайтно, поэтому расхождение приводит к падению, а не к тихой поставке. |
npm run submodules:sync |
Инициализирует или обновляет вендоренные packages/. |
npm run submodules:check |
Завершается ошибкой, когда закрепление или рабочее дерево submodule разошлось. |
npm run lint |
Линтит бэкенд и скрипты установщика. Обязательно в CI. |
npm run typecheck:scripts |
Проверяет типы в scripts/, которые не покрывает никакой другой конфиг. |
npm run verify -- --read-only --public <url> |
Проверка после развёртывания, которая ничего не записывает. Добавьте --model <slug>, чтобы прочитать одну опубликованную модель. |
npm run wire:github -- --repo <owner/name> [--project <dir>] |
Входит в GitHub, создаёт или находит репозиторий, запечатывает три секрета, которые читает workflow сборки, и отправляет проект после graduation. Выводит настройки вебхука, которые нужно вставить. |
npm run exit -- --site <id> --repo <owner/name> --source <cms.db> --dest <dir> --db <target.db> |
Передаёт сайт в виде репозитория, которым владеет владелец: выполняет graduation с закоммиченным lock-файлом и снимком опубликованных данных, коммитит и отправляет, проверяет, что удалённая ветка действительно указывает на этот коммит, запечатывает секреты Cloudflare, запускает развёртывание только из репозитория именно этого sha и сообщает URL репозитория, ветку, коммит, id артефакта, origin Worker и id D1. Ничего не попадает в Cloudflare, пока коммит не станет читаемым удалённо. С --media опубликованные медиа переносятся в R2, принадлежащий владельцу (EXIT_MEDIA_DESTINATION_*). Исходным медиа в R2 нужны EXIT_MEDIA_SOURCE_ACCOUNT_ID, EXIT_MEDIA_SOURCE_BUCKET, EXIT_MEDIA_SOURCE_ACCESS_KEY_ID и EXIT_MEDIA_SOURCE_SECRET_ACCESS_KEY; исходным медиа в Cloudflare Images нужны EXIT_MEDIA_SOURCE_IMAGES_ACCOUNT_ID и EXIT_MEDIA_SOURCE_IMAGES_API_TOKEN. Требуется только та группа, которую используют опубликованные объекты, отсутствующая группа приводит к отказу до любой записи в место назначения, и никакие учётные данные не выводятся и не сохраняются. |
npm run install:web:dry-run, шаг за шагом — Site, Cloudflare, Administrator, Install — с
аккаунтом-заглушкой, которым он отвечает:




Участие в разработке
Заголовок раздела «Участие в разработке»PR приветствуются. Два соглашения, которые стоит знать:
packages/— это submodules: сначала коммитьте и отправляйте изменения там, затем обновляйте указатель.- Маршруты, привязанные к сайту, должны проходить через слой scoped CRUD, который подставляет
site_id. Обход этого слоя — именно так и происходят чтения чужих данных между арендаторами.
Лицензия
Заголовок раздела «Лицензия»MIT. Copyright (c) 2026 — разрешено владельцем продукта 2026-08-27, с
полным текстом в LICENSE.
Сделано в Таиланде 🇹🇭 для глобального edge
Запуск установщика на общем аккаунте
Заголовок раздела «Запуск установщика на общем аккаунте»backend/wrangler.jsonc жёстко задаёт dee-wan-cms-backend, dee-wan-cms-db и
подобные имена, поэтому две инсталляции в одном аккаунте Cloudflare конфликтуют по именам, а
teardown, направленный не на тот аккаунт, удалил бы базу данных другой инсталляции.
Задайте DEEWAN_RESOURCE_PREFIX, чтобы поместить всё, что создаёт запуск, в отдельное пространство имён:
DEEWAN_RESOURCE_PREFIX=test-a1b2 npm run setup # creates test-a1b2-dee-wan-cms-db, …DEEWAN_RESOURCE_PREFIX=test-a1b2 npm run teardown # dry run; only touches test-a1b2-*Префикс должен состоять из строчных латинских букв, цифр и дефисов, не длиннее 24 символов.
Когда он задан, teardown пропускает и сообщает о любом ресурсе, имя которого его
не содержит, что бы ни говорил файл конфигурации, — поэтому неверно направленный конфиг не может добраться до
продакшн-базы данных. Когда он не задан, поведение ровно такое же, как раньше.
Задавайте его для свежей установки, а не для существующей. wrangler находит базу данных D1
по имени в backend/wrangler.jsonc, поэтому префикс
работает только после того, как setup создал ресурсы и записал имена с префиксом
в этот файл. Если включить его для инсталляции, конфиг которой всё ещё содержит
имена без префикса, каждая команда будет завершаться ошибкой
Couldn't find a D1 DB with the name or binding '<prefix>-…'. Чтобы перевести
существующую инсталляцию под префикс, перезапустите setup; не задавайте просто переменную.
Проверено на данный момент: префикс доходит до каждого скрипта через loadConfig,
идемпотентен, отклоняется при неверном формате, а teardown отказывается трогать ресурсы, которыми он не
владеет (backend/test/teardown.test.ts). Полный цикл setup → Cloudflare → конфиг
не проверялся — для этого нужен аккаунт с D1 и R2.
Smoke-тест развёртывания
Заголовок раздела «Smoke-тест развёртывания»Все остальные наборы тестов работают на локальном стенде: Cypress подменяет API админки и
управляет dev-сервером Vite, тесты бэкенда работают на локальном SQLite. Ни один из них не видит
развёрнутую систему — именно так /content/<id> стал отвечать 404 от API,
пока все 84 e2e-теста оставались зелёными.
DEEWAN_SMOKE_PAGES=https://admin.example.com \DEEWAN_SMOKE_HOST=https://cms.example.com \npm run smoke --workspace=dee-wan-cms-backendОн проверяет две вещи, которые ошибка в подключении ломает первыми, и не требует сессии:
- origin Pages отдаёт SPA для динамических маршрутов, а не только для
/: глубокая ссылка — первое, что ломает неправильно собранный_routes.json, и последнее, что кто-либо проверяет, потому что приложение всегда работает, если переходить в него с главной; - на инсталляциях с включённым Access
/api/*на публичном хосте находится за Access: если этот маршрут или приложение Access удалены, периметра больше нет, и ничто другое в этом репозитории это не проверяет. На инсталляциях по умолчанию (Access выключен) API защищён сессионной аутентификацией, и эта проверка неприменима.
Включается намеренно вручную: если origin не указаны, он пропускается, а не падает, поэтому не может превратиться в красный набор, который люди привыкают игнорировать. Запускайте его после каждого развёртывания.