at-builder 1.2.9 → 1.4.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/lib/at-deploy.js CHANGED
@@ -4,47 +4,59 @@
4
4
  /*
5
5
  * Adobe Target Build Deploy Script
6
6
  * --------------------------------
7
- * This script automates the deployment of build assets to an Adobe Target activity.
7
+ * Automates deployment of build assets to an Adobe Target activity.
8
8
  *
9
9
  * Features:
10
- * - Reads build config and allowed variations from build.config.json in the build folder.
11
- * - Dynamically finds and injects built HTML assets for each experience/variation.
12
- * - Updates only the allowed experiences in Adobe Target.
13
- * - Provides color-coded logs and a summary of what was updated, skipped, or unchanged.
14
- * - Can be run in verbose mode to show more details (use -v or --verbose).
10
+ * - Reads activity id/type/variations from build.config.json (single-page or multi-page).
11
+ * - Supports AB and XT activity types.
12
+ * - Discovers built HTML assets and injects them into the AT activity payload.
13
+ * - Updates only allowed experiences; creates new options when slots are empty.
14
+ * - Prunes orphan options and deduplicates options with identical offerContent
15
+ * (avoids AT's UniqueElements.ABActivity.options rejection on multi-page deploys).
16
+ * - --dry-run : preview the resulting payload without PUTting.
17
+ * - --force : override the 60s cooldown lock between deploys.
18
+ * - -v|--verbose : reserved for future use; kept for parity.
15
19
  *
16
- * Usage:
17
- * node at-deploy.js [-v|--verbose]
20
+ * Usage (via CLI):
21
+ * atb deploy [--dry-run] [--force]
18
22
  *
19
- * Environment variables required:
20
- * ACTIVITY_FOLDER_NAME, ADOBE_CLIENT_ID, ADOBE_CLIENT_SECRET
21
- * (and .env file for any secrets)
23
+ * Usage (direct, when developing at-builder itself):
24
+ * node lib/at-deploy.js [--dry-run] [--force]
22
25
  *
23
- * Example:
24
- * ACTIVITY_FOLDER_NAME="UPSDDO-XXXX" \
25
- * ADOBE_CLIENT_ID=... ADOBE_CLIENT_SECRET=... node at-deploy.js -v
26
+ * Path resolution:
27
+ * at-builder is a globally-installed CLI. Every consumer file resolves against
28
+ * PWD = process.env.executionPath || process.cwd() so that the user's project
29
+ * directory is the source of truth, not at-builder's install location.
30
+ *
31
+ * Environment variables (consumer's .env):
32
+ * ACTIVITY_FOLDER_NAME (required)
33
+ * ACTIVITIES_BASE_FOLDER (optional, defaults to "Activities")
34
+ * ADOBE_CLIENT_ID (required)
35
+ * ADOBE_CLIENT_SECRET (required)
36
+ * ADOBE_TENANT (optional, your AT tenant slug — enables clickable AT UI link)
26
37
  *
27
- * Maintainers: Update build.config.json and build output structure as needed for new activities.
28
- *
29
38
  * Author: Upendra Sengar
30
- * Date: 2024-03-12
31
39
  */
32
40
 
33
41
  /* eslint-env node */
34
42
  const path = require('path');
35
-
36
43
  const dotenv = require('dotenv');
37
44
 
38
45
  const PWD = process.env.executionPath || process.cwd();
46
+ dotenv.config({ path: path.join(PWD, '.env'), quiet: true });
39
47
 
40
- dotenv.config({ path: path.join(PWD, ".env"), quiet: true });
41
-
42
- const { execSync } = require('child_process');
48
+ const { execFileSync } = require('child_process');
43
49
  const axios = require('axios');
44
50
  const qs = require('querystring');
45
51
  const fs = require('fs');
46
52
 
47
53
  const { BASE_URL, IMS_TOKEN_URL, IMS_SCOPE } = require(path.join(PWD, 'adobe.config'));
54
+
55
+ const VALID_ACTIVITY_TYPES = ['ab', 'xt'];
56
+ const OFFER_TEMPLATE_ID = 133;
57
+ const COOLDOWN_SECONDS = 60;
58
+ const DEPLOY_LOCK_FILE = path.join(PWD, '.deploy-lock');
59
+
48
60
  // Fallback to ANSI codes since chalk 5.x is ESM only
49
61
  const chalk = {
50
62
  green: (s) => `\x1b[32m${s}\x1b[0m`,
@@ -54,95 +66,146 @@ const chalk = {
54
66
  bold: (s) => `\x1b[1m${s}\x1b[0m`
55
67
  };
56
68
 
57
- // Validate required environment variables
69
+ // CLI flags
70
+ const args = process.argv.slice(2);
71
+ const DRY_RUN = args.includes('--dry-run');
72
+ const FORCE = args.includes('--force');
73
+
74
+ // ─── Validate env ───
58
75
  if (!process.env.ACTIVITY_FOLDER_NAME || process.env.ACTIVITY_FOLDER_NAME.trim() === '') {
59
76
  console.error(chalk.red('❌ Error: ACTIVITY_FOLDER_NAME environment variable is required but not set.'));
60
- console.error(chalk.yellow('💡 Please set ACTIVITY_FOLDER_NAME in your .env file or run "atb doctor --fix" to check your configuration.'));
77
+ console.error(chalk.yellow('💡 Set ACTIVITY_FOLDER_NAME in your .env file or run "atb doctor --fix".'));
61
78
  process.exit(1);
62
79
  }
63
-
64
- // Build root directory for variations, using same approach as webpack config
65
- const ACTIVITIES_BASE_FOLDER = process.env.ACTIVITIES_BASE_FOLDER || 'Activities';
66
- const ACTIVITY_FOLDER = process.env.ACTIVITY_FOLDER_NAME.trim();
67
- const BUILD_ROOT = path.join(PWD, ACTIVITIES_BASE_FOLDER, ACTIVITY_FOLDER).toString();
68
-
69
- // Validate Adobe credentials
70
80
  if (!process.env.ADOBE_CLIENT_ID || process.env.ADOBE_CLIENT_ID.trim() === '') {
71
81
  console.error(chalk.red('❌ Error: ADOBE_CLIENT_ID environment variable is required but not set.'));
72
- console.error(chalk.yellow('💡 Please set your Adobe Target API credentials in your .env file.'));
82
+ console.error(chalk.yellow('💡 Set your Adobe Target API credentials in your .env file.'));
73
83
  process.exit(1);
74
84
  }
75
-
76
85
  if (!process.env.ADOBE_CLIENT_SECRET || process.env.ADOBE_CLIENT_SECRET.trim() === '') {
77
86
  console.error(chalk.red('❌ Error: ADOBE_CLIENT_SECRET environment variable is required but not set.'));
78
- console.error(chalk.yellow('💡 Please set your Adobe Target API credentials in your .env file.'));
87
+ console.error(chalk.yellow('💡 Set your Adobe Target API credentials in your .env file.'));
79
88
  process.exit(1);
80
89
  }
81
90
 
91
+ const ACTIVITIES_BASE_FOLDER = process.env.ACTIVITIES_BASE_FOLDER || 'Activities';
92
+ const ACTIVITY_FOLDER = process.env.ACTIVITY_FOLDER_NAME.trim();
93
+ const BUILD_ROOT = path.join(PWD, ACTIVITIES_BASE_FOLDER, ACTIVITY_FOLDER).toString();
94
+
82
95
  console.log(chalk.cyan(`📦 Deploying activity: ${ACTIVITY_FOLDER}`));
83
96
  console.log(chalk.cyan(`📁 Activities folder: ${ACTIVITIES_BASE_FOLDER}`));
84
97
 
85
- // Utility: normalize experience name from folder name
98
+ // ─── Helpers ───
99
+
100
+ // Normalize experience name from folder name: lowercase + spaces→hyphens.
101
+ // Matches the convention used by at-sync.js.
86
102
  function normalizeExperienceName(folder) {
87
103
  if (!folder) return '';
88
- const name = folder.toLowerCase();
89
- if (name === 'vcontrol') return 'control';
90
- if (name.startsWith('variation-')) return 'variation ' + name.split('-')[1];
91
- return name;
104
+ return folder.toLowerCase().replace(/\s+/g, '-');
92
105
  }
93
106
 
94
- // Read build.config.json for activity id and allowed variations
107
+ // Read build.config.json. Supports `shared/` and `Shared/` casing.
95
108
  function readBuildJson() {
96
- const buildJsonPath = path.join(BUILD_ROOT, "shared", 'build.config.json');
97
- if (!fs.existsSync(buildJsonPath)) {
98
- throw new Error(`build.json not found at ${buildJsonPath}`);
109
+ const configPaths = [
110
+ path.join(BUILD_ROOT, 'shared', 'build.config.json'),
111
+ path.join(BUILD_ROOT, 'Shared', 'build.config.json')
112
+ ];
113
+ const buildJsonPath = configPaths.find(p => fs.existsSync(p));
114
+ if (!buildJsonPath) {
115
+ throw new Error(`build.config.json not found in ${BUILD_ROOT}/shared/`);
99
116
  }
100
117
  const buildJson = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8'));
101
118
  const activityId = buildJson.activityInfo?.id;
102
- if (activityId === null) {
119
+ if (!activityId) {
103
120
  throw new Error('Invalid build.config.json: missing activity id');
104
121
  }
105
- console.log('buildJson.activityInfo?.variations', buildJson.activityInfo?.variations);
106
- // To support older versions , keeping array checks
122
+ const activityType = (buildJson.activityInfo?.activityType || 'ab').toLowerCase();
123
+ if (!VALID_ACTIVITY_TYPES.includes(activityType)) {
124
+ throw new Error(`Invalid build.config.json: activityType must be one of ${VALID_ACTIVITY_TYPES.join(', ')}, got '${activityType}'`);
125
+ }
126
+
127
+ // Back-compat: array form → object form
107
128
  let allowedVariations = buildJson.activityInfo?.variations;
108
- if (buildJson.activityInfo?.variations && Array.isArray(buildJson.activityInfo.variations)) {
109
- // convert to object
110
- allowedVariations = Object.fromEntries((buildJson.activityInfo?.variations || []).map(v => [v, v]));
129
+ if (Array.isArray(allowedVariations)) {
130
+ allowedVariations = Object.fromEntries(allowedVariations.map(v => [v, v]));
131
+ }
132
+ if (!allowedVariations || Object.keys(allowedVariations).length === 0) {
133
+ throw new Error('Invalid build.config.json: missing variations');
111
134
  }
112
135
 
113
- console.log('allowedVariations', allowedVariations);
136
+ // Detect multi-page format: variation values are objects with { experienceName, pages }
137
+ const firstValue = Object.values(allowedVariations)[0];
138
+ const isMultiPage = typeof firstValue === 'object' && firstValue !== null && !!firstValue.pages;
114
139
 
115
- if (!activityId || !allowedVariations || Object.keys(allowedVariations).length === 0) {
116
- throw new Error('Invalid build.json: missing activity id or variations');
140
+ const pages = buildJson.activityInfo?.pages || null;
141
+ if (isMultiPage && !pages) {
142
+ throw new Error('Invalid build.config.json: multi-page variations require a "pages" map');
117
143
  }
118
- const activityName = buildJson.activityInfo?.name.toLowerCase();
144
+
145
+ const activityName = buildJson.activityInfo?.name?.toLowerCase();
119
146
  if (!activityName) {
120
147
  throw new Error('Invalid build.config.json: missing activity name');
121
148
  }
122
- return { activityId, allowedVariations, activityName };
149
+ return { activityId, activityType, allowedVariations, activityName, isMultiPage, pages };
123
150
  }
124
151
 
125
- // Dynamically discover build assets for each experience/variation
126
- function getBuildAssets() {
152
+ // Discover build assets.
153
+ // Single-page: buildAssets[expKey] = html
154
+ // Multi-page: buildAssets[expKey][pageSubfolder] = html
155
+ function getBuildAssets(isMultiPage, allowedVariations) {
127
156
  const buildAssets = {};
128
157
  if (!fs.existsSync(BUILD_ROOT)) {
129
158
  console.error('Build root does not exist:', BUILD_ROOT);
130
159
  return buildAssets;
131
160
  }
132
161
  const subdirs = fs.readdirSync(BUILD_ROOT, { withFileTypes: true })
133
- .filter(dirent => dirent.isDirectory());
134
- for (const dirent of subdirs) {
135
- const subdir = dirent.name;
136
- const distPath = path.join(BUILD_ROOT, subdir, 'dist', 'at-build.html');
137
- if (fs.existsSync(distPath)) {
138
- const expName = normalizeExperienceName(subdir);
139
- buildAssets[expName] = fs.readFileSync(distPath, 'utf8');
162
+ .filter(d => d.isDirectory() && !['shared', 'Shared', '.DS_Store'].includes(d.name));
163
+
164
+ if (isMultiPage) {
165
+ for (const dirent of subdirs) {
166
+ const expFolder = dirent.name;
167
+ const expKey = normalizeExperienceName(expFolder);
168
+ buildAssets[expKey] = {};
169
+ const expPath = path.join(BUILD_ROOT, expFolder);
170
+
171
+ const pageDirs = fs.readdirSync(expPath, { withFileTypes: true })
172
+ .filter(d => d.isDirectory() && d.name !== 'dist');
173
+ for (const pageDir of pageDirs) {
174
+ const assetPath = path.join(expPath, pageDir.name, 'dist', 'at-build.html');
175
+ if (fs.existsSync(assetPath)) {
176
+ buildAssets[expKey][pageDir.name.toLowerCase()] = fs.readFileSync(assetPath, 'utf8');
177
+ }
178
+ }
179
+
180
+ // Fallback: flat dist/at-build.html reused for every page in the config
181
+ if (Object.keys(buildAssets[expKey]).length === 0) {
182
+ const flatAssetPath = path.join(expPath, 'dist', 'at-build.html');
183
+ if (fs.existsSync(flatAssetPath)) {
184
+ const flatAsset = fs.readFileSync(flatAssetPath, 'utf8');
185
+ const configKey = Object.keys(allowedVariations).find(k => k.toLowerCase() === expKey);
186
+ const varConfig = configKey ? allowedVariations[configKey] : null;
187
+ if (varConfig?.pages) {
188
+ for (const pageFolder of Object.values(varConfig.pages)) {
189
+ const leaf = pageFolder.split('/').pop().toLowerCase();
190
+ buildAssets[expKey][leaf] = flatAsset;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ } else {
197
+ for (const dirent of subdirs) {
198
+ const subdir = dirent.name;
199
+ const distPath = path.join(BUILD_ROOT, subdir, 'dist', 'at-build.html');
200
+ if (fs.existsSync(distPath)) {
201
+ const expKey = normalizeExperienceName(subdir);
202
+ buildAssets[expKey] = fs.readFileSync(distPath, 'utf8');
203
+ }
140
204
  }
141
205
  }
142
206
  return buildAssets;
143
207
  }
144
208
 
145
- // Fetch Adobe OAuth token using client credentials
146
209
  async function fetchAdobeToken() {
147
210
  const data = qs.stringify({
148
211
  grant_type: 'client_credentials',
@@ -150,11 +213,10 @@ async function fetchAdobeToken() {
150
213
  client_secret: process.env.ADOBE_CLIENT_SECRET,
151
214
  scope: IMS_SCOPE
152
215
  });
153
- const headers = {
154
- 'Content-Type': 'application/x-www-form-urlencoded'
155
- };
156
216
  try {
157
- const res = await axios.post(IMS_TOKEN_URL, data, { headers });
217
+ const res = await axios.post(IMS_TOKEN_URL, data, {
218
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
219
+ });
158
220
  return res.data.access_token;
159
221
  } catch (err) {
160
222
  console.error('Failed to fetch Adobe token:', err.response ? err.response.data : err.message);
@@ -162,26 +224,66 @@ async function fetchAdobeToken() {
162
224
  }
163
225
  }
164
226
 
165
- // 1. Run the build (calls your build system, e.g. webpack)
227
+ // Build a clickable AT UI link if ADOBE_TENANT is set; otherwise return null.
228
+ function buildAtUiLink(activityType, activityId) {
229
+ const tenant = (process.env.ADOBE_TENANT || '').trim();
230
+ if (!tenant) return null;
231
+ const slug = { ab: 'ab_manual', xt: 'experience_targeting' }[activityType] || activityType;
232
+ return `https://experience.adobe.com/#/@${tenant}/target/activities/activity-details/${slug}/${activityId}/overview`;
233
+ }
234
+
235
+ // ─── Cooldown lock ───
236
+ if (!DRY_RUN && !FORCE && fs.existsSync(DEPLOY_LOCK_FILE)) {
237
+ const lastDeploy = parseInt(fs.readFileSync(DEPLOY_LOCK_FILE, 'utf8'), 10);
238
+ const elapsed = Math.floor((Date.now() - lastDeploy) / 1000);
239
+ if (Number.isFinite(elapsed) && elapsed < COOLDOWN_SECONDS) {
240
+ console.error(chalk.red(`⛔ Deploy blocked — last deploy was ${elapsed}s ago (cooldown: ${COOLDOWN_SECONDS}s)`));
241
+ console.error(chalk.yellow(` Use --force to override, or wait ${COOLDOWN_SECONDS - elapsed}s.`));
242
+ process.exit(1);
243
+ }
244
+ }
245
+
246
+ if (DRY_RUN) {
247
+ console.log(chalk.bold(chalk.cyan('\n🔍 DRY RUN MODE — no changes will be deployed\n')));
248
+ }
249
+
250
+ // 1. Build (runs in at-builder install dir; webpack reads consumer files via executionPath)
251
+ // stdio is inherited so webpack's per-error output (incl. ESLint file:line:col
252
+ // messages) lands directly in the user's terminal. If the build fails we exit
253
+ // cleanly with the child's exit code instead of letting execFileSync's throw
254
+ // surface as an unhandled Node stack trace.
166
255
  console.log('Running build...');
167
- execSync('npm run atb:build:prod', { stdio: 'inherit' });
256
+ try {
257
+ execFileSync('npm', ['run', 'atb:build:prod'], { stdio: 'inherit', shell: true });
258
+ } catch (err) {
259
+ console.error(chalk.red('\n❌ Build failed. Fix the errors above and re-run "atb deploy".'));
260
+ process.exit(typeof err.status === 'number' ? err.status : 1);
261
+ }
168
262
 
169
263
  (async () => {
170
264
  try {
171
- // Read build config and allowed variations
172
- const { activityId, allowedVariations, activityName } = readBuildJson();
173
- let listOfAllowedVariations = [];
174
-
175
- listOfAllowedVariations = Object.keys(allowedVariations).map(v => v.toLowerCase());
176
-
177
- const allowedVariationReversedKey = Object.fromEntries(Object.entries(allowedVariations).map(([key, value]) => [value.toLowerCase(), key.toLowerCase()]));
265
+ const { activityId, activityType, allowedVariations, activityName, isMultiPage, pages } = readBuildJson();
266
+ const ACTIVITY_URL = `${BASE_URL}${activityType}/${activityId}`;
267
+ console.log(chalk.cyan(`Activity Type: ${activityType.toUpperCase()} | ID: ${activityId}${isMultiPage ? ' | Multi-Page' : ''}`));
268
+
269
+ // Reverse lookup: AT experience name → config folder key
270
+ const listOfAllowedVariations = Object.keys(allowedVariations).map(v => v.toLowerCase());
271
+ let allowedVariationReversedKey = {};
272
+ if (isMultiPage) {
273
+ for (const [folderKey, config] of Object.entries(allowedVariations)) {
274
+ allowedVariationReversedKey[config.experienceName.toLowerCase()] = folderKey.toLowerCase();
275
+ }
276
+ } else {
277
+ allowedVariationReversedKey = Object.fromEntries(
278
+ Object.entries(allowedVariations).map(([key, value]) => [value.toLowerCase(), key.toLowerCase()])
279
+ );
280
+ }
178
281
 
179
282
  const API_KEY = process.env.ADOBE_CLIENT_ID;
180
283
  const token = await fetchAdobeToken();
181
284
 
182
- // 2. Fetch current Adobe Target activity
183
- const getActivity = async (activityId) => {
184
- const res = await axios.get(`${BASE_URL}${activityId}`, {
285
+ const getActivity = async () => {
286
+ const res = await axios.get(ACTIVITY_URL, {
185
287
  headers: {
186
288
  'X-Api-Key': API_KEY,
187
289
  Accept: 'application/vnd.adobe.target.v3+json',
@@ -191,96 +293,165 @@ execSync('npm run atb:build:prod', { stdio: 'inherit' });
191
293
  return res.data;
192
294
  };
193
295
 
194
- // 3. Update activity with new build info
195
- const updateActivity = async (activity, activityId) => {
196
- if (activity.state === "approved") {
197
- /*
198
- Status Mapping Logic for UI Display:
199
-
200
- API State | Start At | End At | UI Status
201
- -------------------------------------------------------------
202
- Approved | in the past | in the future | Live
203
- Approved | in the past | in the past | Ended
204
- Approved | in the future | in the future | Scheduled
205
- Saved | - | - | Inactive
206
- Deactivated | - | - | Archived
207
- Deleted | - | - | Does Not Appear in the UI
208
-
209
- Notes:
210
- - "Start At" and "End At" are datetime values used to determine temporal status.
211
- - For states with "-", time values are not evaluated.
212
- - Items with "Deleted" status should be filtered out entirely from the UI.
213
- */
214
-
296
+ const updateActivity = async (activity) => {
297
+ if (activity.state === 'approved') {
215
298
  throw new Error("Live activity can't be updated");
216
299
  }
217
- // Dynamically get build assets
218
- const buildAssets = getBuildAssets();
219
-
220
- // Prepare new payload (copy structure)
221
- const newPayload = {
222
- name: activity.name,
223
- options: activity.options,
224
- locations: activity.locations,
225
- experiences: activity.experiences,
226
- metrics: activity.metrics,
227
- analytics: activity.analytics
228
- };
300
+
301
+ const buildAssets = getBuildAssets(isMultiPage, allowedVariations);
302
+
303
+ // Deep copy so we can safely mutate options/optionLocations.
304
+ const newPayload = JSON.parse(JSON.stringify(activity));
229
305
 
230
306
  if (activityName !== activity.name.toLowerCase()) {
231
307
  throw new Error('Activity name in build.config.json does not match activity name in Adobe Target');
232
308
  }
233
309
 
310
+ // Track next available optionLocalId for newly created options.
311
+ let nextOptionId = Math.max(...newPayload.options.map(o => o.optionLocalId)) + 1;
312
+
313
+ function createOption(asset, label) {
314
+ const newId = nextOptionId++;
315
+ newPayload.options.push({
316
+ optionLocalId: newId,
317
+ name: `Offer ${newId}`,
318
+ offerId: 0,
319
+ offerTemplates: [{
320
+ offerTemplateId: OFFER_TEMPLATE_ID,
321
+ templateParameters: [
322
+ { name: 'uiData', value: JSON.stringify({ tagType: 'Code', actionType: 'added' }) },
323
+ { name: 'offerContent', value: asset }
324
+ ]
325
+ }]
326
+ });
327
+ console.log(chalk.green(` + Created new option[${newId}] for ${label}`));
328
+ return newId;
329
+ }
330
+
234
331
  const updatedExperiences = [];
235
332
  const skippedExperiences = [];
236
333
  const unchangedExperiences = [];
334
+ const createdOptions = [];
237
335
  let anyChange = false;
238
336
 
239
- // For each experience, update only if in allowed variations and asset exists
240
337
  newPayload.experiences.forEach(exp => {
241
338
  let expName = exp.name.trim().toLowerCase();
242
- // Added to handle new type of build config
243
339
  if (allowedVariationReversedKey) {
244
340
  expName = allowedVariationReversedKey[expName] || expName;
245
341
  }
246
-
247
342
  if (!listOfAllowedVariations.includes(expName)) {
248
- skippedExperiences.push({ name: exp.name, reason: 'Not in allowed variations in build.config.json' });
343
+ skippedExperiences.push({ name: exp.name, reason: 'Not in allowed variations' });
249
344
  return;
250
345
  }
251
346
 
347
+ expName = normalizeExperienceName(expName);
252
348
  if (!buildAssets[expName]) {
253
349
  skippedExperiences.push({ name: exp.name, reason: 'No build asset found' });
254
350
  return;
255
351
  }
352
+
256
353
  let changed = false;
257
- exp.optionLocations.forEach(ol => {
258
- const { optionLocalId } = ol;
259
- const option = newPayload.options.find(o => o.optionLocalId === optionLocalId);
260
- if (!option) return;
261
- option.offerTemplates.forEach(offerTemplate => {
262
- if (offerTemplate.offerTemplateId === 133) {
263
- offerTemplate.templateParameters.forEach(param => {
264
- if (param.name === 'offerContent') {
265
- if (param.value !== buildAssets[expName]) {
266
- param.value = buildAssets[expName];
354
+
355
+ if (isMultiPage) {
356
+ const variationConfig = allowedVariations[
357
+ Object.keys(allowedVariations).find(k => k.toLowerCase() === expName)
358
+ ];
359
+ const pageMap = variationConfig?.pages || {};
360
+
361
+ const locationToPage = {};
362
+ for (const [pageName, locId] of Object.entries(pages)) {
363
+ locationToPage[locId] = pageName;
364
+ }
365
+
366
+ const claimedOptions = new Set();
367
+
368
+ exp.optionLocations.forEach(ol => {
369
+ const { locationLocalId, optionLocalId } = ol;
370
+ const pageName = locationToPage[locationLocalId];
371
+ if (!pageName) return;
372
+
373
+ const assetSubPath = pageMap[pageName];
374
+ if (!assetSubPath) return;
375
+
376
+ const pageFolder = assetSubPath.split('/').pop().toLowerCase();
377
+ const asset = buildAssets[expName]?.[pageFolder];
378
+ if (!asset) {
379
+ console.log(chalk.yellow(` No asset for ${exp.name} → ${pageName} (${pageFolder})`));
380
+ return;
381
+ }
382
+
383
+ if (optionLocalId === 0) {
384
+ const newId = createOption(asset, `${exp.name} → ${pageName}`);
385
+ ol.optionLocalId = newId;
386
+ changed = true;
387
+ anyChange = true;
388
+ createdOptions.push(`${exp.name} → ${pageName}`);
389
+ return;
390
+ }
391
+
392
+ if (claimedOptions.has(optionLocalId)) {
393
+ const existingOption = newPayload.options.find(o => o.optionLocalId === optionLocalId);
394
+ const existingContent = existingOption?.offerTemplates
395
+ ?.find(t => t.offerTemplateId === OFFER_TEMPLATE_ID)
396
+ ?.templateParameters?.find(p => p.name === 'offerContent')?.value;
397
+
398
+ if (existingContent === asset) {
399
+ console.log(chalk.green(` ✓ ${exp.name} → ${pageName} (reused)`));
400
+ return;
401
+ }
402
+
403
+ const newId = createOption(asset, `${exp.name} → ${pageName}`);
404
+ ol.optionLocalId = newId;
405
+ changed = true;
406
+ anyChange = true;
407
+ createdOptions.push(`${exp.name} → ${pageName}`);
408
+ return;
409
+ }
410
+
411
+ claimedOptions.add(optionLocalId);
412
+ const option = newPayload.options.find(o => o.optionLocalId === optionLocalId);
413
+ if (!option) return;
414
+ option.offerTemplates.forEach(ot => {
415
+ if (ot.offerTemplateId === OFFER_TEMPLATE_ID) {
416
+ ot.templateParameters.forEach(param => {
417
+ if (param.name === 'offerContent' && param.value !== asset) {
418
+ param.value = asset;
267
419
  changed = true;
268
420
  anyChange = true;
421
+ console.log(chalk.green(` ✓ ${exp.name} → ${pageName}`));
269
422
  }
270
- }
271
- });
272
- }
423
+ });
424
+ }
425
+ });
273
426
  });
274
- });
275
- if (changed) {
276
- updatedExperiences.push(exp.name);
277
427
  } else {
278
- unchangedExperiences.push(exp.name);
428
+ exp.optionLocations.forEach(ol => {
429
+ const { optionLocalId } = ol;
430
+ const option = newPayload.options.find(o => o.optionLocalId === optionLocalId);
431
+ if (!option) return;
432
+ option.offerTemplates.forEach(ot => {
433
+ if (ot.offerTemplateId === OFFER_TEMPLATE_ID) {
434
+ ot.templateParameters.forEach(param => {
435
+ if (param.name === 'offerContent' && param.value !== buildAssets[expName]) {
436
+ param.value = buildAssets[expName];
437
+ changed = true;
438
+ anyChange = true;
439
+ }
440
+ });
441
+ }
442
+ });
443
+ });
279
444
  }
445
+
446
+ if (changed) updatedExperiences.push(exp.name);
447
+ else unchangedExperiences.push(exp.name);
280
448
  });
281
449
 
282
- // Logging summary
450
+ // ── Summary ──
283
451
  console.log(chalk.bold('--- Experience Update Summary ---'));
452
+ if (createdOptions.length > 0) {
453
+ console.log(chalk.green('Created new options:'), createdOptions.join(', '));
454
+ }
284
455
  if (updatedExperiences.length > 0) {
285
456
  console.log(chalk.green('Updated experiences:'), updatedExperiences.join(', '));
286
457
  }
@@ -295,22 +466,85 @@ execSync('npm run atb:build:prod', { stdio: 'inherit' });
295
466
  if (!anyChange) {
296
467
  console.log(chalk.cyan('No changes were made to any experience.'));
297
468
  } else {
298
- // Uncomment to actually update Adobe Target
299
- await axios.put(`${BASE_URL}${activityId}`, newPayload, {
300
- headers: {
301
- 'X-Api-Key': API_KEY,
302
- 'Content-Type': 'application/vnd.adobe.target.v3+json',
303
- Authorization: `Bearer ${token}`
304
- }
469
+ // Prune orphaned options (no longer referenced by any experience).
470
+ const referencedOptionIds = new Set();
471
+ newPayload.experiences.forEach(exp => {
472
+ exp.optionLocations.forEach(ol => referencedOptionIds.add(ol.optionLocalId));
305
473
  });
306
- console.log('New build deployed!');
474
+ const beforeCount = newPayload.options.length;
475
+ newPayload.options = newPayload.options.filter(o => referencedOptionIds.has(o.optionLocalId));
476
+ const pruned = beforeCount - newPayload.options.length;
477
+ if (pruned > 0) {
478
+ console.log(chalk.cyan(`Pruned ${pruned} orphaned option(s)`));
479
+ }
480
+
481
+ // Deduplicate options by offerContent. AT rejects PUTs with two
482
+ // options that have byte-identical offerContent (UniqueElements).
483
+ {
484
+ const contentToSurvivorId = new Map();
485
+ const duplicateIdToSurvivorId = new Map();
486
+
487
+ newPayload.options.forEach(opt => {
488
+ if (opt.optionLocalId === 0) return; // never merge Default Content
489
+ const offerContent = opt.offerTemplates
490
+ ?.find(t => t.offerTemplateId === OFFER_TEMPLATE_ID)
491
+ ?.templateParameters?.find(p => p.name === 'offerContent')?.value;
492
+ if (offerContent === undefined) return;
493
+
494
+ if (!contentToSurvivorId.has(offerContent)) {
495
+ contentToSurvivorId.set(offerContent, opt.optionLocalId);
496
+ } else {
497
+ duplicateIdToSurvivorId.set(opt.optionLocalId, contentToSurvivorId.get(offerContent));
498
+ }
499
+ });
500
+
501
+ if (duplicateIdToSurvivorId.size > 0) {
502
+ newPayload.experiences.forEach(exp => {
503
+ exp.optionLocations.forEach(ol => {
504
+ if (duplicateIdToSurvivorId.has(ol.optionLocalId)) {
505
+ ol.optionLocalId = duplicateIdToSurvivorId.get(ol.optionLocalId);
506
+ }
507
+ });
508
+ });
509
+ const duplicateIds = new Set(duplicateIdToSurvivorId.keys());
510
+ newPayload.options = newPayload.options.filter(o => !duplicateIds.has(o.optionLocalId));
511
+ console.log(chalk.cyan(`Deduplicated ${duplicateIds.size} option(s) with identical offerContent`));
512
+ }
513
+ }
514
+
515
+ if (DRY_RUN) {
516
+ console.log(chalk.cyan('\n🔍 DRY RUN — Changes that would be deployed:'));
517
+ console.log(chalk.cyan(` Options: ${newPayload.options.length}`));
518
+ console.log(chalk.cyan(` Experiences: ${newPayload.experiences.length}`));
519
+ newPayload.experiences.forEach(exp => {
520
+ const opts = exp.optionLocations.map(ol => `loc:${ol.locationLocalId}→opt:${ol.optionLocalId}`);
521
+ console.log(chalk.cyan(` ${exp.name}: [${opts.join(', ')}]`));
522
+ });
523
+ console.log(chalk.bold(chalk.yellow('\n⏭️ Skipped PUT — run without --dry-run to deploy.')));
524
+ } else {
525
+ await axios.put(ACTIVITY_URL, newPayload, {
526
+ headers: {
527
+ 'X-Api-Key': API_KEY,
528
+ 'Content-Type': 'application/vnd.adobe.target.v3+json',
529
+ Authorization: `Bearer ${token}`
530
+ }
531
+ });
532
+ fs.writeFileSync(DEPLOY_LOCK_FILE, String(Date.now()));
533
+ console.log('New build deployed!');
534
+ }
535
+ }
536
+
537
+ const atUiLink = buildAtUiLink(activityType, activityId);
538
+ if (atUiLink) {
539
+ console.log(chalk.cyan(`\n🔗 Activity: ${atUiLink}`));
540
+ } else {
541
+ console.log(chalk.cyan(`\n🔗 Activity ID: ${activityId} (set ADOBE_TENANT in .env for a clickable link)`));
307
542
  }
308
543
  console.log(chalk.bold('---------------------------------'));
309
544
  };
310
545
 
311
- const activity = await getActivity(activityId);
312
- // console.log('Current activity:', activity);
313
- await updateActivity(activity, activityId);
546
+ const activity = await getActivity();
547
+ await updateActivity(activity);
314
548
  } catch (err) {
315
549
  console.error(chalk.red('Error:'), err.response ? err.response.data : err.message);
316
550
  process.exit(1);