Перейти к содержимому
Pre-MVP. Кодовая база 1.0 не является выпущенным продуктом — см. границы продукта 1.0.

Dee Wan CMS

Dee Wan CMS — буквенный знак Dee Wan на баннере с тайским орнаментом

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 schema
frontend/ SvelteKit admin
packages/ schema-parser, prisma-guard, prisma-rbac, eav-to-prisma (submodules)
plugins/ remote plugins built against protocol v1 (Scheduling); not workspaces
scripts/ setup, deploy, migrate, health, teardown

Всё, что ниже этого раздела, создаёт реальные ресурсы Cloudflare. Этот раздел — нет: он запускает весь продукт с локальным файлом D1 на вашей машине, без аккаунта, без API-токена и без DNS.

Окно терминала
node scripts/install-dependencies.mjs # locked tree, scripts off, audited, then rebuilt
npm run local:migrate # apply the schema to the local D1 mirror
npm 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 sites
npm 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, отредактируйте одну из созданных статей и опубликуйте её.

Панель админки на заполненном локальном сайте: итоги по контенту, что опубликовано, последние изменения

Content: три созданных черновика Article в списке, с фильтрами по стадии и доставке

Созданная статья, открытая в редакторе контента, рядом с панелью Publishing

Чтобы прочитать опубликованное так, как это сделал бы веб-сайт:

Окно терминала
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.git
cd dee-wan-cms
node scripts/install-dependencies.mjs
npm run setup # provisions D1 and media storage; offers DNS and optional Access
npm run deploy # builds and deploys the admin worker and admin UI
npm 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, route
npm run deploy

deploy отказывается работать, когда этого файла нет, потому что развёртывание без публичного 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 prompt
export CLOUDFLARE_ACCOUNT_ID=# optional — skips the account picker

npm 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 generation
npx wrangler secret put CF_IMAGES_API_TOKEN # only if STORAGE_PROVIDER=cf-images
npx wrangler secret put CF_PURGE_API_TOKEN # only if caching public reads; see PUBLIC_CACHE_MAX_AGE

DeepInfra — единственный поддерживаемый провайдер инференса. Чат, изображения и движок структурированной генерации работают через него на одном ключе. UI админки никогда не обращается к провайдеру напрямую — всё идёт через /api/ai/* на Worker, который хранит ключ и применяет почасовую квоту для каждого сайта.

Свежей установке ничего из этого не нужно — setup применяет схему. Это для последующих случаев, когда схема меняется:

Окно терминала
npm run migrate:d1 # apply pending migrations to the deployed database
npm run migrate:d1:local # …to the local mirror
npm run migrate:sync # regenerate backend/migrations-wrangler/ from Prisma
npm 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/) в новый сайт:

Мастер импорта, Source: файл экспорта Strapi, копия сайта Dee Wan и два отключённых источника

Мастер импорта, Review: количества, найденные в архиве, и по одной строке на каждый исходный тип, который будет создан

Мастер импорта, Check: вердикт «Can import» над списком находок

Мастер импорта, Run: Start importing, Pause и Cancel до начала запуска

Мастер импорта, Done: количества импортированных, пропущенных, неудавшихся и не запущенных для каждого исходного типа

Импорту архивов нужен 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 config
npm run local:admin # admin UI on http://localhost:5173
npm run local:public # public read Worker on http://127.0.0.1:8788

local: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 checks

health выполняет одни и те же проверки с 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 database
cd frontend && npm test # Vitest
cd frontend && npx svelte-check # type check
npm 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 — с аккаунтом-заглушкой, которым он отвечает:

Веб-установщик, шаг «Сайт»: инсталляция будет работать на собственном домене

Веб-установщик, шаг «Cloudflare»: подключено на этот сеанс, тариф D1 выбран

Веб-установщик, шаг «Установка»: проверка запланированных адресов и всего, что будет создано

Веб-установщик, завершение: все шаги выполнены, кнопка «Создать администратора»

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.

Все остальные наборы тестов работают на локальном стенде: 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 не указаны, он пропускается, а не падает, поэтому не может превратиться в красный набор, который люди привыкают игнорировать. Запускайте его после каждого развёртывания.