at-builder 1.2.9 → 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.
@@ -1,6 +1,25 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
3
  import logger from "./logger";
4
+ import { GITIGNORE_TEMPLATE } from "../constants/config";
5
+
6
+ // lib/install-checks.js is hand-written runtime JS (not TS-compiled). The
7
+ // relative path resolves correctly from both src/services/doctor.ts (source)
8
+ // and bin/services/doctor.js (compiled output) because src/ and bin/ have
9
+ // parallel structure. Typed inline since the module isn't part of the TS graph.
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const installChecks = require("../../lib/install-checks") as {
12
+ checkInstallEnvironment: (opts?: { localBinScript?: string; fixPermissions?: boolean }) => {
13
+ ok: boolean;
14
+ problems: string[];
15
+ globalBin: string;
16
+ shimPath: string;
17
+ shimExecutable: boolean;
18
+ isWin: boolean;
19
+ isGlobalInstall: boolean;
20
+ nodeMajor: number;
21
+ };
22
+ };
4
23
 
5
24
  export interface DiagnosticIssue {
6
25
  id: string;
@@ -23,15 +42,24 @@ export const runDiagnostics = async (projectPath: string, verbose: boolean = fal
23
42
  await checkEnvFile(projectPath, issues);
24
43
  await checkAdobeConfig(projectPath, issues);
25
44
  await checkWatchConfig(projectPath, issues);
45
+ await checkGitignore(projectPath, issues);
26
46
  await checkPackageJson(projectPath, issues);
27
47
  await checkActivitiesFolder(projectPath, issues);
28
-
48
+
29
49
  // Check environment variables
30
50
  await checkEnvVariables(projectPath, issues);
31
-
51
+
32
52
  // Check dependencies
33
53
  await checkDependencies(projectPath, issues);
34
-
54
+
55
+ // Check build.config.json content for every activity
56
+ await checkBuildConfigs(projectPath, issues);
57
+
58
+ // Check install environment (PATH, atb shim, exec bit). Mirrors the
59
+ // postinstall validator so users who installed with --ignore-scripts
60
+ // (or who dismissed the postinstall warning) can still self-diagnose.
61
+ await checkInstallEnv(issues);
62
+
35
63
  return issues;
36
64
  };
37
65
 
@@ -71,8 +99,10 @@ const fixIssue = async (issue: DiagnosticIssue, projectPath: string, verbose: bo
71
99
  return await createEnvFile(projectPath);
72
100
  case 'missing-adobe-config':
73
101
  return await createAdobeConfig(projectPath);
74
- case 'missing-watch-config':
75
- return await createWatchConfig(projectPath);
102
+ case 'legacy-watch-config':
103
+ return await migrateWatchConfig(projectPath);
104
+ case 'missing-gitignore':
105
+ return await createGitignore(projectPath);
76
106
  case 'missing-activities-folder':
77
107
  return await createActivitiesFolder(projectPath);
78
108
  case 'missing-package-json':
@@ -142,19 +172,42 @@ const checkAdobeConfig = async (projectPath: string, issues: DiagnosticIssue[]):
142
172
  };
143
173
 
144
174
  /**
145
- * Check if watch-config.json exists
175
+ * Flag a legacy watch-config.json. As of this release VARIATION/PAGE live in
176
+ * .env; watch-config.json is supported as a fallback (puppeteer.js prefers it
177
+ * if present with a deprecation warning) but new projects don't get one.
178
+ *
179
+ * The fix migrates VARIATION/PAGE into .env and deletes watch-config.json.
146
180
  */
147
181
  const checkWatchConfig = async (projectPath: string, issues: DiagnosticIssue[]): Promise<void> => {
148
182
  const configPath = path.join(projectPath, 'watch-config.json');
149
-
150
- if (!fs.existsSync(configPath)) {
183
+ if (!fs.existsSync(configPath)) return;
184
+
185
+ issues.push({
186
+ id: 'legacy-watch-config',
187
+ severity: 'warning',
188
+ message: 'watch-config.json is deprecated — VARIATION/PAGE now live in .env',
189
+ suggestion: 'Run "atb doctor --fix" to migrate values into .env and delete watch-config.json',
190
+ fixable: true,
191
+ file: 'watch-config.json'
192
+ });
193
+ };
194
+
195
+ /**
196
+ * Check if .gitignore exists. Flags as a warning (not blocking) but fixable —
197
+ * `atb doctor --fix` writes the default template covering .env, .deploy-lock,
198
+ * dist/, etc. Does not modify an existing .gitignore.
199
+ */
200
+ const checkGitignore = async (projectPath: string, issues: DiagnosticIssue[]): Promise<void> => {
201
+ const gitignorePath = path.join(projectPath, '.gitignore');
202
+
203
+ if (!fs.existsSync(gitignorePath)) {
151
204
  issues.push({
152
- id: 'missing-watch-config',
205
+ id: 'missing-gitignore',
153
206
  severity: 'warning',
154
- message: 'watch-config.json file is missing',
155
- suggestion: 'Create build configuration file',
207
+ message: '.gitignore file is missing',
208
+ suggestion: 'Create .gitignore with at-builder defaults (.env, .deploy-lock, dist/, etc.)',
156
209
  fixable: true,
157
- file: 'watch-config.json'
210
+ file: '.gitignore'
158
211
  });
159
212
  }
160
213
  };
@@ -177,24 +230,26 @@ const checkPackageJson = async (projectPath: string, issues: DiagnosticIssue[]):
177
230
  }
178
231
  };
179
232
 
233
+ /**
234
+ * Resolve the configured activities folder name. Reads ACTIVITIES_BASE_FOLDER
235
+ * from the consumer .env (matching webpack/at-deploy/at-sync resolution), falls
236
+ * back to the default "Activities".
237
+ */
238
+ const resolveActivitiesFolderName = (projectPath: string): string => {
239
+ const envPath = path.join(projectPath, '.env');
240
+ if (!fs.existsSync(envPath)) return 'Activities';
241
+ const envContent = fs.readFileSync(envPath, 'utf8');
242
+ const match = envContent.match(/ACTIVITIES_BASE_FOLDER=["']?([^"'\n\r]+)["']?/);
243
+ return match ? match[1] : 'Activities';
244
+ };
245
+
180
246
  /**
181
247
  * Check if Activities folder exists
182
248
  */
183
249
  const checkActivitiesFolder = async (projectPath: string, issues: DiagnosticIssue[]): Promise<void> => {
184
- // Try to read ACTIVITIES_BASE_FOLDER from .env, fallback to default
185
- let activitiesFolder = 'Activities';
186
- const envPath = path.join(projectPath, '.env');
187
-
188
- if (fs.existsSync(envPath)) {
189
- const envContent = fs.readFileSync(envPath, 'utf8');
190
- const match = envContent.match(/ACTIVITIES_BASE_FOLDER=["']?([^"'\n\r]+)["']?/);
191
- if (match) {
192
- activitiesFolder = match[1];
193
- }
194
- }
195
-
250
+ const activitiesFolder = resolveActivitiesFolderName(projectPath);
196
251
  const activitiesPath = path.join(projectPath, activitiesFolder);
197
-
252
+
198
253
  if (!fs.existsSync(activitiesPath)) {
199
254
  issues.push({
200
255
  id: 'missing-activities-folder',
@@ -207,6 +262,172 @@ const checkActivitiesFolder = async (projectPath: string, issues: DiagnosticIssu
207
262
  }
208
263
  };
209
264
 
265
+ /**
266
+ * Validate every shared/build.config.json under the activities folder.
267
+ *
268
+ * Catches the deploy-time errors before deploy:
269
+ * - missing or invalid activityType (must be "ab" or "xt")
270
+ * - null id (deploy will fail when it tries to GET the activity)
271
+ * - multi-page variation values without an activityInfo.pages map
272
+ * - variation/page folders referenced in config but absent on disk
273
+ *
274
+ * Issues are not auto-fixable (they're content problems requiring human
275
+ * judgment), so each one is reported with a clear suggestion.
276
+ */
277
+ const checkBuildConfigs = async (projectPath: string, issues: DiagnosticIssue[]): Promise<void> => {
278
+ const activitiesFolder = resolveActivitiesFolderName(projectPath);
279
+ const activitiesPath = path.join(projectPath, activitiesFolder);
280
+ if (!fs.existsSync(activitiesPath)) return; // already flagged elsewhere
281
+
282
+ const activityDirs = fs.readdirSync(activitiesPath, { withFileTypes: true })
283
+ .filter(d => d.isDirectory() && !d.name.startsWith('.'));
284
+
285
+ for (const activityDir of activityDirs) {
286
+ const activityRoot = path.join(activitiesPath, activityDir.name);
287
+ const configCandidates = [
288
+ path.join(activityRoot, 'shared', 'build.config.json'),
289
+ path.join(activityRoot, 'Shared', 'build.config.json')
290
+ ];
291
+ const configPath = configCandidates.find(p => fs.existsSync(p));
292
+ if (!configPath) continue; // missing build.config is its own concern
293
+
294
+ const relConfig = path.relative(projectPath, configPath);
295
+
296
+ let parsed: { activityInfo?: Record<string, unknown> };
297
+ try {
298
+ parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
299
+ } catch (err) {
300
+ issues.push({
301
+ id: `invalid-build-config-json:${activityDir.name}`,
302
+ severity: 'error',
303
+ message: `${relConfig} is not valid JSON`,
304
+ suggestion: `Open ${relConfig} and fix the JSON syntax. Error: ${(err as Error).message}`,
305
+ fixable: false,
306
+ file: relConfig
307
+ });
308
+ continue;
309
+ }
310
+
311
+ const activityInfo = (parsed && parsed.activityInfo) as Record<string, unknown> | undefined;
312
+ if (!activityInfo || typeof activityInfo !== 'object') {
313
+ issues.push({
314
+ id: `missing-activity-info:${activityDir.name}`,
315
+ severity: 'error',
316
+ message: `${relConfig} is missing activityInfo`,
317
+ suggestion: 'Re-run "atb new" or restore activityInfo manually',
318
+ fixable: false,
319
+ file: relConfig
320
+ });
321
+ continue;
322
+ }
323
+
324
+ // activityType validation
325
+ const activityType = activityInfo.activityType as string | undefined;
326
+ if (!activityType) {
327
+ issues.push({
328
+ id: `missing-activity-type:${activityDir.name}`,
329
+ severity: 'warning',
330
+ message: `${relConfig} is missing activityType`,
331
+ suggestion: 'Add `"activityType": "ab"` (or "xt") to activityInfo',
332
+ fixable: false,
333
+ file: relConfig
334
+ });
335
+ } else if (!['ab', 'xt'].includes(String(activityType).toLowerCase())) {
336
+ issues.push({
337
+ id: `invalid-activity-type:${activityDir.name}`,
338
+ severity: 'error',
339
+ message: `${relConfig} has invalid activityType "${activityType}"`,
340
+ suggestion: 'activityType must be "ab" or "xt"',
341
+ fixable: false,
342
+ file: relConfig
343
+ });
344
+ }
345
+
346
+ // id check (warning only — null is fine until first deploy)
347
+ if (activityInfo.id === null || activityInfo.id === undefined) {
348
+ issues.push({
349
+ id: `unset-activity-id:${activityDir.name}`,
350
+ severity: 'warning',
351
+ message: `${relConfig} has no activity id (deploy will fail until set)`,
352
+ suggestion: 'Set activityInfo.id once the activity exists in Adobe Target, or run "atb sync"',
353
+ fixable: false,
354
+ file: relConfig
355
+ });
356
+ }
357
+
358
+ // Variations validation
359
+ const variations = activityInfo.variations as Record<string, unknown> | unknown[] | undefined;
360
+ if (!variations || (Array.isArray(variations) && variations.length === 0) || (typeof variations === 'object' && !Array.isArray(variations) && Object.keys(variations).length === 0)) {
361
+ issues.push({
362
+ id: `missing-variations:${activityDir.name}`,
363
+ severity: 'error',
364
+ message: `${relConfig} has no variations`,
365
+ suggestion: 'Add at least one variation to activityInfo.variations',
366
+ fixable: false,
367
+ file: relConfig
368
+ });
369
+ continue;
370
+ }
371
+
372
+ // Multi-page detection: any variation value is an object with a `pages` key
373
+ const variationEntries = Array.isArray(variations)
374
+ ? []
375
+ : Object.entries(variations);
376
+ const isMultiPage = variationEntries.some(([, v]) =>
377
+ typeof v === 'object' && v !== null && 'pages' in (v as object)
378
+ );
379
+
380
+ if (isMultiPage) {
381
+ const pages = activityInfo.pages as Record<string, unknown> | undefined;
382
+ if (!pages || typeof pages !== 'object' || Object.keys(pages).length === 0) {
383
+ issues.push({
384
+ id: `multi-page-missing-pages:${activityDir.name}`,
385
+ severity: 'error',
386
+ message: `${relConfig} uses multi-page variations but has no activityInfo.pages map`,
387
+ suggestion: 'Add `"pages": { "<pageName>": <locationLocalId>, ... }` or run "atb sync" to populate it from Adobe Target',
388
+ fixable: false,
389
+ file: relConfig
390
+ });
391
+ }
392
+
393
+ // Each multi-page variation: pages.<page> should map to a folder that exists
394
+ for (const [variationKey, variationValue] of variationEntries) {
395
+ if (typeof variationValue !== 'object' || variationValue === null || !('pages' in (variationValue as object))) continue;
396
+ const pagesMap = (variationValue as { pages?: Record<string, unknown> }).pages || {};
397
+ for (const [pageName, subPath] of Object.entries(pagesMap)) {
398
+ if (typeof subPath !== 'string') continue;
399
+ const folderAbs = path.join(activityRoot, subPath);
400
+ if (!fs.existsSync(folderAbs)) {
401
+ issues.push({
402
+ id: `missing-page-folder:${activityDir.name}:${variationKey}:${pageName}`,
403
+ severity: 'warning',
404
+ message: `${relConfig}: variation "${variationKey}" → page "${pageName}" expects folder "${subPath}" but it doesn't exist`,
405
+ suggestion: 'Run "atb sync --scaffold" to auto-create missing variation folders, or create them manually',
406
+ fixable: false,
407
+ file: relConfig
408
+ });
409
+ }
410
+ }
411
+ }
412
+ } else {
413
+ // Single-page: each variation key should map to a folder under the activity root
414
+ for (const [variationKey] of variationEntries) {
415
+ const folderAbs = path.join(activityRoot, variationKey);
416
+ if (!fs.existsSync(folderAbs)) {
417
+ issues.push({
418
+ id: `missing-variation-folder:${activityDir.name}:${variationKey}`,
419
+ severity: 'warning',
420
+ message: `${relConfig}: variation "${variationKey}" has no folder at ${path.relative(projectPath, folderAbs)}`,
421
+ suggestion: 'Create the folder, run "atb new" to scaffold variations, or remove the entry from build.config.json',
422
+ fixable: false,
423
+ file: relConfig
424
+ });
425
+ }
426
+ }
427
+ }
428
+ }
429
+ };
430
+
210
431
  /**
211
432
  * Check environment variables content
212
433
  */
@@ -247,6 +468,20 @@ const checkEnvVariables = async (projectPath: string, issues: DiagnosticIssue[])
247
468
  fixable: false
248
469
  });
249
470
  }
471
+
472
+ // ADOBE_TENANT — if missing or empty, BASE_URL in adobe.config.js will fall
473
+ // back to the literal "YOUR_TENANT" placeholder and every API call will 404.
474
+ const tenantMatch = envContent.match(/^ADOBE_TENANT=(.*)$/m);
475
+ const tenantValue = tenantMatch ? tenantMatch[1].trim().replace(/^["']|["']$/g, '') : '';
476
+ if (!tenantValue) {
477
+ issues.push({
478
+ id: 'empty-adobe-tenant',
479
+ severity: 'warning',
480
+ message: 'ADOBE_TENANT is missing or empty',
481
+ suggestion: 'Set ADOBE_TENANT to your AT tenant slug (the segment after mc.adobe.io/ in the AT URL)',
482
+ fixable: false
483
+ });
484
+ }
250
485
  };
251
486
 
252
487
  /**
@@ -255,9 +490,9 @@ const checkEnvVariables = async (projectPath: string, issues: DiagnosticIssue[])
255
490
  const checkDependencies = async (projectPath: string, issues: DiagnosticIssue[]): Promise<void> => {
256
491
  const packagePath = path.join(projectPath, 'package.json');
257
492
  if (!fs.existsSync(packagePath)) return;
258
-
493
+
259
494
  const nodeModulesPath = path.join(projectPath, 'node_modules');
260
-
495
+
261
496
  if (!fs.existsSync(nodeModulesPath)) {
262
497
  issues.push({
263
498
  id: 'missing-node-modules',
@@ -269,6 +504,41 @@ const checkDependencies = async (projectPath: string, issues: DiagnosticIssue[])
269
504
  }
270
505
  };
271
506
 
507
+ /**
508
+ * Validate that the at-builder global install is healthy:
509
+ * - global npm bin is on PATH
510
+ * - the `atb` shim exists in the global bin
511
+ * - on POSIX, the shim and the package's bin/index.js are executable
512
+ * (auto-chmods 755 if missing the bit)
513
+ *
514
+ * Same checks as lib/postinstall.js, just surfaced through `atb doctor` for
515
+ * users who installed with --ignore-scripts or dismissed the postinstall
516
+ * warning. Issues are flagged but not auto-fixable here — the suggested
517
+ * commands need to run in the user's shell, not in this Node process.
518
+ */
519
+ const checkInstallEnv = async (issues: DiagnosticIssue[]): Promise<void> => {
520
+ let result;
521
+ try {
522
+ result = installChecks.checkInstallEnvironment();
523
+ } catch (err) {
524
+ // Don't let a bug in install-checks block the rest of doctor.
525
+ logger.error("checkInstallEnv", `Skipped: ${(err as Error).message}`);
526
+ return;
527
+ }
528
+
529
+ for (const problem of result.problems) {
530
+ issues.push({
531
+ id: 'install-env',
532
+ severity: 'warning',
533
+ message: problem,
534
+ suggestion: result.isWin
535
+ ? `On Windows: ensure ${result.globalBin || 'your npm global bin (npm config get prefix)'} is on PATH, then reinstall: npm i -g at-builder`
536
+ : `On macOS/Linux: chmod +x "${result.shimPath || 'atb shim'}" and ensure ${result.globalBin || '$(npm config get prefix)/bin'} is on PATH; then run: hash -r`,
537
+ fixable: false
538
+ });
539
+ }
540
+ };
541
+
272
542
  // Fix functions
273
543
 
274
544
  const createEnvFile = async (projectPath: string): Promise<boolean> => {
@@ -278,14 +548,22 @@ ACTIVITY_FOLDER_NAME=""
278
548
  PUPPETEER_LANDING_PAGE=""
279
549
  TARGET_URL=""
280
550
  LOGIN_URL=""
551
+
552
+ # Dev-server selection (used by \`atb dev --browser\`).
553
+ # Edit and save while puppeteer is running to hot-swap the previewed bundle.
554
+ # PAGE is only meaningful for multi-page activities — leave empty otherwise.
281
555
  VARIATION="Variation-1"
556
+ PAGE=""
557
+
282
558
  NODE_ENV="development"
283
559
  VERBOSE=false
284
560
 
285
561
  # Adobe Target Deployment Configuration
562
+ # ADOBE_TENANT is your AT tenant slug — find it in the AT URL after "mc.adobe.io/".
563
+ ADOBE_TENANT=""
286
564
  ADOBE_CLIENT_ID=""
287
565
  ADOBE_CLIENT_SECRET=""`;
288
-
566
+
289
567
  try {
290
568
  fs.writeFileSync(envPath, envContent, 'utf8');
291
569
  return true;
@@ -298,14 +576,19 @@ const createAdobeConfig = async (projectPath: string): Promise<boolean> => {
298
576
  const configPath = path.join(projectPath, 'adobe.config.js');
299
577
  const configContent = `/**
300
578
  * Adobe Target API Configuration
301
- *
302
- * Configuration constants for Adobe Target API integration.
303
- * These values are used by the deployment script to connect to Adobe Target.
579
+ *
580
+ * Used by at-sync.js and at-deploy.js. BASE_URL is the activities root —
581
+ * callers append \`\${activityType}/\${activityId}\` (e.g. ab/12345, xt/67890).
582
+ *
583
+ * ADOBE_TENANT comes from the consumer .env. Both at-sync and at-deploy load
584
+ * dotenv before requiring this file, so process.env is populated by the time
585
+ * BASE_URL is built.
304
586
  */
305
587
 
588
+ const TENANT = process.env.ADOBE_TENANT || 'YOUR_TENANT';
589
+
306
590
  module.exports = {
307
- BASE_URL_NEW: 'https://mc.adobe.io/ups/target/',
308
- BASE_URL: 'https://mc.adobe.io/ups/target/activities/ab/',
591
+ BASE_URL: \`https://mc.adobe.io/\${TENANT}/target/activities/\`,
309
592
  IMS_TOKEN_URL: 'https://ims-na1.adobelogin.com/ims/token/v3',
310
593
  IMS_SCOPE: 'openid,AdobeID,target_sdk,additional_info.projectedProductContext'
311
594
  };`;
@@ -318,14 +601,69 @@ module.exports = {
318
601
  }
319
602
  };
320
603
 
321
- const createWatchConfig = async (projectPath: string): Promise<boolean> => {
604
+ /**
605
+ * Migrate VARIATION/PAGE from a legacy watch-config.json into .env, then
606
+ * delete the watch-config.json. Idempotent: existing .env entries are
607
+ * overwritten so the migrated values win (matching puppeteer's runtime
608
+ * precedence — watch-config.json was the source of truth).
609
+ */
610
+ const migrateWatchConfig = async (projectPath: string): Promise<boolean> => {
322
611
  const configPath = path.join(projectPath, 'watch-config.json');
323
- const configContent = {
324
- "VARIATION": "Variation-1"
325
- };
326
-
612
+ const envPath = path.join(projectPath, '.env');
613
+ if (!fs.existsSync(configPath)) return true; // nothing to migrate
614
+
615
+ let watchCfg: { VARIATION?: string; PAGE?: string };
616
+ try {
617
+ watchCfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
618
+ } catch (err) {
619
+ console.error(`Failed to parse watch-config.json: ${(err as Error).message}`);
620
+ return false;
621
+ }
622
+
623
+ const variation = (watchCfg.VARIATION || '').trim();
624
+ const page = (watchCfg.PAGE || '').trim();
625
+
626
+ if (!fs.existsSync(envPath)) {
627
+ // No .env yet — synthesize a minimal one with just the migrated keys.
628
+ // The user can add the rest via `atb init` or by editing manually.
629
+ fs.writeFileSync(envPath, `VARIATION="${variation}"\nPAGE="${page}"\n`, 'utf8');
630
+ } else {
631
+ let envContent = fs.readFileSync(envPath, 'utf8');
632
+ envContent = upsertEnvLine(envContent, 'VARIATION', variation);
633
+ envContent = upsertEnvLine(envContent, 'PAGE', page);
634
+ fs.writeFileSync(envPath, envContent, 'utf8');
635
+ }
636
+
637
+ try {
638
+ fs.unlinkSync(configPath);
639
+ } catch (err) {
640
+ console.error(`Migrated values into .env, but failed to delete watch-config.json: ${(err as Error).message}`);
641
+ return false;
642
+ }
643
+
644
+ console.log(`✓ Migrated VARIATION="${variation}" PAGE="${page}" from watch-config.json into .env`);
645
+ return true;
646
+ };
647
+
648
+ /**
649
+ * Insert or replace a `KEY="value"` line in a .env file body. Preserves
650
+ * surrounding lines; appends if the key wasn't present.
651
+ */
652
+ const upsertEnvLine = (envContent: string, key: string, value: string): string => {
653
+ const line = `${key}="${value}"`;
654
+ const re = new RegExp(`^${key}=.*$`, 'm');
655
+ if (re.test(envContent)) {
656
+ return envContent.replace(re, line);
657
+ }
658
+ if (!envContent.endsWith('\n')) envContent += '\n';
659
+ return envContent + line + '\n';
660
+ };
661
+
662
+ const createGitignore = async (projectPath: string): Promise<boolean> => {
663
+ const gitignorePath = path.join(projectPath, '.gitignore');
664
+
327
665
  try {
328
- fs.writeFileSync(configPath, JSON.stringify(configContent, null, 2), 'utf8');
666
+ fs.writeFileSync(gitignorePath, GITIGNORE_TEMPLATE, 'utf8');
329
667
  return true;
330
668
  } catch (error) {
331
669
  return false;