@coraltravelcenter/b2c-landing-builder 2.10.0 → 2.11.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,12 +22,8 @@ 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
- b2c-landing-vite deploy --assets-only
30
- b2c-landing-vite deploy --assets-only --prune-assets
25
+ b2c-landing-vite deploy --section hero
26
+ b2c-landing-vite deploy assets
31
27
  b2c-landing-vite block:add hero
32
28
  b2c-landing-vite block:rename hero main-hero
33
29
  b2c-landing-vite update
@@ -36,54 +32,47 @@ b2c-landing-vite update
36
32
  - `check` проверяет конфигурацию, `order.json` и контракт JS-блоков.
37
33
  - `dev` запускает Vite и userscript для Tampermonkey.
38
34
  - `build` создаёт CMS-файлы в `@CMS/` и переписывает пути ассетов на CDN.
39
- - `deploy --assets-only` выполняет свежую сборку и загружает только изменённые
40
- deploy-ассеты. При первом запуске builder запрашивает backoffice-логин и
41
- скрытый пароль через ADFS. На диск сохраняется только токен (не пароль), токен
42
- автоматически используется до истечения. Для CI можно передать
35
+ - `deploy` собирает проект, проверяет ассеты на CDN и разворачивает CMS-виджеты.
36
+ - `deploy --section <key>` разворачивает только выбранную CMS-секцию, не изменяя
37
+ и не удаляя соседние секции.
38
+ - `deploy assets` собирает проект и синхронизирует ассеты с B2C CDN.
39
+ - При первом deploy builder запрашивает backoffice-логин и скрытый пароль через
40
+ ADFS. На диск сохраняется только токен. Для CI можно передать
43
41
  `B2C_BACKOFFICE_TOKEN`.
44
42
  - Backoffice-запросы ограничены 30 секундами. Безопасные GET-запросы повторяются
45
43
  до двух раз при сетевом сбое, timeout, HTTP 429, 502, 503 или 504. Изменяющие
46
44
  CMS запросы автоматически не повторяются, чтобы не создать дубликаты.
47
- - `deploy` при первом запуске предлагает выбрать application, layout и HTML-зону,
48
- создаёт CMS-страницу и размещает блоки в порядке `order.json`. При повторном
49
- запуске для опубликованной страницы создаётся checkout-версия.
50
- - `deploy --dry-run` выполняет свежую локальную сборку, читает состояние CMS и
51
- показывает точный план загрузки ассетов, создания версии и изменений виджетов.
52
- Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
53
- - `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
54
- - `deploy --update` обновляет сохранённую версию страницы без создания новой.
55
- - `deploy --relink` восстанавливает или меняет привязку проекта к существующей
56
- CMS-странице. Builder предлагает application, поиск страницы по названию или
57
- URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
58
- опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
59
- - `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
60
- план, но не сохраняет новую привязку и не изменяет CMS.
61
- - `deploy --publish` после успешной синхронизации виджетов публикует выбранную
62
- версию. Публикация блокируется, если в Backoffice не заполнены title, URL,
63
- SEO title или SEO description; builder не придумывает метаданные сам.
64
- - `deploy --assets-only --prune-assets` удаляет из CMS только отсутствующие в
65
- текущей сборке файлы, которые этот builder сам загрузил и записал как
66
- управляемые в deploy-state проекта. Уже существовавшие в CMS файлы не
67
- считаются управляемыми и не удаляются. Команда
68
- намеренно отделена от обычного deploy и никогда не удаляет неизвестные файлы.
69
- Сначала рекомендуется запустить её с `--dry-run`.
70
- - Перед любым deploy сохранённые pageContentId и layoutAreaId сверяются с CMS.
71
- Устаревшая привязка останавливает операцию до загрузки файлов и предлагает
72
- `deploy --relink`.
45
+ - После успешного deploy builder сохраняет снимок секций в `.b2c/deploy/` как
46
+ достоверный `base`. Следующий deploy структурно сравнивает секции `local`,
47
+ актуальный `remote` и `base`. Изменения разных секций объединяются; изменение
48
+ одной секции с обеих сторон считается конфликтом.
49
+ - Если `base` ещё отсутствует, отличие local от remote считается потенциальным
50
+ конфликтом. Обычный deploy не выполняет silent overwrite и предлагает явно
51
+ сохранить CMS-версию, применить local или отменить операцию.
52
+ - Стабильные `pageId`, `layoutAreaId` и позиция сохраняются отдельно в
53
+ `.b2c/deploy/<site>--<folder>.binding.json`. Этот файл можно хранить в Git,
54
+ чтобы команда находила ту же CMS-страницу после нового клонирования проекта.
55
+ - Перед deploy builder получает список версий сохранённой страницы. Published
56
+ выбирается по умолчанию; из Published или Unpublished создаётся checkout,
57
+ существующий Checkout обновляется напрямую. Потерянную привязку можно
58
+ восстановить интерактивным выбором существующей страницы.
73
59
  - Одновременные deploy одного проекта на компьютере блокируются lock-файлом в
74
60
  `.b2c/deploy/`; аварийно оставленный lock автоматически признаётся устаревшим.
61
+ - Непосредственно перед первой записью CMS-состояние читается повторно. Если оно
62
+ изменилось во время проверки/выбора стратегии, deploy останавливается.
75
63
  - `block:add` создаёт файлы в выбранных форматах разметки и стилей.
76
64
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
77
65
  - `update` проверяет npm registry и показывает команду ручного обновления.
78
66
 
79
- После успешного выполнения рабочих команд builder коротко проверяет npm
80
- registry. Если доступна новая стабильная версия, терминал показывает текущую и
81
- новую версии и команду ручного обновления. Ошибки сети не влияют на работу CLI.
82
- Builder никогда не обновляется автоматически:
67
+ После успешного выполнения рабочих команд builder коротко и независимо
68
+ проверяет в npm registry версии самого builder и шаблона проекта. Для проекта
69
+ без `template.version` шаблон помечается как `legacy`. Ошибки сети не влияют на
70
+ работу CLI; ничего не обновляется автоматически:
83
71
 
84
72
  ```bash
85
73
  b2c-landing-vite update
86
74
  # Update manually: npm install --global @coraltravelcenter/b2c-landing-builder@latest
75
+ # Update project template: npx @coraltravelcenter/create-b2c-landing-vite@latest migrate
87
76
  ```
88
77
 
89
78
  ## Создание проекта
@@ -105,7 +94,10 @@ npm create @coraltravelcenter/b2c-landing-vite
105
94
  export default {
106
95
  schemaVersion: 2,
107
96
  builder: {
108
- minVersion: "2.5.0",
97
+ minVersion: "2.10.0",
98
+ },
99
+ template: {
100
+ version: "2.13.0",
109
101
  },
110
102
  project: {
111
103
  name: "uae-promo",
@@ -121,6 +113,10 @@ export default {
121
113
  };
122
114
  ```
123
115
 
116
+ `template.version` записывается генератором и показывает, какой версией шаблона
117
+ создан проект. Для старых проектов поле необязательно; его отсутствие позволяет
118
+ builder распознать legacy-шаблон и предложить миграцию в будущих версиях.
119
+
124
120
  Имя проекта должно соответствовать `^[a-z0-9][a-z0-9-]*$`.
125
121
 
126
122
  Поддерживаются:
@@ -162,6 +158,7 @@ src/
162
158
  welcome.css | welcome.scss | welcome.less
163
159
  scripts/
164
160
  welcome.js | welcome.ts
161
+ utils/ # общие JavaScript-утилиты проекта
165
162
  components/ # только для Vue
166
163
  public/
167
164
  pug.rc # только для Pug
@@ -223,10 +220,39 @@ build; дополнительные контейнеры builder не созда
223
220
  ```
224
221
 
225
222
  В dev пути направляются на `http://127.0.0.1:5173`. При production build они
226
- преобразуются в `<assetsBase>/landing-pages/<project.name>/...`. Builder
223
+ преобразуются в `<assetsBase>/landing-pages/<folder>/...`. Builder
227
224
  переписывает только изображения `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif` и
228
225
  `svg`; остальные root-relative ресурсы не изменяются.
229
226
 
227
+ ### Deploy assets
228
+
229
+ ```bash
230
+ b2c-landing-vite deploy assets
231
+ ```
232
+
233
+ Команда выполняет свежую сборку, сравнивает build manifest с deploy-state и CDN,
234
+ а затем показывает план загрузки. Перед отправкой файлов требуется явное
235
+ подтверждение; по умолчанию выбран отказ.
236
+
237
+ Вложенная структура `public/` сохраняется на CDN. Например,
238
+ `public/hotels/gallery/room.webp` загружается как
239
+ `landing-pages/<folder>/hotels/gallery/room.webp`.
240
+
241
+ Существующие CDN-файлы не перезаписываются и не удаляются. Если содержимое
242
+ изменилось, файл нужно переименовать, чтобы опубликовать его по новому URL.
243
+ Deploy-state обновляется атомарно только после успешной загрузки всех файлов.
244
+ В итоговом блоке команда также выводит корневой URL `CDN folder`, например
245
+ `https://b2ccdn.coral.ru/content/landing-pages/uae-promo/`.
246
+
247
+ Обычный `deploy` не загружает ассеты. Он проверяет наличие всех файлов build
248
+ manifest на CDN и останавливается до изменения CMS, если сначала требуется
249
+ выполнить `deploy assets`.
250
+
251
+ Перед изменением CMS команда показывает план добавления, обновления, удаления и
252
+ перестановки управляемых виджетов. Подтверждение и последующее предложение
253
+ публикации по умолчанию имеют значение `No`. После deploy выводятся Preview и
254
+ прямая ссылка на созданную checkout-версию в BackOffice.
255
+
230
256
  ## Совместимость
231
257
 
232
258
  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.0",
3
+ "version": "2.11.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.0",
9
+ "version": "2.11.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.0",
3
+ "version": "2.11.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,
@@ -32,7 +32,7 @@ export function resolveSections({local, remote, base, scope, strategy = "merge"}
32
32
  const scopedRemote = Object.fromEntries(Object.entries(remote).filter(([id]) => selected.has(id)));
33
33
  const scopedBase = base && Object.fromEntries(Object.entries(base).filter(([id]) => selected.has(id)));
34
34
  const changes = compareSections({local: scopedLocal, remote: scopedRemote, base: scopedBase});
35
- const unsafe = changes.filter((change) => change.kind === "conflict" || change.kind === "unknown" || change.kind === "remote");
35
+ const unsafe = changes.filter((change) => change.kind === "conflict" || change.kind === "unknown");
36
36
  if (strategy === "merge" && unsafe.length) return {changes, unsafe, sections: null};
37
37
  const sections = {...remote};
38
38
  for (const id of selected) {
@@ -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
  }