@coraltravelcenter/b2c-landing-builder 2.10.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,42 +22,27 @@ b2c-landing-vite check
22
22
  b2c-landing-vite dev
23
23
  b2c-landing-vite build
24
24
  b2c-landing-vite deploy
25
- b2c-landing-vite deploy --dry-run
26
- b2c-landing-vite deploy --update
27
- b2c-landing-vite deploy --relink
28
- b2c-landing-vite deploy --publish
29
25
  b2c-landing-vite deploy --section hero
30
- b2c-landing-vite deploy --remote
31
- b2c-landing-vite deploy --force
32
- b2c-landing-vite deploy --assets-only
33
- b2c-landing-vite deploy --assets-only --prune-assets
26
+ b2c-landing-vite deploy assets
34
27
  b2c-landing-vite block:add hero
35
28
  b2c-landing-vite block:rename hero main-hero
36
29
  b2c-landing-vite update
37
30
  ```
38
31
 
39
32
  - `check` проверяет конфигурацию, `order.json` и контракт JS-блоков.
40
- - `dev` запускает Vite и userscript для Tampermonkey.
33
+ - `dev` запускает Vite и отдельный userscript с названием проекта для
34
+ Tampermonkey.
41
35
  - `build` создаёт CMS-файлы в `@CMS/` и переписывает пути ассетов на CDN.
42
- - `deploy --assets-only` выполняет свежую сборку и загружает только изменённые
43
- deploy-ассеты. При первом запуске builder запрашивает backoffice-логин и
44
- скрытый пароль через ADFS. На диск сохраняется только токен (не пароль), токен
45
- автоматически используется до истечения. Для CI можно передать
36
+ - `deploy` собирает проект, проверяет ассеты на CDN и разворачивает CMS-виджеты.
37
+ - `deploy --section <key>` разворачивает только выбранную CMS-секцию, не изменяя
38
+ и не удаляя соседние секции.
39
+ - `deploy assets` собирает проект и синхронизирует ассеты с B2C CDN.
40
+ - При первом deploy builder запрашивает backoffice-логин и скрытый пароль через
41
+ ADFS. На диск сохраняется только токен. Для CI можно передать
46
42
  `B2C_BACKOFFICE_TOKEN`.
47
43
  - Backoffice-запросы ограничены 30 секундами. Безопасные GET-запросы повторяются
48
44
  до двух раз при сетевом сбое, timeout, HTTP 429, 502, 503 или 504. Изменяющие
49
45
  CMS запросы автоматически не повторяются, чтобы не создать дубликаты.
50
- - `deploy` при первом запуске предлагает выбрать application, layout и HTML-зону,
51
- создаёт CMS-страницу и размещает блоки в порядке `order.json`. При повторном
52
- запуске для опубликованной страницы создаётся checkout-версия.
53
- - `deploy --dry-run` выполняет свежую локальную сборку, читает состояние CMS и
54
- показывает точный план загрузки ассетов, создания версии и изменений виджетов.
55
- Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
56
- - `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
57
- - `deploy --update` обновляет сохранённую версию страницы без создания новой.
58
- - `deploy --section <key>` меняет только блок с ключом из `order.json`. Builder
59
- берёт за основу актуальную remote-страницу, поэтому локальные копии соседних
60
- секций не попадают в payload и не могут затереть изменения из CMS.
61
46
  - После успешного deploy builder сохраняет снимок секций в `.b2c/deploy/` как
62
47
  достоверный `base`. Следующий deploy структурно сравнивает секции `local`,
63
48
  актуальный `remote` и `base`. Изменения разных секций объединяются; изменение
@@ -65,28 +50,13 @@ b2c-landing-vite update
65
50
  - Если `base` ещё отсутствует, отличие local от remote считается потенциальным
66
51
  конфликтом. Обычный deploy не выполняет silent overwrite и предлагает явно
67
52
  сохранить CMS-версию, применить local или отменить операцию.
68
- - `deploy --remote` не перезаписывает конфликтующие секции локальной версией.
69
- `deploy --force` осознанно выбирает local. Для section deploy обе стратегии
70
- ограничены выбранной секцией: `deploy --section hero --remote` или
71
- `deploy --section hero --force`; соседние remote-секции сохраняются.
72
- - `deploy --relink` восстанавливает или меняет привязку проекта к существующей
73
- CMS-странице. Builder предлагает application, поиск страницы по названию или
74
- URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
75
- опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
76
- - `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
77
- план, но не сохраняет новую привязку и не изменяет CMS.
78
- - `deploy --publish` после успешной синхронизации виджетов публикует выбранную
79
- версию. Публикация блокируется, если в Backoffice не заполнены title, URL,
80
- SEO title или SEO description; builder не придумывает метаданные сам.
81
- - `deploy --assets-only --prune-assets` удаляет из CMS только отсутствующие в
82
- текущей сборке файлы, которые этот builder сам загрузил и записал как
83
- управляемые в deploy-state проекта. Уже существовавшие в CMS файлы не
84
- считаются управляемыми и не удаляются. Команда
85
- намеренно отделена от обычного deploy и никогда не удаляет неизвестные файлы.
86
- Сначала рекомендуется запустить её с `--dry-run`.
87
- - Перед любым deploy сохранённые pageContentId и layoutAreaId сверяются с CMS.
88
- Устаревшая привязка останавливает операцию до загрузки файлов и предлагает
89
- `deploy --relink`.
53
+ - Стабильные `pageId`, `layoutAreaId` и позиция сохраняются отдельно в
54
+ `.b2c/deploy/<site>--<folder>.binding.json`. Этот файл можно хранить в Git,
55
+ чтобы команда находила ту же CMS-страницу после нового клонирования проекта.
56
+ - Перед deploy builder получает список версий сохранённой страницы. Published
57
+ выбирается по умолчанию; из Published или Unpublished создаётся checkout,
58
+ существующий Checkout обновляется напрямую. Потерянную привязку можно
59
+ восстановить интерактивным выбором существующей страницы.
90
60
  - Одновременные deploy одного проекта на компьютере блокируются lock-файлом в
91
61
  `.b2c/deploy/`; аварийно оставленный lock автоматически признаётся устаревшим.
92
62
  - Непосредственно перед первой записью CMS-состояние читается повторно. Если оно
@@ -95,14 +65,15 @@ b2c-landing-vite update
95
65
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
96
66
  - `update` проверяет npm registry и показывает команду ручного обновления.
97
67
 
98
- После успешного выполнения рабочих команд builder коротко проверяет npm
99
- registry. Если доступна новая стабильная версия, терминал показывает текущую и
100
- новую версии и команду ручного обновления. Ошибки сети не влияют на работу CLI.
101
- Builder никогда не обновляется автоматически:
68
+ После успешного выполнения рабочих команд builder коротко и независимо
69
+ проверяет в npm registry версии самого builder и шаблона проекта. Для проекта
70
+ без `template.version` шаблон помечается как `legacy`. Ошибки сети не влияют на
71
+ работу CLI; ничего не обновляется автоматически:
102
72
 
103
73
  ```bash
104
74
  b2c-landing-vite update
105
75
  # Update manually: npm install --global @coraltravelcenter/b2c-landing-builder@latest
76
+ # Update project template: npx @coraltravelcenter/create-b2c-landing-vite@latest migrate
106
77
  ```
107
78
 
108
79
  ## Создание проекта
@@ -126,6 +97,9 @@ export default {
126
97
  builder: {
127
98
  minVersion: "2.10.0",
128
99
  },
100
+ template: {
101
+ version: "2.13.0",
102
+ },
129
103
  project: {
130
104
  name: "uae-promo",
131
105
  },
@@ -140,6 +114,10 @@ export default {
140
114
  };
141
115
  ```
142
116
 
117
+ `template.version` записывается генератором и показывает, какой версией шаблона
118
+ создан проект. Для старых проектов поле необязательно; его отсутствие позволяет
119
+ builder распознать legacy-шаблон и предложить миграцию в будущих версиях.
120
+
143
121
  Имя проекта должно соответствовать `^[a-z0-9][a-z0-9-]*$`.
144
122
 
145
123
  Поддерживаются:
@@ -167,6 +145,15 @@ Monkey URL, контейнер и CDN prefix не задаются вручну
167
145
  - контейнер: `#monkey-app`;
168
146
  - CDN prefix: `landing-pages/<project.name>`.
169
147
 
148
+ Dev-сервер начинает с `127.0.0.1:5173` и автоматически выбирает следующий
149
+ свободный порт, поэтому несколько проектов можно запускать одновременно. Если
150
+ задан `B2C_PORT`, порт считается обязательным и занятое значение завершает
151
+ команду ошибкой.
152
+
153
+ Userscript ограничен доменом выбранного бренда и дополнительно проверяет hostname
154
+ перед монтированием. Namespace также содержит бренд, поэтому Coral и Sunmar не
155
+ конфликтуют даже при одинаковом названии проекта.
156
+
170
157
  Для Coral и Sunmar настроены отдельные production CDN. Команда `build`
171
158
  автоматически выбирает CDN по `site.preset`.
172
159
 
@@ -242,11 +229,41 @@ build; дополнительные контейнеры builder не созда
242
229
  }
243
230
  ```
244
231
 
245
- В dev пути направляются на `http://127.0.0.1:5173`. При production build они
246
- преобразуются в `<assetsBase>/landing-pages/<project.name>/...`. Builder
232
+ В dev пути направляются на фактический origin текущего Vite-сервера. При
233
+ production build они
234
+ преобразуются в `<assetsBase>/landing-pages/<folder>/...`. Builder
247
235
  переписывает только изображения `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif` и
248
236
  `svg`; остальные root-relative ресурсы не изменяются.
249
237
 
238
+ ### Deploy assets
239
+
240
+ ```bash
241
+ b2c-landing-vite deploy assets
242
+ ```
243
+
244
+ Команда выполняет свежую сборку, сравнивает build manifest с deploy-state и CDN,
245
+ а затем показывает план загрузки. Перед отправкой файлов требуется явное
246
+ подтверждение; по умолчанию выбран отказ.
247
+
248
+ Вложенная структура `public/` сохраняется на CDN. Например,
249
+ `public/hotels/gallery/room.webp` загружается как
250
+ `landing-pages/<folder>/hotels/gallery/room.webp`.
251
+
252
+ Существующие CDN-файлы не перезаписываются и не удаляются. Если содержимое
253
+ изменилось, файл нужно переименовать, чтобы опубликовать его по новому URL.
254
+ Deploy-state обновляется атомарно только после успешной загрузки всех файлов.
255
+ В итоговом блоке команда также выводит корневой URL `CDN folder`, например
256
+ `https://b2ccdn.coral.ru/content/landing-pages/uae-promo/`.
257
+
258
+ Обычный `deploy` не загружает ассеты. Он проверяет наличие всех файлов build
259
+ manifest на CDN и останавливается до изменения CMS, если сначала требуется
260
+ выполнить `deploy assets`.
261
+
262
+ Перед изменением CMS команда показывает план добавления, обновления, удаления и
263
+ перестановки управляемых виджетов. Подтверждение и последующее предложение
264
+ публикации по умолчанию имеют значение `No`. После deploy выводятся Preview и
265
+ прямая ссылка на созданную checkout-версию в BackOffice.
266
+
250
267
  ## Совместимость
251
268
 
252
269
  Builder проверяет `schemaVersion` до запуска команд. Несовместимая версия схемы
@@ -9,7 +9,9 @@ runCli(args).then(async () => {
9
9
  if (!command || ["help", "--help", "-h", "update"].includes(command)) return;
10
10
 
11
11
  const {printUpdateNotice} = await import("../src/update/check-update.mjs");
12
- await printUpdateNotice();
12
+ const {loadConfig} = await import("../src/config/load-config.mjs");
13
+ const config = await loadConfig().catch(() => null);
14
+ await printUpdateNotice({templateVersion: config?.template?.version});
13
15
  }).catch((error) => {
14
16
  console.error(`[b2c-landing-vite] ${error.message}`);
15
17
  process.exitCode = 1;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@coraltravelcenter/b2c-landing-builder",
9
- "version": "2.10.1",
9
+ "version": "2.12.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@babel/parser": "7.28.6",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "description": "CLI and build toolkit for B2C landing projects",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.mjs CHANGED
@@ -8,16 +8,8 @@ Commands:
8
8
  dev Start the Vite development server
9
9
  build Build CMS-ready HTML files
10
10
  deploy Build, sync assets, and deploy CMS widgets
11
- deploy --dry-run Show the exact deploy plan without changing CMS
12
- deploy --update Update the current CMS version in place
13
- deploy --relink Attach the project to an existing CMS page
14
- deploy --publish Publish after a successful deploy
15
- deploy --section <key> Deploy one section, preserving all other remote sections
16
- deploy --force Explicitly overwrite remote changes in the current scope
17
- deploy --remote Keep remote content when changes conflict
18
- deploy --assets-only --prune-assets
19
- Remove obsolete project assets recorded in deploy-state
20
- deploy --assets-only Build and sync assets with the B2C CDN
11
+ deploy --section <key> Deploy one CMS section
12
+ deploy assets Build and sync assets with the B2C CDN
21
13
  check Validate project configuration
22
14
  block:add <key> Create a block
23
15
  block:rename <old> <new> Rename a block
@@ -85,56 +77,38 @@ export async function runCli(args) {
85
77
  return;
86
78
  }
87
79
  if (command === "deploy") {
88
- const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink", "--publish", "--prune-assets", "--force", "--remote"]);
89
- const flags = new Set(rest.filter((argument) => allowed.has(argument)));
90
- const sectionIndex = rest.indexOf("--section");
91
- const section = sectionIndex >= 0 ? rest[sectionIndex + 1] : null;
92
- const consumed = new Set([...flags, ...(sectionIndex >= 0 ? ["--section", section] : [])]);
93
- if (rest.some((argument) => !consumed.has(argument)) || (sectionIndex >= 0 && !section) ||
94
- (flags.has("--force") && flags.has("--remote")) ||
95
- (flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink") || flags.has("--publish"))) ||
96
- (flags.has("--prune-assets") && !flags.has("--assets-only"))) {
97
- throw new Error(
98
- "Usage: b2c-landing-vite deploy [--section <key>] [--force | --remote] [--update] [--relink] [--publish] [--dry-run]"
99
- );
80
+ const assetsOnly = rest.length === 1 && rest[0] === "assets";
81
+ const sectionOnly = rest.length === 2 && rest[0] === "--section" && Boolean(rest[1]);
82
+ if (rest.length && !assetsOnly && !sectionOnly) {
83
+ throw new Error("Usage: b2c-landing-vite deploy [assets | --section <key>]");
100
84
  }
101
85
  const config = await loadConfig();
102
86
  const project = validateProject(config);
103
87
  const preset = resolvePreset(config.site.preset, {requireAssets: true});
104
88
  const {withDeploymentLock} = await import("../deploy/lock.mjs");
105
89
  return withDeploymentLock({site: preset.id, folder: project.folder}, async () => {
106
- await runCli(["build"]);
107
- const {getBackofficeToken} = await import("../deploy/auth.mjs");
108
- const token = await getBackofficeToken({brand: preset.id});
109
- const {deployAssetsOnly, deployProject, planDeployment} = await import("../deploy/deploy.mjs");
110
- if (flags.has("--dry-run")) {
111
- return planDeployment({
90
+ if (assetsOnly) {
91
+ const {deployAssetsCommand} = await import("../deploy/assets-command.mjs");
92
+ return deployAssetsCommand({
112
93
  config,
94
+ project,
113
95
  preset,
114
- token,
115
- update: flags.has("--update"),
116
- assetsOnly: flags.has("--assets-only"),
117
- relink: flags.has("--relink"),
118
- publish: flags.has("--publish"),
119
- pruneAssets: flags.has("--prune-assets"),
96
+ build: () => runCli(["build"]),
97
+ getToken: async () => {
98
+ const {getBackofficeToken} = await import("../deploy/auth.mjs");
99
+ return getBackofficeToken({brand: preset.id});
100
+ },
120
101
  });
121
102
  }
122
- if (flags.has("--assets-only")) return deployAssetsOnly({
123
- config,
124
- preset,
125
- token,
126
- pruneAssets: flags.has("--prune-assets"),
127
- });
103
+ await runCli(["build"]);
104
+ const {getBackofficeToken} = await import("../deploy/auth.mjs");
105
+ const token = await getBackofficeToken({brand: preset.id});
106
+ const {deployProject} = await import("../deploy/deploy.mjs");
128
107
  return deployProject({
129
108
  config,
130
109
  preset,
131
110
  token,
132
- update: flags.has("--update"),
133
- relink: flags.has("--relink"),
134
- publish: flags.has("--publish"),
135
- pruneAssets: flags.has("--prune-assets"),
136
- section,
137
- strategy: flags.has("--force") ? "force" : flags.has("--remote") ? "remote" : "ask",
111
+ section: sectionOnly ? rest[1] : null,
138
112
  });
139
113
  });
140
114
  }
@@ -33,6 +33,9 @@ export function validateConfig(config) {
33
33
  );
34
34
  }
35
35
  }
36
+ if (config.template !== undefined && !VERSION_RE.test(config.template?.version || "")) {
37
+ throw new Error("template.version must be a semantic version such as 2.12.2");
38
+ }
36
39
  if (!PROJECT_NAME_RE.test(config.project?.name || "")) {
37
40
  throw new Error("project.name must match ^[a-z0-9][a-z0-9-]*$");
38
41
  }
@@ -0,0 +1,124 @@
1
+ import {planAssetChanges, uploadChangedAssets} from "./assets.mjs";
2
+ import {BackofficeClient} from "./backoffice-client.mjs";
3
+ import {readBuildManifest} from "./manifest.mjs";
4
+ import {readDeploymentState, writeDeploymentState} from "./state.mjs";
5
+
6
+ function formatFiles(prefix, files) {
7
+ return files.map((file) => `${prefix} ${file}`).join("\n");
8
+ }
9
+
10
+ async function terminalUi() {
11
+ const {cancel, confirm, intro, isCancel, log, note, outro, spinner} = await import("@clack/prompts");
12
+ let progress;
13
+ return {
14
+ start() {
15
+ intro("Deploy assets");
16
+ },
17
+ built() {
18
+ log.success("Project built");
19
+ },
20
+ checking() {
21
+ log.step("Checking CDN state");
22
+ },
23
+ plan({target, upload, skip, unchanged}) {
24
+ note(target, "CDN folder");
25
+ const groups = [];
26
+ if (upload.length) groups.push(`Upload · ${upload.length}\n${formatFiles("+", upload)}`);
27
+ if (skip.length) {
28
+ groups.push(
29
+ `Skipped · ${skip.length}\n${formatFiles("!", skip)}\n` +
30
+ "Existing CDN files are immutable. Rename them to publish new versions."
31
+ );
32
+ }
33
+ groups.push(`Unchanged · ${unchanged}`);
34
+ note(groups.join("\n\n"), "Asset plan");
35
+ },
36
+ async confirm(count) {
37
+ const answer = await confirm({message: `Upload ${count} file${count === 1 ? "" : "s"}?`, initialValue: false});
38
+ return isCancel(answer) ? false : answer;
39
+ },
40
+ uploading(count) {
41
+ progress = spinner();
42
+ progress.start(`Uploading ${count} file${count === 1 ? "" : "s"}`);
43
+ },
44
+ uploaded(count) {
45
+ progress?.stop(`Uploaded ${count} file${count === 1 ? "" : "s"}`);
46
+ },
47
+ failed(error) {
48
+ progress?.stop("Upload failed");
49
+ const uploaded = error.uploaded?.length ? error.uploaded.join(", ") : "none";
50
+ const failed = error.failed?.length ? error.failed.join(", ") : error.message;
51
+ log.error(`Uploaded before failure: ${uploaded}\nFailed: ${failed}\nDeploy state was not updated.`);
52
+ },
53
+ cancelled() {
54
+ cancel("Deploy cancelled");
55
+ },
56
+ complete({uploaded, skipped, unchanged}) {
57
+ outro(uploaded || skipped
58
+ ? `Complete · uploaded ${uploaded} · skipped ${skipped} · unchanged ${unchanged}`
59
+ : "No asset changes");
60
+ },
61
+ };
62
+ }
63
+
64
+ export async function deployAssetsCommand({
65
+ config,
66
+ project,
67
+ preset,
68
+ root = process.cwd(),
69
+ build,
70
+ getToken,
71
+ client,
72
+ ui,
73
+ }) {
74
+ ui ||= await terminalUi();
75
+ ui.start();
76
+ await build();
77
+ ui.built();
78
+
79
+ const manifest = readBuildManifest(root);
80
+ if (manifest.project !== config.project.name || manifest.site !== preset.id || manifest.folder !== project.folder) {
81
+ throw new Error("Build manifest does not match the current project. Run build again.");
82
+ }
83
+ const token = client ? null : await getToken();
84
+ const api = client || new BackofficeClient({brand: preset.id, token});
85
+ const state = readDeploymentState(preset.id, manifest.folder, root);
86
+ const remoteRoot = new URL(preset.assetsBase).pathname;
87
+ const target = `${preset.assetsBase}/${manifest.assetsPrefix}/`;
88
+
89
+ ui.checking();
90
+ const plan = await planAssetChanges({
91
+ manifest,
92
+ state,
93
+ remoteRoot,
94
+ listFiles: api.listFiles.bind(api),
95
+ });
96
+ const upload = plan.upload.map((asset) => asset.path);
97
+ const skip = plan.skip.map((asset) => asset.path);
98
+ ui.plan({brand: preset.id, target, upload, skip, unchanged: plan.unchanged});
99
+
100
+ if (upload.length && !await ui.confirm(upload.length)) {
101
+ ui.cancelled();
102
+ return {cancelled: true, plan, target};
103
+ }
104
+
105
+ if (upload.length) ui.uploading(upload.length);
106
+ let result;
107
+ try {
108
+ result = await uploadChangedAssets({
109
+ manifest,
110
+ state,
111
+ root,
112
+ remoteRoot,
113
+ uploadFile: api.uploadFile.bind(api),
114
+ plan,
115
+ });
116
+ } catch (error) {
117
+ ui.failed(error);
118
+ throw error;
119
+ }
120
+ writeDeploymentState(preset.id, manifest.folder, result.state, root);
121
+ if (upload.length) ui.uploaded(result.uploaded.length);
122
+ ui.complete({uploaded: result.uploaded.length, skipped: result.skipped.length, unchanged: result.unchanged});
123
+ return {...result, cancelled: false, target};
124
+ }
@@ -50,6 +50,23 @@ export async function planAssetChanges({manifest, state, listFiles, remoteRoot =
50
50
  };
51
51
  }
52
52
 
53
+ export async function findMissingRemoteAssets({manifest, listFiles, remoteRoot = "/content"}) {
54
+ const directories = new Map();
55
+ const namesInDirectory = (directory) => {
56
+ if (!directories.has(directory)) {
57
+ directories.set(directory, Promise.resolve(listFiles(directory)).then(filesFromListing));
58
+ }
59
+ return directories.get(directory);
60
+ };
61
+ const missing = [];
62
+ await Promise.all(manifest.assets.map(async (asset) => {
63
+ const remotePath = path.posix.join("/", remoteRoot, manifest.assetsPrefix, asset.path);
64
+ const names = await namesInDirectory(path.posix.dirname(remotePath));
65
+ if (!names.has(path.posix.basename(remotePath))) missing.push(asset.path);
66
+ }));
67
+ return missing.sort();
68
+ }
69
+
53
70
  export async function uploadChangedAssets({
54
71
  manifest,
55
72
  state,
@@ -60,10 +77,12 @@ export async function uploadChangedAssets({
60
77
  concurrency = 5,
61
78
  prune = false,
62
79
  deleteFile,
80
+ plan: preparedPlan,
63
81
  }) {
64
- const plan = await planAssetChanges({manifest, state, listFiles, remoteRoot, prune});
82
+ const plan = preparedPlan || await planAssetChanges({manifest, state, listFiles, remoteRoot, prune});
65
83
  const assets = {...state.assets};
66
84
  const uploaded = [];
85
+ const failures = [];
67
86
  const skipped = plan.skip.map((asset) => asset.path);
68
87
  for (const asset of plan.skip) assets[asset.path] = {...asset, immutable: true, managed: false};
69
88
  let cursor = 0;
@@ -73,14 +92,29 @@ export async function uploadChangedAssets({
73
92
  const asset = plan.upload[cursor++];
74
93
  const localPath = path.join(root, ...(manifest.assetsDirectory || "public").split("/"), ...asset.path.split("/"));
75
94
  const remotePath = path.posix.join("/", remoteRoot, manifest.assetsPrefix, asset.path);
76
- const result = await uploadFile(remotePath, localPath);
77
- assets[asset.path] = {...asset, url: result?.files?.[0]?.url, managed: true};
78
- uploaded.push(asset.path);
95
+ try {
96
+ const result = await uploadFile(remotePath, localPath);
97
+ assets[asset.path] = {...asset, url: result?.files?.[0]?.url, managed: true};
98
+ uploaded.push(asset.path);
99
+ } catch (error) {
100
+ failures.push({path: asset.path, error});
101
+ }
79
102
  }
80
103
  }
81
104
 
82
105
  const workerCount = Math.min(Math.max(1, concurrency), plan.upload.length || 1);
83
106
  await Promise.all(Array.from({length: workerCount}, () => worker()));
107
+ if (failures.length) {
108
+ const reasons = [...new Set(failures.map((failure) => failure.error?.message).filter(Boolean))];
109
+ const error = new AggregateError(
110
+ failures.map((failure) => failure.error),
111
+ `Failed to upload ${failures.length} asset${failures.length === 1 ? "" : "s"}` +
112
+ (reasons.length ? `: ${reasons.join("; ")}` : "")
113
+ );
114
+ error.uploaded = uploaded.sort();
115
+ error.failed = failures.map((failure) => failure.path).sort();
116
+ throw error;
117
+ }
84
118
  const removed = [];
85
119
  if (prune && plan.remove.length) {
86
120
  if (!deleteFile) throw new Error("CMS asset deletion is unavailable");
@@ -89,8 +89,17 @@ async function responseJson(response, label) {
89
89
 
90
90
  export async function authenticateAdfs({brand, login, password, fetchImpl = globalThis.fetch}) {
91
91
  const apiBase = `https://b2capi.${brand}.ru/BackOffice/Adfs`;
92
+ let redirectResponse;
93
+ try {
94
+ redirectResponse = await fetchImpl(`${apiBase}/GetRedirectToAdfsUrl`);
95
+ } catch (error) {
96
+ throw new Error(
97
+ `Backoffice is unavailable. Connect to the office network or VPN. ${error.message}`,
98
+ {cause: error}
99
+ );
100
+ }
92
101
  const redirectPayload = await responseJson(
93
- await fetchImpl(`${apiBase}/GetRedirectToAdfsUrl`),
102
+ redirectResponse,
94
103
  "Getting ADFS login URL"
95
104
  );
96
105
  const loginUrl = redirectPayload?.result?.url;
@@ -139,11 +148,12 @@ export async function getBackofficeToken({
139
148
  brand,
140
149
  env = process.env,
141
150
  fetchImpl = globalThis.fetch,
142
- cachePath = defaultCachePath(),
151
+ cachePath,
143
152
  credentialsProvider = promptCredentials,
144
153
  now = Date.now(),
145
154
  } = {}) {
146
155
  if (env.B2C_BACKOFFICE_TOKEN) return env.B2C_BACKOFFICE_TOKEN;
156
+ cachePath ||= env.B2C_AUTH_CACHE || defaultCachePath();
147
157
  const cache = readCache(cachePath);
148
158
  const existing = cachedToken(cache, brand, now);
149
159
  if (existing) return existing;
@@ -1,7 +1,12 @@
1
- import {planAssetChanges, uploadChangedAssets} from "./assets.mjs";
1
+ import {findMissingRemoteAssets, planAssetChanges, uploadChangedAssets} from "./assets.mjs";
2
2
  import {BackofficeClient} from "./backoffice-client.mjs";
3
3
  import {readBuildManifest} from "./manifest.mjs";
4
- import {readDeploymentState, writeDeploymentState} from "./state.mjs";
4
+ import {
5
+ readDeploymentBinding,
6
+ readDeploymentState,
7
+ writeDeploymentBinding,
8
+ writeDeploymentState,
9
+ } from "./state.mjs";
5
10
  import {resolveSections, summarizeChanges} from "./reconcile.mjs";
6
11
  import {
7
12
  createPagePlacement,
@@ -10,8 +15,10 @@ import {
10
15
  publicationIssues,
11
16
  publishPage,
12
17
  selectExistingPagePlacement,
18
+ selectBoundPagePlacement,
13
19
  selectNewPagePlacement,
14
20
  validateSavedPlacement,
21
+ versionLabel,
15
22
  } from "./page.mjs";
16
23
  import {localSections, planWidgetChanges, remoteSections, syncWidgets} from "./widgets.mjs";
17
24
 
@@ -23,6 +30,61 @@ function logList(log, label, items) {
23
30
  log(`[dry-run] ${label}: ${items.length ? items.join(", ") : "none"}`);
24
31
  }
25
32
 
33
+ async function terminalDeployUi() {
34
+ const {confirm, intro, isCancel, log, note, outro, select, text} = await import("@clack/prompts");
35
+ const answer = async (promise) => {
36
+ const value = await promise;
37
+ if (isCancel(value)) throw new Error("Deploy cancelled");
38
+ return value;
39
+ };
40
+ const list = (prefix, items) => items.map((item) => `${prefix} ${item}`).join("\n");
41
+ return {
42
+ start: () => intro("Deploy"),
43
+ text: (options) => answer(text(options)),
44
+ select: (options) => answer(select(options)),
45
+ plan({page, version, add, update, remove, reorder, section}) {
46
+ note(`${page}\n${version}${section ? `\nSection: ${section}` : ""}`, "CMS target");
47
+ const groups = [];
48
+ if (add.length) groups.push(`Add · ${add.length}\n${list("+", add)}`);
49
+ if (update.length) groups.push(`Update · ${update.length}\n${list("~", update)}`);
50
+ if (remove.length) groups.push(`Remove · ${remove.length}\n${list("-", remove)}`);
51
+ if (reorder.length) groups.push(`Reorder · ${reorder.length}\n${list("↕", reorder)}`);
52
+ if (!groups.length) groups.push("No widget changes");
53
+ note(groups.join("\n\n"), "Widget plan");
54
+ },
55
+ confirmApply: () => answer(confirm({message: "Apply these changes?", initialValue: false})),
56
+ confirmPublish: () => answer(confirm({message: "Publish this page version?", initialValue: false})),
57
+ info: (message) => log.info(message),
58
+ complete({widgets, preview, backoffice, live}) {
59
+ const lines = [
60
+ `Added ${widgets.added} · updated ${widgets.updated} · removed ${widgets.removed} · reordered ${widgets.reordered}`,
61
+ preview ? `\nPreview\n${preview}` : null,
62
+ backoffice ? `\nBackOffice\n${backoffice}` : null,
63
+ live ? `\nLive\n${live}` : null,
64
+ ].filter(Boolean);
65
+ outro(lines.join("\n"));
66
+ },
67
+ };
68
+ }
69
+
70
+ function scopedWidgetPlan(plan, section) {
71
+ if (!section) return plan;
72
+ const matches = (title) => title.endsWith(` | ${section}`);
73
+ return {
74
+ add: plan.add.filter(matches),
75
+ update: plan.update.filter(matches),
76
+ remove: plan.remove.filter(matches),
77
+ reorder: plan.reorder.filter((item) => matches(item.title)),
78
+ };
79
+ }
80
+
81
+ function backofficePageUrl(preset, pageContentId) {
82
+ if (!preset.backofficeBase || !pageContentId) return null;
83
+ const url = new URL("/ru-RU/pagemanagement/pages", preset.backofficeBase);
84
+ url.searchParams.set("pageContentId", pageContentId);
85
+ return String(url);
86
+ }
87
+
26
88
  export async function planDeployment({
27
89
  config,
28
90
  preset,
@@ -135,58 +197,94 @@ export async function deployProject({
135
197
  root = process.cwd(),
136
198
  token,
137
199
  client,
138
- update = false,
139
- relink = false,
140
- pruneAssets = false,
141
- publish = false,
142
200
  prompts,
143
201
  section,
144
202
  strategy = "ask",
145
203
  log = console.log,
204
+ ui,
146
205
  }) {
147
206
  const manifest = readBuildManifest(root);
148
207
  const api = client || new BackofficeClient({brand: preset.id, token});
149
208
  const previous = readDeploymentState(preset.id, manifest.folder, root);
150
- log(`[deploy] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
151
- log(previous.placement && !relink
152
- ? "[deploy] validating saved CMS placement..."
153
- : relink ? "[deploy] selecting an existing CMS page..." : "[deploy] a new CMS page will be created");
209
+ const binding = readDeploymentBinding(preset.id, manifest.folder, root);
210
+ if (section && !manifest.blocks.some((block) => block.path === `${section}.html`)) {
211
+ const available = manifest.blocks.map((block) => block.path.replace(/\.html$/, ""));
212
+ throw new Error(`Section ${JSON.stringify(section)} not found. Available sections: ${available.join(", ") || "none"}`);
213
+ }
214
+ if (!ui) {
215
+ if (prompts) {
216
+ ui = {
217
+ ...prompts,
218
+ start() {},
219
+ plan() {},
220
+ confirmApply: prompts.confirmApply || (async () => true),
221
+ confirmPublish: prompts.confirmPublish || (async () => false),
222
+ info: (message) => log(`[deploy] ${message}`),
223
+ complete() {},
224
+ };
225
+ } else ui = await terminalDeployUi();
226
+ }
227
+ ui.start();
228
+ ui.info(`Checking ${manifest.assets.length} CDN asset${manifest.assets.length === 1 ? "" : "s"}`);
229
+ const missingAssets = await findMissingRemoteAssets({
230
+ manifest,
231
+ remoteRoot: new URL(preset.assetsBase).pathname,
232
+ listFiles: api.listFiles.bind(api),
233
+ });
234
+ if (missingAssets.length) {
235
+ throw new Error(
236
+ `Deploy stopped: ${missingAssets.length} asset${missingAssets.length === 1 ? " is" : "s are"} missing on CDN: ` +
237
+ `${missingAssets.join(", ")}. Run b2c-landing-vite deploy assets first.`
238
+ );
239
+ }
240
+
154
241
  let selectedPage = null;
242
+ let selectedDraft = null;
243
+ let nextBinding = binding;
155
244
  let remoteBefore = null;
156
- if (relink) selectedPage = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
157
- else if (previous.placement) {
158
- selectedPage = {
159
- placement: previous.placement,
160
- pageContent: await validateSavedPlacement({client: api, placement: previous.placement}),
161
- };
245
+ if (binding) {
246
+ try {
247
+ selectedPage = await selectBoundPagePlacement({client: api, binding, prompts: ui});
248
+ nextBinding = selectedPage.binding;
249
+ } catch (error) {
250
+ const stale = error.code === "NOT_FOUND" || error.status === 404 ||
251
+ /no versions|missing CMS page|area that no longer exists/i.test(error.message);
252
+ if (!stale) throw error;
253
+ ui.info("Saved CMS binding is no longer valid; select a page again");
254
+ nextBinding = null;
255
+ }
162
256
  }
163
- const availableSections = manifest.blocks.map((block) => block.path.replace(/\.html$/, ""));
164
- if (section && !availableSections.includes(section)) {
165
- throw new Error(`Section ${JSON.stringify(section)} not found. Available sections: ${availableSections.join(", ") || "none"}`);
257
+ if (!selectedPage) {
258
+ const mode = await ui.select({
259
+ message: "CMS page",
260
+ initialValue: "create",
261
+ options: [
262
+ {label: "Create a new CMS page", value: "create"},
263
+ {label: "Use an existing CMS page", value: "existing"},
264
+ ],
265
+ });
266
+ if (mode === "existing") {
267
+ selectedPage = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts: ui});
268
+ nextBinding = selectedPage.binding;
269
+ } else {
270
+ selectedDraft = await selectNewPagePlacement({client: api, manifest, prompts: ui});
271
+ }
166
272
  }
273
+
167
274
  let reconciliation = null;
168
275
  if (selectedPage) {
169
- log(`[deploy] scope: ${section ? `section ${JSON.stringify(section)}` : "page"}`);
170
276
  const local = localSections(manifest, root);
171
277
  const remote = remoteSections(selectedPage.pageContent, selectedPage.placement, manifest.folder);
172
278
  remoteBefore = remote;
173
279
  const base = previous.cms?.sections || null;
174
280
  let chosen = strategy;
175
- reconciliation = resolveSections({local, remote, base, scope: section ? [section] : undefined, strategy: chosen === "ask" ? "merge" : chosen});
281
+ reconciliation = resolveSections({local, remote, base, scope: section ? [section] : undefined, strategy: chosen === "ask" ? "merge" : chosen});
176
282
  if (reconciliation.changes.length) {
177
283
  const summary = summarizeChanges(reconciliation.changes);
178
- for (const [kind, ids] of Object.entries(summary)) if (ids.length) log(`[deploy] ${kind}: ${ids.join(", ")}`);
284
+ for (const [kind, ids] of Object.entries(summary)) if (ids.length) ui.info(`${kind}: ${ids.join(", ")}`);
179
285
  }
180
286
  if (reconciliation.unsafe.length && chosen === "ask") {
181
- prompts ||= await (async () => {
182
- const {isCancel, select} = await import("@clack/prompts");
183
- return {select: async (options) => {
184
- const value = await select(options);
185
- if (isCancel(value)) throw new Error("Deploy cancelled");
186
- return value;
187
- }};
188
- })();
189
- chosen = await prompts.select({
287
+ chosen = await ui.select({
190
288
  message: "Remote CMS changes detected",
191
289
  options: [
192
290
  {label: "Keep remote changes", value: "remote"},
@@ -197,9 +295,22 @@ export async function deployProject({
197
295
  if (chosen === "cancel") throw new Error("Deploy cancelled; CMS was not modified");
198
296
  reconciliation = resolveSections({local, remote, base, scope: section ? [section] : undefined, strategy: chosen});
199
297
  } else if (reconciliation.unsafe.length) {
200
- throw new Error(`Deploy stopped: remote changes require --force or --remote (${reconciliation.unsafe.map((item) => item.id).join(", ")})`);
298
+ throw new Error(`Deploy stopped: unresolved remote changes (${reconciliation.unsafe.map((item) => item.id).join(", ")})`);
201
299
  }
202
300
  }
301
+
302
+ const placement = selectedPage?.placement || selectedDraft.placement;
303
+ const pageContent = selectedPage?.pageContent || {pageContents: []};
304
+ const widgetPlan = scopedWidgetPlan(planWidgetChanges({manifest, placement, pageContent, root}), section);
305
+ ui.plan({
306
+ page: nextBinding?.pageId || selectedDraft.draft.pageName,
307
+ version: selectedPage ? versionLabel(selectedPage.pageContent) : "New page",
308
+ ...widgetPlan,
309
+ reorder: widgetPlan.reorder.map((item) => `${item.title} (${item.from} → ${item.to})`),
310
+ section,
311
+ });
312
+ if (!await ui.confirmApply()) throw new Error("Deploy cancelled; CMS was not modified");
313
+
203
314
  // No remote mutation happens until the page state and the user's resolution are known.
204
315
  if (selectedPage) {
205
316
  const verified = await validateSavedPlacement({client: api, placement: selectedPage.placement});
@@ -207,63 +318,75 @@ export async function deployProject({
207
318
  throw new Error("CMS page changed during deploy. Nothing was modified; run deploy again.");
208
319
  }
209
320
  }
210
- log("[deploy] synchronizing CDN assets...");
211
- const assets = await uploadChangedAssets({
212
- manifest,
213
- state: previous,
214
- root,
215
- remoteRoot: new URL(preset.assetsBase).pathname,
216
- uploadFile: api.uploadFile.bind(api),
217
- listFiles: api.listFiles?.bind(api),
218
- prune: pruneAssets,
219
- deleteFile: api.deleteFile?.bind(api),
220
- });
221
321
  const page = selectedPage
222
- ? await prepareExistingPage({client: api, placement: selectedPage.placement, update})
223
- : await createPagePlacement({client: api, manifest, prompts});
322
+ ? await prepareExistingPage({client: api, placement: selectedPage.placement})
323
+ : await createPagePlacement({client: api, manifest, prompts: ui, selected: selectedDraft});
324
+ nextBinding = {...(page.binding || nextBinding), lastPageContentId: page.placement.pageContentId};
224
325
  if (selectedPage && page.placement.pageContentId === selectedPage.placement.pageContentId &&
225
326
  pageRevision(page.pageContent) !== pageRevision(selectedPage.pageContent)) {
226
327
  throw new Error("CMS page changed during deploy. No widgets were modified; run deploy again.");
227
328
  }
329
+ let sectionIds = null;
330
+ if (section) {
331
+ sectionIds = reconciliation?.sections?.[section] === remoteBefore?.[section] ? [] : [section];
332
+ } else if (reconciliation) {
333
+ sectionIds = [...new Set([
334
+ ...Object.keys(remoteBefore || {}),
335
+ ...Object.keys(reconciliation.sections || {}),
336
+ ])].filter((id) => reconciliation.sections?.[id] !== remoteBefore?.[id]);
337
+ }
228
338
  const widgets = await syncWidgets({
229
339
  client: api,
230
340
  manifest,
231
341
  placement: page.placement,
232
342
  pageContent: page.pageContent,
233
343
  root,
234
- sectionIds: reconciliation ? [...new Set([
235
- ...Object.keys(remoteBefore || {}),
236
- ...Object.keys(reconciliation.sections || {}),
237
- ])].filter((id) => reconciliation.sections?.[id] !== remoteBefore?.[id]) : null,
344
+ sectionIds,
238
345
  });
346
+ const verifiedContent = await api.getContent(page.placement.pageContentId);
347
+ const remoteAfter = remoteSections(verifiedContent, page.placement, manifest.folder);
348
+ const storedSections = section ? {...(previous.cms?.sections || {})} : remoteAfter;
349
+ if (section) {
350
+ if (remoteAfter[section] === undefined) delete storedSections[section];
351
+ else storedSections[section] = remoteAfter[section];
352
+ }
239
353
  const state = {
240
- ...assets.state,
241
- placement: page.placement,
354
+ ...previous,
242
355
  cms: {
243
356
  verifiedAt: new Date().toISOString(),
244
- sections: remoteSections(await api.getContent(page.placement.pageContentId), page.placement, manifest.folder),
357
+ sections: storedSections,
245
358
  },
246
359
  };
360
+ writeDeploymentState(preset.id, manifest.folder, state, root);
361
+ writeDeploymentBinding(preset.id, manifest.folder, nextBinding, root);
362
+
363
+ let finalPageContent = verifiedContent;
247
364
  let published = false;
248
- let finalPageContent = await api.getContent(page.placement.pageContentId);
249
- if (publish) {
250
- log("[deploy] validating CMS metadata and publishing...");
251
- await publishPage({client: api, pageContent: finalPageContent});
252
- published = true;
253
- finalPageContent = await api.getContent(page.placement.pageContentId);
365
+ if (await ui.confirmPublish()) {
366
+ const issues = publicationIssues(finalPageContent);
367
+ if (issues.length) {
368
+ ui.info(`Publication blocked; fill in ${issues.join(", ")} in Backoffice`);
369
+ } else {
370
+ await publishPage({client: api, pageContent: finalPageContent});
371
+ published = true;
372
+ finalPageContent = await api.getContent(page.placement.pageContentId);
373
+ }
254
374
  }
255
- writeDeploymentState(preset.id, manifest.folder, state, root);
256
375
  const preview = finalPageContent.uniqueId
257
376
  ? `https://${preset.domain}/preview/${finalPageContent.uniqueId}/`
258
377
  : null;
259
378
  const ru = finalPageContent.pageContents?.find((content) => String(content.languageId) === "1049");
260
379
  const live = published && ru?.perma ? `https://${preset.domain}/${ru.perma.replace(/^\/+/, "")}` : null;
261
- log(`[deploy] assets: uploaded=${assets.uploaded.length}, skipped=${assets.skipped.length}, removed=${assets.removed.length}, unchanged=${assets.unchanged}`);
262
- if (assets.skipped.length) {
263
- log(`[deploy] existing assets were not overwritten: ${assets.skipped.join(", ")}. Rename changed files to publish new URLs.`);
264
- }
265
- log(`[deploy] widgets: added=${widgets.added}, updated=${widgets.updated}, removed=${widgets.removed}`);
266
- if (preview) log(`[deploy] preview: ${preview}`);
267
- if (live) log(`[deploy] live: ${live}`);
268
- return {assets, widgets, placement: page.placement, preview, live, published};
380
+ const backoffice = backofficePageUrl(preset, page.placement.pageContentId);
381
+ ui.complete({widgets, preview, backoffice, live});
382
+ return {
383
+ assets: {verified: manifest.assets.length, missing: []},
384
+ widgets,
385
+ binding: nextBinding,
386
+ placement: page.placement,
387
+ preview,
388
+ backoffice,
389
+ live,
390
+ published,
391
+ };
269
392
  }
@@ -47,12 +47,35 @@ function pageLabel(page, domain) {
47
47
  return `${page.pageTitle || page.name || page.pageId} — ${link}`;
48
48
  }
49
49
 
50
- function versionLabel(content) {
50
+ export function versionLabel(content) {
51
51
  const details = [content.statusName, content.version != null ? `v${content.version}` : null, content.author]
52
52
  .filter(Boolean).join(", ");
53
53
  return `${details || content.pageContentId}${content.modifiedTime ? ` — ${content.modifiedTime}` : ""}`;
54
54
  }
55
55
 
56
+ function contentId(content) {
57
+ return content.pageContentId || content.contentId || content._id;
58
+ }
59
+
60
+ function isPublishedVersion(content) {
61
+ return Number(content.statusId ?? content.status) === STATUS.published ||
62
+ String(content.statusName || "").toLowerCase() === "published";
63
+ }
64
+
65
+ export async function selectPageVersion({client, pageId, versions, prompts}) {
66
+ prompts ||= await defaultPrompts();
67
+ versions ||= await client.getContentVersions(pageId);
68
+ if (!versions?.length) throw new Error("The selected CMS page has no versions");
69
+ if (versions.length === 1) return client.getContent(contentId(versions[0]));
70
+ const published = versions.find(isPublishedVersion);
71
+ const pageContentId = await prompts.select({
72
+ message: "Source page version",
73
+ initialValue: contentId(published || versions[0]),
74
+ options: versions.map((content) => ({label: versionLabel(content), value: contentId(content)})),
75
+ });
76
+ return client.getContent(pageContentId);
77
+ }
78
+
56
79
  async function defaultPrompts() {
57
80
  const {isCancel, select, text} = await import("@clack/prompts");
58
81
  const answer = async (promise) => {
@@ -148,13 +171,13 @@ export async function selectExistingPagePlacement({client, domain, prompts}) {
148
171
  options: pages.map((page) => ({label: pageLabel(page, domain), value: page.pageId || page.id, page})),
149
172
  });
150
173
  const page = pages.find((candidate) => (candidate.pageId || candidate.id) === pageId);
151
- const versions = page.contents?.length ? page.contents : await client.getContentVersions(pageId);
152
- if (!versions?.length) throw new Error("The selected CMS page has no versions");
153
- const pageContentId = await prompts.select({
154
- message: "Page version",
155
- options: versions.map((content) => ({label: versionLabel(content), value: content.pageContentId || content.contentId})),
174
+ const pageContent = await selectPageVersion({
175
+ client,
176
+ pageId,
177
+ versions: page.contents?.length ? page.contents : undefined,
178
+ prompts,
156
179
  });
157
- const pageContent = await client.getContent(pageContentId);
180
+ const pageContentId = contentId(pageContent);
158
181
  const areas = suitableAreas(pageContent.layout?.schema || []);
159
182
  if (!areas.length) throw new Error("The selected page layout has no HTML widget area");
160
183
  const widgets = pageWidgets(pageContent);
@@ -176,11 +199,15 @@ export async function selectExistingPagePlacement({client, domain, prompts}) {
176
199
  value: index,
177
200
  })),
178
201
  });
179
- return {pageContent, placement: {pageContentId, layoutAreaId, orderIndex}};
202
+ return {
203
+ pageContent,
204
+ placement: {pageContentId, layoutAreaId, orderIndex},
205
+ binding: {pageId, applicationId, layoutAreaId, orderIndex, lastPageContentId: pageContentId},
206
+ };
180
207
  }
181
208
 
182
209
  export async function createPagePlacement(options) {
183
- const selected = await selectNewPagePlacement(options);
210
+ const selected = options.selected || await selectNewPagePlacement(options);
184
211
  const {client} = options;
185
212
  const created = await client.createPage({
186
213
  applicationId: selected.draft.applicationId,
@@ -188,9 +215,19 @@ export async function createPagePlacement(options) {
188
215
  name: selected.draft.pageName,
189
216
  });
190
217
  if (!created?.pageContentId) throw new Error("Backoffice did not return pageContentId for the new page");
218
+ const pageContent = await client.getContent(created.pageContentId);
219
+ const pageId = created.pageId || pageContent.pageId;
220
+ if (!pageId) throw new Error("Backoffice did not return pageId for the new page");
191
221
  return {
192
- pageContent: await client.getContent(created.pageContentId),
222
+ pageContent,
193
223
  placement: {...selected.placement, pageContentId: created.pageContentId},
224
+ binding: {
225
+ pageId,
226
+ applicationId: selected.draft.applicationId,
227
+ layoutAreaId: selected.placement.layoutAreaId,
228
+ orderIndex: selected.placement.orderIndex,
229
+ lastPageContentId: created.pageContentId,
230
+ },
194
231
  };
195
232
  }
196
233
 
@@ -234,18 +271,29 @@ export async function validateSavedPlacement({client, placement}) {
234
271
  pageContent = await client.getContent(placement.pageContentId);
235
272
  } catch (error) {
236
273
  if (error.code === "NOT_FOUND" || error.status === 404) {
237
- throw new Error("Saved deploy-state points to a missing CMS page version. Run deploy --relink.", {cause: error});
274
+ throw new Error("Saved deploy-state points to a missing CMS page version.", {cause: error});
238
275
  }
239
276
  throw error;
240
277
  }
241
278
  const schema = pageContent.layout?.schema;
242
279
  if (Array.isArray(schema) && !schema.flatMap((row) => row.children || [])
243
280
  .some((area) => (area.layoutAreaId || area.id) === placement.layoutAreaId)) {
244
- throw new Error("Saved deploy-state points to an HTML area that no longer exists. Run deploy --relink.");
281
+ throw new Error("Saved deploy-state points to an HTML area that no longer exists.");
245
282
  }
246
283
  return pageContent;
247
284
  }
248
285
 
286
+ export async function selectBoundPagePlacement({client, binding, prompts}) {
287
+ const pageContent = await selectPageVersion({client, pageId: binding.pageId, prompts});
288
+ const placement = {
289
+ pageContentId: contentId(pageContent),
290
+ layoutAreaId: binding.layoutAreaId,
291
+ orderIndex: binding.orderIndex,
292
+ };
293
+ await validateSavedPlacement({client, placement});
294
+ return {pageContent, placement, binding: {...binding, lastPageContentId: placement.pageContentId}};
295
+ }
296
+
249
297
  export function pageRevision(pageContent) {
250
298
  return JSON.stringify({
251
299
  id: pageContent._id || pageContent.pageContentId,
@@ -2,13 +2,20 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  const STATE_VERSION = 1;
5
+ const BINDING_VERSION = 1;
5
6
 
6
7
  export function deploymentStatePath(site, folder, root = process.cwd()) {
7
- return path.join(root, ".b2c", "deploy", `${site}--${folder}.json`);
8
+ return path.join(root, ".b2c", "deploy", `${site}--${folder}.state.json`);
9
+ }
10
+
11
+ export function deploymentBindingPath(site, folder, root = process.cwd()) {
12
+ return path.join(root, ".b2c", "deploy", `${site}--${folder}.binding.json`);
8
13
  }
9
14
 
10
15
  export function readDeploymentState(site, folder, root = process.cwd()) {
11
- const filePath = deploymentStatePath(site, folder, root);
16
+ let filePath = deploymentStatePath(site, folder, root);
17
+ const legacyPath = path.join(root, ".b2c", "deploy", `${site}--${folder}.json`);
18
+ if (!fs.existsSync(filePath) && fs.existsSync(legacyPath)) filePath = legacyPath;
12
19
  if (!fs.existsSync(filePath)) return {schemaVersion: STATE_VERSION, assets: {}};
13
20
  const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
14
21
  if (state.schemaVersion !== STATE_VERSION || !state.assets || typeof state.assets !== "object") {
@@ -17,6 +24,26 @@ export function readDeploymentState(site, folder, root = process.cwd()) {
17
24
  return state;
18
25
  }
19
26
 
27
+ export function readDeploymentBinding(site, folder, root = process.cwd()) {
28
+ const filePath = deploymentBindingPath(site, folder, root);
29
+ if (!fs.existsSync(filePath)) return null;
30
+ const binding = JSON.parse(fs.readFileSync(filePath, "utf8"));
31
+ if (binding.schemaVersion !== BINDING_VERSION || !binding.pageId || !binding.layoutAreaId) {
32
+ throw new Error(`Invalid deployment binding: ${filePath}`);
33
+ }
34
+ return binding;
35
+ }
36
+
37
+ export function writeDeploymentBinding(site, folder, binding, root = process.cwd()) {
38
+ const filePath = deploymentBindingPath(site, folder, root);
39
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
40
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
41
+ const next = {...binding, schemaVersion: BINDING_VERSION};
42
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`);
43
+ fs.renameSync(temporaryPath, filePath);
44
+ return next;
45
+ }
46
+
20
47
  export function writeDeploymentState(site, folder, state, root = process.cwd()) {
21
48
  const filePath = deploymentStatePath(site, folder, root);
22
49
  fs.mkdirSync(path.dirname(filePath), {recursive: true});
@@ -2,4 +2,5 @@ export default {
2
2
  id: "coral",
3
3
  domain: "www.coral.ru",
4
4
  assetsBase: "https://b2ccdn.coral.ru/content",
5
+ backofficeBase: "https://backoffice.coral.ru",
5
6
  };
@@ -2,4 +2,5 @@ export default {
2
2
  id: "sunmar",
3
3
  domain: "www.sunmar.ru",
4
4
  assetsBase: "https://b2ccdn.sunmar.ru/content",
5
+ backofficeBase: "https://backoffice.sunmar.ru",
5
6
  };
@@ -6,6 +6,7 @@ const packageJson = JSON.parse(
6
6
 
7
7
  export const CURRENT_VERSION = packageJson.version;
8
8
  export const PACKAGE_NAME = packageJson.name;
9
+ export const TEMPLATE_PACKAGE_NAME = "@coraltravelcenter/create-b2c-landing-vite";
9
10
 
10
11
  function parseVersion(version) {
11
12
  const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version || "");
@@ -32,11 +33,12 @@ export function compareVersions(left, right) {
32
33
  export async function checkForUpdate({
33
34
  fetchImpl = globalThis.fetch,
34
35
  currentVersion = CURRENT_VERSION,
36
+ packageName = PACKAGE_NAME,
35
37
  timeoutMs = 3000,
36
38
  } = {}) {
37
39
  if (typeof fetchImpl !== "function") throw new Error("Fetch API is unavailable");
38
40
  const response = await fetchImpl(
39
- `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`,
41
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
40
42
  {headers: {accept: "application/json"}, signal: AbortSignal.timeout(timeoutMs)}
41
43
  );
42
44
  if (!response.ok) throw new Error(`npm registry responded with HTTP ${response.status}`);
@@ -49,6 +51,15 @@ export async function checkForUpdate({
49
51
  };
50
52
  }
51
53
 
54
+ export async function checkTemplateUpdate({templateVersion, ...options} = {}) {
55
+ const status = await checkForUpdate({
56
+ ...options,
57
+ packageName: TEMPLATE_PACKAGE_NAME,
58
+ currentVersion: templateVersion || "0.0.0",
59
+ });
60
+ return {...status, legacy: !templateVersion};
61
+ }
62
+
52
63
  export async function printUpdateStatus(options) {
53
64
  try {
54
65
  const status = await checkForUpdate(options);
@@ -65,15 +76,28 @@ export async function printUpdateStatus(options) {
65
76
  }
66
77
  }
67
78
 
68
- export async function printUpdateNotice({log = console.warn, timeoutMs = 1000, ...options} = {}) {
69
- try {
70
- const status = await checkForUpdate({...options, timeoutMs});
71
- if (!status.updateAvailable) return status;
79
+ export async function printUpdateNotice({
80
+ log = console.warn,
81
+ timeoutMs = 1000,
82
+ templateVersion,
83
+ ...options
84
+ } = {}) {
85
+ const [builder, template] = await Promise.all([
86
+ checkForUpdate({...options, timeoutMs}).catch(() => null),
87
+ checkTemplateUpdate({...options, timeoutMs, templateVersion}).catch(() => null),
88
+ ]);
89
+ const builderUpdate = builder?.updateAvailable;
90
+ const templateUpdate = template?.updateAvailable;
91
+ if (!builderUpdate && !templateUpdate) return {builder, template};
72
92
 
73
- log(`New ${PACKAGE_NAME} version available: ${status.currentVersion} → ${status.latestVersion}`);
74
- log(`Update manually: npm install --global ${PACKAGE_NAME}@latest`);
75
- return status;
76
- } catch {
77
- return null;
93
+ log("Updates available:");
94
+ if (builderUpdate) {
95
+ log(`Builder: ${builder.currentVersion} → ${builder.latestVersion}`);
96
+ log(`npm install --global ${PACKAGE_NAME}@latest`);
97
+ }
98
+ if (templateUpdate) {
99
+ log(`Project template: ${template.legacy ? "legacy" : template.currentVersion} → ${template.latestVersion}`);
100
+ log(`npx ${TEMPLATE_PACKAGE_NAME}@latest migrate`);
78
101
  }
102
+ return {builder, template};
79
103
  }
@@ -30,6 +30,9 @@ function waitForContainer() {
30
30
  });
31
31
  }
32
32
 
33
- const container = await waitForContainer();
34
- mount(container, parts);
35
- setupLocalCdnAssetRewrite({root: container, cdnBase: "http://127.0.0.1:5173"});
33
+ if (window.location.hostname === __B2C_TARGET_HOST__) {
34
+ const container = await waitForContainer();
35
+ mount(container, parts);
36
+ const devOrigin = new URL("/", import.meta.url).origin;
37
+ setupLocalCdnAssetRewrite({root: container, cdnBase: devOrigin});
38
+ }
@@ -18,6 +18,18 @@ export function toViteFsUrl(filePath) {
18
18
  const clientEntryUrl = toViteFsUrl(clientEntry);
19
19
  let monkeyPlugin;
20
20
 
21
+ export function resolveDevServerPort(env = process.env) {
22
+ const explicitPort = env.B2C_PORT;
23
+ if (explicitPort === undefined || explicitPort === "") {
24
+ return {port: 5173, strictPort: false};
25
+ }
26
+ const port = Number(explicitPort);
27
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
28
+ throw new Error("B2C_PORT must be an integer between 1 and 65535");
29
+ }
30
+ return {port, strictPort: true};
31
+ }
32
+
21
33
  async function loadMonkeyPlugin() {
22
34
  if (monkeyPlugin) return monkeyPlugin;
23
35
 
@@ -36,6 +48,7 @@ export async function createViteConfig(root = process.cwd()) {
36
48
  const preset = resolvePreset(config.site.preset);
37
49
  const monkey = await loadMonkeyPlugin();
38
50
  const shouldOpen = process.env.B2C_NO_OPEN !== "1";
51
+ const devPort = resolveDevServerPort();
39
52
  const vue = resolveScriptStack(config.stack.script).vue
40
53
  ? await loadProjectVuePlugin(root)
41
54
  : null;
@@ -45,8 +58,7 @@ export async function createViteConfig(root = process.cwd()) {
45
58
  configFile: false,
46
59
  server: {
47
60
  host: "127.0.0.1",
48
- port: Number(process.env.B2C_PORT || 5173),
49
- strictPort: true,
61
+ ...devPort,
50
62
  cors: true,
51
63
  open: shouldOpen ? "/__vite-plugin-monkey.install.user.js" : false,
52
64
  fs: {allow: [root, builderRoot]},
@@ -59,13 +71,16 @@ export async function createViteConfig(root = process.cwd()) {
59
71
  server: {open: shouldOpen, prefix: false},
60
72
  userscript: {
61
73
  name: config.project.name,
62
- namespace: "b2c-landing-builder",
74
+ namespace: `b2c-landing-builder:${preset.id}`,
63
75
  match: [`https://${preset.domain}/monkey/*`],
64
76
  grant: "none",
65
77
  "run-at": "document-idle",
66
78
  },
67
79
  }),
68
80
  ].filter(Boolean),
81
+ define: {
82
+ __B2C_TARGET_HOST__: JSON.stringify(preset.domain),
83
+ },
69
84
  };
70
85
  }
71
86