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/.claude/settings.local.json +53 -11
- package/.plop/constants/index.js +0 -7
- package/.plop/generators/actions.js +217 -126
- package/.plop/generators/prompts.js +50 -18
- package/.plop/utils/index.js +19 -5
- package/.vscode/settings.json +6 -0
- package/DEVELOPMENT.md +164 -0
- package/README.md +23 -8
- package/at-builder-0.0.2.vsix +0 -0
- package/bin/constants/config.js +169 -167
- package/bin/index.js +494 -182
- package/bin/services/doctor.js +752 -290
- package/bin/services/logger.js +40 -20
- package/lib/at-deploy.js +379 -145
- package/lib/at-sync.js +455 -0
- package/lib/eslint-flat-config-plugin.js +34 -33
- package/lib/install-checks.js +236 -0
- package/lib/postinstall.js +90 -0
- package/package/package.json +86 -0
- package/package.json +18 -11
- package/puppeteer.js +128 -32
- package/src/constants/config.ts +84 -9
- package/src/index.ts +107 -11
- package/src/services/doctor.ts +377 -39
- package/webpack.config.js +228 -39
- package/.plop/templates/build-template.hbs +0 -7
- package/.plop/templates/build.config.hbs +0 -7
- package/.plop/templates/observer.hbs +0 -18
package/lib/at-deploy.js
CHANGED
|
@@ -4,47 +4,59 @@
|
|
|
4
4
|
/*
|
|
5
5
|
* Adobe Target Build Deploy Script
|
|
6
6
|
* --------------------------------
|
|
7
|
-
*
|
|
7
|
+
* Automates deployment of build assets to an Adobe Target activity.
|
|
8
8
|
*
|
|
9
9
|
* Features:
|
|
10
|
-
* - Reads
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
14
|
-
* -
|
|
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
|
-
*
|
|
20
|
+
* Usage (via CLI):
|
|
21
|
+
* atb deploy [--dry-run] [--force]
|
|
18
22
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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('💡
|
|
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('💡
|
|
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('💡
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
107
|
+
// Read build.config.json. Supports `shared/` and `Shared/` casing.
|
|
95
108
|
function readBuildJson() {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
119
|
+
if (!activityId) {
|
|
103
120
|
throw new Error('Invalid build.config.json: missing activity id');
|
|
104
121
|
}
|
|
105
|
-
|
|
106
|
-
|
|
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 (
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
buildAssets[
|
|
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, {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
218
|
-
const buildAssets = getBuildAssets();
|
|
219
|
-
|
|
220
|
-
//
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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(
|
|
312
|
-
|
|
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);
|