pabal-resource-mcp 1.10.5 → 1.10.6

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.
@@ -14,7 +14,7 @@ import {
14
14
  getPushDataDir,
15
15
  loadAsoFromConfig,
16
16
  saveAsoToAsoDir
17
- } from "../chunk-4JFBWKY4.js";
17
+ } from "../chunk-H4MYWLFK.js";
18
18
  import {
19
19
  DEFAULT_LOCALE,
20
20
  appStoreToUnified,
@@ -189,7 +189,7 @@ This locale has data from BOTH Google Play and App Store. Use the following merg
189
189
  - Keep proper nouns and technical terms in English when translating
190
190
  - Keep terms like "Always-On Display", "E-Ink", "Android", brand names (e.g., "Boox", "Meebook"), and other technical/proper nouns in English
191
191
  - Only translate descriptive text, not technical terminology or proper nouns
192
- - Example: "Always-On Display" should remain "Always-On Display" in Korean, not translated to "\uD56D\uC0C1 \uCF1C\uC9C4 \uD654\uBA74"
192
+ - Example: "Always-On Display" should remain "Always-On Display" in translated locales, not translated into a generic phrase like "screen that is always on"
193
193
 
194
194
  ${screenshotPaths ? `
195
195
  **Screenshot Paths:**
@@ -358,6 +358,7 @@ import path4 from "path";
358
358
 
359
359
  // src/utils/aso-validation.util.ts
360
360
  var FIELD_LIMITS_DOC_PATH = "docs/aso/ASO_FIELD_LIMITS.md";
361
+ var ASO_OVERVIEW_DOC_PATH = "docs/aso/ASO_OVERVIEW.md";
361
362
  var APP_STORE_LIMITS = {
362
363
  name: 30,
363
364
  subtitle: 30,
@@ -542,6 +543,32 @@ function checkKeywordDuplicates(keywords) {
542
543
  uniqueKeywords
543
544
  };
544
545
  }
546
+ var normalizeKeyword = (value) => value.trim().toLowerCase();
547
+ function getTitleAndSubtitleKeywordSet(name, subtitle) {
548
+ const sourceText = [name, subtitle].filter((value) => Boolean(value)).join(" ").toLowerCase();
549
+ const terms = sourceText.split(/[^\p{L}\p{N}]+/u).map((term) => term.trim()).filter(Boolean);
550
+ return new Set(terms);
551
+ }
552
+ function validateKeywordRules(keywords, name, subtitle) {
553
+ const duplicateResult = checkKeywordDuplicates(keywords);
554
+ const keywordList = keywords.split(",").map(normalizeKeyword).filter(Boolean);
555
+ const titleAndSubtitleKeywords = getTitleAndSubtitleKeywordSet(
556
+ name,
557
+ subtitle
558
+ );
559
+ const formatting = [];
560
+ if (/\s/.test(keywords)) {
561
+ formatting.push("Use comma-only keywords with no spaces");
562
+ }
563
+ const repeatedFromTitleOrSubtitle = keywordList.filter((keyword) => {
564
+ return titleAndSubtitleKeywords.has(keyword);
565
+ });
566
+ return {
567
+ duplicates: duplicateResult.duplicates,
568
+ formatting,
569
+ repeatedFromTitleOrSubtitle
570
+ };
571
+ }
545
572
  function validateKeywords(configData) {
546
573
  const issues = [];
547
574
  if (configData.appStore) {
@@ -549,9 +576,14 @@ function validateKeywords(configData) {
549
576
  const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
550
577
  for (const [locale, data] of Object.entries(locales)) {
551
578
  if (data.keywords) {
552
- const result = checkKeywordDuplicates(data.keywords);
553
- if (result.hasDuplicates) {
554
- issues.push({ locale, duplicates: result.duplicates });
579
+ const result = validateKeywordRules(
580
+ data.keywords,
581
+ data.name,
582
+ data.subtitle
583
+ );
584
+ const hasIssues = result.duplicates.length > 0 || result.formatting.length > 0 || result.repeatedFromTitleOrSubtitle.length > 0;
585
+ if (hasIssues) {
586
+ issues.push({ locale, ...result });
555
587
  }
556
588
  }
557
589
  }
@@ -894,7 +926,7 @@ This tool:
894
926
  4. Copies/downloads screenshots to .aso/pushData/products/[slug]/store/screenshots/
895
927
  5. Validates text field lengths against ${FIELD_LIMITS_DOC_PATH} (fails if over limits)
896
928
 
897
- Before running, review ${FIELD_LIMITS_DOC_PATH} for per-store limits. This prepares data for pushing to stores without actually uploading.`,
929
+ Before running, review ${ASO_OVERVIEW_DOC_PATH} for ASO strategy and ${FIELD_LIMITS_DOC_PATH} for per-store limits. This prepares data for pushing to stores without actually uploading.`,
898
930
  inputSchema: inputSchema2
899
931
  };
900
932
  async function handlePublicToAso(input) {
@@ -1049,7 +1081,7 @@ ${validationMessage}`
1049
1081
  responseText += `
1050
1082
  Next step: Push to stores using pabal-store-api-mcp's aso-push tool`;
1051
1083
  responseText += `
1052
- Reference: ${FIELD_LIMITS_DOC_PATH}`;
1084
+ References: ${ASO_OVERVIEW_DOC_PATH}, ${FIELD_LIMITS_DOC_PATH}`;
1053
1085
  if (sanitizeWarnings.length > 0) {
1054
1086
  responseText += `
1055
1087
  Sanitized invalid characters:
@@ -1349,7 +1381,11 @@ function generateKeywordSuggestions(args) {
1349
1381
  `;
1350
1382
  suggestions += `6. **template.intro**: Use up to 300 chars to naturally incorporate more keywords and provide richer context
1351
1383
  `;
1352
- suggestions += `7. **App Store Keywords**: 100 char limit, comma-separated, avoid duplicates from name/subtitle
1384
+ suggestions += `7. **App Store Keywords**: 100 char limit, comma-separated with commas only/no spaces, avoid duplicates from name/subtitle, prefer singular forms, order important keywords first, and fill as close to 100 chars as possible with relevant keywords only
1385
+ `;
1386
+ suggestions += `8. **Keyword Selection**: Follow ${ASO_OVERVIEW_DOC_PATH}: popularity >=20 when available, achievable difficulty, beatable top-10 competitors, strong relevance, and target-user search intent
1387
+ `;
1388
+ suggestions += `9. **Field Limits**: Follow ${FIELD_LIMITS_DOC_PATH}
1353
1389
 
1354
1390
  `;
1355
1391
  if (category) {
@@ -1385,7 +1421,7 @@ function formatLocaleSection(args) {
1385
1421
  const screenshots = landing.screenshots?.images || [];
1386
1422
  const features = landing.features?.items || [];
1387
1423
  const lengthOf = (value) => value ? value.length : 0;
1388
- const keywordsLength = Array.isArray(aso.keywords) ? aso.keywords.join(", ").length : lengthOf(typeof aso.keywords === "string" ? aso.keywords : void 0);
1424
+ const keywordsLength = Array.isArray(aso.keywords) ? aso.keywords.join(",").length : lengthOf(typeof aso.keywords === "string" ? aso.keywords : void 0);
1389
1425
  const header = `--- ${locale}${locale === primaryLocale ? " (primary)" : ""} ---`;
1390
1426
  const stats = [
1391
1427
  `Path: public/products/${slug}/locales/${locale}.json`,
@@ -1442,7 +1478,16 @@ ${json}
1442
1478
  }
1443
1479
 
1444
1480
  // src/tools/aso/utils/improve/generate-aso-prompt.util.ts
1445
- var FIELD_LIMITS_DOC_PATH2 = "docs/aso/ASO_FIELD_LIMITS.md";
1481
+ var ASO_RULES_SUMMARY = [
1482
+ `Use ${ASO_OVERVIEW_DOC_PATH} for keyword strategy and ${FIELD_LIMITS_DOC_PATH} for hard limits.`,
1483
+ "`aso.title`: keep app name + the most relevant high-priority keyword, usually `App Name: Primary Keyword`.",
1484
+ "`aso.subtitle`: use important keywords that do not repeat the title.",
1485
+ "`aso.keywords`: comma-separated with commas only, no spaces, no duplicates, no title/subtitle repetition.",
1486
+ "`aso.keywords`: use as much of the 100-character limit as possible with relevant keywords; do not leave meaningful capacity unused and do not use filler.",
1487
+ '`aso.keywords`: split phrase intent into reusable single terms when appropriate, e.g. `sound,relaxing,rain` for "relaxing sound" + "rain sound".',
1488
+ "`aso.keywords`: prefer singular forms, allow real searched misspellings, and order by importance.",
1489
+ "Choose keywords with popularity >=20, achievable difficulty, beatable top-10 competitors, strong relevance, and likely target-user search intent."
1490
+ ].join("\n- ");
1446
1491
  function generatePrimaryOptimizationPrompt(args) {
1447
1492
  const {
1448
1493
  slug,
@@ -1457,6 +1502,12 @@ function generatePrimaryOptimizationPrompt(args) {
1457
1502
  `;
1458
1503
  prompt += `Product: ${slug} | Category: ${category || "N/A"} | Primary: ${primaryLocale}
1459
1504
 
1505
+ `;
1506
+ prompt += `## ASO Basics
1507
+
1508
+ `;
1509
+ prompt += `- ${ASO_RULES_SUMMARY}
1510
+
1460
1511
  `;
1461
1512
  prompt += `## Task
1462
1513
 
@@ -1514,18 +1565,18 @@ ${researchSections.join("\n")}
1514
1565
  `;
1515
1566
  prompt += `- \`aso.title\` (\u226430): **"App Name: [Tier1 Keyword]"** format
1516
1567
  `;
1517
- prompt += ` - App name in English, keyword in target language, uppercase after colon
1568
+ prompt += ` - App name in English, keyword in target language with natural casing
1518
1569
  `;
1519
1570
  prompt += ` - **Do NOT translate/rename the app name**
1520
1571
  `;
1521
- prompt += `- \`aso.subtitle\` (\u226430): Use remaining Tier 1 keywords
1572
+ prompt += `- \`aso.subtitle\` (\u226430): Use remaining Tier 1 keywords without repeating title terms
1522
1573
  `;
1523
1574
  prompt += `- \`aso.shortDescription\` (\u226480): Tier 1 + Tier 2 keywords (no emojis/CAPS)
1524
1575
 
1525
1576
  `;
1526
1577
  prompt += `### Tier 2 Keywords (Feature) \u2192 Keywords Field & Descriptions
1527
1578
  `;
1528
- prompt += `- \`aso.keywords\` (\u2264100): ALL tiers, comma-separated (Tier 1 first, then Tier 2, then Tier 3)
1579
+ prompt += `- \`aso.keywords\` (\u2264100): ALL tiers, comma-separated with commas only and no spaces (Tier 1 first, then Tier 2, then Tier 3); fill as close to 100 chars as possible using relevant keywords only
1529
1580
  `;
1530
1581
  prompt += `- \`landing.hero.title\`: Tier 1 + Tier 2 keywords
1531
1582
  `;
@@ -1566,11 +1617,15 @@ ${researchSections.join("\n")}
1566
1617
  prompt += `## Step 3: Validate (after applying all keywords)
1567
1618
 
1568
1619
  `;
1569
- prompt += `Check all limits using ${FIELD_LIMITS_DOC_PATH2}: title \u226430, subtitle \u226430, shortDescription \u226480, keywords \u2264100, intro \u2264300, outro \u2264200
1620
+ prompt += `Check all limits using ${FIELD_LIMITS_DOC_PATH}: title \u226430, subtitle \u226430, shortDescription \u226480, keywords \u2264100, intro \u2264300, outro \u2264200
1621
+ `;
1622
+ prompt += `- Apply ${ASO_OVERVIEW_DOC_PATH}: keyword popularity \u226520 where available, achievable difficulty, relevance, likely user intent, singular forms, important keywords first
1570
1623
  `;
1571
- prompt += `- Remove keyword duplicates (unique list; avoid repeating title/subtitle terms verbatim)
1624
+ prompt += `- Maximize keyword field utilization: target 90-100/100 chars when enough relevant keywords exist; explain any lower count
1572
1625
  `;
1573
- prompt += `- Ensure App Store/Play Store rules from ${FIELD_LIMITS_DOC_PATH2} are satisfied (no disallowed characters/formatting)
1626
+ prompt += `- Remove keyword duplicates (unique list; avoid repeating title/subtitle terms verbatim; no spaces after commas)
1627
+ `;
1628
+ prompt += `- Ensure App Store/Play Store rules from ${FIELD_LIMITS_DOC_PATH} are satisfied (no disallowed characters/formatting)
1574
1629
 
1575
1630
  `;
1576
1631
  prompt += `## Current Data
@@ -1614,18 +1669,18 @@ ${researchSections.join("\n")}
1614
1669
  `;
1615
1670
  prompt += ` - shortDescription: X/80 \u2713/\u2717
1616
1671
  `;
1617
- prompt += ` - keywords: X/100 \u2713/\u2717 (deduped \u2713/\u2717)
1672
+ prompt += ` - keywords: X/100 \u2713/\u2717 (target 90-100 when possible; deduped \u2713/\u2717)
1618
1673
  `;
1619
1674
  prompt += ` - intro: X/300 \u2713/\u2717
1620
1675
  `;
1621
1676
  prompt += ` - outro: X/200 \u2713/\u2717
1622
1677
  `;
1623
- prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH2}): \u2713/\u2717
1678
+ prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH}): \u2713/\u2717
1624
1679
  `;
1625
1680
  prompt += ` - Density: X% (2.5-3%) \u2713/\u2717
1626
1681
 
1627
1682
  `;
1628
- prompt += `**Reference**: ${FIELD_LIMITS_DOC_PATH2}
1683
+ prompt += `**References**: ${ASO_OVERVIEW_DOC_PATH}, ${FIELD_LIMITS_DOC_PATH}
1629
1684
 
1630
1685
  `;
1631
1686
  prompt += `---
@@ -1669,6 +1724,12 @@ function generateKeywordLocalizationPrompt(args) {
1669
1724
  ", "
1670
1725
  )}
1671
1726
 
1727
+ `;
1728
+ prompt += `## ASO Basics
1729
+
1730
+ `;
1731
+ prompt += `- ${ASO_RULES_SUMMARY}
1732
+
1672
1733
  `;
1673
1734
  if (batchIndex !== void 0 && totalBatches !== void 0) {
1674
1735
  prompt += `**\u26A0\uFE0F BATCH PROCESSING MODE**
@@ -1698,7 +1759,7 @@ function generateKeywordLocalizationPrompt(args) {
1698
1759
  `;
1699
1760
  prompt += `2. **Replace ONLY keywords with optimized keywords** - keep ALL existing content, structure, tone, and context unchanged. Only swap keywords for better ASO keywords.
1700
1761
  `;
1701
- prompt += `3. After all keywords are applied, validate character limits + store rules (${FIELD_LIMITS_DOC_PATH2}) + keyword duplication
1762
+ prompt += `3. After all keywords are applied, validate ASO basics (${ASO_OVERVIEW_DOC_PATH}) + character limits/store rules (${FIELD_LIMITS_DOC_PATH}) + keyword duplication
1702
1763
  `;
1703
1764
  prompt += `4. **SAVE the updated JSON to file** using the save-locale-file tool (only if file exists)
1704
1765
 
@@ -1802,17 +1863,17 @@ ${optimizedPrimary}
1802
1863
  `;
1803
1864
  prompt += ` - App name: **ALWAYS in English** (e.g., "Aurora EOS", "Timeline", "Recaply)
1804
1865
  `;
1805
- prompt += ` - Primary keyword: **In target language** (e.g., "\uC624\uB85C\uB77C \uC608\uBCF4" for Korean, "\u30AA\u30FC\u30ED\u30E9\u4E88\u5831" for Japanese)
1866
+ prompt += ` - Primary keyword: **In target language** (e.g., "aurora forecast" for English, "pronostico de auroras" for Spanish)
1806
1867
  `;
1807
- prompt += ` - Example: "Aurora EOS: \uC624\uB85C\uB77C \uC608\uBCF4" (Korean), "Aurora EOS: \u30AA\u30FC\u30ED\u30E9\u4E88\u5831" (Japanese)
1868
+ prompt += ` - Example: "Aurora EOS: Aurora Forecast" (English), "Aurora EOS: Pronostico de Auroras" (Spanish)
1808
1869
  `;
1809
- prompt += ` - The keyword after the colon must start with an uppercase letter
1870
+ prompt += ` - Use natural casing for the target language
1810
1871
  `;
1811
1872
  prompt += ` - **Do NOT translate/rename the app name**; keep the original English app name across all locales.
1812
1873
  `;
1813
1874
  prompt += ` - **Only replace the keyword part** - keep the app name and format structure unchanged
1814
1875
  `;
1815
- prompt += `4. Deduplicate keywords: final \`aso.keywords\` must be unique and should not repeat title/subtitle terms verbatim
1876
+ prompt += `4. Deduplicate keywords: final \`aso.keywords\` must be unique, comma-only without spaces, as close to 100 chars as possible, and should not repeat title/subtitle terms verbatim
1816
1877
  `;
1817
1878
  prompt += `5. **Replace keywords in existing sentences** - swap ONLY the keywords, keep everything else:
1818
1879
  `;
@@ -1854,11 +1915,11 @@ ${optimizedPrimary}
1854
1915
  `;
1855
1916
  prompt += `- Original: "Track aurora with real-time forecasts"
1856
1917
  `;
1857
- prompt += `- Optimized keywords: \uC624\uB85C\uB77C, \uC608\uBCF4, \uC2E4\uC2DC\uAC04
1918
+ prompt += `- Optimized keywords: aurora,forecast,real-time
1858
1919
  `;
1859
- prompt += `- Result: "Track \uC624\uB85C\uB77C with \uC2E4\uC2DC\uAC04 \uC608\uBCF4" (keywords replaced, structure kept)
1920
+ prompt += `- Result: "Track aurora with real-time forecasts" (keywords replaced, structure kept)
1860
1921
  `;
1861
- prompt += ` OR: "\uC2E4\uC2DC\uAC04 \uC608\uBCF4\uB85C \uC624\uB85C\uB77C \uCD94\uC801" (if natural keyword placement requires minor word order, but keep meaning identical)
1922
+ prompt += ` OR: "Track real-time aurora forecasts" (if natural keyword placement requires minor word order, but keep meaning identical)
1862
1923
 
1863
1924
  `;
1864
1925
  prompt += `## Current Translated Locales (This Batch)
@@ -1899,7 +1960,7 @@ ${optimizedPrimary}
1899
1960
  `;
1900
1961
  prompt += `3. **CRITICAL**: Ensure ALL landing fields are translated (not English)
1901
1962
  `;
1902
- prompt += `4. After swapping keywords, validate limits + store rules (${FIELD_LIMITS_DOC_PATH2}) + keyword duplication (unique list; avoid repeating title/subtitle terms verbatim)
1963
+ prompt += `4. After swapping keywords, validate ASO basics (${ASO_OVERVIEW_DOC_PATH}) + limits/store rules (${FIELD_LIMITS_DOC_PATH}) + keyword utilization (target 90-100/100 when enough relevant keywords exist) + keyword duplication (unique list; avoid repeating title/subtitle terms verbatim; no spaces after commas)
1903
1964
  `;
1904
1965
  prompt += `5. **SAVE the updated JSON to file** using save-locale-file tool
1905
1966
  `;
@@ -1979,13 +2040,13 @@ ${optimizedPrimary}
1979
2040
  `;
1980
2041
  prompt += ` - shortDescription: X/80 \u2713/\u2717
1981
2042
  `;
1982
- prompt += ` - keywords: X/100 \u2713/\u2717 (deduped \u2713/\u2717; not repeating title/subtitle)
2043
+ prompt += ` - keywords: X/100 \u2713/\u2717 (target 90-100 when possible; deduped \u2713/\u2717; comma-only/no spaces \u2713/\u2717; not repeating title/subtitle)
1983
2044
  `;
1984
2045
  prompt += ` - intro: X/300 \u2713/\u2717
1985
2046
  `;
1986
2047
  prompt += ` - outro: X/200 \u2713/\u2717
1987
2048
  `;
1988
- prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH2}): \u2713/\u2717
2049
+ prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH}): \u2713/\u2717
1989
2050
 
1990
2051
  `;
1991
2052
  prompt += `**4. File Save Confirmation**
@@ -2386,6 +2447,7 @@ This tool returns a PROMPT containing:
2386
2447
  - Saved keyword research data (Tier 1/2/3 keywords with traffic/difficulty scores)
2387
2448
  - Current locale data
2388
2449
  - Optimization instructions
2450
+ - ASO basics from ${ASO_OVERVIEW_DOC_PATH} and field limits from ${FIELD_LIMITS_DOC_PATH}
2389
2451
 
2390
2452
  **YOU MUST:**
2391
2453
  1. Read the returned prompt carefully
@@ -2575,8 +2637,9 @@ var validateAsoTool = {
2575
2637
  - App Store: name \u2264${APP_STORE_LIMITS.name}, subtitle \u2264${APP_STORE_LIMITS.subtitle}, keywords \u2264${APP_STORE_LIMITS.keywords}, description \u2264${APP_STORE_LIMITS.description}
2576
2638
  - Google Play: title \u2264${GOOGLE_PLAY_LIMITS.title}, shortDescription \u2264${GOOGLE_PLAY_LIMITS.shortDescription}, fullDescription \u2264${GOOGLE_PLAY_LIMITS.fullDescription}
2577
2639
 
2578
- 2. **Keyword Duplicates** (App Store only):
2579
- - Checks for duplicate keywords in comma-separated list
2640
+ 2. **Keyword Rules** (App Store only):
2641
+ - Checks duplicate keywords, comma-only/no-space formatting, and title/subtitle repetition
2642
+ - Strategy reference: ${ASO_OVERVIEW_DOC_PATH}
2580
2643
 
2581
2644
  3. **Invalid Characters**:
2582
2645
  - Control characters, BOM, zero-width/invisible characters, variation selectors
@@ -2699,10 +2762,26 @@ async function handleValidateAso(input) {
2699
2762
  const keywordIssues = validateKeywords(dataToValidate);
2700
2763
  const filteredKeywordIssues = locale ? keywordIssues.filter((issue) => issue.locale === locale) : keywordIssues;
2701
2764
  if (filteredKeywordIssues.length > 0) {
2702
- results.push(`## Keyword Duplicates
2765
+ results.push(`## Keyword Rule Violations
2703
2766
  `);
2704
2767
  for (const issue of filteredKeywordIssues) {
2705
- results.push(`- [${issue.locale}]: ${issue.duplicates.join(", ")}`);
2768
+ if (issue.duplicates.length > 0) {
2769
+ results.push(
2770
+ `- [${issue.locale}] duplicates: ${issue.duplicates.join(", ")}`
2771
+ );
2772
+ }
2773
+ if (issue.formatting.length > 0) {
2774
+ results.push(
2775
+ `- [${issue.locale}] formatting: ${issue.formatting.join(", ")}`
2776
+ );
2777
+ }
2778
+ if (issue.repeatedFromTitleOrSubtitle.length > 0) {
2779
+ results.push(
2780
+ `- [${issue.locale}] repeats title/subtitle: ${issue.repeatedFromTitleOrSubtitle.join(
2781
+ ", "
2782
+ )}`
2783
+ );
2784
+ }
2706
2785
  }
2707
2786
  results.push("");
2708
2787
  }
@@ -2717,7 +2796,7 @@ async function handleValidateAso(input) {
2717
2796
  `\u274C **Validation failed** - Fix the issues above before pushing to stores.`
2718
2797
  );
2719
2798
  results.push(`
2720
- Reference: ${FIELD_LIMITS_DOC_PATH}`);
2799
+ References: ${ASO_OVERVIEW_DOC_PATH}, ${FIELD_LIMITS_DOC_PATH}`);
2721
2800
  } else if (hasSanitizeWarnings && !fix) {
2722
2801
  results.push(
2723
2802
  `\u26A0\uFE0F **Invalid characters detected** - Run with \`fix: true\` to auto-remove.`
@@ -0,0 +1,418 @@
1
+ import {
2
+ DEFAULT_LOCALE,
3
+ isAppStoreLocale,
4
+ isGooglePlayLocale,
5
+ isSupportedLocale
6
+ } from "./chunk-BOWRBVVV.js";
7
+
8
+ // src/utils/config.util.ts
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+ function getAsoDataDir() {
13
+ const configPath = path.join(
14
+ os.homedir(),
15
+ ".config",
16
+ "pabal-mcp",
17
+ "config.json"
18
+ );
19
+ if (!fs.existsSync(configPath)) {
20
+ throw new Error(
21
+ `Config file not found at ${configPath}. Please create the config file and set the 'dataDir' property to specify the ASO data directory.`
22
+ );
23
+ }
24
+ try {
25
+ const configContent = fs.readFileSync(configPath, "utf-8");
26
+ const config = JSON.parse(configContent);
27
+ if (!config.dataDir) {
28
+ throw new Error(
29
+ `'dataDir' property is not set in ${configPath}. Please set 'dataDir' to specify the ASO data directory.`
30
+ );
31
+ }
32
+ if (path.isAbsolute(config.dataDir)) {
33
+ return config.dataDir;
34
+ }
35
+ return path.resolve(os.homedir(), config.dataDir);
36
+ } catch (error) {
37
+ if (error instanceof Error && error.message.includes("dataDir")) {
38
+ throw error;
39
+ }
40
+ throw new Error(
41
+ `Failed to read config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`
42
+ );
43
+ }
44
+ }
45
+ function getPullDataDir() {
46
+ return path.join(getAsoDataDir(), ".aso", "pullData");
47
+ }
48
+ function getPushDataDir() {
49
+ return path.join(getAsoDataDir(), ".aso", "pushData");
50
+ }
51
+ function getPublicDir() {
52
+ return path.join(getAsoDataDir(), "public");
53
+ }
54
+ function getKeywordResearchDir() {
55
+ return path.join(getAsoDataDir(), ".aso", "keywordResearch");
56
+ }
57
+ function getProductsDir() {
58
+ return path.join(getPublicDir(), "products");
59
+ }
60
+ function loadConfig() {
61
+ const configPath = path.join(
62
+ os.homedir(),
63
+ ".config",
64
+ "pabal-mcp",
65
+ "config.json"
66
+ );
67
+ if (!fs.existsSync(configPath)) {
68
+ return {};
69
+ }
70
+ try {
71
+ const configContent = fs.readFileSync(configPath, "utf-8");
72
+ return JSON.parse(configContent);
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+ function getGeminiApiKey() {
78
+ const config = loadConfig();
79
+ if (config.gemini?.apiKey) {
80
+ return config.gemini.apiKey;
81
+ }
82
+ const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
83
+ if (envKey) {
84
+ return envKey;
85
+ }
86
+ throw new Error(
87
+ `Gemini API key not found. Set it in ~/.config/pabal-mcp/config.json under "gemini.apiKey" or use GEMINI_API_KEY environment variable.`
88
+ );
89
+ }
90
+
91
+ // src/utils/aso-converter.ts
92
+ import fs2 from "fs";
93
+ import path2 from "path";
94
+ function generateFullDescription(localeData, metadata = {}) {
95
+ const { aso, landing } = localeData;
96
+ const template = aso?.template;
97
+ if (!template) {
98
+ return "";
99
+ }
100
+ const landingFeatures = landing?.features?.items || [];
101
+ const landingScreenshots = landing?.screenshots?.images || [];
102
+ const keyHeading = template.keyFeaturesHeading || "Key Features";
103
+ const featuresHeading = template.featuresHeading || "Additional Features";
104
+ const parts = [template.intro];
105
+ if (landingFeatures.length > 0) {
106
+ parts.push(
107
+ "",
108
+ keyHeading,
109
+ "",
110
+ ...landingFeatures.map(
111
+ (feature) => [`\u25B6\uFE0E ${feature.title}`, feature.body || ""].filter(Boolean).join("\n")
112
+ )
113
+ );
114
+ }
115
+ if (landingScreenshots.length > 0) {
116
+ parts.push("", featuresHeading, "");
117
+ parts.push(
118
+ ...landingScreenshots.map(
119
+ (screenshot) => [`\u25B6\uFE0E ${screenshot.title}`, screenshot.description || ""].filter(Boolean).join("\n")
120
+ )
121
+ );
122
+ }
123
+ parts.push("", template.outro);
124
+ const includeSupport = template.includeSupportLinks ?? true;
125
+ if (includeSupport) {
126
+ const contactLines = [
127
+ metadata.instagram ? `Instagram: ${metadata.instagram}` : null,
128
+ metadata.contactEmail ? `Email: ${metadata.contactEmail}` : null,
129
+ metadata.termsUrl ? `- Terms of Use: ${metadata.termsUrl}` : null,
130
+ metadata.privacyUrl ? `- Privacy Policy: ${metadata.privacyUrl}` : null
131
+ ].filter((line) => line !== null);
132
+ if (contactLines.length > 0) {
133
+ parts.push("", "[Contact & Support]", "", ...contactLines);
134
+ }
135
+ }
136
+ return parts.join("\n");
137
+ }
138
+ function loadAsoFromConfig(slug) {
139
+ const productsDir = getProductsDir();
140
+ const configPath = path2.join(productsDir, slug, "config.json");
141
+ console.debug(`[loadAsoFromConfig] Looking for ${slug}:`);
142
+ console.debug(` - productsDir: ${productsDir}`);
143
+ console.debug(` - configPath: ${configPath}`);
144
+ console.debug(` - configPath exists: ${fs2.existsSync(configPath)}`);
145
+ if (!fs2.existsSync(configPath)) {
146
+ console.warn(`[loadAsoFromConfig] Config file not found at ${configPath}`);
147
+ return {};
148
+ }
149
+ try {
150
+ const configContent = fs2.readFileSync(configPath, "utf-8");
151
+ const config = JSON.parse(configContent);
152
+ const localesDir = path2.join(productsDir, slug, "locales");
153
+ console.debug(` - localesDir: ${localesDir}`);
154
+ console.debug(` - localesDir exists: ${fs2.existsSync(localesDir)}`);
155
+ if (!fs2.existsSync(localesDir)) {
156
+ console.warn(
157
+ `[loadAsoFromConfig] Locales directory not found at ${localesDir}`
158
+ );
159
+ return {};
160
+ }
161
+ const localeFiles = fs2.readdirSync(localesDir).filter((f) => f.endsWith(".json"));
162
+ const locales = {};
163
+ for (const file of localeFiles) {
164
+ const localeCode = file.replace(".json", "");
165
+ const localePath = path2.join(localesDir, file);
166
+ const localeContent = fs2.readFileSync(localePath, "utf-8");
167
+ locales[localeCode] = JSON.parse(localeContent);
168
+ }
169
+ console.debug(
170
+ ` - Found ${Object.keys(locales).length} locale file(s): ${Object.keys(
171
+ locales
172
+ ).join(", ")}`
173
+ );
174
+ if (Object.keys(locales).length === 0) {
175
+ console.warn(
176
+ `[loadAsoFromConfig] No locale files found in ${localesDir}`
177
+ );
178
+ }
179
+ const defaultLocale = config.content?.defaultLocale || DEFAULT_LOCALE;
180
+ const asoData = {};
181
+ if (config.packageName) {
182
+ const googlePlayLocales = {};
183
+ const metadata = config.metadata || {};
184
+ const screenshots = metadata.screenshots || {};
185
+ for (const [locale, localeData] of Object.entries(locales)) {
186
+ if (!isSupportedLocale(locale)) {
187
+ console.debug(
188
+ `Skipping locale ${locale} - not a valid unified locale`
189
+ );
190
+ continue;
191
+ }
192
+ if (!isGooglePlayLocale(locale)) {
193
+ console.debug(
194
+ `Skipping locale ${locale} - not supported by Google Play`
195
+ );
196
+ continue;
197
+ }
198
+ const aso = localeData.aso || {};
199
+ if (!aso || !aso.title && !aso.shortDescription) {
200
+ console.warn(
201
+ `Locale ${locale} has no ASO data (title or shortDescription)`
202
+ );
203
+ }
204
+ const screenshotsDir = path2.join(
205
+ productsDir,
206
+ slug,
207
+ "screenshots",
208
+ locale
209
+ );
210
+ const phoneDir = path2.join(screenshotsDir, "phone");
211
+ const tabletDir = path2.join(screenshotsDir, "tablet");
212
+ const featureGraphicPath = path2.join(
213
+ screenshotsDir,
214
+ "feature-graphic.png"
215
+ );
216
+ const hasPhoneScreenshots = fs2.existsSync(phoneDir);
217
+ const hasTabletScreenshots = fs2.existsSync(tabletDir);
218
+ const hasFeatureGraphic = fs2.existsSync(featureGraphicPath);
219
+ const localeScreenshots = {
220
+ phone: hasPhoneScreenshots ? screenshots.phone?.map(
221
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
222
+ ) : void 0,
223
+ tablet: hasTabletScreenshots ? screenshots.tablet?.map(
224
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
225
+ ) : void 0
226
+ };
227
+ googlePlayLocales[locale] = {
228
+ title: aso.title || "",
229
+ shortDescription: aso.shortDescription || "",
230
+ fullDescription: generateFullDescription(localeData, metadata),
231
+ packageName: config.packageName,
232
+ defaultLanguage: locale,
233
+ screenshots: {
234
+ phone: localeScreenshots.phone || [],
235
+ tablet: localeScreenshots.tablet
236
+ },
237
+ featureGraphic: hasFeatureGraphic ? `/products/${slug}/screenshots/${locale}/feature-graphic.png` : metadata.featureGraphic
238
+ };
239
+ }
240
+ const googleLocaleKeys = Object.keys(googlePlayLocales);
241
+ if (googleLocaleKeys.length > 0) {
242
+ const hasConfigDefault = isGooglePlayLocale(defaultLocale) && Boolean(googlePlayLocales[defaultLocale]);
243
+ const resolvedDefault = hasConfigDefault ? defaultLocale : googlePlayLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : googleLocaleKeys[0];
244
+ asoData.googlePlay = {
245
+ locales: googlePlayLocales,
246
+ defaultLocale: resolvedDefault,
247
+ // App-level contact information
248
+ contactEmail: metadata.contactEmail,
249
+ contactWebsite: metadata.supportUrl,
250
+ youtubeUrl: metadata.youtubeUrl
251
+ };
252
+ }
253
+ }
254
+ if (config.bundleId) {
255
+ const appStoreLocales = {};
256
+ const metadata = config.metadata || {};
257
+ const screenshots = metadata.screenshots || {};
258
+ for (const [locale, localeData] of Object.entries(locales)) {
259
+ if (!isSupportedLocale(locale)) {
260
+ console.debug(
261
+ `Skipping locale ${locale} - not a valid unified locale`
262
+ );
263
+ continue;
264
+ }
265
+ if (!isAppStoreLocale(locale)) {
266
+ console.debug(
267
+ `Skipping locale ${locale} - not supported by App Store`
268
+ );
269
+ continue;
270
+ }
271
+ const aso = localeData.aso || {};
272
+ if (!aso || !aso.title && !aso.shortDescription) {
273
+ console.warn(
274
+ `Locale ${locale} has no ASO data (title or shortDescription)`
275
+ );
276
+ }
277
+ const screenshotsDir = path2.join(
278
+ productsDir,
279
+ slug,
280
+ "screenshots",
281
+ locale
282
+ );
283
+ const phoneDir = path2.join(screenshotsDir, "phone");
284
+ const tabletDir = path2.join(screenshotsDir, "tablet");
285
+ const hasPhoneScreenshots = fs2.existsSync(phoneDir);
286
+ const hasTabletScreenshots = fs2.existsSync(tabletDir);
287
+ const localeScreenshots = {
288
+ phone: hasPhoneScreenshots ? screenshots.phone?.map(
289
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
290
+ ) : void 0,
291
+ tablet: hasTabletScreenshots ? screenshots.tablet?.map(
292
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
293
+ ) : void 0
294
+ };
295
+ appStoreLocales[locale] = {
296
+ name: aso.title || "",
297
+ subtitle: aso.subtitle,
298
+ description: generateFullDescription(localeData, metadata),
299
+ keywords: Array.isArray(aso.keywords) ? aso.keywords.join(",") : aso.keywords,
300
+ promotionalText: void 0,
301
+ bundleId: config.bundleId,
302
+ locale,
303
+ screenshots: {
304
+ // Map phone screenshots to iphone65
305
+ iphone65: localeScreenshots.phone || [],
306
+ // Map tablet screenshots to ipadPro129
307
+ ipadPro129: localeScreenshots.tablet
308
+ }
309
+ };
310
+ }
311
+ const appStoreLocaleKeys = Object.keys(appStoreLocales);
312
+ if (appStoreLocaleKeys.length > 0) {
313
+ const hasConfigDefault = isAppStoreLocale(defaultLocale) && Boolean(appStoreLocales[defaultLocale]);
314
+ const resolvedDefault = hasConfigDefault ? defaultLocale : appStoreLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : appStoreLocaleKeys[0];
315
+ asoData.appStore = {
316
+ locales: appStoreLocales,
317
+ defaultLocale: resolvedDefault,
318
+ // App-level contact information
319
+ contactEmail: metadata.contactEmail,
320
+ supportUrl: metadata.supportUrl,
321
+ marketingUrl: metadata.marketingUrl,
322
+ privacyPolicyUrl: metadata.privacyUrl,
323
+ termsUrl: metadata.termsUrl
324
+ };
325
+ }
326
+ }
327
+ const hasGooglePlay = !!asoData.googlePlay;
328
+ const hasAppStore = !!asoData.appStore;
329
+ console.debug(`[loadAsoFromConfig] Result for ${slug}:`);
330
+ console.debug(
331
+ ` - Google Play data: ${hasGooglePlay ? "found" : "not found"}`
332
+ );
333
+ console.debug(` - App Store data: ${hasAppStore ? "found" : "not found"}`);
334
+ if (!hasGooglePlay && !hasAppStore) {
335
+ console.warn(`[loadAsoFromConfig] No ASO data generated for ${slug}`);
336
+ }
337
+ return asoData;
338
+ } catch (error) {
339
+ console.error(
340
+ `[loadAsoFromConfig] Failed to load ASO data from config for ${slug}:`,
341
+ error
342
+ );
343
+ return {};
344
+ }
345
+ }
346
+ function saveAsoToConfig(slug, config) {
347
+ const productsDir = getProductsDir();
348
+ const configPath = path2.join(productsDir, slug, "config.json");
349
+ fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
350
+ }
351
+ function saveAsoToAsoDir(slug, asoData) {
352
+ const rootDir = getPushDataDir();
353
+ if (asoData.googlePlay) {
354
+ const asoPath = path2.join(
355
+ rootDir,
356
+ "products",
357
+ slug,
358
+ "store",
359
+ "google-play",
360
+ "aso-data.json"
361
+ );
362
+ const dir = path2.dirname(asoPath);
363
+ if (!fs2.existsSync(dir)) {
364
+ fs2.mkdirSync(dir, { recursive: true });
365
+ }
366
+ const googlePlayData = asoData.googlePlay;
367
+ const multilingualData = "locales" in googlePlayData ? googlePlayData : {
368
+ locales: {
369
+ [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
370
+ },
371
+ defaultLocale: googlePlayData.defaultLanguage || DEFAULT_LOCALE
372
+ };
373
+ fs2.writeFileSync(
374
+ asoPath,
375
+ JSON.stringify({ googlePlay: multilingualData }, null, 2) + "\n",
376
+ "utf-8"
377
+ );
378
+ }
379
+ if (asoData.appStore) {
380
+ const asoPath = path2.join(
381
+ rootDir,
382
+ "products",
383
+ slug,
384
+ "store",
385
+ "app-store",
386
+ "aso-data.json"
387
+ );
388
+ const dir = path2.dirname(asoPath);
389
+ if (!fs2.existsSync(dir)) {
390
+ fs2.mkdirSync(dir, { recursive: true });
391
+ }
392
+ const appStoreData = asoData.appStore;
393
+ const multilingualData = "locales" in appStoreData ? appStoreData : {
394
+ locales: {
395
+ [appStoreData.locale || DEFAULT_LOCALE]: appStoreData
396
+ },
397
+ defaultLocale: appStoreData.locale || DEFAULT_LOCALE
398
+ };
399
+ fs2.writeFileSync(
400
+ asoPath,
401
+ JSON.stringify({ appStore: multilingualData }, null, 2) + "\n",
402
+ "utf-8"
403
+ );
404
+ }
405
+ }
406
+
407
+ export {
408
+ getAsoDataDir,
409
+ getPullDataDir,
410
+ getPushDataDir,
411
+ getPublicDir,
412
+ getKeywordResearchDir,
413
+ getProductsDir,
414
+ getGeminiApiKey,
415
+ loadAsoFromConfig,
416
+ saveAsoToConfig,
417
+ saveAsoToAsoDir
418
+ };
package/dist/index.d.ts CHANGED
@@ -6,20 +6,20 @@ import 'appstore-connect-sdk/openapi';
6
6
  /**
7
7
  * ASO Data Converter
8
8
  *
9
- * config.json (source of truth) aso-data.json (build artifact) 변환 유틸리티
9
+ * Converts between config.json (source of truth) and aso-data.json (build artifact).
10
10
  */
11
11
 
12
12
  /**
13
- * config.json에서 ASO 데이터를 읽어옵니다
14
- * 구조: config.json (메타데이터) + locales/{locale}.json (콘텐츠)
13
+ * Read ASO data from config.json.
14
+ * New structure: config.json (metadata) + locales/{locale}.json (content)
15
15
  */
16
16
  declare function loadAsoFromConfig(slug: string): AsoData;
17
17
  /**
18
- * config.json에 ASO 데이터를 저장합니다
18
+ * Save ASO data to config.json.
19
19
  */
20
20
  declare function saveAsoToConfig(slug: string, config: ProductConfig): void;
21
21
  /**
22
- * ASO 데이터를 지정한 ASO 디렉토리에 저장합니다
22
+ * Save ASO data to the configured ASO directory.
23
23
  */
24
24
  declare function saveAsoToAsoDir(slug: string, asoData: AsoData): void;
25
25
 
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  loadAsoFromConfig,
10
10
  saveAsoToAsoDir,
11
11
  saveAsoToConfig
12
- } from "./chunk-4JFBWKY4.js";
12
+ } from "./chunk-H4MYWLFK.js";
13
13
  import {
14
14
  APP_STORE_TO_UNIFIED,
15
15
  DEFAULT_LOCALE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-resource-mcp",
3
- "version": "1.10.5",
3
+ "version": "1.10.6",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",