@localizeaso/cli 0.1.0-preview.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.
Files changed (58) hide show
  1. package/README.md +24 -0
  2. package/package.json +35 -0
  3. package/packages/asc-shared/dist/app-store-review.d.ts +610 -0
  4. package/packages/asc-shared/dist/app-store-review.d.ts.map +1 -0
  5. package/packages/asc-shared/dist/app-store-review.js +242 -0
  6. package/packages/asc-shared/dist/aso-keyword-map.d.ts +94 -0
  7. package/packages/asc-shared/dist/aso-keyword-map.d.ts.map +1 -0
  8. package/packages/asc-shared/dist/aso-keyword-map.js +292 -0
  9. package/packages/asc-shared/dist/constants.d.ts +15 -0
  10. package/packages/asc-shared/dist/constants.d.ts.map +1 -0
  11. package/packages/asc-shared/dist/constants.js +130 -0
  12. package/packages/asc-shared/dist/cross-localization.d.ts +29 -0
  13. package/packages/asc-shared/dist/cross-localization.d.ts.map +1 -0
  14. package/packages/asc-shared/dist/cross-localization.js +189 -0
  15. package/packages/asc-shared/dist/dedupe.d.ts +17 -0
  16. package/packages/asc-shared/dist/dedupe.d.ts.map +1 -0
  17. package/packages/asc-shared/dist/dedupe.js +104 -0
  18. package/packages/asc-shared/dist/design-tokens.d.ts +83 -0
  19. package/packages/asc-shared/dist/design-tokens.d.ts.map +1 -0
  20. package/packages/asc-shared/dist/design-tokens.js +73 -0
  21. package/packages/asc-shared/dist/index.d.ts +16 -0
  22. package/packages/asc-shared/dist/index.d.ts.map +1 -0
  23. package/packages/asc-shared/dist/index.js +16 -0
  24. package/packages/asc-shared/dist/keywords.d.ts +48 -0
  25. package/packages/asc-shared/dist/keywords.d.ts.map +1 -0
  26. package/packages/asc-shared/dist/keywords.js +376 -0
  27. package/packages/asc-shared/dist/limits.d.ts +11 -0
  28. package/packages/asc-shared/dist/limits.d.ts.map +1 -0
  29. package/packages/asc-shared/dist/limits.js +9 -0
  30. package/packages/asc-shared/dist/locales.d.ts +10 -0
  31. package/packages/asc-shared/dist/locales.d.ts.map +1 -0
  32. package/packages/asc-shared/dist/locales.js +314 -0
  33. package/packages/asc-shared/dist/monetization-boundary.d.ts +148 -0
  34. package/packages/asc-shared/dist/monetization-boundary.d.ts.map +1 -0
  35. package/packages/asc-shared/dist/monetization-boundary.js +365 -0
  36. package/packages/asc-shared/dist/post-approval-paths.d.ts +30 -0
  37. package/packages/asc-shared/dist/post-approval-paths.d.ts.map +1 -0
  38. package/packages/asc-shared/dist/post-approval-paths.js +25 -0
  39. package/packages/asc-shared/dist/review-gate-summary.d.ts +166 -0
  40. package/packages/asc-shared/dist/review-gate-summary.d.ts.map +1 -0
  41. package/packages/asc-shared/dist/review-gate-summary.js +354 -0
  42. package/packages/asc-shared/dist/reviewer-feedback.d.ts +19 -0
  43. package/packages/asc-shared/dist/reviewer-feedback.d.ts.map +1 -0
  44. package/packages/asc-shared/dist/reviewer-feedback.js +94 -0
  45. package/packages/asc-shared/dist/screenshot-review.d.ts +478 -0
  46. package/packages/asc-shared/dist/screenshot-review.d.ts.map +1 -0
  47. package/packages/asc-shared/dist/screenshot-review.js +17 -0
  48. package/packages/asc-shared/dist/supabase.types.d.ts +541 -0
  49. package/packages/asc-shared/dist/supabase.types.d.ts.map +1 -0
  50. package/packages/asc-shared/dist/supabase.types.js +5 -0
  51. package/packages/asc-shared/dist/validation.d.ts +42 -0
  52. package/packages/asc-shared/dist/validation.d.ts.map +1 -0
  53. package/packages/asc-shared/dist/validation.js +113 -0
  54. package/scripts/ensure-shared-build.mjs +76 -0
  55. package/scripts/export-astro-mcp-apps.mjs +841 -0
  56. package/scripts/localizeaso.mjs +2100 -0
  57. package/scripts/review-agent.mjs +9092 -0
  58. package/scripts/review-mcp.mjs +5931 -0
@@ -0,0 +1,841 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { ensureSharedBuild } from './ensure-shared-build.mjs';
10
+
11
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:8089/mcp';
12
+ const DEFAULT_DEVELOPER = 'Wotaso GmbH';
13
+ const DEFAULT_OUT_DIR = 'exports/astro-mcp';
14
+ const DEFAULT_TIMEOUT_MS = 120_000;
15
+
16
+ let buildLocalizeAsoMonetizationBoundary = null;
17
+ let localizeAsoPostApprovalProtectedActionBoundary = '';
18
+
19
+ function printHelpAndExit(exitCode, reason = '') {
20
+ if (reason) process.stderr.write(`${reason}\n\n`);
21
+ process.stdout.write(`Export Astro MCP Apps ZIP
22
+
23
+ Read-only export of own tracked Astro ASO data into a ZIP. Data is fetched from
24
+ Astro MCP tools, not from App Store Connect. ASC is only used as an optional
25
+ app-id allowlist filter so competitor apps tracked in Astro are excluded.
26
+
27
+ Usage:
28
+ node scripts/export-astro-mcp-apps.mjs [options]
29
+
30
+ Options:
31
+ --endpoint <url> Astro MCP endpoint (default: ASTRO_MCP_URL or ${DEFAULT_ENDPOINT})
32
+ --app <id> Export only this App Store app ID. Can be repeated or comma-separated.
33
+ --developer <name> Own developer filter (default: ${DEFAULT_DEVELOPER})
34
+ --all-tracked Export every Astro-tracked app. Disables developer and ASC filtering.
35
+ --no-asc-allowlist Do not intersect selected apps with local "asc apps list" IDs.
36
+ --store <code> Limit exported stores. Can be repeated or comma-separated.
37
+ --out-dir <dir> Working export directory (default: ${DEFAULT_OUT_DIR}/<timestamp>)
38
+ --zip <file> ZIP output path (default: <out-dir>.zip)
39
+ --keyword-context-out <file>
40
+ Also write provider-neutral LocalizeASO keyword-context JSON.
41
+ --keep-dir Keep the unpacked working directory after ZIP creation.
42
+ --skip-ranking-history Skip per-keyword search_rankings history exports.
43
+ --history-period <period> Ranking history period: week, month, year, all (default: all)
44
+ --max-ranking-history <n> Limit ranking-history calls, useful for smoke tests.
45
+ --include-suggestions Export get_keyword_suggestions per app/store.
46
+ --include-competitors Export extract_competitors_keywords per keyword/store.
47
+ --timeout-ms <n> Per HTTP/tool/zip timeout (default: ${DEFAULT_TIMEOUT_MS})
48
+ --pretty Pretty-print JSON files.
49
+ --dry-run Discover/filter apps and print the export plan only.
50
+ --help, -h Show this help.
51
+
52
+ Examples:
53
+ pnpm astro:mcp:export
54
+ pnpm astro:mcp:export -- --app 6755280377 --zip ./exports/camera-roll-astro.zip
55
+ pnpm astro:mcp:export -- --app 6755280377 --keyword-context-out ./keyword-context.json
56
+ pnpm astro:mcp:export -- --skip-ranking-history --keep-dir
57
+ `);
58
+ process.exit(exitCode);
59
+ }
60
+
61
+ function parseArgs(argv) {
62
+ const args = {
63
+ endpoint: process.env.ASTRO_MCP_URL || '',
64
+ appIds: [],
65
+ developer: process.env.ASTRO_MCP_EXPORT_DEVELOPER || DEFAULT_DEVELOPER,
66
+ allTracked: false,
67
+ ascAllowlist: !['0', 'false', 'no'].includes(String(process.env.ASTRO_MCP_ASC_ALLOWLIST || '').toLowerCase()),
68
+ stores: [],
69
+ outDir: '',
70
+ zipPath: '',
71
+ keywordContextOut: '',
72
+ keepDir: false,
73
+ includeRankingHistory: true,
74
+ historyPeriod: 'all',
75
+ maxRankingHistory: 0,
76
+ includeSuggestions: false,
77
+ includeCompetitors: false,
78
+ timeoutMs: DEFAULT_TIMEOUT_MS,
79
+ pretty: false,
80
+ dryRun: false,
81
+ };
82
+
83
+ for (let index = 0; index < argv.length; index += 1) {
84
+ const token = argv[index];
85
+ const next = argv[index + 1];
86
+ if (token === '--') {
87
+ continue;
88
+ } else if (token === '--endpoint') {
89
+ args.endpoint = requiredNext(token, next);
90
+ index += 1;
91
+ } else if (token === '--app') {
92
+ args.appIds.push(...splitList(requiredNext(token, next)));
93
+ index += 1;
94
+ } else if (token === '--developer') {
95
+ args.developer = requiredNext(token, next);
96
+ index += 1;
97
+ } else if (token === '--all-tracked') {
98
+ args.allTracked = true;
99
+ } else if (token === '--no-asc-allowlist') {
100
+ args.ascAllowlist = false;
101
+ } else if (token === '--store') {
102
+ args.stores.push(...splitList(requiredNext(token, next)).map((store) => store.toLowerCase()));
103
+ index += 1;
104
+ } else if (token === '--out-dir') {
105
+ args.outDir = requiredNext(token, next);
106
+ index += 1;
107
+ } else if (token === '--zip') {
108
+ args.zipPath = requiredNext(token, next);
109
+ index += 1;
110
+ } else if (token === '--keyword-context-out') {
111
+ args.keywordContextOut = requiredNext(token, next);
112
+ index += 1;
113
+ } else if (token === '--keep-dir') {
114
+ args.keepDir = true;
115
+ } else if (token === '--skip-ranking-history') {
116
+ args.includeRankingHistory = false;
117
+ } else if (token === '--history-period') {
118
+ args.historyPeriod = requiredNext(token, next);
119
+ if (!['week', 'month', 'year', 'all'].includes(args.historyPeriod)) {
120
+ printHelpAndExit(1, `Invalid --history-period: ${args.historyPeriod}`);
121
+ }
122
+ index += 1;
123
+ } else if (token === '--max-ranking-history') {
124
+ args.maxRankingHistory = positiveInteger(token, next, true);
125
+ index += 1;
126
+ } else if (token === '--include-suggestions') {
127
+ args.includeSuggestions = true;
128
+ } else if (token === '--include-competitors') {
129
+ args.includeCompetitors = true;
130
+ } else if (token === '--timeout-ms') {
131
+ args.timeoutMs = positiveInteger(token, next, false);
132
+ if (args.timeoutMs < 1000) printHelpAndExit(1, '--timeout-ms must be >= 1000.');
133
+ index += 1;
134
+ } else if (token === '--pretty') {
135
+ args.pretty = true;
136
+ } else if (token === '--dry-run') {
137
+ args.dryRun = true;
138
+ } else if (token === '--help' || token === '-h') {
139
+ printHelpAndExit(0);
140
+ } else {
141
+ printHelpAndExit(1, `Unknown argument: ${token}`);
142
+ }
143
+ }
144
+
145
+ args.endpoint = args.endpoint || readAstroEndpointFromCodexConfig() || DEFAULT_ENDPOINT;
146
+ args.appIds = [...new Set(args.appIds.map((value) => value.trim()).filter(Boolean))];
147
+ args.stores = [...new Set(args.stores.map((value) => value.trim().toLowerCase()).filter(Boolean))];
148
+
149
+ const timestamp = timestampForPath(new Date());
150
+ if (!args.outDir) args.outDir = path.join(DEFAULT_OUT_DIR, timestamp);
151
+ if (!args.zipPath) args.zipPath = `${args.outDir.replace(/\/+$/, '')}.zip`;
152
+ if (args.allTracked) args.ascAllowlist = false;
153
+ return args;
154
+ }
155
+
156
+ function requiredNext(flag, value) {
157
+ if (!value) printHelpAndExit(1, `Missing value for ${flag}.`);
158
+ return String(value);
159
+ }
160
+
161
+ function positiveInteger(flag, value, allowZero) {
162
+ const parsed = Number.parseInt(String(value || ''), 10);
163
+ if (!Number.isFinite(parsed) || parsed < (allowZero ? 0 : 1)) {
164
+ printHelpAndExit(1, `Invalid value for ${flag}: ${String(value || '')}`);
165
+ }
166
+ return parsed;
167
+ }
168
+
169
+ function splitList(value) {
170
+ return String(value)
171
+ .split(',')
172
+ .map((entry) => entry.trim())
173
+ .filter(Boolean);
174
+ }
175
+
176
+ function readAstroEndpointFromCodexConfig() {
177
+ try {
178
+ const configPath = path.join(os.homedir(), '.codex', 'config.toml');
179
+ const text = requireTextSync(configPath);
180
+ const match = text.match(/\[mcp_servers\.astro_aso_tool\][\s\S]*?^\s*url\s*=\s*"([^"]+)"/m);
181
+ return match?.[1] || '';
182
+ } catch {
183
+ return '';
184
+ }
185
+ }
186
+
187
+ function requireTextSync(file) {
188
+ return String(readFileSync(file, 'utf8'));
189
+ }
190
+
191
+ function timestampForPath(date) {
192
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[:]/g, '').replace('T', '-');
193
+ }
194
+
195
+ function safePathPart(value, fallback = 'unknown') {
196
+ const cleaned = String(value || fallback)
197
+ .normalize('NFKD')
198
+ .replace(/[^\w.-]+/g, '-')
199
+ .replace(/^-+|-+$/g, '')
200
+ .slice(0, 100);
201
+ return cleaned || fallback;
202
+ }
203
+
204
+ function jsonStringify(value, pretty = false) {
205
+ return JSON.stringify(value, null, pretty ? 2 : 0);
206
+ }
207
+
208
+ async function ensureDir(dir) {
209
+ await mkdir(dir, { recursive: true });
210
+ }
211
+
212
+ async function writeJson(file, value, pretty = false) {
213
+ await ensureDir(path.dirname(file));
214
+ await writeFile(file, `${jsonStringify(value, pretty)}\n`, 'utf8');
215
+ }
216
+
217
+ function parseMaybeJson(value) {
218
+ if (typeof value !== 'string') return value;
219
+ const text = value.trim();
220
+ if (!text) return '';
221
+ try {
222
+ return JSON.parse(text);
223
+ } catch {
224
+ return value;
225
+ }
226
+ }
227
+
228
+ function cleanString(value) {
229
+ return typeof value === 'string' ? value.trim() : '';
230
+ }
231
+
232
+ function firstCleanString(...values) {
233
+ for (const value of values) {
234
+ const cleaned = cleanString(value);
235
+ if (cleaned) return cleaned;
236
+ }
237
+ return '';
238
+ }
239
+
240
+ function optionalNumber(value) {
241
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
242
+ if (typeof value !== 'string') return undefined;
243
+ const cleaned = value
244
+ .trim()
245
+ .replace(/[%\s]/g, '')
246
+ .replace(/^[^\d+-.]+/, '')
247
+ .replace(/[^\d,.-]+$/, '');
248
+ if (!cleaned) return undefined;
249
+ const normalized = cleaned.includes(',') && !cleaned.includes('.')
250
+ ? cleaned.replace(',', '.')
251
+ : cleaned.replace(/,/g, '');
252
+ const parsed = Number(normalized);
253
+ return Number.isFinite(parsed) ? parsed : undefined;
254
+ }
255
+
256
+ function optionalBoolean(value) {
257
+ if (typeof value === 'boolean') return value;
258
+ if (typeof value === 'number') return value === 1 ? true : value === 0 ? false : undefined;
259
+ if (typeof value !== 'string') return undefined;
260
+ const cleaned = value.trim().toLowerCase();
261
+ if (['true', '1', 'yes', 'y', 'preferred', 'favorite', 'favourite'].includes(cleaned)) return true;
262
+ if (['false', '0', 'no', 'n'].includes(cleaned)) return false;
263
+ return undefined;
264
+ }
265
+
266
+ function extractAstroKeywordRows(value) {
267
+ if (Array.isArray(value)) return value;
268
+ if (!value || typeof value !== 'object') return [];
269
+ for (const key of ['keywords', 'rows', 'data', 'items', 'results']) {
270
+ if (Array.isArray(value[key])) return value[key];
271
+ }
272
+ return [];
273
+ }
274
+
275
+ function keywordTextFromAstroRow(row) {
276
+ if (!row || typeof row !== 'object') return '';
277
+ return firstCleanString(row.keyword, row.keywords, row.term, row.searchTerm, row.query, row.phrase, row.name);
278
+ }
279
+
280
+ function storeTextFromAstroRow(row) {
281
+ if (!row || typeof row !== 'object') return '';
282
+ return firstCleanString(
283
+ row.locale,
284
+ row.storeLocale,
285
+ row.store,
286
+ row.country,
287
+ row.market,
288
+ row.territory,
289
+ row.storefront,
290
+ row.language,
291
+ );
292
+ }
293
+
294
+ function appFallbackLocale(app, resolveStoreLocale) {
295
+ const stores = appStores(app, []);
296
+ if (stores.length !== 1) return '';
297
+ return resolveStoreLocale(stores[0]) || '';
298
+ }
299
+
300
+ function normalizeAstroKeywordsForLocalizeAso(app, keywordResponse, resolveStoreLocale) {
301
+ const rows = [];
302
+ const warnings = [];
303
+ const seen = new Set();
304
+ const fallbackLocale = appFallbackLocale(app, resolveStoreLocale);
305
+
306
+ for (const [index, row] of extractAstroKeywordRows(keywordResponse).entries()) {
307
+ const keyword = keywordTextFromAstroRow(row);
308
+ if (!keyword) {
309
+ warnings.push({
310
+ kind: 'missing_keyword',
311
+ appId: cleanString(app?.appId),
312
+ rowNumber: index + 1,
313
+ });
314
+ continue;
315
+ }
316
+
317
+ const storeRaw = storeTextFromAstroRow(row);
318
+ const locale = resolveStoreLocale(storeRaw) || fallbackLocale || '*';
319
+ const key = `${cleanString(app?.appId)}|${locale}|${keyword.toLowerCase()}`;
320
+ if (seen.has(key)) continue;
321
+ seen.add(key);
322
+
323
+ const popularity = optionalNumber(row?.popularity ?? row?.popularityScore ?? row?.searchVolume ?? row?.volume ?? row?.score);
324
+ const difficulty = optionalNumber(row?.difficulty ?? row?.competition ?? row?.hardness ?? row?.kd);
325
+ const isPreferred = optionalBoolean(row?.isPreferred ?? row?.preferred ?? row?.favorite ?? row?.favourite);
326
+
327
+ rows.push({
328
+ locale,
329
+ keyword,
330
+ ...(popularity !== undefined ? { popularity } : {}),
331
+ ...(difficulty !== undefined ? { difficulty } : {}),
332
+ ...(isPreferred !== undefined ? { isPreferred } : {}),
333
+ source: 'astro-mcp',
334
+ appId: cleanString(app?.appId),
335
+ ...(cleanString(app?.name) ? { appName: cleanString(app.name) } : {}),
336
+ ...(storeRaw ? { sourceStore: storeRaw } : {}),
337
+ });
338
+ }
339
+
340
+ return { rows, warnings };
341
+ }
342
+
343
+ function keywordContextReviewSafety() {
344
+ return {
345
+ kind: 'localizeaso_keyword_context_review_safety',
346
+ source: 'astro-mcp',
347
+ agentSafe: true,
348
+ readOnlySourceExport: true,
349
+ attachesReviewKeywordContextOnly: true,
350
+ createsReviewJob: false,
351
+ submitsProposal: false,
352
+ humanReviewRequiredBeforeApproval: true,
353
+ humanApprovalRequiredBeforeProtectedActions: true,
354
+ humanPostApprovalConsentRequired: true,
355
+ approvalAllowedFromAgent: false,
356
+ applyAllowedFromAgent: false,
357
+ statusUpdateAllowedFromAgent: false,
358
+ appStoreConnectMutationAllowed: false,
359
+ protectedActionsRemainHumanOnly: true,
360
+ protectedActionBoundary: localizeAsoPostApprovalProtectedActionBoundary,
361
+ monetizationBoundary:
362
+ typeof buildLocalizeAsoMonetizationBoundary === 'function'
363
+ ? buildLocalizeAsoMonetizationBoundary('workspace', { reviewSurface: 'keywords' })
364
+ : null,
365
+ protectedActions: [
366
+ 'approve_review',
367
+ 'reject_review',
368
+ 'figma_apply',
369
+ 'metadata_draft_apply',
370
+ 'keyword_apply',
371
+ 'pricing_schedule',
372
+ 'status_update',
373
+ 'app_store_upload',
374
+ 'app_store_submit',
375
+ 'app_store_publish',
376
+ ],
377
+ agentInstruction:
378
+ 'Use this Astro keyword context only as proposal input. Do not approve, reject, apply, schedule pricing, mark status, upload, publish, or submit to App Store Connect from an agent pass.',
379
+ };
380
+ }
381
+
382
+ function buildLocalizeAsoKeywordContext(appKeywordContexts, args, startedAt, keywordContextPath) {
383
+ const rows = [];
384
+ const warnings = [];
385
+ for (const context of appKeywordContexts) {
386
+ rows.push(...context.rows);
387
+ warnings.push(...context.warnings);
388
+ }
389
+
390
+ rows.sort((a, b) => {
391
+ const localeCompare = String(a.locale || '').localeCompare(String(b.locale || ''));
392
+ if (localeCompare !== 0) return localeCompare;
393
+ const popularityA = typeof a.popularity === 'number' ? a.popularity : -1;
394
+ const popularityB = typeof b.popularity === 'number' ? b.popularity : -1;
395
+ if (popularityA !== popularityB) return popularityB - popularityA;
396
+ const difficultyA = typeof a.difficulty === 'number' ? a.difficulty : Number.POSITIVE_INFINITY;
397
+ const difficultyB = typeof b.difficulty === 'number' ? b.difficulty : Number.POSITIVE_INFINITY;
398
+ if (difficultyA !== difficultyB) return difficultyA - difficultyB;
399
+ return String(a.keyword || '').localeCompare(String(b.keyword || ''));
400
+ });
401
+
402
+ const keywords = {};
403
+ for (const row of rows) {
404
+ const locale = cleanString(row.locale) || '*';
405
+ const keyword = cleanString(row.keyword);
406
+ if (!keyword) continue;
407
+ keywords[locale] ??= [];
408
+ if (!keywords[locale].includes(keyword)) keywords[locale].push(keyword);
409
+ }
410
+
411
+ return {
412
+ sources: ['astro-mcp'],
413
+ keywords,
414
+ rows,
415
+ summary: {
416
+ source: 'astro-mcp',
417
+ generatedAt: new Date().toISOString(),
418
+ exportStartedAt: startedAt,
419
+ endpoint: args.endpoint,
420
+ appCount: new Set(rows.map((row) => row.appId).filter(Boolean)).size,
421
+ selectedAppIds: [...new Set(rows.map((row) => row.appId).filter(Boolean))],
422
+ localeCount: Object.keys(keywords).length,
423
+ keywordCount: rows.length,
424
+ warningCount: warnings.length,
425
+ readOnly: true,
426
+ mutatesAstro: false,
427
+ mutatesAppStoreConnect: false,
428
+ mutatesLocalizeAso: false,
429
+ reviewSafety: keywordContextReviewSafety(),
430
+ attachCommands: {
431
+ screenshot: `pnpm review:agent keyword-context SCREENSHOT_JOB_ID --file ${keywordContextPath}`,
432
+ field: `pnpm review:agent field-keyword-context FIELD_JOB_ID --file ${keywordContextPath}`,
433
+ },
434
+ },
435
+ warnings,
436
+ };
437
+ }
438
+
439
+ function extractMcpContent(result) {
440
+ const content = result?.content;
441
+ if (!Array.isArray(content)) return result;
442
+ if (content.length === 1 && content[0]?.type === 'text') return parseMaybeJson(content[0].text);
443
+ return content.map((item) => (item?.type === 'text' ? parseMaybeJson(item.text) : item));
444
+ }
445
+
446
+ function withTimeout(promise, timeoutMs, label) {
447
+ let timer;
448
+ const timeout = new Promise((_, reject) => {
449
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
450
+ });
451
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
452
+ }
453
+
454
+ class AstroMcpClient {
455
+ constructor(endpoint, timeoutMs) {
456
+ this.endpoint = endpoint;
457
+ this.timeoutMs = timeoutMs;
458
+ this.nextId = 1;
459
+ this.sessionId = '';
460
+ }
461
+
462
+ async initialize() {
463
+ const response = await this.request('initialize', {
464
+ protocolVersion: '2025-03-26',
465
+ capabilities: {},
466
+ clientInfo: { name: 'localizeaso-astro-mcp-export', version: '0.1.0' },
467
+ }, false);
468
+ this.sessionId = response.sessionId || '';
469
+ return response.body.result;
470
+ }
471
+
472
+ async listTools() {
473
+ return (await this.request('tools/list', {})).body.result?.tools || [];
474
+ }
475
+
476
+ async callTool(name, input = {}) {
477
+ const response = await this.request('tools/call', { name, arguments: input });
478
+ if (response.body.error) {
479
+ throw new Error(`${name}: ${response.body.error.message || JSON.stringify(response.body.error)}`);
480
+ }
481
+ if (response.body.result?.isError) {
482
+ throw new Error(`${name}: ${JSON.stringify(extractMcpContent(response.body.result))}`);
483
+ }
484
+ return extractMcpContent(response.body.result);
485
+ }
486
+
487
+ async request(method, params, includeSession = true) {
488
+ const headers = {
489
+ Accept: 'application/json, text/event-stream',
490
+ 'Content-Type': 'application/json',
491
+ };
492
+ if (includeSession && this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
493
+ const body = {
494
+ jsonrpc: '2.0',
495
+ id: this.nextId++,
496
+ method,
497
+ params,
498
+ };
499
+ const response = await withTimeout(
500
+ fetch(this.endpoint, { method: 'POST', headers, body: JSON.stringify(body) }),
501
+ this.timeoutMs,
502
+ method,
503
+ );
504
+ const text = await response.text();
505
+ if (!response.ok) {
506
+ throw new Error(`${method} failed with HTTP ${response.status}: ${text}`);
507
+ }
508
+ const parsed = parseMaybeJson(text);
509
+ if (!parsed || typeof parsed !== 'object') {
510
+ throw new Error(`${method} returned non-JSON response: ${text.slice(0, 500)}`);
511
+ }
512
+ return {
513
+ body: parsed,
514
+ sessionId: response.headers.get('mcp-session-id') || response.headers.get('Mcp-Session-Id') || '',
515
+ };
516
+ }
517
+ }
518
+
519
+ function appStores(app, storeFilter) {
520
+ const stores = Array.isArray(app?.stores) ? app.stores.map((store) => String(store).toLowerCase()) : [];
521
+ if (!storeFilter.length) return stores;
522
+ return stores.filter((store) => storeFilter.includes(store));
523
+ }
524
+
525
+ function uniqueKeywordStorePairs(keywords, storeFilter) {
526
+ const pairs = [];
527
+ const seen = new Set();
528
+ for (const row of Array.isArray(keywords) ? keywords : []) {
529
+ const keyword = typeof row?.keyword === 'string' ? row.keyword.trim() : '';
530
+ const store = typeof row?.store === 'string' ? row.store.trim().toLowerCase() : '';
531
+ if (!keyword || !store) continue;
532
+ if (storeFilter.length && !storeFilter.includes(store)) continue;
533
+ const key = `${store}\u0000${keyword}`;
534
+ if (seen.has(key)) continue;
535
+ seen.add(key);
536
+ pairs.push({ keyword, store });
537
+ }
538
+ return pairs;
539
+ }
540
+
541
+ async function loadAscAppIdAllowlist(timeoutMs) {
542
+ const run = await runCommand('asc', ['apps', 'list', '--paginate', '--output', 'json'], { timeoutMs });
543
+ if (run.code !== 0) {
544
+ return { ids: null, warning: run.stderr.trim() || run.stdout.trim() || 'asc apps list failed' };
545
+ }
546
+ const parsed = parseMaybeJson(run.stdout);
547
+ const data = Array.isArray(parsed?.data) ? parsed.data : Array.isArray(parsed) ? parsed : [];
548
+ return { ids: new Set(data.map((app) => String(app?.id || '')).filter(Boolean)), warning: '' };
549
+ }
550
+
551
+ function selectApps(apps, args, ascIds) {
552
+ let selected = Array.isArray(apps) ? apps : [];
553
+ if (args.appIds.length) {
554
+ const wanted = new Set(args.appIds);
555
+ selected = selected.filter((app) => wanted.has(String(app?.appId || '')));
556
+ } else if (!args.allTracked && args.developer) {
557
+ const developer = args.developer.toLowerCase();
558
+ selected = selected.filter((app) => String(app?.developer || '').toLowerCase() === developer);
559
+ }
560
+ if (!args.allTracked && ascIds) {
561
+ selected = selected.filter((app) => ascIds.has(String(app?.appId || '')));
562
+ }
563
+ if (args.stores.length) {
564
+ selected = selected.filter((app) => appStores(app, args.stores).length > 0);
565
+ }
566
+ return selected;
567
+ }
568
+
569
+ async function exportApp(client, app, appDir, context) {
570
+ await ensureDir(appDir);
571
+ await writeJson(path.join(appDir, 'app.json'), app, context.args.pretty);
572
+
573
+ const appId = String(app.appId || '');
574
+ const keywords = await callAndWrite(client, 'get_app_keywords', { appId }, path.join(appDir, 'keywords.json'), context);
575
+ const keywordContext = normalizeAstroKeywordsForLocalizeAso(app, keywords, context.resolveStoreLocale);
576
+ await callAndWrite(client, 'get_app_ratings', { appId, includeHistory: true }, path.join(appDir, 'ratings-history.json'), context);
577
+
578
+ const stores = appStores(app, context.args.stores);
579
+ for (const store of stores) {
580
+ await callAndWrite(client, 'get_app_ratings', { appId, store, includeHistory: true }, path.join(appDir, 'stores', store, 'ratings-history.json'), context, true);
581
+ if (context.args.includeSuggestions) {
582
+ await callAndWrite(client, 'get_keyword_suggestions', { appId, store }, path.join(appDir, 'stores', store, 'keyword-suggestions.json'), context, true);
583
+ }
584
+ }
585
+
586
+ if (!context.args.includeRankingHistory && !context.args.includeCompetitors) return keywordContext;
587
+
588
+ const pairs = uniqueKeywordStorePairs(keywords, context.args.stores);
589
+ let exportedHistory = 0;
590
+ for (const pair of pairs) {
591
+ const baseDir = path.join(appDir, 'stores', safePathPart(pair.store), 'keywords', safePathPart(pair.keyword));
592
+ if (context.args.includeRankingHistory) {
593
+ if (!context.args.maxRankingHistory || exportedHistory < context.args.maxRankingHistory) {
594
+ await callAndWrite(
595
+ client,
596
+ 'search_rankings',
597
+ {
598
+ appId,
599
+ keyword: pair.keyword,
600
+ store: pair.store,
601
+ includeHistory: true,
602
+ includeStatistics: true,
603
+ period: context.args.historyPeriod,
604
+ },
605
+ path.join(baseDir, 'ranking-history.json'),
606
+ context,
607
+ true,
608
+ );
609
+ exportedHistory += 1;
610
+ }
611
+ }
612
+ if (context.args.includeCompetitors) {
613
+ await callAndWrite(
614
+ client,
615
+ 'extract_competitors_keywords',
616
+ { keyword: pair.keyword, store: pair.store },
617
+ path.join(baseDir, 'competitor-keywords.json'),
618
+ context,
619
+ true,
620
+ );
621
+ }
622
+ }
623
+
624
+ return keywordContext;
625
+ }
626
+
627
+ async function callAndWrite(client, tool, input, file, context, optional = false) {
628
+ const startedAt = new Date().toISOString();
629
+ try {
630
+ const data = await client.callTool(tool, input);
631
+ context.calls.push({ tool, input, file, ok: true, startedAt, finishedAt: new Date().toISOString() });
632
+ await writeJson(file, data, context.args.pretty);
633
+ return data;
634
+ } catch (error) {
635
+ const issue = {
636
+ tool,
637
+ input,
638
+ file,
639
+ optional,
640
+ error: error instanceof Error ? error.message : String(error),
641
+ startedAt,
642
+ finishedAt: new Date().toISOString(),
643
+ };
644
+ context.calls.push({ ...issue, ok: false });
645
+ if (optional) context.warnings.push(issue);
646
+ else context.errors.push(issue);
647
+ await writeJson(file, { error: issue.error, tool, input, optional }, context.args.pretty);
648
+ return null;
649
+ }
650
+ }
651
+
652
+ function runCommand(command, commandArgs, options = {}) {
653
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
654
+ return new Promise((resolve) => {
655
+ const child = spawn(command, commandArgs, {
656
+ cwd: options.cwd || process.cwd(),
657
+ env: process.env,
658
+ stdio: ['ignore', 'pipe', 'pipe'],
659
+ });
660
+ let stdout = '';
661
+ let stderr = '';
662
+ let timedOut = false;
663
+ let forceKill = null;
664
+ const timer = setTimeout(() => {
665
+ timedOut = true;
666
+ child.kill('SIGTERM');
667
+ forceKill = setTimeout(() => child.kill('SIGKILL'), 2_000);
668
+ }, timeoutMs);
669
+ child.stdout.on('data', (chunk) => {
670
+ stdout += String(chunk);
671
+ });
672
+ child.stderr.on('data', (chunk) => {
673
+ stderr += String(chunk);
674
+ });
675
+ child.on('error', (error) => {
676
+ clearTimeout(timer);
677
+ if (forceKill) clearTimeout(forceKill);
678
+ resolve({ code: 1, stdout, stderr: `${stderr}\n${error.message}`.trim(), timedOut });
679
+ });
680
+ child.on('close', (code) => {
681
+ clearTimeout(timer);
682
+ if (forceKill) clearTimeout(forceKill);
683
+ resolve({ code: typeof code === 'number' ? code : 1, stdout, stderr, timedOut });
684
+ });
685
+ });
686
+ }
687
+
688
+ async function createZip(sourceDir, zipPath, timeoutMs) {
689
+ await rm(zipPath, { force: true });
690
+ await ensureDir(path.dirname(zipPath));
691
+ const run = await runCommand('zip', ['-r', path.resolve(zipPath), path.basename(sourceDir)], {
692
+ cwd: path.dirname(sourceDir),
693
+ timeoutMs: Math.max(timeoutMs, 300_000),
694
+ });
695
+ if (run.code !== 0) throw new Error(`zip failed: ${run.stderr.trim() || run.stdout.trim()}`);
696
+ }
697
+
698
+ async function main() {
699
+ const args = parseArgs(process.argv.slice(2));
700
+ await ensureSharedBuild();
701
+ const {
702
+ resolveStoreLocale,
703
+ buildLocalizeAsoMonetizationBoundary: sharedBuildLocalizeAsoMonetizationBoundary,
704
+ LOCALIZEASO_POST_APPROVAL_PROTECTED_ACTION_BOUNDARY:
705
+ sharedLocalizeAsoPostApprovalProtectedActionBoundary,
706
+ } = await import('../packages/asc-shared/dist/index.js');
707
+ buildLocalizeAsoMonetizationBoundary = sharedBuildLocalizeAsoMonetizationBoundary;
708
+ localizeAsoPostApprovalProtectedActionBoundary =
709
+ sharedLocalizeAsoPostApprovalProtectedActionBoundary;
710
+ const outDir = path.resolve(args.outDir);
711
+ const zipPath = path.resolve(args.zipPath);
712
+ const keywordContextPath = args.keywordContextOut ? path.resolve(args.keywordContextOut) : '';
713
+ const startedAt = new Date().toISOString();
714
+ const context = { args, calls: [], warnings: [], errors: [], resolveStoreLocale };
715
+
716
+ const client = new AstroMcpClient(args.endpoint, args.timeoutMs);
717
+ const serverInfo = await client.initialize();
718
+ const tools = await client.listTools();
719
+ const allApps = await client.callTool('list_apps', {});
720
+
721
+ let ascAllowlist = null;
722
+ if (args.ascAllowlist && !args.allTracked) {
723
+ const loaded = await loadAscAppIdAllowlist(args.timeoutMs);
724
+ ascAllowlist = loaded.ids;
725
+ if (loaded.warning) context.warnings.push({ kind: 'asc_allowlist', warning: loaded.warning });
726
+ }
727
+ const selectedApps = selectApps(allApps, args, ascAllowlist);
728
+
729
+ if (args.dryRun) {
730
+ process.stdout.write(
731
+ `${jsonStringify(
732
+ {
733
+ dryRun: true,
734
+ endpoint: args.endpoint,
735
+ serverInfo,
736
+ totalTrackedApps: Array.isArray(allApps) ? allApps.length : 0,
737
+ selectedAppCount: selectedApps.length,
738
+ selectedApps,
739
+ ascAllowlistEnabled: Boolean(ascAllowlist),
740
+ developerFilter: args.allTracked ? null : args.developer,
741
+ stores: args.stores,
742
+ includeRankingHistory: args.includeRankingHistory,
743
+ includeSuggestions: args.includeSuggestions,
744
+ includeCompetitors: args.includeCompetitors,
745
+ keywordContextOut: keywordContextPath || null,
746
+ keywordContextAttachCommands: keywordContextPath
747
+ ? {
748
+ screenshot: `pnpm review:agent keyword-context SCREENSHOT_JOB_ID --file ${keywordContextPath}`,
749
+ field: `pnpm review:agent field-keyword-context FIELD_JOB_ID --file ${keywordContextPath}`,
750
+ }
751
+ : null,
752
+ keywordContextReviewSafety: keywordContextPath ? keywordContextReviewSafety() : null,
753
+ outDir,
754
+ zipPath,
755
+ warnings: context.warnings,
756
+ toolNames: tools.map((tool) => tool.name),
757
+ },
758
+ true,
759
+ )}\n`,
760
+ );
761
+ return;
762
+ }
763
+
764
+ await ensureDir(outDir);
765
+ await writeJson(path.join(outDir, 'astro-server.json'), serverInfo, args.pretty);
766
+ await writeJson(path.join(outDir, 'astro-tools.json'), tools, args.pretty);
767
+ await writeJson(path.join(outDir, 'tracked-apps-all.json'), allApps, args.pretty);
768
+ await writeJson(path.join(outDir, 'selected-apps.json'), selectedApps, args.pretty);
769
+
770
+ const appKeywordContexts = [];
771
+ for (const app of selectedApps) {
772
+ const appDir = path.join(outDir, 'apps', safePathPart(`${app.name || 'app'}-${app.appId}`));
773
+ const keywordContext = await exportApp(client, app, appDir, context);
774
+ if (keywordContext) appKeywordContexts.push(keywordContext);
775
+ }
776
+
777
+ if (keywordContextPath) {
778
+ const keywordContext = buildLocalizeAsoKeywordContext(
779
+ appKeywordContexts,
780
+ args,
781
+ startedAt,
782
+ keywordContextPath,
783
+ );
784
+ await writeJson(keywordContextPath, keywordContext, true);
785
+ }
786
+
787
+ const manifest = {
788
+ kind: 'astro_mcp_apps_export',
789
+ version: 1,
790
+ startedAt,
791
+ finishedAt: new Date().toISOString(),
792
+ endpoint: args.endpoint,
793
+ serverInfo,
794
+ host: os.hostname(),
795
+ totalTrackedApps: Array.isArray(allApps) ? allApps.length : 0,
796
+ selectedAppCount: selectedApps.length,
797
+ selectedAppIds: selectedApps.map((app) => app.appId).filter(Boolean),
798
+ developerFilter: args.allTracked ? null : args.developer,
799
+ ascAllowlistEnabled: Boolean(ascAllowlist),
800
+ stores: args.stores,
801
+ includeRankingHistory: args.includeRankingHistory,
802
+ historyPeriod: args.historyPeriod,
803
+ includeSuggestions: args.includeSuggestions,
804
+ includeCompetitors: args.includeCompetitors,
805
+ zipPath,
806
+ outDir,
807
+ callCount: context.calls.length,
808
+ warningCount: context.warnings.length,
809
+ errorCount: context.errors.length,
810
+ keywordContextPath: keywordContextPath || null,
811
+ warnings: context.warnings,
812
+ errors: context.errors,
813
+ calls: context.calls,
814
+ };
815
+ await writeJson(path.join(outDir, 'manifest.json'), manifest, true);
816
+ await createZip(outDir, zipPath, args.timeoutMs);
817
+
818
+ if (!args.keepDir) {
819
+ await rm(outDir, { recursive: true, force: true });
820
+ }
821
+
822
+ process.stdout.write(
823
+ `${jsonStringify(
824
+ {
825
+ ok: true,
826
+ zipPath,
827
+ outDir: args.keepDir ? outDir : null,
828
+ selectedAppCount: selectedApps.length,
829
+ warningCount: context.warnings.length,
830
+ errorCount: context.errors.length,
831
+ keywordContextPath: keywordContextPath || null,
832
+ },
833
+ true,
834
+ )}\n`,
835
+ );
836
+ }
837
+
838
+ main().catch((error) => {
839
+ process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
840
+ process.exit(1);
841
+ });