at-builder 1.2.8 → 1.3.3

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-sync.js ADDED
@@ -0,0 +1,455 @@
1
+ /* eslint-disable no-undef */
2
+ /* eslint-disable @typescript-eslint/no-require-imports */
3
+
4
+ /*
5
+ * at-sync.js — Sync build.config.json with Adobe Target activity details
6
+ * ----------------------------------------------------------------------
7
+ * Reads activity id/type from build.config.json, fetches the full activity
8
+ * from the AT API, and rewrites build.config.json with:
9
+ * - Activity name
10
+ * - Experiences/variations with correct names
11
+ * - Pages and locationLocalId mapping (for multi-page activities)
12
+ * - locationsMeta block (informational; preserved across syncs)
13
+ *
14
+ * Usage (via CLI):
15
+ * atb sync [--scaffold]
16
+ *
17
+ * Usage (direct, when developing at-builder itself):
18
+ * node lib/at-sync.js [--scaffold]
19
+ *
20
+ * Path resolution:
21
+ * at-builder is a globally-installed CLI. Every consumer file resolves against
22
+ * PWD = process.env.executionPath || process.cwd() so that the user's project
23
+ * directory is the source of truth, not at-builder's install location.
24
+ *
25
+ * Environment variables (consumer's .env):
26
+ * ACTIVITY_FOLDER_NAME (required)
27
+ * ACTIVITIES_BASE_FOLDER (optional, defaults to "Activities")
28
+ * ADOBE_CLIENT_ID (required)
29
+ * ADOBE_CLIENT_SECRET (required)
30
+ * ADOBE_TENANT (optional, your AT tenant slug — enables clickable AT UI link)
31
+ */
32
+
33
+ /* eslint-env node */
34
+ 'use strict';
35
+
36
+ const path = require('path');
37
+ const fs = require('fs');
38
+ const axios = require('axios');
39
+ const qs = require('querystring');
40
+ const dotenv = require('dotenv');
41
+
42
+ const PWD = process.env.executionPath || process.cwd();
43
+ dotenv.config({ path: path.join(PWD, '.env'), quiet: true });
44
+
45
+ const SCAFFOLD = process.argv.includes('--scaffold');
46
+
47
+ // Fallback to ANSI codes since chalk 5.x is ESM only
48
+ const chalk = {
49
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
50
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
51
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
52
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
53
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
54
+ gray: (s) => `\x1b[90m${s}\x1b[0m`,
55
+ white: (s) => `\x1b[37m${s}\x1b[0m`
56
+ };
57
+
58
+ // ─── Validate env ───
59
+ if (!process.env.ACTIVITY_FOLDER_NAME || process.env.ACTIVITY_FOLDER_NAME.trim() === '') {
60
+ console.error(chalk.red('❌ Error: ACTIVITY_FOLDER_NAME environment variable is required but not set.'));
61
+ console.error(chalk.yellow('💡 Set ACTIVITY_FOLDER_NAME in your .env file or run "atb doctor --fix".'));
62
+ process.exit(1);
63
+ }
64
+ if (!process.env.ADOBE_CLIENT_ID || !process.env.ADOBE_CLIENT_SECRET) {
65
+ console.error(chalk.red('❌ Error: ADOBE_CLIENT_ID and ADOBE_CLIENT_SECRET are required.'));
66
+ console.error(chalk.yellow('💡 Set your Adobe Target API credentials in your .env file.'));
67
+ process.exit(1);
68
+ }
69
+
70
+ const { BASE_URL, IMS_TOKEN_URL, IMS_SCOPE } = require(path.join(PWD, 'adobe.config'));
71
+
72
+ const API_KEY = process.env.ADOBE_CLIENT_ID;
73
+ const ACTIVITIES_BASE_FOLDER = process.env.ACTIVITIES_BASE_FOLDER || 'Activities';
74
+ const ACTIVITY_FOLDER = process.env.ACTIVITY_FOLDER_NAME.trim();
75
+ const BUILD_ROOT = path.join(PWD, ACTIVITIES_BASE_FOLDER, ACTIVITY_FOLDER);
76
+
77
+ const CONFIG_PATHS = [
78
+ path.join(BUILD_ROOT, 'shared', 'build.config.json'),
79
+ path.join(BUILD_ROOT, 'Shared', 'build.config.json')
80
+ ];
81
+
82
+ // ─── Auth ───
83
+ async function fetchToken() {
84
+ const data = qs.stringify({
85
+ grant_type: 'client_credentials',
86
+ client_id: process.env.ADOBE_CLIENT_ID,
87
+ client_secret: process.env.ADOBE_CLIENT_SECRET,
88
+ scope: IMS_SCOPE
89
+ });
90
+ const res = await axios.post(IMS_TOKEN_URL, data, {
91
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
92
+ });
93
+ return res.data.access_token;
94
+ }
95
+
96
+ // Build a clickable AT UI link if ADOBE_TENANT is set; otherwise return null.
97
+ function buildAtUiLink(activityType, activityId) {
98
+ const tenant = (process.env.ADOBE_TENANT || '').trim();
99
+ if (!tenant) return null;
100
+ return `https://experience.adobe.com/#/@${tenant}/target/activities/${activityType}/${activityId}`;
101
+ }
102
+
103
+ // ─── Main ───
104
+ (async () => {
105
+ try {
106
+ console.log(chalk.cyan(`📦 Syncing activity: ${ACTIVITY_FOLDER}`));
107
+ console.log(chalk.cyan(`📁 Activities folder: ${ACTIVITIES_BASE_FOLDER}`));
108
+
109
+ // 1. Read existing config
110
+ const configPath = CONFIG_PATHS.find(p => fs.existsSync(p));
111
+ if (!configPath) {
112
+ console.error(chalk.red('❌ No build.config.json found. Checked:'));
113
+ CONFIG_PATHS.forEach(p => console.error(chalk.gray(` ${p}`)));
114
+ console.error(chalk.yellow('💡 Run "atb new" to create an activity, then add the activity id to build.config.json.'));
115
+ process.exit(1);
116
+ }
117
+
118
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
119
+ const { id, activityType } = config.activityInfo || {};
120
+
121
+ if (!id) {
122
+ console.error(chalk.red('❌ No activity id found in build.config.json'));
123
+ console.error(chalk.yellow('💡 Edit shared/build.config.json and set activityInfo.id to your AT activity id.'));
124
+ process.exit(1);
125
+ }
126
+
127
+ const type = (activityType || 'ab').toLowerCase();
128
+ console.log(chalk.cyan(`\n🔄 Syncing activity ${type.toUpperCase()} #${id}...`));
129
+
130
+ // 2. Fetch from AT API
131
+ const token = await fetchToken();
132
+ const activityUrl = `${BASE_URL}${type}/${id}`;
133
+
134
+ const res = await axios.get(activityUrl, {
135
+ headers: {
136
+ 'X-Api-Key': API_KEY,
137
+ Accept: 'application/vnd.adobe.target.v3+json',
138
+ Authorization: `Bearer ${token}`
139
+ }
140
+ });
141
+
142
+ const activity = res.data;
143
+ console.log(chalk.green(`✓ Fetched: "${activity.name}" (state: ${activity.state})`));
144
+
145
+ // 3. Extract locations (pages)
146
+ const selectors = activity.locations?.selectors || activity.locations?.mboxes || [];
147
+ const hasMultipleLocations = selectors.length > 1;
148
+
149
+ if (hasMultipleLocations) {
150
+ console.log(chalk.cyan(`\n📄 Pages (${selectors.length} locations):`));
151
+ selectors.forEach(s => {
152
+ console.log(chalk.gray(` [${s.locationLocalId}] ${s.name}`));
153
+ });
154
+ }
155
+
156
+ // 4. Extract experiences
157
+ console.log(chalk.cyan(`\n👥 Experiences (${activity.experiences.length}):`));
158
+ activity.experiences.forEach(exp => {
159
+ const optionIds = [...new Set(exp.optionLocations.map(ol => ol.optionLocalId))];
160
+ console.log(chalk.gray(` ${exp.name} → options: [${optionIds.join(', ')}]`));
161
+ });
162
+
163
+ // 5. Build updated config
164
+ const updatedConfig = { activityInfo: {} };
165
+ updatedConfig.activityInfo.id = id;
166
+ updatedConfig.activityInfo.activityType = type;
167
+ updatedConfig.activityInfo.name = activity.name;
168
+
169
+ if (hasMultipleLocations) {
170
+ // Preserve existing page names (AT API only returns generic "Location N";
171
+ // descriptive names live in the UI and the config carries them across syncs).
172
+ const existingPages = config.activityInfo?.pages || {};
173
+ const locIdToExistingName = {};
174
+ for (const [name, locId] of Object.entries(existingPages)) {
175
+ locIdToExistingName[locId] = name;
176
+ }
177
+
178
+ updatedConfig.activityInfo.pages = {};
179
+ const hasGenericNames = [];
180
+ selectors.forEach(s => {
181
+ const customName = locIdToExistingName[s.locationLocalId];
182
+ const pageName = customName || s.name;
183
+ updatedConfig.activityInfo.pages[pageName] = s.locationLocalId;
184
+ if (!customName) hasGenericNames.push(pageName);
185
+ });
186
+
187
+ if (hasGenericNames.length > 0) {
188
+ console.log(chalk.yellow(`\n⚠️ ${hasGenericNames.length} page(s) have generic names: ${hasGenericNames.join(', ')}`));
189
+ console.log(chalk.yellow(' Rename them in build.config.json → pages to match your AT UI page names.'));
190
+ }
191
+
192
+ const locIdToName = {};
193
+ for (const [name, locId] of Object.entries(updatedConfig.activityInfo.pages)) {
194
+ locIdToName[locId] = name;
195
+ }
196
+
197
+ updatedConfig.activityInfo.variations = {};
198
+
199
+ activity.experiences.forEach(exp => {
200
+ const expName = exp.name.trim();
201
+ const folderName = expName.replace(/\s+/g, '-');
202
+
203
+ const expPages = {};
204
+ exp.optionLocations.forEach(ol => {
205
+ // Skip locations using Default Content (optionLocalId 0) —
206
+ // they have no code to deploy, so no asset path needed.
207
+ if (ol.optionLocalId === 0) return;
208
+
209
+ const pageName = locIdToName[ol.locationLocalId];
210
+ if (pageName) {
211
+ const pageFolder = pageName
212
+ .replace(/[^a-zA-Z0-9\s]/g, '')
213
+ .replace(/\s+/g, '')
214
+ .trim();
215
+ expPages[pageName] = `${folderName}/${pageFolder}`;
216
+ }
217
+ });
218
+
219
+ updatedConfig.activityInfo.variations[folderName] = {
220
+ experienceName: expName,
221
+ pages: expPages
222
+ };
223
+ });
224
+
225
+ // ── locationsMeta: human-readable location reference ──
226
+ // Purely informational — never read by at-deploy.js.
227
+ // Shows selector, audiences, and per-experience active/inactive status.
228
+ // The "note" field is freeform and preserved across syncs.
229
+ {
230
+ const existingMeta = config.activityInfo?.locationsMeta || {};
231
+
232
+ const expContentMap = {};
233
+ activity.experiences.forEach(exp => {
234
+ const folderName = exp.name.trim().replace(/\s+/g, '-');
235
+ expContentMap[folderName] = {};
236
+ exp.optionLocations.forEach(ol => {
237
+ expContentMap[folderName][ol.locationLocalId] = ol.optionLocalId !== 0;
238
+ });
239
+ });
240
+
241
+ const locationsMeta = {};
242
+ selectors.forEach(s => {
243
+ const pageName = locIdToName[s.locationLocalId] || s.name;
244
+ const existing = existingMeta[pageName] || {};
245
+
246
+ const experiences = {};
247
+ Object.entries(expContentMap).forEach(([expFolder, locMap]) => {
248
+ const hasContent = locMap[s.locationLocalId];
249
+ experiences[expFolder] = hasContent === true ? 'active'
250
+ : hasContent === false ? 'inactive'
251
+ : 'not-assigned';
252
+ });
253
+
254
+ locationsMeta[pageName] = {
255
+ id: s.locationLocalId,
256
+ selector: s.selector,
257
+ note: existing.note || '',
258
+ experiences
259
+ };
260
+ });
261
+
262
+ updatedConfig.activityInfo.locationsMeta = locationsMeta;
263
+ }
264
+ } else {
265
+ // Single-page: simple string variations
266
+ updatedConfig.activityInfo.variations = {};
267
+ activity.experiences.forEach(exp => {
268
+ const expName = exp.name.trim();
269
+ const folderName = expName.replace(/\s+/g, '-');
270
+ updatedConfig.activityInfo.variations[folderName] = expName;
271
+ });
272
+ }
273
+
274
+ // Preserve buildFolders if present (Vanalytics, etc.)
275
+ if (config.activityInfo?.buildFolders) {
276
+ updatedConfig.activityInfo.buildFolders = config.activityInfo.buildFolders;
277
+ }
278
+
279
+ // 6. Write updated config
280
+ fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 4) + '\n');
281
+ console.log(chalk.green(`\n✅ Updated ${configPath}`));
282
+
283
+ // 7. Summary
284
+ console.log(chalk.bold('\n--- Sync Summary ---'));
285
+ console.log(chalk.white(`Activity: ${activity.name}`));
286
+ console.log(chalk.white(`Type: ${type.toUpperCase()}`));
287
+ console.log(chalk.white(`State: ${activity.state}`));
288
+ console.log(chalk.white(`Pages: ${hasMultipleLocations ? selectors.length : 1}`));
289
+ console.log(chalk.white(`Experiences: ${activity.experiences.length}`));
290
+
291
+ const variations = updatedConfig.activityInfo.variations;
292
+ console.log(chalk.cyan('\nGenerated variations:'));
293
+ Object.entries(variations).forEach(([folder, value]) => {
294
+ if (typeof value === 'string') {
295
+ console.log(chalk.gray(` ${folder}/ → "${value}"`));
296
+ } else {
297
+ console.log(chalk.gray(` ${folder}/ → "${value.experienceName}"`));
298
+ Object.entries(value.pages).forEach(([page, subpath]) => {
299
+ console.log(chalk.gray(` └── ${page} → ${subpath}/`));
300
+ });
301
+ }
302
+ });
303
+
304
+ // 8. Folder status
305
+ console.log(chalk.cyan('\nFolder status:'));
306
+ const foldersToCreate = [];
307
+ Object.entries(variations).forEach(([folder, value]) => {
308
+ if (typeof value === 'string') {
309
+ const fullPath = path.join(BUILD_ROOT, folder);
310
+ const exists = fs.existsSync(fullPath);
311
+ console.log(` ${exists ? '✅' : '❌'} ${folder}/`);
312
+ if (!exists) foldersToCreate.push(folder);
313
+ } else {
314
+ Object.values(value.pages).forEach(subpath => {
315
+ const fullPath = path.join(BUILD_ROOT, subpath);
316
+ const exists = fs.existsSync(fullPath);
317
+ console.log(` ${exists ? '✅' : '❌'} ${subpath}/`);
318
+ if (!exists) foldersToCreate.push(subpath);
319
+ });
320
+ }
321
+ });
322
+
323
+ if (foldersToCreate.length > 0) {
324
+ if (SCAFFOLD) {
325
+ console.log(chalk.green(`\n📁 Scaffolding ${foldersToCreate.length} missing folder(s)...`));
326
+ foldersToCreate.forEach(f => {
327
+ const fullPath = path.join(BUILD_ROOT, f);
328
+ scaffoldFolder(fullPath);
329
+ });
330
+ console.log(chalk.green('✅ All folders scaffolded!'));
331
+ } else {
332
+ console.log(chalk.yellow(`\n⚠️ ${foldersToCreate.length} folder(s) don't exist yet.`));
333
+ console.log(chalk.yellow(' Run "atb sync --scaffold" to auto-create, or create manually:'));
334
+ foldersToCreate.forEach(f => {
335
+ console.log(chalk.gray(` mkdir -p "${path.join(BUILD_ROOT, f)}"`));
336
+ });
337
+ }
338
+ }
339
+
340
+ const atUiLink = buildAtUiLink(type, id);
341
+ if (atUiLink) {
342
+ console.log(chalk.cyan(`\n🔗 Activity: ${atUiLink}`));
343
+ } else {
344
+ console.log(chalk.cyan(`\n🔗 Activity ID: ${id} (set ADOBE_TENANT in .env for a clickable link)`));
345
+ }
346
+ console.log(chalk.bold('\n--------------------\n'));
347
+ } catch (err) {
348
+ if (err.response) {
349
+ console.error(chalk.red('❌ API Error:'), err.response.data);
350
+ } else {
351
+ console.error(chalk.red('❌ Error:'), err.message);
352
+ }
353
+ process.exit(1);
354
+ }
355
+ })();
356
+
357
+ /**
358
+ * Scaffold a missing variation folder with at-builder's variation skeleton:
359
+ * index.js, scripts/app.js, css/style.scss, constants/index.js
360
+ *
361
+ * Mirrors the boilerplate emitted by .plop/templates so scaffolded variations
362
+ * are indistinguishable from ones created via `atb new`.
363
+ */
364
+ function scaffoldFolder(fullPath) {
365
+ const indexContent = `/**
366
+ * This is the test entry point and main variations gets executed from here
367
+ */
368
+
369
+ // Load styles
370
+ import "./css/style.scss";
371
+
372
+ import { App } from './scripts/app';
373
+ const app = new App();
374
+ app.init();
375
+ `;
376
+
377
+ const appContent = `// import * as CONSTANTS from '../constants';
378
+
379
+ export class App {
380
+ /**
381
+ * @param define constructor properties
382
+ */
383
+ constructor() {
384
+ this.state = {}; // For managing app state
385
+ }
386
+
387
+ /**
388
+ * Initializes the application, sets up observer and logs the event
389
+ */
390
+ init = () => {
391
+ this.isRunning = true;
392
+ this.onStart();
393
+ }
394
+
395
+ /**
396
+ * Starts the app or app-specific functionality
397
+ */
398
+ onStart = () => {
399
+ // Additional initialization logic (e.g., setting up listeners, data fetching)
400
+ }
401
+
402
+ /**
403
+ * Reset the app to its initial state
404
+ */
405
+ reset = () => {
406
+ this.state = {};
407
+ this.onStart();
408
+ }
409
+
410
+ /**
411
+ * Disconnect mutation observer, clear any window variable or perform cleanup activity
412
+ */
413
+ destroy = () => {
414
+ }
415
+
416
+ /**
417
+ * Mutation Observer Callback
418
+ */
419
+ callback = () => {
420
+ // Callback logic for mutation observer
421
+ }
422
+ }
423
+ `;
424
+
425
+ const styleContent = `/* Add variation specific scss properties here */
426
+
427
+
428
+ `;
429
+
430
+ const constantsContent = `/**
431
+ * Declare all Variation level constants here
432
+ */
433
+
434
+ export const VariationConstants = {
435
+ sampleConstants: { country: "us" }
436
+ };
437
+ `;
438
+
439
+ const files = [
440
+ { path: path.join(fullPath, 'index.js'), content: indexContent },
441
+ { path: path.join(fullPath, 'scripts', 'app.js'), content: appContent },
442
+ { path: path.join(fullPath, 'css', 'style.scss'), content: styleContent },
443
+ { path: path.join(fullPath, 'constants', 'index.js'), content: constantsContent }
444
+ ];
445
+
446
+ files.forEach(({ path: filePath, content }) => {
447
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
448
+ if (!fs.existsSync(filePath)) {
449
+ fs.writeFileSync(filePath, content);
450
+ console.log(chalk.green(` ✅ Created ${path.relative(BUILD_ROOT, filePath)}`));
451
+ } else {
452
+ console.log(chalk.gray(` ⏭️ Skipped ${path.relative(BUILD_ROOT, filePath)} (exists)`));
453
+ }
454
+ });
455
+ }
@@ -68,8 +68,16 @@ class ESLintFlatConfigPlugin {
68
68
  ...overrideConfigs
69
69
  ];
70
70
 
71
+ // overrideConfigFile: true tells ESLint v9 to skip the
72
+ // cwd-based flat-config lookup entirely and treat
73
+ // overrideConfig as the complete configuration. Without
74
+ // this, ESLint walks the consumer's PWD looking for an
75
+ // `eslint.config.js` (which they don't have, since at-builder
76
+ // ships its own) and fails the build with "Could not find
77
+ // config file." even though we provided everything inline.
71
78
  const eslint = new ESLint({
72
79
  cwd: this.options.context,
80
+ overrideConfigFile: true,
73
81
  overrideConfig: mergedConfig,
74
82
  ignore: false, // Don't use default ignore patterns
75
83
  fix: this.options.fix // Enable auto-fix if option is set
@@ -82,44 +90,37 @@ class ESLintFlatConfigPlugin {
82
90
  await ESLint.outputFixes(results);
83
91
  }
84
92
 
85
- let hasErrors = false;
86
- let hasWarnings = false;
87
-
93
+ // Push every ESLint message as a discrete error/warning on
94
+ // the compilation. Webpack renders each one with its file
95
+ // path, line/column, message, and rule. The build fails
96
+ // automatically when compilation.errors is non-empty, so
97
+ // there's no need to throw — throwing here used to abort
98
+ // the hook before webpack could render the queued errors,
99
+ // leaving the user with a generic "Build failed due to
100
+ // linting errors" and no detail.
88
101
  for (const result of results) {
89
- if (result.errorCount > 0) {
90
- hasErrors = true;
91
- for (const message of result.messages.filter(m => m.severity === 2)) {
92
- const error = new Error(
93
- `[ESLint] ${result.filePath}:${message.line}:${message.column}\n ${message.message} (${message.ruleId})`
94
- );
95
- error.file = result.filePath;
96
- compilation.errors.push(error);
97
- }
98
- }
99
-
100
- if (result.warningCount > 0) {
101
- hasWarnings = true;
102
- for (const message of result.messages.filter(m => m.severity === 1)) {
103
- const warning = new Error(
104
- `[ESLint] ${result.filePath}:${message.line}:${message.column}\n ${message.message} (${message.ruleId})`
105
- );
106
- warning.file = result.filePath;
107
- compilation.warnings.push(warning);
102
+ for (const message of result.messages) {
103
+ const formatted = new Error(
104
+ `[ESLint] ${result.filePath}:${message.line}:${message.column}\n ${message.message} (${message.ruleId})`
105
+ );
106
+ formatted.file = result.filePath;
107
+
108
+ const isError = message.severity === 2;
109
+ const isWarning = message.severity === 1;
110
+ const treatAsError =
111
+ (isError && this.options.failOnError) ||
112
+ (isWarning && this.options.failOnWarning);
113
+
114
+ if (treatAsError) {
115
+ compilation.errors.push(formatted);
116
+ } else if (isError || isWarning) {
117
+ compilation.warnings.push(formatted);
108
118
  }
109
119
  }
110
120
  }
111
-
112
- if (this.options.failOnError && hasErrors) {
113
- throw new Error('[ESLint] Build failed due to linting errors');
114
- }
115
-
116
- if (this.options.failOnWarning && hasWarnings) {
117
- throw new Error('[ESLint] Build failed due to linting warnings');
118
- }
119
121
  } catch (error) {
120
- if (error.message.includes('Build failed')) {
121
- throw error;
122
- }
122
+ // ESLint setup itself failed (config load, file resolution,
123
+ // etc.). Surface as a single compilation error.
123
124
  compilation.errors.push(error);
124
125
  }
125
126
  });