appship-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3697 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ EXPO_APP_JSON_EVIDENCE,
4
+ UnsupportedProjectError,
5
+ analyzeFlutterProject,
6
+ analyzeReactNativeProject,
7
+ detectProjectType,
8
+ normalizeAndroidPermission,
9
+ readExpoConfig,
10
+ readPackageJson,
11
+ readPubspec
12
+ } from "./chunk-7GCQ6I27.js";
13
+ import {
14
+ analyzeNativeAndroidProject,
15
+ analyzeNativeIosProject
16
+ } from "./chunk-TLOV7VBN.js";
17
+ import "./chunk-CVYD5FVI.js";
18
+ import {
19
+ UploadError,
20
+ assertUploadLane,
21
+ buildUploadPlan,
22
+ findArtifact,
23
+ resolveFastlaneCommand,
24
+ runUpload,
25
+ spawnRunner
26
+ } from "./chunk-ICHGACSP.js";
27
+
28
+ // src/index.ts
29
+ import { Command as Command11 } from "commander";
30
+
31
+ // src/cli/init.ts
32
+ import { Command } from "commander";
33
+ import pc from "picocolors";
34
+ import * as p from "@clack/prompts";
35
+
36
+ // src/core/rules-update/index.ts
37
+ import { mkdir, readFile as readFile3, rm, writeFile } from "fs/promises";
38
+ import { existsSync as existsSync2 } from "fs";
39
+ import { homedir } from "os";
40
+ import { dirname as dirname2, join as join3 } from "path";
41
+ import { parse as parse3 } from "yaml";
42
+ import "zod";
43
+
44
+ // src/core/scanner/signatures.ts
45
+ import { readFile } from "fs/promises";
46
+ import { existsSync } from "fs";
47
+ import { dirname, join } from "path";
48
+ import { fileURLToPath } from "url";
49
+ import { parse } from "yaml";
50
+ import { z } from "zod";
51
+ var sdkSignatureSchema = z.object({
52
+ id: z.string().min(1),
53
+ name: z.string().min(1).optional(),
54
+ category: z.string().min(1),
55
+ detect: z.object({
56
+ dependencies: z.array(z.string()).default([]),
57
+ source_patterns: z.array(z.string()).default([]),
58
+ config_keys: z.array(z.string()).default([])
59
+ }),
60
+ data_safety: z.object({
61
+ collects: z.array(z.string()).min(1),
62
+ purpose_defaults: z.array(z.string()),
63
+ shared_default: z.boolean(),
64
+ requires_confirmation: z.boolean()
65
+ })
66
+ });
67
+ var signaturesFileSchema = z.array(sdkSignatureSchema);
68
+ function findDataDir() {
69
+ let dir = dirname(fileURLToPath(import.meta.url));
70
+ for (let i = 0; i < 6; i++) {
71
+ const candidate = join(dir, "data");
72
+ if (existsSync(join(candidate, "sdk-signatures"))) return candidate;
73
+ dir = dirname(dir);
74
+ }
75
+ throw new Error("Could not locate the appship data/ directory");
76
+ }
77
+ async function loadSignatures(dataDir = findDataDir()) {
78
+ const path = join(dataDir, "sdk-signatures", "signatures.yml");
79
+ const raw = await readFile(path, "utf8");
80
+ const parsed = signaturesFileSchema.safeParse(parse(raw));
81
+ if (!parsed.success) {
82
+ const details = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
83
+ throw new Error(`Invalid SDK signature database (${path}):
84
+ ${details}`);
85
+ }
86
+ return parsed.data;
87
+ }
88
+
89
+ // src/core/doctor/rules.ts
90
+ import { readdir, readFile as readFile2 } from "fs/promises";
91
+ import { join as join2 } from "path";
92
+ import { parse as parse2 } from "yaml";
93
+ import { z as z2 } from "zod";
94
+ var storeSchema = z2.enum(["app-store", "google-play"]);
95
+ var checkSchema = z2.object({
96
+ type: z2.enum(["max_length", "file_exists", "not_contains", "no_emoji"]),
97
+ value: z2.union([z2.string(), z2.number()]).optional()
98
+ });
99
+ var ruleSchema = z2.object({
100
+ id: z2.string().min(1),
101
+ store: storeSchema,
102
+ category: z2.enum(["required", "length", "policy", "quality", "consistency"]),
103
+ severity: z2.enum(["error", "warning"]),
104
+ /** Glob relative to .appship/ that the check runs against (content checks). */
105
+ target: z2.string().optional(),
106
+ /** Only evaluate when this scanner-derived finding is present. */
107
+ condition: z2.object({ finding: z2.string() }).optional(),
108
+ check: checkSchema,
109
+ message: z2.string().min(1),
110
+ /** Report line when the check passes; defaults to a humanized rule id. */
111
+ pass_message: z2.string().optional(),
112
+ guideline: z2.string().optional(),
113
+ fix_suggestions: z2.array(z2.string()).optional()
114
+ });
115
+ var ruleFileSchema = z2.array(ruleSchema);
116
+ async function loadRules(dataDir = findDataDir()) {
117
+ const rulesDir = join2(dataDir, "rules");
118
+ const files = (await readdir(rulesDir)).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
119
+ const rules = [];
120
+ for (const file of files.sort()) {
121
+ const raw = await readFile2(join2(rulesDir, file), "utf8");
122
+ const parsed = ruleFileSchema.safeParse(parse2(raw));
123
+ if (!parsed.success) {
124
+ const details = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
125
+ throw new Error(`Invalid doctor rule file (${file}):
126
+ ${details}`);
127
+ }
128
+ rules.push(...parsed.data);
129
+ }
130
+ return rules;
131
+ }
132
+
133
+ // src/core/rules-update/index.ts
134
+ var RulesUpdateError = class extends Error {
135
+ };
136
+ var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/gunwooko/appship/main/data";
137
+ var SYNCED_FILES = [
138
+ {
139
+ path: "rules/app-store.yml",
140
+ validate: (raw) => parseWith(ruleFileSchema, raw, "rules/app-store.yml").length
141
+ },
142
+ {
143
+ path: "rules/google-play.yml",
144
+ validate: (raw) => parseWith(ruleFileSchema, raw, "rules/google-play.yml").length
145
+ },
146
+ {
147
+ path: "sdk-signatures/signatures.yml",
148
+ validate: (raw) => parseWith(signaturesFileSchema, raw, "sdk-signatures/signatures.yml").length
149
+ }
150
+ ];
151
+ function parseWith(schema, raw, label) {
152
+ const parsed = schema.safeParse(parse3(raw));
153
+ if (!parsed.success) {
154
+ throw new RulesUpdateError(
155
+ `Downloaded ${label} does not match the schema this appship version understands (update appship itself, or retry later).`
156
+ );
157
+ }
158
+ return parsed.data;
159
+ }
160
+ function dataCacheDir() {
161
+ return process.env.APPSHIP_DATA_CACHE_DIR ?? join3(homedir(), ".appship", "data-cache");
162
+ }
163
+ async function readCacheMeta(cacheDir = dataCacheDir()) {
164
+ try {
165
+ return JSON.parse(await readFile3(join3(cacheDir, "meta.json"), "utf8"));
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ var defaultFetch = async (url) => {
171
+ const response = await fetch(url);
172
+ if (!response.ok) {
173
+ throw new RulesUpdateError(`Download failed (${response.status}) for ${url}`);
174
+ }
175
+ return response.text();
176
+ };
177
+ async function updateRules(options = {}) {
178
+ const source = (options.source ?? DEFAULT_RULES_SOURCE).replace(/\/$/, "");
179
+ const cacheDir = options.cacheDir ?? dataCacheDir();
180
+ const fetchText = options.fetchText ?? defaultFetch;
181
+ const bundledDir = findDataDir();
182
+ const downloads = [];
183
+ for (const file of SYNCED_FILES) {
184
+ let content;
185
+ try {
186
+ content = await fetchText(`${source}/${file.path}`);
187
+ } catch (error) {
188
+ if (error instanceof RulesUpdateError) throw error;
189
+ throw new RulesUpdateError(
190
+ `Could not download ${file.path} from ${source} \u2014 check your network and retry.`
191
+ );
192
+ }
193
+ downloads.push({ path: file.path, content, entries: file.validate(content) });
194
+ }
195
+ const files = [];
196
+ for (const download of downloads) {
197
+ const cachePath = join3(cacheDir, download.path);
198
+ let current = null;
199
+ try {
200
+ current = await readFile3(
201
+ existsSync2(cachePath) ? cachePath : join3(bundledDir, download.path),
202
+ "utf8"
203
+ );
204
+ } catch {
205
+ }
206
+ await mkdir(dirname2(cachePath), { recursive: true });
207
+ await writeFile(cachePath, download.content, "utf8");
208
+ files.push({
209
+ path: download.path,
210
+ entries: download.entries,
211
+ changed: current !== download.content
212
+ });
213
+ }
214
+ const updatedAt = (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
215
+ const meta = { updatedAt, source };
216
+ await writeFile(join3(cacheDir, "meta.json"), JSON.stringify(meta, null, 2) + "\n", "utf8");
217
+ return { files, cacheDir, updatedAt };
218
+ }
219
+ async function resetRules(cacheDir = dataCacheDir()) {
220
+ if (!existsSync2(cacheDir)) return false;
221
+ await rm(cacheDir, { recursive: true, force: true });
222
+ return true;
223
+ }
224
+ async function loadRulesWithCache(cacheDir = dataCacheDir()) {
225
+ if (existsSync2(join3(cacheDir, "rules"))) {
226
+ try {
227
+ return await loadRules(cacheDir);
228
+ } catch {
229
+ }
230
+ }
231
+ return loadRules();
232
+ }
233
+ async function loadSignaturesWithCache(cacheDir = dataCacheDir()) {
234
+ if (existsSync2(join3(cacheDir, "sdk-signatures"))) {
235
+ try {
236
+ return await loadSignatures(cacheDir);
237
+ } catch {
238
+ }
239
+ }
240
+ return loadSignatures();
241
+ }
242
+
243
+ // src/core/scanner/permissions.ts
244
+ import { readFile as readFile4 } from "fs/promises";
245
+ import { join as join4 } from "path";
246
+ import glob from "fast-glob";
247
+ import * as plist from "plist";
248
+ import { XMLParser } from "fast-xml-parser";
249
+
250
+ // src/core/types.ts
251
+ function requireEvidence(evidence, context) {
252
+ if (evidence.length === 0) {
253
+ throw new Error(`Refusing to create a finding without evidence: ${context}`);
254
+ }
255
+ return evidence;
256
+ }
257
+
258
+ // src/core/scanner/permissions.ts
259
+ var IGNORE_DIRS = ["**/node_modules/**", "**/Pods/**", "**/build/**", "**/.git/**"];
260
+ var IOS_PLIST_GLOBS = {
261
+ "react-native": ["ios/**/Info.plist"],
262
+ flutter: ["ios/**/Info.plist"],
263
+ "native-ios": ["**/Info.plist"],
264
+ "native-android": []
265
+ };
266
+ var ANDROID_MANIFEST_GLOBS = {
267
+ "react-native": ["android/**/AndroidManifest.xml"],
268
+ flutter: ["android/**/AndroidManifest.xml"],
269
+ "native-ios": [],
270
+ "native-android": ["**/AndroidManifest.xml"]
271
+ };
272
+ var GENERIC_MESSAGE_PATTERNS = [
273
+ /^this app (requires|needs|uses)\b/i,
274
+ /^(requires|needs|used for)\b/i,
275
+ /\baccess\.?$/i
276
+ ];
277
+ function assessUsageDescription(message) {
278
+ const trimmed = message.trim();
279
+ if (trimmed.length === 0) return "missing";
280
+ if (trimmed.length < 25) return "needs_improvement";
281
+ if (GENERIC_MESSAGE_PATTERNS.some((p5) => p5.test(trimmed))) return "needs_improvement";
282
+ return "ok";
283
+ }
284
+ async function scanIosPermissions(projectRoot, projectType = "react-native") {
285
+ const plistFiles = await glob(IOS_PLIST_GLOBS[projectType], {
286
+ cwd: projectRoot,
287
+ ignore: IGNORE_DIRS
288
+ });
289
+ const findings = /* @__PURE__ */ new Map();
290
+ for (const file of plistFiles) {
291
+ const raw = await readFile4(join4(projectRoot, file), "utf8");
292
+ let parsed;
293
+ try {
294
+ parsed = plist.parse(raw);
295
+ } catch {
296
+ continue;
297
+ }
298
+ for (const [key, value] of Object.entries(parsed)) {
299
+ if (!key.endsWith("UsageDescription")) continue;
300
+ const message = typeof value === "string" ? value : "";
301
+ const existing = findings.get(key);
302
+ if (existing) {
303
+ existing.evidence.push(file);
304
+ } else {
305
+ findings.set(key, {
306
+ key,
307
+ currentMessage: message,
308
+ qualityAssessment: assessUsageDescription(message),
309
+ evidence: requireEvidence([file], `ios permission ${key}`)
310
+ });
311
+ }
312
+ }
313
+ }
314
+ const expo = await readExpoConfig(projectRoot);
315
+ for (const [key, value] of Object.entries(expo?.ios?.infoPlist ?? {})) {
316
+ if (!key.endsWith("UsageDescription") || findings.has(key)) continue;
317
+ const message = typeof value === "string" ? value : "";
318
+ findings.set(key, {
319
+ key,
320
+ currentMessage: message,
321
+ qualityAssessment: assessUsageDescription(message),
322
+ evidence: requireEvidence([EXPO_APP_JSON_EVIDENCE], `ios permission ${key}`)
323
+ });
324
+ }
325
+ return [...findings.values()];
326
+ }
327
+ async function scanAndroidPermissions(projectRoot, projectType = "react-native") {
328
+ const manifestFiles = await glob(ANDROID_MANIFEST_GLOBS[projectType], {
329
+ cwd: projectRoot,
330
+ ignore: [...IGNORE_DIRS, "**/debug/**", "**/androidTest/**"]
331
+ });
332
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
333
+ const findings = /* @__PURE__ */ new Map();
334
+ for (const file of manifestFiles) {
335
+ const raw = await readFile4(join4(projectRoot, file), "utf8");
336
+ let manifest;
337
+ try {
338
+ manifest = parser.parse(raw);
339
+ } catch {
340
+ continue;
341
+ }
342
+ const root = manifest["manifest"];
343
+ if (!root) continue;
344
+ const entries = root["uses-permission"];
345
+ const list = Array.isArray(entries) ? entries : entries ? [entries] : [];
346
+ for (const entry of list) {
347
+ const name = entry["@_android:name"];
348
+ if (!name) continue;
349
+ const existing = findings.get(name);
350
+ if (existing) {
351
+ existing.evidence.push(file);
352
+ } else {
353
+ findings.set(name, {
354
+ key: name,
355
+ evidence: requireEvidence([file], `android permission ${name}`)
356
+ });
357
+ }
358
+ }
359
+ }
360
+ const expo = await readExpoConfig(projectRoot);
361
+ for (const raw of expo?.android?.permissions ?? []) {
362
+ const name = normalizeAndroidPermission(raw);
363
+ if (findings.has(name)) continue;
364
+ findings.set(name, {
365
+ key: name,
366
+ evidence: requireEvidence([EXPO_APP_JSON_EVIDENCE], `android permission ${name}`)
367
+ });
368
+ }
369
+ return [...findings.values()];
370
+ }
371
+ async function scanPermissions(projectRoot, projectType = "react-native") {
372
+ const [ios, android] = await Promise.all([
373
+ scanIosPermissions(projectRoot, projectType),
374
+ scanAndroidPermissions(projectRoot, projectType)
375
+ ]);
376
+ return { ios, android };
377
+ }
378
+
379
+ // src/core/scanner/sdk.ts
380
+ import { readFile as readFile5 } from "fs/promises";
381
+ import { join as join5 } from "path";
382
+ import glob2 from "fast-glob";
383
+ var SOURCE_GLOBS = ["**/*.{js,jsx,ts,tsx}", "lib/**/*.dart", "**/*.swift", "**/*.{kt,java}"];
384
+ var SOURCE_IGNORE = [
385
+ "**/node_modules/**",
386
+ "ios/**",
387
+ "android/**",
388
+ "**/dist/**",
389
+ "**/build/**",
390
+ "**/.git/**",
391
+ "**/*.test.*",
392
+ "**/*.spec.*"
393
+ ];
394
+ function confidenceOf(match) {
395
+ if (match.dependencyEvidence.length > 0) return "high";
396
+ if (match.sourceEvidence.length > 0) return "medium";
397
+ if (match.configEvidence.length > 0) return "low";
398
+ return null;
399
+ }
400
+ async function readPodfile(projectRoot) {
401
+ for (const path of [join5("ios", "Podfile"), "Podfile"]) {
402
+ try {
403
+ return { path, content: await readFile5(join5(projectRoot, path), "utf8") };
404
+ } catch {
405
+ }
406
+ }
407
+ return null;
408
+ }
409
+ async function collectGradleFiles(projectRoot) {
410
+ const files = await glob2(["**/build.gradle", "**/build.gradle.kts"], {
411
+ cwd: projectRoot,
412
+ ignore: SOURCE_IGNORE.filter((i) => i !== "android/**")
413
+ });
414
+ return Promise.all(
415
+ files.map(async (path) => ({
416
+ path,
417
+ content: await readFile5(join5(projectRoot, path), "utf8")
418
+ }))
419
+ );
420
+ }
421
+ async function collectSourceFiles(projectRoot) {
422
+ const files = await glob2(SOURCE_GLOBS, { cwd: projectRoot, ignore: SOURCE_IGNORE });
423
+ return Promise.all(
424
+ files.map(async (path) => ({
425
+ path,
426
+ lines: (await readFile5(join5(projectRoot, path), "utf8")).split("\n")
427
+ }))
428
+ );
429
+ }
430
+ async function scanSdks(projectRoot, signatures, permissions) {
431
+ const pkg = await readPackageJson(projectRoot);
432
+ const dependencyNames = Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies });
433
+ const pubspec = await readPubspec(projectRoot);
434
+ const pubspecDependencyNames = Object.keys({
435
+ ...pubspec?.dependencies,
436
+ ...pubspec?.dev_dependencies
437
+ });
438
+ const podfile = await readPodfile(projectRoot);
439
+ const gradleFiles = await collectGradleFiles(projectRoot);
440
+ const sourceFiles = await collectSourceFiles(projectRoot);
441
+ const permissionKeys = /* @__PURE__ */ new Map();
442
+ for (const finding of [...permissions.ios, ...permissions.android]) {
443
+ permissionKeys.set(finding.key, finding.evidence);
444
+ }
445
+ const sdks = [];
446
+ const dataCollection = {};
447
+ for (const signature of signatures) {
448
+ const match = {
449
+ dependencyEvidence: [],
450
+ sourceEvidence: [],
451
+ configEvidence: []
452
+ };
453
+ for (const dep of signature.detect.dependencies) {
454
+ if (dependencyNames.includes(dep)) {
455
+ match.dependencyEvidence.push(`package.json: ${dep}`);
456
+ }
457
+ if (pubspecDependencyNames.includes(dep)) {
458
+ match.dependencyEvidence.push(`pubspec.yaml: ${dep}`);
459
+ }
460
+ if (!/^[a-z0-9_]+$/.test(dep) && podfile?.content.includes(dep)) {
461
+ match.dependencyEvidence.push(`${podfile.path}: ${dep}`);
462
+ }
463
+ if (dep.includes(":")) {
464
+ for (const gradle of gradleFiles) {
465
+ if (gradle.content.includes(dep)) {
466
+ match.dependencyEvidence.push(`${gradle.path}: ${dep}`);
467
+ }
468
+ }
469
+ }
470
+ }
471
+ const patterns = signature.detect.source_patterns.map((p5) => new RegExp(p5));
472
+ for (const { path, lines } of sourceFiles) {
473
+ for (const pattern of patterns) {
474
+ const lineIndex = lines.findIndex((line) => pattern.test(line));
475
+ if (lineIndex >= 0) {
476
+ match.sourceEvidence.push(`${path}:${lineIndex + 1}`);
477
+ break;
478
+ }
479
+ }
480
+ }
481
+ for (const key of signature.detect.config_keys) {
482
+ const evidence2 = permissionKeys.get(key);
483
+ if (evidence2) match.configEvidence.push(...evidence2);
484
+ }
485
+ const confidence = confidenceOf(match);
486
+ if (!confidence) continue;
487
+ const evidence = requireEvidence(
488
+ [...match.dependencyEvidence, ...match.sourceEvidence, ...match.configEvidence],
489
+ `sdk ${signature.id}`
490
+ );
491
+ sdks.push({
492
+ id: signature.id,
493
+ name: signature.name ?? signature.id,
494
+ category: signature.category,
495
+ confidence,
496
+ evidence
497
+ });
498
+ for (const dataType of signature.data_safety.collects) {
499
+ const existing = dataCollection[dataType];
500
+ if (existing) {
501
+ existing.purpose = [.../* @__PURE__ */ new Set([...existing.purpose, ...signature.data_safety.purpose_defaults])];
502
+ existing.shared = existing.shared || signature.data_safety.shared_default;
503
+ existing.evidence = [.../* @__PURE__ */ new Set([...existing.evidence, ...evidence])];
504
+ existing.requiresConfirmation = existing.requiresConfirmation || signature.data_safety.requires_confirmation;
505
+ } else {
506
+ dataCollection[dataType] = {
507
+ collected: true,
508
+ purpose: [...signature.data_safety.purpose_defaults],
509
+ shared: signature.data_safety.shared_default,
510
+ evidence: [...evidence],
511
+ requiresConfirmation: signature.data_safety.requires_confirmation,
512
+ confirmed: null
513
+ };
514
+ }
515
+ }
516
+ }
517
+ return { sdkReport: { sdks }, privacyReport: { dataCollection } };
518
+ }
519
+
520
+ // src/core/scanner/index.ts
521
+ function analyzeByType(projectRoot, projectType) {
522
+ switch (projectType) {
523
+ case "flutter":
524
+ return analyzeFlutterProject(projectRoot);
525
+ case "native-ios":
526
+ return analyzeNativeIosProject(projectRoot);
527
+ case "native-android":
528
+ return analyzeNativeAndroidProject(projectRoot);
529
+ default:
530
+ return analyzeReactNativeProject(projectRoot);
531
+ }
532
+ }
533
+ async function scanProject(projectRoot) {
534
+ const projectType = await detectProjectType(projectRoot);
535
+ const [project, permissions, signatures] = await Promise.all([
536
+ analyzeByType(projectRoot, projectType),
537
+ scanPermissions(projectRoot, projectType),
538
+ loadSignaturesWithCache()
539
+ ]);
540
+ const { sdkReport, privacyReport } = await scanSdks(projectRoot, signatures, permissions);
541
+ return { project, permissions, sdkReport, privacyReport };
542
+ }
543
+
544
+ // src/core/config/load.ts
545
+ import { readFile as readFile6 } from "fs/promises";
546
+ import { join as join6 } from "path";
547
+ import { parse as parse5 } from "yaml";
548
+
549
+ // src/core/config/schema.ts
550
+ import { z as z4 } from "zod";
551
+ var aiProviderSchema = z4.enum([
552
+ "anthropic",
553
+ "openai",
554
+ "gemini",
555
+ "ollama",
556
+ "openai-compatible"
557
+ ]);
558
+ var appshipConfigSchema = z4.object({
559
+ project: z4.object({
560
+ name: z4.string().min(1),
561
+ description: z4.string().min(1),
562
+ audience: z4.array(z4.string()).default([]),
563
+ requires_login: z4.boolean().optional(),
564
+ collects_personal_data: z4.boolean().optional(),
565
+ support_url: z4.string().url().optional()
566
+ }),
567
+ platforms: z4.object({
568
+ ios: z4.object({
569
+ bundle_id: z4.string().min(1)
570
+ }).optional(),
571
+ android: z4.object({
572
+ package_name: z4.string().min(1)
573
+ }).optional()
574
+ }),
575
+ stores: z4.object({
576
+ default_locale: z4.string().default("en-US"),
577
+ locales: z4.array(z4.string()).min(1).default(["en-US"]),
578
+ countries: z4.array(z4.string()).default([])
579
+ }),
580
+ ai: z4.object({
581
+ provider: aiProviderSchema.default("anthropic"),
582
+ model: z4.string().optional(),
583
+ /** Endpoint for openai-compatible (and optionally ollama) providers. */
584
+ base_url: z4.string().url().optional(),
585
+ tone: z4.string().default("friendly")
586
+ }).default({}),
587
+ privacy: z4.object({
588
+ send_source_code_to_ai: z4.boolean().default(false),
589
+ require_manual_confirmation: z4.boolean().default(true),
590
+ scan_dependencies: z4.boolean().default(true),
591
+ scan_source_code: z4.boolean().default(true)
592
+ }).default({})
593
+ });
594
+
595
+ // src/core/config/load.ts
596
+ var CONFIG_FILENAME = "appship.yml";
597
+ var ConfigError = class extends Error {
598
+ };
599
+ async function loadConfig(projectRoot) {
600
+ const path = join6(projectRoot, CONFIG_FILENAME);
601
+ let raw;
602
+ try {
603
+ raw = await readFile6(path, "utf8");
604
+ } catch {
605
+ throw new ConfigError(
606
+ `${CONFIG_FILENAME} not found in ${projectRoot}. Run \`appship init\` first.`
607
+ );
608
+ }
609
+ const parsed = parse5(raw);
610
+ const result = appshipConfigSchema.safeParse(parsed);
611
+ if (!result.success) {
612
+ const details = result.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
613
+ throw new ConfigError(`Invalid ${CONFIG_FILENAME}:
614
+ ${details}`);
615
+ }
616
+ return result.data;
617
+ }
618
+
619
+ // src/core/init/init.ts
620
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
621
+ import { join as join7 } from "path";
622
+ import { stringify } from "yaml";
623
+ var APPSHIP_DIR = ".appship";
624
+ var DESCRIPTION_PLACEHOLDER = "[CONFIRM: describe what your app does in one or two sentences]";
625
+ function defaultAnswers(scan) {
626
+ return {
627
+ description: DESCRIPTION_PLACEHOLDER,
628
+ audience: [],
629
+ requiresLogin: null,
630
+ // suggest true when the scanner already found data collection
631
+ collectsPersonalData: Object.keys(scan.privacyReport.dataCollection).length > 0 ? true : null,
632
+ countries: [],
633
+ locales: ["en-US"]
634
+ };
635
+ }
636
+ function buildConfig(scan, answers, existing) {
637
+ const bundleId = scan.project.ios?.bundleId ?? null;
638
+ const packageName = scan.project.android?.packageName ?? null;
639
+ const locales = answers.locales.length > 0 ? answers.locales : ["en-US"];
640
+ return appshipConfigSchema.parse({
641
+ project: {
642
+ name: scan.project.appName ?? "My App",
643
+ description: answers.description || DESCRIPTION_PLACEHOLDER,
644
+ audience: answers.audience,
645
+ ...answers.requiresLogin === null ? {} : { requires_login: answers.requiresLogin },
646
+ ...answers.collectsPersonalData === null ? {} : { collects_personal_data: answers.collectsPersonalData },
647
+ // preserve fields init doesn't ask about — re-running must not lose them
648
+ ...existing?.project.support_url ? { support_url: existing.project.support_url } : {}
649
+ },
650
+ platforms: {
651
+ ...bundleId ? { ios: { bundle_id: bundleId } } : {},
652
+ ...packageName ? { android: { package_name: packageName } } : {}
653
+ },
654
+ stores: {
655
+ default_locale: locales[0],
656
+ locales,
657
+ countries: answers.countries
658
+ },
659
+ ...existing ? { ai: existing.ai, privacy: existing.privacy } : {}
660
+ });
661
+ }
662
+ async function writeConfig(projectRoot, config) {
663
+ const header = "# AppShip configuration\n# Generated by `appship init` \u2014 edit freely, then run `appship generate`.\n# API keys are read from environment variables and never stored here.\n";
664
+ const path = join7(projectRoot, CONFIG_FILENAME);
665
+ await writeFile2(path, header + stringify(config), "utf8");
666
+ return path;
667
+ }
668
+ async function writeAnalysis(projectRoot, scan) {
669
+ const analysisDir = join7(projectRoot, APPSHIP_DIR, "analysis");
670
+ await mkdir2(analysisDir, { recursive: true });
671
+ const files = [
672
+ ["project.json", scan.project],
673
+ ["permissions.json", scan.permissions],
674
+ ["sdk-report.json", scan.sdkReport],
675
+ ["privacy-report.json", scan.privacyReport]
676
+ ];
677
+ const written = [];
678
+ for (const [name, data] of files) {
679
+ const path = join7(analysisDir, name);
680
+ await writeFile2(path, JSON.stringify(data, null, 2) + "\n", "utf8");
681
+ written.push(path);
682
+ }
683
+ return written;
684
+ }
685
+ async function runInit(projectRoot, scan, answers, existing) {
686
+ const config = buildConfig(scan, answers, existing);
687
+ const [configPath, analysisPaths] = await Promise.all([
688
+ writeConfig(projectRoot, config),
689
+ writeAnalysis(projectRoot, scan)
690
+ ]);
691
+ return { configPath, analysisPaths, config };
692
+ }
693
+
694
+ // src/cli/init.ts
695
+ var SENSITIVE_PERMISSION_NAMES = {
696
+ NSMicrophoneUsageDescription: "Microphone",
697
+ NSCameraUsageDescription: "Camera",
698
+ NSLocationWhenInUseUsageDescription: "Location",
699
+ NSLocationAlwaysAndWhenInUseUsageDescription: "Location (always)",
700
+ NSContactsUsageDescription: "Contacts",
701
+ NSPhotoLibraryUsageDescription: "Photo Library",
702
+ "android.permission.RECORD_AUDIO": "Microphone",
703
+ "android.permission.CAMERA": "Camera",
704
+ "android.permission.ACCESS_FINE_LOCATION": "Location",
705
+ "android.permission.ACCESS_COARSE_LOCATION": "Location",
706
+ "android.permission.READ_CONTACTS": "Contacts",
707
+ "android.permission.POST_NOTIFICATIONS": "Notifications"
708
+ };
709
+ function printDetectionSummary(scan) {
710
+ const check = (label) => console.log(`${pc.green("\u2713")} ${label}`);
711
+ const typeLabels = {
712
+ "react-native": "React Native",
713
+ flutter: "Flutter",
714
+ "native-ios": "Native iOS",
715
+ "native-android": "Native Android"
716
+ };
717
+ check(`${typeLabels[scan.project.projectType] ?? scan.project.projectType} project detected`);
718
+ if (scan.project.ios?.bundleId) check(`iOS bundle ID: ${scan.project.ios.bundleId}`);
719
+ if (scan.project.android?.packageName)
720
+ check(`Android package: ${scan.project.android.packageName}`);
721
+ if (scan.project.appName) check(`App name: ${scan.project.appName}`);
722
+ if (scan.project.version) check(`Version: ${scan.project.version}`);
723
+ const permissionNames = [
724
+ ...new Set(
725
+ [...scan.permissions.ios, ...scan.permissions.android].map((f) => SENSITIVE_PERMISSION_NAMES[f.key]).filter((name) => name !== void 0)
726
+ )
727
+ ];
728
+ if (permissionNames.length > 0) {
729
+ check(`Permissions detected: ${permissionNames.join(", ")}`);
730
+ }
731
+ for (const sdk of scan.sdkReport.sdks) {
732
+ check(`${sdk.name} detected`);
733
+ }
734
+ }
735
+ function parseList(value) {
736
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
737
+ }
738
+ async function askQuestions(scan, existing) {
739
+ const defaults = defaultAnswers(scan);
740
+ p.intro("A few questions the code cannot answer:");
741
+ const existingDescription = existing && existing.project.description !== DESCRIPTION_PLACEHOLDER ? existing.project.description : void 0;
742
+ const description = await p.text({
743
+ message: "What does your app do?",
744
+ placeholder: "e.g. A language learning app with real-time voice rooms",
745
+ ...existingDescription ? { initialValue: existingDescription } : {},
746
+ validate: (v) => !v || v.trim().length === 0 ? "A short description is required." : void 0
747
+ });
748
+ if (p.isCancel(description)) return null;
749
+ const audience = await p.text({
750
+ message: "Who is the target audience? (comma-separated, optional)",
751
+ placeholder: "e.g. language learners, international students",
752
+ ...existing?.project.audience.length ? { initialValue: existing.project.audience.join(", ") } : {},
753
+ defaultValue: ""
754
+ });
755
+ if (p.isCancel(audience)) return null;
756
+ const requiresLogin = await p.confirm({
757
+ message: "Does the app require login?",
758
+ initialValue: existing?.project.requires_login ?? false
759
+ });
760
+ if (p.isCancel(requiresLogin)) return null;
761
+ const detectedCollection = Object.keys(scan.privacyReport.dataCollection);
762
+ const collectsPersonalData = await p.confirm({
763
+ message: detectedCollection.length > 0 ? `Does the app collect personal data? (detected: ${detectedCollection.join(", ")})` : "Does the app collect personal data?",
764
+ initialValue: existing?.project.collects_personal_data ?? defaults.collectsPersonalData ?? false
765
+ });
766
+ if (p.isCancel(collectsPersonalData)) return null;
767
+ const countries = await p.text({
768
+ message: "Which countries will you release in? (comma-separated, optional)",
769
+ placeholder: "e.g. US, KR, JP \u2014 leave empty for worldwide",
770
+ ...existing?.stores.countries.length ? { initialValue: existing.stores.countries.join(", ") } : {},
771
+ defaultValue: ""
772
+ });
773
+ if (p.isCancel(countries)) return null;
774
+ const locales = await p.text({
775
+ message: "Which store listing languages should be generated? (comma-separated)",
776
+ initialValue: existing?.stores.locales.join(", ") ?? "en-US"
777
+ });
778
+ if (p.isCancel(locales)) return null;
779
+ p.outro("Thanks \u2014 writing your files now.");
780
+ return {
781
+ description: description.trim(),
782
+ audience: parseList(audience),
783
+ requiresLogin,
784
+ collectsPersonalData,
785
+ countries: parseList(countries),
786
+ locales: parseList(locales).length > 0 ? parseList(locales) : ["en-US"]
787
+ };
788
+ }
789
+ function answersFromExisting(scan, existing) {
790
+ return {
791
+ description: existing.project.description,
792
+ audience: existing.project.audience,
793
+ requiresLogin: existing.project.requires_login ?? null,
794
+ collectsPersonalData: existing.project.collects_personal_data ?? null,
795
+ countries: existing.stores.countries,
796
+ locales: existing.stores.locales
797
+ };
798
+ }
799
+ var initCommand = new Command("init").description("Analyze the project and create appship.yml and the .appship/ folder").option("--force", "ignore existing appship.yml and re-initialize from scratch").option("--non-interactive", "skip interactive questions (unconfirmed findings are left for doctor)").action(async (options) => {
800
+ const projectRoot = process.cwd();
801
+ let scan;
802
+ try {
803
+ scan = await scanProject(projectRoot);
804
+ } catch (error) {
805
+ if (error instanceof UnsupportedProjectError) {
806
+ console.error(pc.red(`\u2717 ${error.message}`));
807
+ process.exitCode = 1;
808
+ return;
809
+ }
810
+ throw error;
811
+ }
812
+ printDetectionSummary(scan);
813
+ console.log();
814
+ let existing = null;
815
+ if (!options.force) {
816
+ try {
817
+ existing = await loadConfig(projectRoot);
818
+ console.log(pc.dim(`Existing ${CONFIG_FILENAME} found \u2014 using it as defaults.`));
819
+ } catch (error) {
820
+ if (!(error instanceof ConfigError)) throw error;
821
+ }
822
+ }
823
+ const interactive = !options.nonInteractive && process.stdout.isTTY;
824
+ let answers;
825
+ if (interactive) {
826
+ const asked = await askQuestions(scan, existing);
827
+ if (asked === null) {
828
+ console.log(pc.yellow("Cancelled \u2014 no files were written."));
829
+ process.exitCode = 1;
830
+ return;
831
+ }
832
+ answers = asked;
833
+ } else {
834
+ answers = existing ? answersFromExisting(scan, existing) : defaultAnswers(scan);
835
+ }
836
+ const result = await runInit(projectRoot, scan, answers, existing ?? void 0);
837
+ console.log();
838
+ console.log(pc.green("\u2713") + ` ${CONFIG_FILENAME} written`);
839
+ for (const path of result.analysisPaths) {
840
+ console.log(pc.green("\u2713") + ` ${path.replace(projectRoot + "/", "")} written`);
841
+ }
842
+ if (result.config.project.description.startsWith("[CONFIRM:")) {
843
+ console.log(
844
+ pc.yellow("\u26A0") + ` project.description in ${CONFIG_FILENAME} still needs your input before generate.`
845
+ );
846
+ }
847
+ console.log();
848
+ console.log(`Next: run ${pc.cyan("appship generate")} to create your store materials.`);
849
+ });
850
+
851
+ // src/cli/generate.ts
852
+ import { writeFile as writeFile4 } from "fs/promises";
853
+ import { existsSync as existsSync3 } from "fs";
854
+ import { join as join10 } from "path";
855
+ import { Command as Command2 } from "commander";
856
+ import pc2 from "picocolors";
857
+ import * as p2 from "@clack/prompts";
858
+
859
+ // src/core/scanner/confirmations.ts
860
+ import { readFile as readFile7 } from "fs/promises";
861
+ import { join as join8 } from "path";
862
+ var PRIVACY_REPORT_PATH = ".appship/analysis/privacy-report.json";
863
+ async function mergePreviousConfirmations(projectRoot, scan) {
864
+ try {
865
+ const raw = await readFile7(join8(projectRoot, PRIVACY_REPORT_PATH), "utf8");
866
+ const previous = JSON.parse(raw);
867
+ for (const [dataType, entry] of Object.entries(previous.dataCollection ?? {})) {
868
+ const current = scan.privacyReport.dataCollection[dataType];
869
+ if (current && entry.confirmed !== null && entry.confirmed !== void 0) {
870
+ current.confirmed = entry.confirmed;
871
+ }
872
+ }
873
+ } catch {
874
+ }
875
+ }
876
+
877
+ // src/core/ai/anthropic.ts
878
+ import Anthropic from "@anthropic-ai/sdk";
879
+
880
+ // src/core/ai/provider.ts
881
+ var AIProviderError = class extends Error {
882
+ };
883
+
884
+ // src/core/ai/anthropic.ts
885
+ var DEFAULT_MODEL = "claude-opus-4-8";
886
+ var DEFAULT_MAX_TOKENS = 16e3;
887
+ var AnthropicProvider = class {
888
+ name = "anthropic";
889
+ client;
890
+ model;
891
+ constructor(model) {
892
+ this.client = new Anthropic();
893
+ this.model = model ?? DEFAULT_MODEL;
894
+ }
895
+ async createMessage(request, outputConfig) {
896
+ let response;
897
+ try {
898
+ response = await this.client.messages.create({
899
+ model: this.model,
900
+ max_tokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
901
+ system: request.system,
902
+ messages: [{ role: "user", content: request.prompt }],
903
+ ...outputConfig ? { output_config: outputConfig } : {}
904
+ });
905
+ } catch (error) {
906
+ if (error instanceof Anthropic.AuthenticationError) {
907
+ throw new AIProviderError(
908
+ "Anthropic authentication failed. Set ANTHROPIC_API_KEY or run `ant auth login`."
909
+ );
910
+ }
911
+ if (error instanceof Anthropic.APIError) {
912
+ throw new AIProviderError(`Anthropic API error (${error.status}): ${error.message}`);
913
+ }
914
+ throw error;
915
+ }
916
+ if (response.stop_reason === "refusal") {
917
+ throw new AIProviderError("The model declined to generate this content.");
918
+ }
919
+ const text2 = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
920
+ if (response.stop_reason === "max_tokens") {
921
+ throw new AIProviderError("Generation was truncated (max_tokens reached).");
922
+ }
923
+ return text2;
924
+ }
925
+ async generateText(request) {
926
+ return this.createMessage(request);
927
+ }
928
+ async generateObject(request) {
929
+ const text2 = await this.createMessage(request, {
930
+ format: { type: "json_schema", schema: request.jsonSchema }
931
+ });
932
+ try {
933
+ return JSON.parse(text2);
934
+ } catch {
935
+ throw new AIProviderError(`Model returned invalid JSON: ${text2.slice(0, 200)}`);
936
+ }
937
+ }
938
+ };
939
+
940
+ // src/core/ai/openai-compatible.ts
941
+ var DEFAULT_MAX_TOKENS2 = 16e3;
942
+ var MAX_RETRIES_429 = 3;
943
+ var DEFAULT_RETRY_AFTER_SECONDS = 15;
944
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
945
+ function extractJson(text2) {
946
+ const trimmed = text2.trim();
947
+ const candidates = [trimmed];
948
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
949
+ if (fenced?.[1]) candidates.push(fenced[1].trim());
950
+ const first = trimmed.indexOf("{");
951
+ const last = trimmed.lastIndexOf("}");
952
+ if (first >= 0 && last > first) candidates.push(trimmed.slice(first, last + 1));
953
+ for (const candidate of candidates) {
954
+ try {
955
+ return JSON.parse(candidate);
956
+ } catch {
957
+ }
958
+ }
959
+ throw new AIProviderError(`Model returned invalid JSON: ${text2.slice(0, 200)}`);
960
+ }
961
+ var OpenAICompatibleProvider = class {
962
+ name;
963
+ options;
964
+ fetchImpl;
965
+ sleep;
966
+ constructor(options, fetchImpl = (url, init) => fetch(url, init), sleep = defaultSleep) {
967
+ this.name = `${options.name} (${options.model})`;
968
+ this.options = options;
969
+ this.fetchImpl = fetchImpl;
970
+ this.sleep = sleep;
971
+ }
972
+ resolveApiKey() {
973
+ for (const env of this.options.apiKeyEnv) {
974
+ const value = process.env[env];
975
+ if (value) return value;
976
+ }
977
+ return null;
978
+ }
979
+ async complete(system, prompt, maxTokens) {
980
+ const apiKey = this.resolveApiKey();
981
+ if (this.options.apiKeyEnv.length > 0 && !apiKey) {
982
+ throw new AIProviderError(
983
+ `No API key found for ${this.options.name}. ` + (this.options.keyHint ?? `Set ${this.options.apiKeyEnv[0]}.`)
984
+ );
985
+ }
986
+ const url = `${this.options.baseUrl.replace(/\/$/, "")}/chat/completions`;
987
+ const body = JSON.stringify({
988
+ model: this.options.model,
989
+ max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS2,
990
+ messages: [
991
+ { role: "system", content: system },
992
+ { role: "user", content: prompt }
993
+ ]
994
+ });
995
+ let response;
996
+ for (let attempt = 0; ; attempt++) {
997
+ try {
998
+ response = await this.fetchImpl(url, {
999
+ method: "POST",
1000
+ headers: {
1001
+ "content-type": "application/json",
1002
+ ...apiKey ? { authorization: `Bearer ${apiKey}` } : {}
1003
+ },
1004
+ body
1005
+ });
1006
+ } catch (error) {
1007
+ throw new AIProviderError(
1008
+ `Could not reach ${this.options.name} at ${this.options.baseUrl} \u2014 ${error instanceof Error ? error.message : "network error"}.`
1009
+ );
1010
+ }
1011
+ if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES_429) {
1012
+ const retryAfter = Number(response.headers.get("retry-after"));
1013
+ const seconds = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) : DEFAULT_RETRY_AFTER_SECONDS;
1014
+ await this.sleep(seconds * 1e3);
1015
+ continue;
1016
+ }
1017
+ break;
1018
+ }
1019
+ if (response.status === 401 || response.status === 403) {
1020
+ throw new AIProviderError(
1021
+ `${this.options.name} rejected the API key (${response.status}). ` + (this.options.keyHint ?? `Check ${this.options.apiKeyEnv[0] ?? "your credentials"}.`)
1022
+ );
1023
+ }
1024
+ if (!response.ok) {
1025
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
1026
+ throw new AIProviderError(
1027
+ `${this.options.name} API error (${response.status})${detail ? `: ${detail}` : ""}`
1028
+ );
1029
+ }
1030
+ const parsed = await response.json().catch(() => null);
1031
+ const choice = parsed?.choices?.[0];
1032
+ const content = choice?.message?.content;
1033
+ if (typeof content !== "string" || content.length === 0) {
1034
+ throw new AIProviderError(`${this.options.name} returned an empty response.`);
1035
+ }
1036
+ if (choice?.finish_reason === "length") {
1037
+ throw new AIProviderError("Generation was truncated (max_tokens reached).");
1038
+ }
1039
+ return content;
1040
+ }
1041
+ async generateText(request) {
1042
+ return this.complete(request.system, request.prompt, request.maxTokens);
1043
+ }
1044
+ async generateObject(request) {
1045
+ const system = request.system + "\n\nOutput format: respond with a single JSON object and nothing else \u2014 no prose, no markdown fences. The object must match this JSON Schema exactly:\n" + JSON.stringify(request.jsonSchema);
1046
+ const text2 = await this.complete(system, request.prompt, request.maxTokens);
1047
+ return extractJson(text2);
1048
+ }
1049
+ };
1050
+ function createOpenAICompatibleProvider(config) {
1051
+ switch (config.provider) {
1052
+ case "gemini":
1053
+ return new OpenAICompatibleProvider({
1054
+ name: "gemini",
1055
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
1056
+ model: config.model ?? "gemini-2.5-flash",
1057
+ apiKeyEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
1058
+ keyHint: "Set GEMINI_API_KEY \u2014 free keys are available at https://aistudio.google.com/apikey."
1059
+ });
1060
+ case "openai":
1061
+ return new OpenAICompatibleProvider({
1062
+ name: "openai",
1063
+ baseUrl: "https://api.openai.com/v1",
1064
+ model: config.model ?? "gpt-4o",
1065
+ apiKeyEnv: ["OPENAI_API_KEY"],
1066
+ keyHint: "Set OPENAI_API_KEY (https://platform.openai.com/api-keys)."
1067
+ });
1068
+ case "ollama": {
1069
+ if (!config.model) {
1070
+ throw new AIProviderError(
1071
+ "Set ai.model in appship.yml when using ollama (e.g. llama3.1, qwen2.5)."
1072
+ );
1073
+ }
1074
+ return new OpenAICompatibleProvider({
1075
+ name: "ollama",
1076
+ baseUrl: config.baseUrl ?? "http://localhost:11434/v1",
1077
+ model: config.model,
1078
+ apiKeyEnv: []
1079
+ });
1080
+ }
1081
+ case "openai-compatible": {
1082
+ if (!config.baseUrl) {
1083
+ throw new AIProviderError(
1084
+ "Set ai.base_url in appship.yml when using openai-compatible (e.g. https://openrouter.ai/api/v1)."
1085
+ );
1086
+ }
1087
+ if (!config.model) {
1088
+ throw new AIProviderError("Set ai.model in appship.yml when using openai-compatible.");
1089
+ }
1090
+ return new OpenAICompatibleProvider({
1091
+ name: "openai-compatible",
1092
+ baseUrl: config.baseUrl,
1093
+ model: config.model,
1094
+ apiKeyEnv: ["OPENAI_COMPATIBLE_API_KEY", "OPENAI_API_KEY"],
1095
+ keyHint: "Set OPENAI_COMPATIBLE_API_KEY (or OPENAI_API_KEY) for your endpoint."
1096
+ });
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // src/core/ai/payload.ts
1102
+ var PERMISSION_FEATURES = {
1103
+ NSMicrophoneUsageDescription: "microphone",
1104
+ "android.permission.RECORD_AUDIO": "microphone",
1105
+ NSCameraUsageDescription: "camera",
1106
+ "android.permission.CAMERA": "camera",
1107
+ NSLocationWhenInUseUsageDescription: "location",
1108
+ NSLocationAlwaysAndWhenInUseUsageDescription: "location",
1109
+ "android.permission.ACCESS_FINE_LOCATION": "location",
1110
+ "android.permission.ACCESS_COARSE_LOCATION": "location",
1111
+ NSContactsUsageDescription: "contacts",
1112
+ "android.permission.READ_CONTACTS": "contacts",
1113
+ NSPhotoLibraryUsageDescription: "photo-library",
1114
+ "android.permission.POST_NOTIFICATIONS": "notifications"
1115
+ };
1116
+ function buildSummaryPayload(scan, config) {
1117
+ const permissions = [
1118
+ ...new Set(
1119
+ [...scan.permissions.ios, ...scan.permissions.android].map((f) => PERMISSION_FEATURES[f.key]).filter((name) => name !== void 0)
1120
+ )
1121
+ ];
1122
+ const features = [
1123
+ .../* @__PURE__ */ new Set([
1124
+ ...scan.sdkReport.sdks.map((s) => s.category),
1125
+ ...permissions,
1126
+ ...config.project.requires_login ? ["login"] : []
1127
+ ])
1128
+ ];
1129
+ return {
1130
+ projectType: scan.project.projectType,
1131
+ appName: scan.project.appName,
1132
+ userDescription: config.project.description,
1133
+ audience: config.project.audience,
1134
+ features,
1135
+ permissions,
1136
+ sdks: scan.sdkReport.sdks.map((s) => s.name),
1137
+ locales: config.stores.locales
1138
+ };
1139
+ }
1140
+
1141
+ // src/core/ai/index.ts
1142
+ function createProvider(config) {
1143
+ switch (config.ai.provider) {
1144
+ case "anthropic":
1145
+ return new AnthropicProvider(config.ai.model);
1146
+ case "gemini":
1147
+ case "openai":
1148
+ case "ollama":
1149
+ case "openai-compatible":
1150
+ return createOpenAICompatibleProvider({
1151
+ provider: config.ai.provider,
1152
+ ...config.ai.model ? { model: config.ai.model } : {},
1153
+ ...config.ai.base_url ? { baseUrl: config.ai.base_url } : {}
1154
+ });
1155
+ }
1156
+ }
1157
+
1158
+ // src/core/generate/index.ts
1159
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
1160
+ import { dirname as dirname3, join as join9 } from "path";
1161
+ import glob3 from "fast-glob";
1162
+ import { stringify as stringify3 } from "yaml";
1163
+ import { z as z6 } from "zod";
1164
+
1165
+ // src/core/generate/listings.ts
1166
+ import { z as z5 } from "zod";
1167
+
1168
+ // src/core/metadata/validator.ts
1169
+ var APP_STORE_LIMITS = [
1170
+ { field: "name", maxLength: 30 },
1171
+ { field: "subtitle", maxLength: 30 },
1172
+ { field: "description", maxLength: 4e3 },
1173
+ { field: "keywords", maxLength: 100 },
1174
+ { field: "promotionalText", maxLength: 170 },
1175
+ { field: "releaseNotes", maxLength: 4e3 }
1176
+ ];
1177
+ var GOOGLE_PLAY_LIMITS = [
1178
+ { field: "title", maxLength: 30 },
1179
+ { field: "shortDescription", maxLength: 80 },
1180
+ { field: "fullDescription", maxLength: 4e3 },
1181
+ { field: "releaseNotes", maxLength: 500 }
1182
+ ];
1183
+ var EMOJI_PATTERN = new RegExp("\\p{Extended_Pictographic}", "u");
1184
+ function validateFields(values, limits) {
1185
+ const violations = [];
1186
+ for (const { field, maxLength } of limits) {
1187
+ const value = values[field];
1188
+ if (value === void 0) continue;
1189
+ if (value.length > maxLength) {
1190
+ violations.push({
1191
+ field,
1192
+ message: `${field} is ${value.length} characters; the store limit is ${maxLength}.`
1193
+ });
1194
+ }
1195
+ }
1196
+ return violations;
1197
+ }
1198
+ function validatePlayTitlePolicy(title) {
1199
+ if (EMOJI_PATTERN.test(title)) {
1200
+ return [{ field: "title", message: "Title contains emoji, which Google Play metadata policy disallows." }];
1201
+ }
1202
+ return [];
1203
+ }
1204
+
1205
+ // src/core/generate/listings.ts
1206
+ var MAX_RETRIES = 3;
1207
+ var appStoreListingSchema = z5.object({
1208
+ name: z5.string(),
1209
+ subtitle: z5.string(),
1210
+ description: z5.string(),
1211
+ keywords: z5.string(),
1212
+ promotionalText: z5.string(),
1213
+ releaseNotes: z5.string(),
1214
+ reviewNotes: z5.string()
1215
+ });
1216
+ var playListingSchema = z5.object({
1217
+ title: z5.string(),
1218
+ shortDescription: z5.string(),
1219
+ fullDescription: z5.string(),
1220
+ releaseNotes: z5.string(),
1221
+ suggestedCategory: z5.string(),
1222
+ suggestedTags: z5.array(z5.string())
1223
+ });
1224
+ var APP_STORE_JSON_SCHEMA = {
1225
+ type: "object",
1226
+ additionalProperties: false,
1227
+ required: [
1228
+ "name",
1229
+ "subtitle",
1230
+ "description",
1231
+ "keywords",
1232
+ "promotionalText",
1233
+ "releaseNotes",
1234
+ "reviewNotes"
1235
+ ],
1236
+ properties: {
1237
+ name: { type: "string", description: "App name, at most 30 characters" },
1238
+ subtitle: { type: "string", description: "Subtitle, at most 30 characters" },
1239
+ description: { type: "string", description: "Full description, at most 4000 characters" },
1240
+ keywords: {
1241
+ type: "string",
1242
+ description: "Comma-separated keywords, at most 100 characters total"
1243
+ },
1244
+ promotionalText: { type: "string", description: "Promotional text, at most 170 characters" },
1245
+ releaseNotes: { type: "string", description: "Release notes for this version" },
1246
+ reviewNotes: {
1247
+ type: "string",
1248
+ description: "Notes for the App Review team: how to test the app, login/test account needs, subscription details"
1249
+ }
1250
+ }
1251
+ };
1252
+ var PLAY_JSON_SCHEMA = {
1253
+ type: "object",
1254
+ additionalProperties: false,
1255
+ required: [
1256
+ "title",
1257
+ "shortDescription",
1258
+ "fullDescription",
1259
+ "releaseNotes",
1260
+ "suggestedCategory",
1261
+ "suggestedTags"
1262
+ ],
1263
+ properties: {
1264
+ title: { type: "string", description: "App title, at most 30 characters, no emoji" },
1265
+ shortDescription: { type: "string", description: "Short description, at most 80 characters" },
1266
+ fullDescription: { type: "string", description: "Full description, at most 4000 characters" },
1267
+ releaseNotes: { type: "string", description: "Release notes, at most 500 characters" },
1268
+ suggestedCategory: { type: "string", description: "Suggested Google Play category" },
1269
+ suggestedTags: { type: "array", items: { type: "string" }, description: "Suggested tags" }
1270
+ }
1271
+ };
1272
+ var SYSTEM_PROMPT = `You are AppShip, an assistant that writes app store submission materials.
1273
+
1274
+ Rules you must never break:
1275
+ - Only describe features, permissions, and SDKs listed in the provided project summary. Never invent capabilities the summary does not mention.
1276
+ - Never make definitive privacy claims like "does not share user data". If something is unknown, either omit it or mark it as [CONFIRM: <question for the developer>].
1277
+ - Respect every character limit stated in the field descriptions.
1278
+ - Avoid store-policy violations: no emoji or repeated special characters in titles, no ranking claims ("#1 app"), no price mentions in App Store metadata, no misleading claims.
1279
+ - Write in the locale requested by the user.`;
1280
+ function buildPrompt(payload, locale, store) {
1281
+ return `Write ${store} listing metadata for this app, in locale "${locale}".
1282
+
1283
+ Project summary (the only source of truth about the app):
1284
+ ` + JSON.stringify(payload, null, 2);
1285
+ }
1286
+ async function generateWithRetry(provider, basePrompt, jsonSchema, parse7, validate, system = SYSTEM_PROMPT) {
1287
+ let prompt = basePrompt;
1288
+ let listing = parse7(await provider.generateObject({ system, prompt, jsonSchema }));
1289
+ let violations = validate(listing);
1290
+ for (let attempt = 0; violations.length > 0 && attempt < MAX_RETRIES; attempt++) {
1291
+ prompt = basePrompt + "\n\nYour previous attempt violated these constraints \u2014 fix them and regenerate:\n" + violations.map((v) => `- ${v.message}`).join("\n") + "\n\nFor character-limit violations: character limits are hard store rules, not suggestions. Count the characters of your replacement before answering and make it comfortably shorter than the limit (aim for ~10% under) \u2014 cutting words is better than exceeding the limit.";
1292
+ listing = parse7(await provider.generateObject({ system, prompt, jsonSchema }));
1293
+ violations = validate(listing);
1294
+ }
1295
+ return { listing, violations };
1296
+ }
1297
+ async function generateAppStoreListing(provider, payload, locale) {
1298
+ return generateWithRetry(
1299
+ provider,
1300
+ buildPrompt(payload, locale, "Apple App Store"),
1301
+ APP_STORE_JSON_SCHEMA,
1302
+ (raw) => appStoreListingSchema.parse(raw),
1303
+ (l) => validateFields({ ...l }, APP_STORE_LIMITS)
1304
+ );
1305
+ }
1306
+ async function generatePlayListing(provider, payload, locale) {
1307
+ return generateWithRetry(
1308
+ provider,
1309
+ buildPrompt(payload, locale, "Google Play"),
1310
+ PLAY_JSON_SCHEMA,
1311
+ (raw) => playListingSchema.parse(raw),
1312
+ (l) => [
1313
+ ...validateFields(
1314
+ {
1315
+ title: l.title,
1316
+ shortDescription: l.shortDescription,
1317
+ fullDescription: l.fullDescription,
1318
+ releaseNotes: l.releaseNotes
1319
+ },
1320
+ GOOGLE_PLAY_LIMITS
1321
+ ),
1322
+ ...validatePlayTitlePolicy(l.title)
1323
+ ]
1324
+ );
1325
+ }
1326
+
1327
+ // src/core/generate/legal.ts
1328
+ var LEGAL_SYSTEM_PROMPT = `You are AppShip, drafting legal/support documents for a mobile app's store submission.
1329
+
1330
+ Rules you must never break:
1331
+ - Ground every statement in the provided project summary and data-collection findings. Never invent data practices.
1332
+ - Never write definitive negative claims like "we do not share your data". Instead describe only what was detected, and add: "Please verify whether data is transferred to these providers." where relevant.
1333
+ - For anything the developer must decide or confirm (company name, contact email, jurisdiction, retention periods), insert a placeholder in the exact form [CONFIRM: <question>].
1334
+ - Start the document with a blockquote disclaimer: "> This is an AI-generated draft, not legal advice. Review it with a qualified professional before publishing."
1335
+ - Output plain Markdown only.`;
1336
+ var LEGAL_DOCS = [
1337
+ {
1338
+ filename: "privacy-policy.md",
1339
+ title: "Privacy Policy",
1340
+ instructions: "Cover: what data is collected (from the findings), purposes, third-party SDKs detected, user rights, contact. "
1341
+ },
1342
+ {
1343
+ filename: "terms-of-service.md",
1344
+ title: "Terms of Service",
1345
+ instructions: "Cover: acceptance, license, user accounts (if login is a feature), acceptable use, subscriptions/purchases only if payments SDKs were detected, liability, governing law."
1346
+ },
1347
+ {
1348
+ filename: "account-deletion.md",
1349
+ title: "Account Deletion Instructions",
1350
+ instructions: "Explain how a user deletes their account and data (Apple Guideline 5.1.1). If the summary does not include login, state that the app has no accounts and this page may not be required."
1351
+ },
1352
+ {
1353
+ filename: "support-page.md",
1354
+ title: "Support",
1355
+ instructions: "A short support page: what the app does, FAQ stubs based on the features, and a contact placeholder."
1356
+ }
1357
+ ];
1358
+ async function generateLegalDoc(provider, spec, payload, privacyReport) {
1359
+ const prompt = `Write the "${spec.title}" document for this app. ${spec.instructions}
1360
+
1361
+ Project summary:
1362
+ ${JSON.stringify(payload, null, 2)}
1363
+
1364
+ Detected data collection (scanner findings with evidence):
1365
+ ` + JSON.stringify(privacyReport.dataCollection, null, 2);
1366
+ return provider.generateText({ system: LEGAL_SYSTEM_PROMPT, prompt });
1367
+ }
1368
+
1369
+ // src/core/generate/privacy-docs.ts
1370
+ import { stringify as stringify2 } from "yaml";
1371
+ function renderDataSafetyYaml(report) {
1372
+ const header = "# Google Play Data Safety draft \u2014 generated by appship from code analysis.\n# Data Safety answers are the developer's self-declaration: review every\n# entry, resolve the confirm_before_submitting items, then transcribe into\n# the Play Console form.\n";
1373
+ const dataCollection = {};
1374
+ for (const [dataType, entry] of Object.entries(report.dataCollection)) {
1375
+ dataCollection[dataType] = {
1376
+ collected: entry.collected,
1377
+ purpose: entry.purpose,
1378
+ shared: entry.shared,
1379
+ evidence: entry.evidence,
1380
+ ...entry.requiresConfirmation && entry.confirmed === null ? { confirm_before_submitting: "Detection could not determine the exact answer \u2014 confirm manually." } : {}
1381
+ };
1382
+ }
1383
+ return header + stringify2({ data_collection: dataCollection });
1384
+ }
1385
+ function renderPrivacyQuestionnaireYaml(report) {
1386
+ const header = "# Apple privacy questionnaire draft (App Privacy / Nutrition Labels) \u2014\n# generated by appship from code analysis. Review before answering in\n# App Store Connect.\n";
1387
+ const dataTypes = Object.entries(report.dataCollection).map(([dataType, entry]) => ({
1388
+ data_type: dataType,
1389
+ collected: entry.collected,
1390
+ linked_to_user: "[CONFIRM: is this data linked to the user's identity?]",
1391
+ used_for_tracking: entry.shared ? "[CONFIRM: shared with third parties \u2014 is it used for tracking?]" : false,
1392
+ purposes: entry.purpose,
1393
+ evidence: entry.evidence
1394
+ }));
1395
+ return header + stringify2({ privacy_questionnaire: dataTypes });
1396
+ }
1397
+ function renderContentRatingYaml(config) {
1398
+ const header = "# Google Play content rating questionnaire draft. Every answer must be\n# confirmed by the developer \u2014 appship cannot infer app content.\n";
1399
+ return header + stringify2({
1400
+ content_rating: {
1401
+ app_name: config.project.name,
1402
+ category: "[CONFIRM: pick the questionnaire category in Play Console]",
1403
+ violence: "[CONFIRM]",
1404
+ sexuality: "[CONFIRM]",
1405
+ language: "[CONFIRM]",
1406
+ controlled_substances: "[CONFIRM]",
1407
+ user_generated_content: "[CONFIRM: does the app host user-generated content?]",
1408
+ user_interaction: "[CONFIRM: can users interact or share location?]"
1409
+ }
1410
+ });
1411
+ }
1412
+
1413
+ // src/core/generate/checklists.ts
1414
+ function item(text2) {
1415
+ return `- [ ] ${text2}`;
1416
+ }
1417
+ function renderAppStoreChecklist(config, scan) {
1418
+ const lines = [
1419
+ `# App Store submission checklist \u2014 ${config.project.name}`,
1420
+ "",
1421
+ "## Metadata",
1422
+ item("Review generated name/subtitle/description/keywords in .appship/app-store/"),
1423
+ item("Review review-notes.txt (test account, subscription details)"),
1424
+ item("Support URL configured in App Store Connect"),
1425
+ item("Marketing URL (optional) configured"),
1426
+ "",
1427
+ "## Privacy",
1428
+ item("Resolve every [CONFIRM] in .appship/legal/ and privacy-questionnaire.yml"),
1429
+ item("Answer App Privacy questionnaire in App Store Connect"),
1430
+ item("PrivacyInfo.xcprivacy present in the Xcode project"),
1431
+ ...config.project.requires_login ? [item("Account deletion flow available in-app (Guideline 5.1.1) and documented")] : [],
1432
+ "",
1433
+ "## Permissions",
1434
+ ...scan.permissions.ios.map(
1435
+ (p5) => item(
1436
+ `${p5.key}: usage description reviewed` + (p5.qualityAssessment !== "ok" ? " (\u26A0 current message needs improvement)" : "")
1437
+ )
1438
+ ),
1439
+ "",
1440
+ "## Assets",
1441
+ item("App icon (1024\xD71024) uploaded"),
1442
+ item("Screenshots for required device sizes (see screenshots/screenshot-plan.yml)"),
1443
+ ""
1444
+ ];
1445
+ return lines.join("\n");
1446
+ }
1447
+ function renderGooglePlayChecklist(config, scan) {
1448
+ const lines = [
1449
+ `# Google Play submission checklist \u2014 ${config.project.name}`,
1450
+ "",
1451
+ "## Metadata",
1452
+ item("Review generated title/short/full description in .appship/google-play/"),
1453
+ item("Category and tags set in Play Console"),
1454
+ "",
1455
+ "## Data Safety & Policy",
1456
+ item("Resolve confirm_before_submitting items in data-safety.yml, then fill the Data Safety form"),
1457
+ item("Complete the content rating questionnaire (see content-rating.yml)"),
1458
+ item("Privacy policy URL published and linked in Play Console"),
1459
+ ...config.project.requires_login ? [item("Account deletion URL provided in Play Console (required when accounts exist)")] : [],
1460
+ item("App access instructions provided if login is required"),
1461
+ item("Ads declaration completed"),
1462
+ "",
1463
+ "## Permissions",
1464
+ ...scan.permissions.android.map((p5) => item(`${p5.key}: usage justified in the declaration`)),
1465
+ "",
1466
+ "## Assets",
1467
+ item("App icon (512\xD7512) and feature graphic (1024\xD7500) uploaded"),
1468
+ item("Screenshots uploaded (see screenshots plan)"),
1469
+ ""
1470
+ ];
1471
+ return lines.join("\n");
1472
+ }
1473
+
1474
+ // src/core/generate/index.ts
1475
+ var screenshotPlanSchema = z6.object({
1476
+ screens: z6.array(z6.object({ screen: z6.string(), headline: z6.string() }))
1477
+ });
1478
+ var SCREENSHOT_JSON_SCHEMA = {
1479
+ type: "object",
1480
+ additionalProperties: false,
1481
+ required: ["screens"],
1482
+ properties: {
1483
+ screens: {
1484
+ type: "array",
1485
+ items: {
1486
+ type: "object",
1487
+ additionalProperties: false,
1488
+ required: ["screen", "headline"],
1489
+ properties: {
1490
+ screen: { type: "string", description: "Screen name exactly as given" },
1491
+ headline: { type: "string", description: "Marketing headline overlay, under 60 characters" }
1492
+ }
1493
+ }
1494
+ }
1495
+ }
1496
+ };
1497
+ async function detectScreens(projectRoot) {
1498
+ const files = await glob3(["src/screens/*.{tsx,jsx,ts,js}", "app/screens/*.{tsx,jsx,ts,js}"], {
1499
+ cwd: projectRoot,
1500
+ ignore: ["**/node_modules/**", "**/*.test.*"]
1501
+ });
1502
+ return files.map((route) => ({
1503
+ name: (route.split("/").pop() ?? route).replace(/\.(tsx|jsx|ts|js)$/, ""),
1504
+ route
1505
+ }));
1506
+ }
1507
+ async function buildScreenshotPlan(provider, projectRoot, payloadJson) {
1508
+ const screens = await detectScreens(projectRoot);
1509
+ const header = "# Screenshot plan \u2014 generated by appship. Screenshot capture automation\n# is planned for a later release; use this as the shot list.\n";
1510
+ if (screens.length === 0) {
1511
+ return header + stringify3({
1512
+ screens: [],
1513
+ note: "No screen components detected under src/screens or app/screens. Add entries manually."
1514
+ });
1515
+ }
1516
+ const raw = await provider.generateObject({
1517
+ system: "You write short app-store screenshot marketing headlines. Only use the provided app summary; never invent features.",
1518
+ prompt: `App summary:
1519
+ ${payloadJson}
1520
+
1521
+ Screens: ${screens.map((s) => s.name).join(", ")}
1522
+
1523
+ Write one headline per screen.`,
1524
+ jsonSchema: SCREENSHOT_JSON_SCHEMA
1525
+ });
1526
+ const plan = screenshotPlanSchema.parse(raw);
1527
+ const headlines = new Map(plan.screens.map((s) => [s.screen, s.headline]));
1528
+ return header + stringify3({
1529
+ screens: screens.map((s) => ({
1530
+ screen: s.name,
1531
+ headline: headlines.get(s.name) ?? "[CONFIRM: write a headline]",
1532
+ source_route: s.route
1533
+ }))
1534
+ });
1535
+ }
1536
+ async function runGenerate(projectRoot, config, scan, provider, options = {}) {
1537
+ const locales = options.locales ?? config.stores.locales;
1538
+ const payload = buildSummaryPayload(scan, config);
1539
+ const payloadJson = JSON.stringify(payload, null, 2);
1540
+ const files = [];
1541
+ const add = (path, content, violations = []) => files.push({ path: join9(".appship", path), content, violations });
1542
+ const wantIos = options.target !== "android";
1543
+ const wantAndroid = options.target !== "ios";
1544
+ for (const locale of locales) {
1545
+ if (wantIos) {
1546
+ const { listing, violations } = await generateAppStoreListing(provider, payload, locale);
1547
+ const dir = `app-store/${locale}`;
1548
+ add(`${dir}/name.txt`, listing.name + "\n", violations.filter((v) => v.field === "name"));
1549
+ add(`${dir}/subtitle.txt`, listing.subtitle + "\n", violations.filter((v) => v.field === "subtitle"));
1550
+ add(`${dir}/description.txt`, listing.description + "\n", violations.filter((v) => v.field === "description"));
1551
+ add(`${dir}/keywords.txt`, listing.keywords + "\n", violations.filter((v) => v.field === "keywords"));
1552
+ add(`${dir}/promotional-text.txt`, listing.promotionalText + "\n", violations.filter((v) => v.field === "promotionalText"));
1553
+ add(`${dir}/release-notes.txt`, listing.releaseNotes + "\n", violations.filter((v) => v.field === "releaseNotes"));
1554
+ add(`${dir}/review-notes.txt`, listing.reviewNotes + "\n");
1555
+ }
1556
+ if (wantAndroid) {
1557
+ const { listing, violations } = await generatePlayListing(provider, payload, locale);
1558
+ const dir = `google-play/${locale}`;
1559
+ add(`${dir}/title.txt`, listing.title + "\n", violations.filter((v) => v.field === "title"));
1560
+ add(`${dir}/short-description.txt`, listing.shortDescription + "\n", violations.filter((v) => v.field === "shortDescription"));
1561
+ add(`${dir}/full-description.txt`, listing.fullDescription + "\n", violations.filter((v) => v.field === "fullDescription"));
1562
+ add(`${dir}/release-notes.txt`, listing.releaseNotes + "\n", violations.filter((v) => v.field === "releaseNotes"));
1563
+ add(
1564
+ `${dir}/store-settings.yml`,
1565
+ stringify3({ suggested_category: listing.suggestedCategory, suggested_tags: listing.suggestedTags })
1566
+ );
1567
+ }
1568
+ }
1569
+ if (wantIos) {
1570
+ add("app-store/privacy/privacy-questionnaire.yml", renderPrivacyQuestionnaireYaml(scan.privacyReport));
1571
+ }
1572
+ if (wantAndroid) {
1573
+ add("google-play/data-safety.yml", renderDataSafetyYaml(scan.privacyReport));
1574
+ add("google-play/content-rating.yml", renderContentRatingYaml(config));
1575
+ }
1576
+ for (const spec of LEGAL_DOCS) {
1577
+ const doc = await generateLegalDoc(provider, spec, payload, scan.privacyReport);
1578
+ add(`legal/${spec.filename}`, doc.endsWith("\n") ? doc : doc + "\n");
1579
+ }
1580
+ const screenshotPlan = await buildScreenshotPlan(provider, projectRoot, payloadJson);
1581
+ if (wantIos) add("app-store/screenshots/screenshot-plan.yml", screenshotPlan);
1582
+ if (wantAndroid) add("google-play/screenshots/screenshot-plan.yml", screenshotPlan);
1583
+ if (wantIos) add("checklist/app-store.md", renderAppStoreChecklist(config, scan));
1584
+ if (wantAndroid) add("checklist/google-play.md", renderGooglePlayChecklist(config, scan));
1585
+ if (!options.dryRun) {
1586
+ for (const file of files) {
1587
+ const absolute = join9(projectRoot, file.path);
1588
+ await mkdir3(dirname3(absolute), { recursive: true });
1589
+ await writeFile3(absolute, file.content, "utf8");
1590
+ }
1591
+ }
1592
+ return { files };
1593
+ }
1594
+ function planArtifacts(config, options = {}) {
1595
+ const locales = options.locales ?? config.stores.locales;
1596
+ const wantIos = options.target !== "android";
1597
+ const wantAndroid = options.target !== "ios";
1598
+ const paths = [];
1599
+ for (const locale of locales) {
1600
+ if (wantIos) {
1601
+ for (const f of ["name", "subtitle", "description", "keywords", "promotional-text", "release-notes", "review-notes"]) {
1602
+ paths.push(`.appship/app-store/${locale}/${f}.txt`);
1603
+ }
1604
+ }
1605
+ if (wantAndroid) {
1606
+ for (const f of ["title", "short-description", "full-description", "release-notes"]) {
1607
+ paths.push(`.appship/google-play/${locale}/${f}.txt`);
1608
+ }
1609
+ paths.push(`.appship/google-play/${locale}/store-settings.yml`);
1610
+ }
1611
+ }
1612
+ if (wantIos) paths.push(".appship/app-store/privacy/privacy-questionnaire.yml");
1613
+ if (wantAndroid) {
1614
+ paths.push(".appship/google-play/data-safety.yml", ".appship/google-play/content-rating.yml");
1615
+ }
1616
+ paths.push(...LEGAL_DOCS.map((d) => `.appship/legal/${d.filename}`));
1617
+ if (wantIos) paths.push(".appship/app-store/screenshots/screenshot-plan.yml");
1618
+ if (wantAndroid) paths.push(".appship/google-play/screenshots/screenshot-plan.yml");
1619
+ if (wantIos) paths.push(".appship/checklist/app-store.md");
1620
+ if (wantAndroid) paths.push(".appship/checklist/google-play.md");
1621
+ return paths;
1622
+ }
1623
+
1624
+ // src/cli/generate.ts
1625
+ async function confirmFindings(scan, interactive) {
1626
+ const pending = Object.entries(scan.privacyReport.dataCollection).filter(
1627
+ ([, entry]) => entry.requiresConfirmation && entry.confirmed === null
1628
+ );
1629
+ if (pending.length === 0) return true;
1630
+ if (!interactive) {
1631
+ console.log(
1632
+ pc2.yellow("\u26A0") + ` ${pending.length} detection(s) need your confirmation (${pending.map(([t]) => t).join(", ")}). Generated privacy docs will carry confirm markers.`
1633
+ );
1634
+ return true;
1635
+ }
1636
+ for (const [dataType, entry] of pending) {
1637
+ console.log();
1638
+ console.log(pc2.yellow("\u26A0") + ` ${dataType} collection detected. Evidence:`);
1639
+ for (const ev of entry.evidence) console.log(pc2.dim(` - ${ev}`));
1640
+ const answer = await p2.confirm({
1641
+ message: `Does the app collect "${dataType}" as detected?`,
1642
+ initialValue: true
1643
+ });
1644
+ if (p2.isCancel(answer)) return false;
1645
+ entry.confirmed = answer;
1646
+ }
1647
+ return true;
1648
+ }
1649
+ var generateCommand = new Command2("generate").description("Generate store metadata, privacy documents, and checklists into .appship/").argument("[target]", "ios | android (default: both)").option("--locale <locale>", "generate only for the given locale").option("--dry-run", "list what would be generated without calling AI or writing files").option("--yes", "overwrite existing generated files without asking").option("--non-interactive", "never prompt (CI): skip confirmations, overwrite existing files").action(
1650
+ async (targetArg, options) => {
1651
+ const projectRoot = process.cwd();
1652
+ let target;
1653
+ if (targetArg === "ios" || targetArg === "android") target = targetArg;
1654
+ else if (targetArg !== void 0 && targetArg !== "metadata") {
1655
+ console.error(pc2.red(`\u2717 Unknown target "${targetArg}". Use ios, android, or omit.`));
1656
+ process.exitCode = 1;
1657
+ return;
1658
+ }
1659
+ let config;
1660
+ try {
1661
+ config = await loadConfig(projectRoot);
1662
+ } catch (error) {
1663
+ if (error instanceof ConfigError) {
1664
+ console.error(pc2.red(`\u2717 ${error.message}`));
1665
+ process.exitCode = 1;
1666
+ return;
1667
+ }
1668
+ throw error;
1669
+ }
1670
+ if (config.project.description.startsWith("[CONFIRM:")) {
1671
+ console.error(
1672
+ pc2.red("\u2717 project.description in appship.yml is still a placeholder. ") + "Fill it in (or re-run `appship init`) before generating."
1673
+ );
1674
+ process.exitCode = 1;
1675
+ return;
1676
+ }
1677
+ const generateOptions = {
1678
+ ...target ? { target } : {},
1679
+ ...options.locale ? { locales: [options.locale] } : {}
1680
+ };
1681
+ if (options.dryRun) {
1682
+ console.log(pc2.bold("Would generate (no AI calls, no writes):"));
1683
+ for (const path of planArtifacts(config, generateOptions)) {
1684
+ console.log(` ${path}`);
1685
+ }
1686
+ return;
1687
+ }
1688
+ let scan;
1689
+ try {
1690
+ scan = await scanProject(projectRoot);
1691
+ } catch (error) {
1692
+ if (error instanceof UnsupportedProjectError) {
1693
+ console.error(pc2.red(`\u2717 ${error.message}`));
1694
+ process.exitCode = 1;
1695
+ return;
1696
+ }
1697
+ throw error;
1698
+ }
1699
+ await mergePreviousConfirmations(projectRoot, scan);
1700
+ const interactive = !options.nonInteractive && Boolean(process.stdout.isTTY);
1701
+ if (!await confirmFindings(scan, interactive)) {
1702
+ console.log(pc2.yellow("Cancelled \u2014 nothing was generated."));
1703
+ process.exitCode = 1;
1704
+ return;
1705
+ }
1706
+ const existing = planArtifacts(config, generateOptions).filter(
1707
+ (path) => existsSync3(join10(projectRoot, path))
1708
+ );
1709
+ if (existing.length > 0 && !options.yes && interactive) {
1710
+ const overwrite = await p2.confirm({
1711
+ message: `${existing.length} generated file(s) already exist and will be overwritten. Continue?`,
1712
+ initialValue: true
1713
+ });
1714
+ if (p2.isCancel(overwrite) || !overwrite) {
1715
+ console.log(pc2.yellow("Cancelled \u2014 nothing was generated."));
1716
+ process.exitCode = 1;
1717
+ return;
1718
+ }
1719
+ }
1720
+ let provider;
1721
+ try {
1722
+ provider = createProvider(config);
1723
+ } catch (error) {
1724
+ if (error instanceof AIProviderError) {
1725
+ console.error(pc2.red(`\u2717 ${error.message}`));
1726
+ process.exitCode = 1;
1727
+ return;
1728
+ }
1729
+ throw error;
1730
+ }
1731
+ console.log(
1732
+ `Generating with ${pc2.cyan(provider.name)} for locales: ${(generateOptions.locales ?? config.stores.locales).join(", ")} ...`
1733
+ );
1734
+ let result;
1735
+ try {
1736
+ result = await runGenerate(projectRoot, config, scan, provider, generateOptions);
1737
+ } catch (error) {
1738
+ if (error instanceof AIProviderError) {
1739
+ console.error(pc2.red(`\u2717 ${error.message}`));
1740
+ process.exitCode = 1;
1741
+ return;
1742
+ }
1743
+ throw error;
1744
+ }
1745
+ await writeFile4(
1746
+ join10(projectRoot, PRIVACY_REPORT_PATH),
1747
+ JSON.stringify(scan.privacyReport, null, 2) + "\n",
1748
+ "utf8"
1749
+ );
1750
+ console.log();
1751
+ for (const file of result.files) {
1752
+ const marker = file.violations.length > 0 ? pc2.yellow("\u26A0") : pc2.green("\u2713");
1753
+ console.log(`${marker} ${file.path}`);
1754
+ for (const violation of file.violations) {
1755
+ console.log(pc2.yellow(` VALIDATION: ${violation.message}`));
1756
+ }
1757
+ }
1758
+ const violationCount = result.files.reduce((n, f) => n + f.violations.length, 0);
1759
+ console.log();
1760
+ if (violationCount > 0) {
1761
+ console.log(
1762
+ pc2.yellow(`\u26A0 ${violationCount} constraint violation(s) remain after retries \u2014 fix manually.`)
1763
+ );
1764
+ }
1765
+ console.log(`Next: run ${pc2.cyan("appship doctor")} to check submission readiness.`);
1766
+ }
1767
+ );
1768
+
1769
+ // src/cli/doctor.ts
1770
+ import { mkdir as mkdir4, writeFile as writeFile5 } from "fs/promises";
1771
+ import { join as join12 } from "path";
1772
+ import { Command as Command3 } from "commander";
1773
+ import pc3 from "picocolors";
1774
+
1775
+ // src/core/doctor/engine.ts
1776
+ import { existsSync as existsSync4 } from "fs";
1777
+ import { readFile as readFile8 } from "fs/promises";
1778
+ import { join as join11 } from "path";
1779
+ import glob4 from "fast-glob";
1780
+ var EMOJI_PATTERN2 = new RegExp("\\p{Extended_Pictographic}", "u");
1781
+ function humanize(id) {
1782
+ return id.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
1783
+ }
1784
+ function deriveFindings(ctx) {
1785
+ const findings = /* @__PURE__ */ new Set();
1786
+ if (ctx.config.project.requires_login === true || ctx.scan.sdkReport.sdks.some((s) => s.category === "auth")) {
1787
+ findings.add("login_detected");
1788
+ }
1789
+ if (ctx.scan.sdkReport.sdks.some((s) => s.category === "analytics")) {
1790
+ findings.add("analytics_detected");
1791
+ }
1792
+ if (ctx.scan.sdkReport.sdks.some((s) => s.category === "payments")) {
1793
+ findings.add("payments_detected");
1794
+ }
1795
+ return findings;
1796
+ }
1797
+ async function evaluateRule(rule, ctx, findings) {
1798
+ const base = {
1799
+ id: rule.id,
1800
+ store: rule.store,
1801
+ category: rule.category,
1802
+ severity: rule.severity,
1803
+ ...rule.guideline ? { guideline: rule.guideline } : {},
1804
+ ...rule.fix_suggestions ? { fixSuggestions: rule.fix_suggestions } : {}
1805
+ };
1806
+ const pass = () => ({
1807
+ ...base,
1808
+ status: "pass",
1809
+ message: rule.pass_message ?? humanize(rule.id)
1810
+ });
1811
+ const fail = (detail) => ({
1812
+ ...base,
1813
+ status: "fail",
1814
+ message: rule.message,
1815
+ ...detail ? { detail } : {}
1816
+ });
1817
+ const skip = (why) => ({ ...base, status: "skip", message: why });
1818
+ if (rule.condition && !findings.has(rule.condition.finding)) {
1819
+ return skip(`not applicable (${rule.condition.finding} not found)`);
1820
+ }
1821
+ const appshipDir = join11(ctx.projectRoot, ".appship");
1822
+ if (rule.check.type === "file_exists") {
1823
+ const target = join11(appshipDir, String(rule.check.value));
1824
+ return existsSync4(target) ? pass() : fail();
1825
+ }
1826
+ const targets = rule.target ? await glob4(rule.target, { cwd: appshipDir }) : [];
1827
+ if (targets.length === 0) {
1828
+ return skip("no generated files to check (run appship generate)");
1829
+ }
1830
+ for (const file of targets) {
1831
+ const content = (await readFile8(join11(appshipDir, file), "utf8")).trimEnd();
1832
+ switch (rule.check.type) {
1833
+ case "max_length":
1834
+ if (content.length > Number(rule.check.value)) {
1835
+ return fail(`${file}: ${content.length} characters`);
1836
+ }
1837
+ break;
1838
+ case "not_contains": {
1839
+ const needle = String(rule.check.value);
1840
+ if (content.includes(needle)) {
1841
+ const count = content.split(needle).length - 1;
1842
+ return fail(`${file}: ${count} occurrence(s)`);
1843
+ }
1844
+ break;
1845
+ }
1846
+ case "no_emoji":
1847
+ if (EMOJI_PATTERN2.test(content)) {
1848
+ return fail(file);
1849
+ }
1850
+ break;
1851
+ }
1852
+ }
1853
+ return pass();
1854
+ }
1855
+ async function builtInChecks(ctx, findings) {
1856
+ const results = [];
1857
+ const { config, scan, projectRoot } = ctx;
1858
+ const push = (store, id, category, severity, ok, passMessage, failMessage, extra = {}) => results.push({
1859
+ id,
1860
+ store,
1861
+ category,
1862
+ severity,
1863
+ status: ok ? "pass" : "fail",
1864
+ message: ok ? passMessage : failMessage,
1865
+ ...extra
1866
+ });
1867
+ push(
1868
+ "app-store",
1869
+ "bundle-id-configured",
1870
+ "required",
1871
+ "error",
1872
+ scan.project.ios?.bundleId != null,
1873
+ "Bundle ID configured",
1874
+ "iOS bundle ID could not be detected"
1875
+ );
1876
+ push(
1877
+ "google-play",
1878
+ "package-name-configured",
1879
+ "required",
1880
+ "error",
1881
+ scan.project.android?.packageName != null,
1882
+ "Android package name configured",
1883
+ "Android package name could not be detected"
1884
+ );
1885
+ const appIcon = await glob4("ios/**/AppIcon.appiconset", {
1886
+ cwd: projectRoot,
1887
+ onlyDirectories: true,
1888
+ ignore: ["**/node_modules/**", "**/Pods/**"]
1889
+ });
1890
+ push(
1891
+ "app-store",
1892
+ "app-icon-present",
1893
+ "required",
1894
+ "warning",
1895
+ appIcon.length > 0,
1896
+ "App icon found",
1897
+ "App icon (AppIcon.appiconset) not found in the iOS project"
1898
+ );
1899
+ const privacyManifest = await glob4("ios/**/PrivacyInfo.xcprivacy", {
1900
+ cwd: projectRoot,
1901
+ ignore: ["**/node_modules/**", "**/Pods/**"]
1902
+ });
1903
+ push(
1904
+ "app-store",
1905
+ "privacy-manifest-present",
1906
+ "required",
1907
+ "warning",
1908
+ privacyManifest.length > 0,
1909
+ "Privacy manifest found",
1910
+ "PrivacyInfo.xcprivacy not found \u2014 Apple requires a privacy manifest"
1911
+ );
1912
+ for (const store of ["app-store", "google-play"]) {
1913
+ push(
1914
+ store,
1915
+ "support-url-configured",
1916
+ "required",
1917
+ "error",
1918
+ config.project.support_url !== void 0,
1919
+ "Support URL configured",
1920
+ "Support URL missing \u2014 add project.support_url to appship.yml"
1921
+ );
1922
+ push(
1923
+ store,
1924
+ "description-filled",
1925
+ "required",
1926
+ "error",
1927
+ !config.project.description.startsWith("[CONFIRM:"),
1928
+ "Project description filled in",
1929
+ "project.description in appship.yml is still a placeholder"
1930
+ );
1931
+ push(
1932
+ store,
1933
+ "generated-materials-exist",
1934
+ "required",
1935
+ "error",
1936
+ existsSync4(join11(projectRoot, ".appship", store)),
1937
+ "Store materials generated",
1938
+ `No generated materials for ${store} \u2014 run appship generate`
1939
+ );
1940
+ }
1941
+ for (const permission of scan.permissions.ios) {
1942
+ push(
1943
+ "app-store",
1944
+ `permission-quality:${permission.key}`,
1945
+ "quality",
1946
+ "warning",
1947
+ permission.qualityAssessment === "ok",
1948
+ `${permission.key} usage description looks specific`,
1949
+ `${permission.key} usage description ${permission.qualityAssessment === "missing" ? "is empty" : "is too generic"}: "${permission.currentMessage}"`,
1950
+ {
1951
+ fixSuggestions: [
1952
+ 'Describe the concrete user-facing situation, e.g. "Microphone access is used when you join a voice room and speak with other participants."'
1953
+ ]
1954
+ }
1955
+ );
1956
+ }
1957
+ const unconfirmed = Object.entries(scan.privacyReport.dataCollection).filter(([, e]) => e.requiresConfirmation && e.confirmed === null).map(([t]) => t);
1958
+ for (const store of ["app-store", "google-play"]) {
1959
+ push(
1960
+ store,
1961
+ "data-collection-confirmed",
1962
+ "consistency",
1963
+ "error",
1964
+ unconfirmed.length === 0,
1965
+ "All detected data collection confirmed",
1966
+ `Unconfirmed data-collection detection(s): ${unconfirmed.join(", ")} \u2014 run appship generate to confirm`
1967
+ );
1968
+ }
1969
+ const privacyPolicyPath = join11(projectRoot, ".appship/legal/privacy-policy.md");
1970
+ if (findings.has("analytics_detected") || scan.sdkReport.sdks.some((s) => s.category === "crash-reporting")) {
1971
+ if (existsSync4(privacyPolicyPath)) {
1972
+ const policy = await readFile8(privacyPolicyPath, "utf8");
1973
+ const missing = scan.sdkReport.sdks.filter((s) => s.category === "analytics" || s.category === "crash-reporting").filter((s) => !policy.toLowerCase().includes(s.name.toLowerCase())).map((s) => s.name);
1974
+ for (const store of ["app-store", "google-play"]) {
1975
+ push(
1976
+ store,
1977
+ "privacy-policy-mentions-sdks",
1978
+ "consistency",
1979
+ "warning",
1980
+ missing.length === 0,
1981
+ "Privacy policy mentions detected analytics/crash SDKs",
1982
+ `Privacy policy does not mention: ${missing.join(", ")}`
1983
+ );
1984
+ }
1985
+ }
1986
+ }
1987
+ return results;
1988
+ }
1989
+ function scoreOf(results) {
1990
+ const evaluated = results.filter((r) => r.status !== "skip");
1991
+ const weight = (r) => r.severity === "error" ? 3 : 1;
1992
+ const total = evaluated.reduce((n, r) => n + weight(r), 0);
1993
+ if (total === 0) return 100;
1994
+ const passed = evaluated.filter((r) => r.status === "pass").reduce((n, r) => n + weight(r), 0);
1995
+ return Math.round(100 * passed / total);
1996
+ }
1997
+ async function runDoctor(ctx, rules, stores = ["app-store", "google-play"]) {
1998
+ const findings = deriveFindings(ctx);
1999
+ const declarative = await Promise.all(rules.map((rule) => evaluateRule(rule, ctx, findings)));
2000
+ const builtIn = await builtInChecks(ctx, findings);
2001
+ const all = [...builtIn, ...declarative];
2002
+ return stores.map((store) => {
2003
+ const results = all.filter((r) => r.store === store);
2004
+ return { store, score: scoreOf(results), results };
2005
+ });
2006
+ }
2007
+
2008
+ // src/cli/doctor.ts
2009
+ var STORE_LABELS = {
2010
+ "app-store": "App Store",
2011
+ "google-play": "Google Play"
2012
+ };
2013
+ function renderReport(report) {
2014
+ console.log();
2015
+ console.log(pc3.bold(`${STORE_LABELS[report.store]} readiness: ${report.score}%`));
2016
+ console.log();
2017
+ const order = { pass: 0, fail: 1, skip: 2 };
2018
+ const sorted = [...report.results].sort(
2019
+ (a, b) => order[a.status] - order[b.status] || (a.severity === b.severity ? 0 : a.severity === "error" ? -1 : 1)
2020
+ );
2021
+ for (const result of sorted) {
2022
+ if (result.status === "skip") continue;
2023
+ const icon = result.status === "pass" ? pc3.green("\u2713") : result.severity === "error" ? pc3.red("\u2717") : pc3.yellow("\u26A0");
2024
+ const detail = result.detail ? pc3.dim(` (${result.detail})`) : "";
2025
+ console.log(`${icon} ${result.message}${detail}`);
2026
+ if (result.status === "fail") {
2027
+ if (result.guideline) console.log(pc3.dim(` ${result.guideline}`));
2028
+ for (const fix of result.fixSuggestions ?? []) {
2029
+ console.log(pc3.dim(` \u2192 ${fix}`));
2030
+ }
2031
+ }
2032
+ }
2033
+ const skipped = report.results.filter((r) => r.status === "skip").length;
2034
+ if (skipped > 0) {
2035
+ console.log(pc3.dim(`(${skipped} check(s) skipped \u2014 not applicable or nothing generated yet)`));
2036
+ }
2037
+ }
2038
+ var doctorCommand = new Command3("doctor").description("Check store submission readiness (runs fully offline, no AI calls)").option("--store <store>", "app-store | google-play (default: both)").option("--json", "output machine-readable JSON instead of the human report").action(async (options) => {
2039
+ const projectRoot = process.cwd();
2040
+ let stores = ["app-store", "google-play"];
2041
+ if (options.store !== void 0) {
2042
+ const parsed = storeSchema.safeParse(options.store);
2043
+ if (!parsed.success) {
2044
+ console.error(pc3.red(`\u2717 Unknown store "${options.store}". Use app-store or google-play.`));
2045
+ process.exitCode = 1;
2046
+ return;
2047
+ }
2048
+ stores = [parsed.data];
2049
+ }
2050
+ let config;
2051
+ let scan;
2052
+ try {
2053
+ config = await loadConfig(projectRoot);
2054
+ scan = await scanProject(projectRoot);
2055
+ } catch (error) {
2056
+ if (error instanceof ConfigError || error instanceof UnsupportedProjectError) {
2057
+ console.error(pc3.red(`\u2717 ${error.message}`));
2058
+ process.exitCode = 1;
2059
+ return;
2060
+ }
2061
+ throw error;
2062
+ }
2063
+ await mergePreviousConfirmations(projectRoot, scan);
2064
+ const rules = await loadRulesWithCache();
2065
+ const reports = await runDoctor({ projectRoot, config, scan }, rules, stores);
2066
+ const machineReport = {
2067
+ appshipVersion: scan.project.appshipVersion,
2068
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2069
+ stores: Object.fromEntries(reports.map((r) => [r.store, { score: r.score, results: r.results }]))
2070
+ };
2071
+ const checklistDir = join12(projectRoot, ".appship", "checklist");
2072
+ await mkdir4(checklistDir, { recursive: true });
2073
+ await writeFile5(
2074
+ join12(checklistDir, "release-readiness.json"),
2075
+ JSON.stringify(machineReport, null, 2) + "\n",
2076
+ "utf8"
2077
+ );
2078
+ if (options.json) {
2079
+ console.log(JSON.stringify(machineReport, null, 2));
2080
+ } else {
2081
+ for (const report of reports) renderReport(report);
2082
+ console.log();
2083
+ console.log(pc3.dim("Full report written to .appship/checklist/release-readiness.json"));
2084
+ }
2085
+ const hasErrors = reports.some(
2086
+ (r) => r.results.some((result) => result.status === "fail" && result.severity === "error")
2087
+ );
2088
+ if (hasErrors) process.exitCode = 1;
2089
+ });
2090
+
2091
+ // src/cli/localize.ts
2092
+ import { Command as Command4 } from "commander";
2093
+ import pc4 from "picocolors";
2094
+
2095
+ // src/core/localize/index.ts
2096
+ import { mkdir as mkdir5, readFile as readFile9, writeFile as writeFile6 } from "fs/promises";
2097
+ import { existsSync as existsSync5 } from "fs";
2098
+ import { dirname as dirname4, join as join13 } from "path";
2099
+ import { z as z7 } from "zod";
2100
+ var TRANSLATE_SYSTEM_PROMPT = `You are AppShip, localizing app store listings.
2101
+
2102
+ Rules you must never break:
2103
+ - Translate meaning, not words: write natural, market-appropriate store copy for the target locale.
2104
+ - Do not add, remove, or invent product claims \u2014 the source text is the only source of truth.
2105
+ - Keywords must be adapted for how users in the target market actually search, not translated literally.
2106
+ - Respect every character limit stated in the field descriptions. Translations often expand; shorten rather than exceed limits.
2107
+ - Keep placeholders of the form [CONFIRM: ...] exactly as-is, untranslated.`;
2108
+ var APP_STORE_SPEC = {
2109
+ store: "app-store",
2110
+ storeLabel: "Apple App Store",
2111
+ files: {
2112
+ name: "name",
2113
+ subtitle: "subtitle",
2114
+ description: "description",
2115
+ keywords: "keywords",
2116
+ "promotional-text": "promotionalText",
2117
+ "release-notes": "releaseNotes"
2118
+ },
2119
+ jsonSchema: {
2120
+ type: "object",
2121
+ additionalProperties: false,
2122
+ required: ["name", "subtitle", "description", "keywords", "promotionalText", "releaseNotes"],
2123
+ properties: {
2124
+ name: { type: "string", description: "App name, at most 30 characters" },
2125
+ subtitle: { type: "string", description: "Subtitle, at most 30 characters" },
2126
+ description: { type: "string", description: "Full description, at most 4000 characters" },
2127
+ keywords: {
2128
+ type: "string",
2129
+ description: "Comma-separated keywords adapted to the target market, at most 100 characters total"
2130
+ },
2131
+ promotionalText: { type: "string", description: "Promotional text, at most 170 characters" },
2132
+ releaseNotes: { type: "string", description: "Release notes, at most 4000 characters" }
2133
+ }
2134
+ },
2135
+ validate: (values) => validateFields(values, APP_STORE_LIMITS),
2136
+ // Review notes are for the App Review team — they stay in the source language.
2137
+ copyFiles: ["review-notes"]
2138
+ };
2139
+ var PLAY_SPEC = {
2140
+ store: "google-play",
2141
+ storeLabel: "Google Play",
2142
+ files: {
2143
+ title: "title",
2144
+ "short-description": "shortDescription",
2145
+ "full-description": "fullDescription",
2146
+ "release-notes": "releaseNotes"
2147
+ },
2148
+ jsonSchema: {
2149
+ type: "object",
2150
+ additionalProperties: false,
2151
+ required: ["title", "shortDescription", "fullDescription", "releaseNotes"],
2152
+ properties: {
2153
+ title: { type: "string", description: "App title, at most 30 characters, no emoji" },
2154
+ shortDescription: { type: "string", description: "Short description, at most 80 characters" },
2155
+ fullDescription: { type: "string", description: "Full description, at most 4000 characters" },
2156
+ releaseNotes: { type: "string", description: "Release notes, at most 500 characters" }
2157
+ }
2158
+ },
2159
+ validate: (values) => [
2160
+ ...validateFields(values, GOOGLE_PLAY_LIMITS),
2161
+ ...values["title"] !== void 0 ? validatePlayTitlePolicy(values["title"]) : []
2162
+ ],
2163
+ copyFiles: []
2164
+ };
2165
+ var LocalizeError = class extends Error {
2166
+ };
2167
+ function specsFor(target) {
2168
+ return [
2169
+ ...target !== "android" ? [APP_STORE_SPEC] : [],
2170
+ ...target !== "ios" ? [PLAY_SPEC] : []
2171
+ ];
2172
+ }
2173
+ async function readSourceListing(projectRoot, spec, sourceLocale) {
2174
+ const dir = join13(projectRoot, ".appship", spec.store, sourceLocale);
2175
+ if (!existsSync5(dir)) {
2176
+ throw new LocalizeError(
2177
+ `No generated ${spec.storeLabel} listing for source locale ${sourceLocale} (${dir} not found). Run \`appship generate\` first.`
2178
+ );
2179
+ }
2180
+ const values = {};
2181
+ for (const [filename, field] of Object.entries(spec.files)) {
2182
+ const path = join13(dir, `${filename}.txt`);
2183
+ try {
2184
+ values[field] = (await readFile9(path, "utf8")).trimEnd();
2185
+ } catch {
2186
+ throw new LocalizeError(
2187
+ `Missing source file ${spec.store}/${sourceLocale}/${filename}.txt. Run \`appship generate\` first.`
2188
+ );
2189
+ }
2190
+ }
2191
+ return values;
2192
+ }
2193
+ function planLocalize(options) {
2194
+ const paths = [];
2195
+ for (const spec of specsFor(options.target)) {
2196
+ for (const locale of options.targetLocales) {
2197
+ for (const filename of [...Object.keys(spec.files), ...spec.copyFiles]) {
2198
+ paths.push(`.appship/${spec.store}/${locale}/${filename}.txt`);
2199
+ }
2200
+ }
2201
+ }
2202
+ return paths;
2203
+ }
2204
+ async function runLocalize(projectRoot, config, provider, options) {
2205
+ const sourceLocale = options.sourceLocale ?? config.stores.default_locale;
2206
+ const targets = options.targetLocales.filter((locale) => locale !== sourceLocale);
2207
+ if (targets.length === 0) {
2208
+ throw new LocalizeError("No target locales to localize (source locale excluded).");
2209
+ }
2210
+ const files = [];
2211
+ for (const spec of specsFor(options.target)) {
2212
+ const source = await readSourceListing(projectRoot, spec, sourceLocale);
2213
+ const fieldSchema = z7.record(z7.string(), z7.string());
2214
+ for (const locale of targets) {
2215
+ const prompt = `Translate this ${spec.storeLabel} listing from locale "${sourceLocale}" to locale "${locale}".
2216
+
2217
+ Source listing (JSON):
2218
+ ${JSON.stringify(source, null, 2)}`;
2219
+ const { listing, violations } = await generateWithRetry(
2220
+ provider,
2221
+ prompt,
2222
+ spec.jsonSchema,
2223
+ (raw) => fieldSchema.parse(raw),
2224
+ (values) => spec.validate(values),
2225
+ TRANSLATE_SYSTEM_PROMPT
2226
+ );
2227
+ for (const [filename, field] of Object.entries(spec.files)) {
2228
+ files.push({
2229
+ path: join13(".appship", spec.store, locale, `${filename}.txt`),
2230
+ content: (listing[field] ?? "") + "\n",
2231
+ violations: violations.filter((v) => v.field === field)
2232
+ });
2233
+ }
2234
+ for (const filename of spec.copyFiles) {
2235
+ const sourcePath = join13(projectRoot, ".appship", spec.store, sourceLocale, `${filename}.txt`);
2236
+ if (existsSync5(sourcePath)) {
2237
+ files.push({
2238
+ path: join13(".appship", spec.store, locale, `${filename}.txt`),
2239
+ content: await readFile9(sourcePath, "utf8"),
2240
+ violations: []
2241
+ });
2242
+ }
2243
+ }
2244
+ }
2245
+ }
2246
+ if (!options.dryRun) {
2247
+ for (const file of files) {
2248
+ const absolute = join13(projectRoot, file.path);
2249
+ await mkdir5(dirname4(absolute), { recursive: true });
2250
+ await writeFile6(absolute, file.content, "utf8");
2251
+ }
2252
+ }
2253
+ return { files, sourceLocale };
2254
+ }
2255
+
2256
+ // src/cli/localize.ts
2257
+ var localizeCommand = new Command4("localize").description("Translate generated store listings into additional locales").argument("[locales...]", "target locales, e.g. ko-KR ja-JP (default: appship.yml stores.locales)").option("--source <locale>", "source locale to translate from (default: stores.default_locale)").option("--target <target>", "ios | android (default: both stores)").option("--dry-run", "list what would be localized without calling AI or writing files").action(
2258
+ async (localeArgs, options) => {
2259
+ const projectRoot = process.cwd();
2260
+ let target;
2261
+ if (options.target === "ios" || options.target === "android") target = options.target;
2262
+ else if (options.target !== void 0) {
2263
+ console.error(pc4.red(`\u2717 Unknown target "${options.target}". Use ios or android.`));
2264
+ process.exitCode = 1;
2265
+ return;
2266
+ }
2267
+ let config;
2268
+ try {
2269
+ config = await loadConfig(projectRoot);
2270
+ } catch (error) {
2271
+ if (error instanceof ConfigError) {
2272
+ console.error(pc4.red(`\u2717 ${error.message}`));
2273
+ process.exitCode = 1;
2274
+ return;
2275
+ }
2276
+ throw error;
2277
+ }
2278
+ const sourceLocale = options.source ?? config.stores.default_locale;
2279
+ const targetLocales = (localeArgs.length > 0 ? localeArgs : config.stores.locales).filter((locale) => locale !== sourceLocale);
2280
+ if (targetLocales.length === 0) {
2281
+ console.error(
2282
+ pc4.red("\u2717 No target locales. Pass them as arguments (e.g. `appship localize ko-KR ja-JP`) ") + "or add them to stores.locales in appship.yml."
2283
+ );
2284
+ process.exitCode = 1;
2285
+ return;
2286
+ }
2287
+ const localizeOptions = {
2288
+ targetLocales,
2289
+ sourceLocale,
2290
+ ...target ? { target } : {}
2291
+ };
2292
+ if (options.dryRun) {
2293
+ console.log(pc4.bold(`Would localize ${sourceLocale} \u2192 ${targetLocales.join(", ")} (no AI calls, no writes):`));
2294
+ for (const path of planLocalize(localizeOptions)) {
2295
+ console.log(` ${path}`);
2296
+ }
2297
+ return;
2298
+ }
2299
+ let provider;
2300
+ try {
2301
+ provider = createProvider(config);
2302
+ } catch (error) {
2303
+ if (error instanceof AIProviderError) {
2304
+ console.error(pc4.red(`\u2717 ${error.message}`));
2305
+ process.exitCode = 1;
2306
+ return;
2307
+ }
2308
+ throw error;
2309
+ }
2310
+ console.log(
2311
+ `Localizing ${pc4.cyan(sourceLocale)} \u2192 ${pc4.cyan(targetLocales.join(", "))} with ${provider.name} ...`
2312
+ );
2313
+ let result;
2314
+ try {
2315
+ result = await runLocalize(projectRoot, config, provider, localizeOptions);
2316
+ } catch (error) {
2317
+ if (error instanceof LocalizeError || error instanceof AIProviderError) {
2318
+ console.error(pc4.red(`\u2717 ${error.message}`));
2319
+ process.exitCode = 1;
2320
+ return;
2321
+ }
2322
+ throw error;
2323
+ }
2324
+ console.log();
2325
+ for (const file of result.files) {
2326
+ const marker = file.violations.length > 0 ? pc4.yellow("\u26A0") : pc4.green("\u2713");
2327
+ console.log(`${marker} ${file.path}`);
2328
+ for (const violation of file.violations) {
2329
+ console.log(pc4.yellow(` VALIDATION: ${violation.message}`));
2330
+ }
2331
+ }
2332
+ const missingFromConfig = targetLocales.filter(
2333
+ (locale) => !config.stores.locales.includes(locale)
2334
+ );
2335
+ if (missingFromConfig.length > 0) {
2336
+ console.log();
2337
+ console.log(
2338
+ pc4.yellow("\u26A0") + ` Add ${missingFromConfig.join(", ")} to stores.locales in appship.yml so generate and doctor include them.`
2339
+ );
2340
+ }
2341
+ }
2342
+ );
2343
+
2344
+ // src/cli/export.ts
2345
+ import { existsSync as existsSync8 } from "fs";
2346
+ import { join as join16 } from "path";
2347
+ import { Command as Command5 } from "commander";
2348
+ import pc5 from "picocolors";
2349
+
2350
+ // src/core/fastlane/index.ts
2351
+ import { mkdir as mkdir6, readdir as readdir2, readFile as readFile10, writeFile as writeFile7 } from "fs/promises";
2352
+ import { existsSync as existsSync6 } from "fs";
2353
+ import { dirname as dirname5, join as join14 } from "path";
2354
+ var FastlaneExportError = class extends Error {
2355
+ };
2356
+ var DELIVER_FILE_MAP = [
2357
+ ["name", "name"],
2358
+ ["subtitle", "subtitle"],
2359
+ ["description", "description"],
2360
+ ["keywords", "keywords"],
2361
+ ["promotional-text", "promotional_text"],
2362
+ ["release-notes", "release_notes"]
2363
+ ];
2364
+ var SUPPLY_FILE_MAP = [
2365
+ ["title", "title"],
2366
+ ["short-description", "short_description"],
2367
+ ["full-description", "full_description"]
2368
+ ];
2369
+ var NON_LOCALE_DIRS = /* @__PURE__ */ new Set(["privacy", "screenshots"]);
2370
+ async function listLocales(storeDir) {
2371
+ if (!existsSync6(storeDir)) return [];
2372
+ const entries = await readdir2(storeDir, { withFileTypes: true });
2373
+ return entries.filter((e) => e.isDirectory() && !NON_LOCALE_DIRS.has(e.name)).map((e) => e.name).sort();
2374
+ }
2375
+ async function copyListingFiles(projectRoot, sourceDir, destDir, fileMap, written) {
2376
+ for (const [src, dest] of fileMap) {
2377
+ const sourcePath = join14(projectRoot, sourceDir, `${src}.txt`);
2378
+ if (!existsSync6(sourcePath)) continue;
2379
+ const destPath = join14(destDir, `${dest}.txt`);
2380
+ await mkdir6(dirname5(join14(projectRoot, destPath)), { recursive: true });
2381
+ await writeFile7(join14(projectRoot, destPath), await readFile10(sourcePath, "utf8"), "utf8");
2382
+ written.push(destPath);
2383
+ }
2384
+ }
2385
+ function appfileContent(config) {
2386
+ const lines = ["# Generated by appship \u2014 fastlane app identity."];
2387
+ if (config.platforms.ios) {
2388
+ lines.push(`app_identifier "${config.platforms.ios.bundle_id}"`);
2389
+ }
2390
+ if (config.platforms.android) {
2391
+ lines.push(`package_name "${config.platforms.android.package_name}"`);
2392
+ lines.push('# json_key_file "path/to/play-console-service-account.json"');
2393
+ }
2394
+ return lines.join("\n") + "\n";
2395
+ }
2396
+ function fastfileContent(config) {
2397
+ const sections = ["# Generated by appship \u2014 metadata upload lanes."];
2398
+ if (config.platforms.ios) {
2399
+ sections.push(
2400
+ `platform :ios do
2401
+ desc "Upload App Store metadata prepared by appship (no binary, no screenshots)"
2402
+ lane :upload_metadata do
2403
+ deliver(
2404
+ metadata_path: "fastlane/metadata",
2405
+ skip_binary_upload: true,
2406
+ skip_screenshots: true,
2407
+ force: true
2408
+ )
2409
+ end
2410
+
2411
+ desc "Upload a build to TestFlight (used by appship upload ios)"
2412
+ lane :upload_testflight do |options|
2413
+ upload_to_testflight(
2414
+ ipa: options[:ipa],
2415
+ skip_waiting_for_build_processing: true
2416
+ )
2417
+ end
2418
+
2419
+ desc "Submit an already-uploaded build for App Store review (used by appship submit ios)"
2420
+ lane :submit_review do |options|
2421
+ deliver(
2422
+ metadata_path: "fastlane/metadata",
2423
+ skip_binary_upload: true,
2424
+ skip_screenshots: true,
2425
+ submit_for_review: true,
2426
+ automatic_release: false,
2427
+ build_number: options[:build_number] || "latest",
2428
+ force: true
2429
+ )
2430
+ end
2431
+ end`
2432
+ );
2433
+ }
2434
+ if (config.platforms.android) {
2435
+ sections.push(
2436
+ `platform :android do
2437
+ desc "Upload Google Play metadata prepared by appship (no binary, no images)"
2438
+ lane :upload_metadata do
2439
+ supply(
2440
+ metadata_path: "fastlane/metadata/android",
2441
+ skip_upload_apk: true,
2442
+ skip_upload_aab: true,
2443
+ skip_upload_screenshots: true,
2444
+ skip_upload_images: true,
2445
+ skip_upload_changelogs: false
2446
+ )
2447
+ end
2448
+
2449
+ desc "Upload a build to a Play track (used by appship upload android)"
2450
+ lane :upload_build do |options|
2451
+ upload_to_play_store(
2452
+ aab: options[:aab],
2453
+ track: options[:track] || "internal",
2454
+ skip_upload_metadata: true,
2455
+ skip_upload_screenshots: true,
2456
+ skip_upload_images: true,
2457
+ skip_upload_changelogs: true
2458
+ )
2459
+ end
2460
+
2461
+ desc "Promote a tested build to a release track for review (used by appship submit android)"
2462
+ lane :submit_review do |options|
2463
+ upload_to_play_store(
2464
+ track: options[:from_track] || "internal",
2465
+ track_promote_to: options[:track] || "production",
2466
+ skip_upload_apk: true,
2467
+ skip_upload_aab: true,
2468
+ skip_upload_metadata: true,
2469
+ skip_upload_screenshots: true,
2470
+ skip_upload_images: true,
2471
+ skip_upload_changelogs: true
2472
+ )
2473
+ end
2474
+ end`
2475
+ );
2476
+ }
2477
+ return sections.join("\n\n") + "\n";
2478
+ }
2479
+ async function exportFastlane(projectRoot, config, options = {}) {
2480
+ const written = [];
2481
+ const skipped = [];
2482
+ const wantIos = options.target !== "android" && config.platforms.ios !== void 0;
2483
+ const wantAndroid = options.target !== "ios" && config.platforms.android !== void 0;
2484
+ const appStoreDir = join14(projectRoot, ".appship", "app-store");
2485
+ const playDir = join14(projectRoot, ".appship", "google-play");
2486
+ const iosLocales = wantIos ? await listLocales(appStoreDir) : [];
2487
+ const androidLocales = wantAndroid ? await listLocales(playDir) : [];
2488
+ if (iosLocales.length === 0 && androidLocales.length === 0) {
2489
+ throw new FastlaneExportError(
2490
+ "No generated store listings found under .appship/. Run `appship generate` first."
2491
+ );
2492
+ }
2493
+ for (const locale of iosLocales) {
2494
+ const destDir = join14("fastlane", "metadata", locale);
2495
+ await copyListingFiles(
2496
+ projectRoot,
2497
+ join14(".appship", "app-store", locale),
2498
+ destDir,
2499
+ DELIVER_FILE_MAP,
2500
+ written
2501
+ );
2502
+ if (config.project.support_url) {
2503
+ const path = join14(destDir, "support_url.txt");
2504
+ await mkdir6(dirname5(join14(projectRoot, path)), { recursive: true });
2505
+ await writeFile7(join14(projectRoot, path), config.project.support_url + "\n", "utf8");
2506
+ written.push(path);
2507
+ }
2508
+ }
2509
+ if (iosLocales.length > 0) {
2510
+ const reviewSource = join14(
2511
+ projectRoot,
2512
+ ".appship",
2513
+ "app-store",
2514
+ iosLocales.includes(config.stores.default_locale) ? config.stores.default_locale : iosLocales[0],
2515
+ "review-notes.txt"
2516
+ );
2517
+ if (existsSync6(reviewSource)) {
2518
+ const path = join14("fastlane", "metadata", "review_information", "notes.txt");
2519
+ await mkdir6(dirname5(join14(projectRoot, path)), { recursive: true });
2520
+ await writeFile7(join14(projectRoot, path), await readFile10(reviewSource, "utf8"), "utf8");
2521
+ written.push(path);
2522
+ }
2523
+ }
2524
+ for (const locale of androidLocales) {
2525
+ const destDir = join14("fastlane", "metadata", "android", locale);
2526
+ await copyListingFiles(
2527
+ projectRoot,
2528
+ join14(".appship", "google-play", locale),
2529
+ destDir,
2530
+ SUPPLY_FILE_MAP,
2531
+ written
2532
+ );
2533
+ const releaseNotes = join14(projectRoot, ".appship", "google-play", locale, "release-notes.txt");
2534
+ if (existsSync6(releaseNotes)) {
2535
+ const path = join14(destDir, "changelogs", "default.txt");
2536
+ await mkdir6(dirname5(join14(projectRoot, path)), { recursive: true });
2537
+ await writeFile7(join14(projectRoot, path), await readFile10(releaseNotes, "utf8"), "utf8");
2538
+ written.push(path);
2539
+ }
2540
+ }
2541
+ const scaffolding = [
2542
+ [join14("fastlane", "Appfile"), appfileContent(config)],
2543
+ [join14("fastlane", "Fastfile"), fastfileContent(config)]
2544
+ ];
2545
+ for (const [path, content] of scaffolding) {
2546
+ const absolute = join14(projectRoot, path);
2547
+ if (existsSync6(absolute) && !options.force) {
2548
+ skipped.push(path);
2549
+ continue;
2550
+ }
2551
+ await mkdir6(dirname5(absolute), { recursive: true });
2552
+ await writeFile7(absolute, content, "utf8");
2553
+ written.push(path);
2554
+ }
2555
+ return { written, skipped };
2556
+ }
2557
+
2558
+ // src/core/ci/index.ts
2559
+ import { mkdir as mkdir7, writeFile as writeFile8 } from "fs/promises";
2560
+ import { existsSync as existsSync7 } from "fs";
2561
+ import { dirname as dirname6, join as join15 } from "path";
2562
+ var CIExportError = class extends Error {
2563
+ };
2564
+ function readinessWorkflowContent() {
2565
+ return `# Generated by appship \u2014 release readiness gate.
2566
+ # appship doctor runs fully offline and exits 1 on error-severity findings.
2567
+ name: Release readiness
2568
+
2569
+ on:
2570
+ pull_request:
2571
+
2572
+ jobs:
2573
+ doctor:
2574
+ runs-on: ubuntu-latest
2575
+ steps:
2576
+ - uses: actions/checkout@v4
2577
+ - uses: actions/setup-node@v4
2578
+ with:
2579
+ node-version: 22
2580
+ - run: npx appship-cli doctor
2581
+ `;
2582
+ }
2583
+ var ANDROID_BUILD_STEPS = {
2584
+ rn: ` # TODO(appship): adjust to your release signing setup.
2585
+ - uses: actions/setup-java@v4
2586
+ with:
2587
+ distribution: temurin
2588
+ java-version: 17
2589
+ - name: Build release bundle
2590
+ run: cd android && ./gradlew bundleRelease`,
2591
+ expo: ` # TODO(appship): this project uses Expo \u2014 either build with EAS
2592
+ # (eas build -p android --non-interactive) and download the artifact,
2593
+ # or prebuild and use gradle as below.
2594
+ - uses: actions/setup-java@v4
2595
+ with:
2596
+ distribution: temurin
2597
+ java-version: 17
2598
+ - name: Build release bundle
2599
+ run: npx expo prebuild -p android --no-install && cd android && ./gradlew bundleRelease`,
2600
+ native: ` # TODO(appship): adjust to your release signing setup.
2601
+ - uses: actions/setup-java@v4
2602
+ with:
2603
+ distribution: temurin
2604
+ java-version: 17
2605
+ - name: Build release bundle
2606
+ run: ./gradlew bundleRelease`
2607
+ };
2608
+ function releaseWorkflowContent(config, options) {
2609
+ const wantIos = options.target !== "android" && config.platforms.ios !== void 0;
2610
+ const wantAndroid = options.target !== "ios" && config.platforms.android !== void 0;
2611
+ const npmCi = options.hasNodeProject === false ? "" : "\n - run: npm ci";
2612
+ const jobs = [];
2613
+ if (wantAndroid) {
2614
+ jobs.push(` android:
2615
+ if: \${{ inputs.platform == 'android' || inputs.platform == 'both' }}
2616
+ runs-on: ubuntu-latest
2617
+ env:
2618
+ # Play Console service account JSON \u2014 see fastlane supply docs.
2619
+ SUPPLY_JSON_KEY_DATA: \${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
2620
+ steps:
2621
+ - uses: actions/checkout@v4
2622
+ - uses: actions/setup-node@v4
2623
+ with:
2624
+ node-version: 22${npmCi}
2625
+ ${ANDROID_BUILD_STEPS[options.androidBuildStyle ?? "rn"]}
2626
+ - uses: ruby/setup-ruby@v1
2627
+ with:
2628
+ ruby-version: '3.3'
2629
+ bundler-cache: true
2630
+ - name: Upload to Play track
2631
+ run: npx appship-cli upload android --track \${{ inputs.track }} --yes
2632
+ - name: Submit for review
2633
+ if: \${{ inputs.submit }}
2634
+ run: npx appship-cli submit android --from-track \${{ inputs.track }} --yes`);
2635
+ }
2636
+ if (wantIos) {
2637
+ jobs.push(` ios:
2638
+ if: \${{ inputs.platform == 'ios' || inputs.platform == 'both' }}
2639
+ runs-on: macos-latest
2640
+ env:
2641
+ # App Store Connect API key \u2014 see fastlane app_store_connect_api_key docs.
2642
+ APP_STORE_CONNECT_API_KEY_KEY_ID: \${{ secrets.ASC_KEY_ID }}
2643
+ APP_STORE_CONNECT_API_KEY_ISSUER_ID: \${{ secrets.ASC_ISSUER_ID }}
2644
+ APP_STORE_CONNECT_API_KEY_KEY: \${{ secrets.ASC_KEY_CONTENT }}
2645
+ steps:
2646
+ - uses: actions/checkout@v4
2647
+ - uses: actions/setup-node@v4
2648
+ with:
2649
+ node-version: 22${npmCi}
2650
+ # TODO(appship): building a signed .ipa needs your certificates and
2651
+ # provisioning profiles in CI \u2014 fastlane match is the usual answer
2652
+ # (https://docs.fastlane.tools/actions/match/). Replace this step.
2653
+ - name: Build signed ipa
2654
+ run: 'echo "TODO: build a signed .ipa (e.g. fastlane gym after fastlane match)" && exit 1'
2655
+ - uses: ruby/setup-ruby@v1
2656
+ with:
2657
+ ruby-version: '3.3'
2658
+ bundler-cache: true
2659
+ - name: Upload to TestFlight
2660
+ run: npx appship-cli upload ios --yes
2661
+ - name: Submit for review
2662
+ if: \${{ inputs.submit }}
2663
+ run: npx appship-cli submit ios --yes`);
2664
+ }
2665
+ return `# Generated by appship \u2014 build, upload to test tracks, and optionally submit.
2666
+ # Search for TODO(appship) and finish the build steps before first use.
2667
+ name: Release
2668
+
2669
+ on:
2670
+ workflow_dispatch:
2671
+ inputs:
2672
+ platform:
2673
+ description: Platform to release
2674
+ type: choice
2675
+ options: [${[wantIos ? "ios" : null, wantAndroid ? "android" : null, wantIos && wantAndroid ? "both" : null].filter(Boolean).join(", ")}]
2676
+ required: true
2677
+ track:
2678
+ description: Play track for the android upload
2679
+ type: string
2680
+ default: internal
2681
+ submit:
2682
+ description: Also submit for store review (confirmation-gated by doctor)
2683
+ type: boolean
2684
+ default: false
2685
+
2686
+ jobs:
2687
+ ${jobs.join("\n\n")}
2688
+ `;
2689
+ }
2690
+ async function exportCI(projectRoot, config, options = {}) {
2691
+ const wantIos = options.target !== "android" && config.platforms.ios !== void 0;
2692
+ const wantAndroid = options.target !== "ios" && config.platforms.android !== void 0;
2693
+ if (!wantIos && !wantAndroid) {
2694
+ throw new CIExportError("No platforms configured in appship.yml for the requested target.");
2695
+ }
2696
+ const files = [
2697
+ [join15(".github", "workflows", "appship-readiness.yml"), readinessWorkflowContent()],
2698
+ [join15(".github", "workflows", "appship-release.yml"), releaseWorkflowContent(config, options)]
2699
+ ];
2700
+ const written = [];
2701
+ const skipped = [];
2702
+ for (const [path, content] of files) {
2703
+ const absolute = join15(projectRoot, path);
2704
+ if (existsSync7(absolute) && !options.force) {
2705
+ skipped.push(path);
2706
+ continue;
2707
+ }
2708
+ await mkdir7(dirname6(absolute), { recursive: true });
2709
+ await writeFile8(absolute, content, "utf8");
2710
+ written.push(path);
2711
+ }
2712
+ return { written, skipped };
2713
+ }
2714
+
2715
+ // src/cli/export.ts
2716
+ var exportCommand = new Command5("export").description("Export prepared materials for external tools").argument("<format>", "export format: fastlane | ci").option("--target <target>", "ios | android (default: both)").option("--force", "overwrite existing exported scaffolding files").action(async (format, options) => {
2717
+ const projectRoot = process.cwd();
2718
+ if (format !== "fastlane" && format !== "ci") {
2719
+ console.error(pc5.red(`\u2717 Unknown export format "${format}". Supported: fastlane, ci.`));
2720
+ process.exitCode = 1;
2721
+ return;
2722
+ }
2723
+ let target;
2724
+ if (options.target === "ios" || options.target === "android") target = options.target;
2725
+ else if (options.target !== void 0) {
2726
+ console.error(pc5.red(`\u2717 Unknown target "${options.target}". Use ios or android.`));
2727
+ process.exitCode = 1;
2728
+ return;
2729
+ }
2730
+ let config;
2731
+ try {
2732
+ config = await loadConfig(projectRoot);
2733
+ } catch (error) {
2734
+ if (error instanceof ConfigError) {
2735
+ console.error(pc5.red(`\u2717 ${error.message}`));
2736
+ process.exitCode = 1;
2737
+ return;
2738
+ }
2739
+ throw error;
2740
+ }
2741
+ let result;
2742
+ try {
2743
+ if (format === "fastlane") {
2744
+ result = await exportFastlane(projectRoot, config, {
2745
+ ...target ? { target } : {},
2746
+ ...options.force ? { force: true } : {}
2747
+ });
2748
+ } else {
2749
+ let projectType = null;
2750
+ try {
2751
+ projectType = await detectProjectType(projectRoot);
2752
+ } catch {
2753
+ }
2754
+ const isExpo = projectType === "react-native" && await readExpoConfig(projectRoot) !== null;
2755
+ const androidBuildStyle = projectType === "native-android" ? "native" : isExpo ? "expo" : "rn";
2756
+ result = await exportCI(projectRoot, config, {
2757
+ ...target ? { target } : {},
2758
+ ...options.force ? { force: true } : {},
2759
+ androidBuildStyle,
2760
+ hasNodeProject: existsSync8(join16(projectRoot, "package.json"))
2761
+ });
2762
+ }
2763
+ } catch (error) {
2764
+ if (error instanceof FastlaneExportError || error instanceof CIExportError) {
2765
+ console.error(pc5.red(`\u2717 ${error.message}`));
2766
+ process.exitCode = 1;
2767
+ return;
2768
+ }
2769
+ throw error;
2770
+ }
2771
+ for (const path of result.written) {
2772
+ console.log(`${pc5.green("\u2713")} ${path}`);
2773
+ }
2774
+ for (const path of result.skipped) {
2775
+ console.log(`${pc5.dim("\u2022")} ${path} ${pc5.dim("(exists \u2014 kept; use --force to overwrite)")}`);
2776
+ }
2777
+ console.log();
2778
+ console.log("Next steps:");
2779
+ if (format === "fastlane") {
2780
+ if (config.platforms.ios && target !== "android") {
2781
+ console.log(` ${pc5.cyan("bundle exec fastlane ios upload_metadata")} # App Store Connect`);
2782
+ }
2783
+ if (config.platforms.android && target !== "ios") {
2784
+ console.log(` ${pc5.cyan("bundle exec fastlane android upload_metadata")} # Play Console`);
2785
+ }
2786
+ console.log(pc5.dim(" (requires fastlane auth: App Store Connect API key / Play service account)"));
2787
+ } else {
2788
+ console.log(" 1. Search the workflows for TODO(appship) and finish the build steps.");
2789
+ console.log(
2790
+ " 2. Add repository secrets: " + [
2791
+ config.platforms.android && target !== "ios" ? "PLAY_SERVICE_ACCOUNT_JSON" : null,
2792
+ config.platforms.ios && target !== "android" ? "ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENT" : null
2793
+ ].filter(Boolean).join(" / ")
2794
+ );
2795
+ console.log(' 3. Run the "Release" workflow from the Actions tab (workflow_dispatch).');
2796
+ }
2797
+ });
2798
+
2799
+ // src/cli/upload.ts
2800
+ import { existsSync as existsSync9 } from "fs";
2801
+ import { join as join17 } from "path";
2802
+ import { execFile } from "child_process";
2803
+ import { promisify } from "util";
2804
+ import { Command as Command6 } from "commander";
2805
+ import pc6 from "picocolors";
2806
+ import * as p3 from "@clack/prompts";
2807
+ var execFileAsync = promisify(execFile);
2808
+ async function hasGlobalFastlane() {
2809
+ try {
2810
+ await execFileAsync("fastlane", ["--version"]);
2811
+ return true;
2812
+ } catch {
2813
+ return false;
2814
+ }
2815
+ }
2816
+ var BUILD_HINTS = {
2817
+ ios: "Build an .ipa first (e.g. `eas build -p ios`, Xcode archive, or `fastlane gym`).",
2818
+ android: "Build an .aab first (e.g. `cd android && ./gradlew bundleRelease` or `eas build -p android`)."
2819
+ };
2820
+ var uploadCommand = new Command6("upload").description("Upload a built binary to a test track via fastlane (TestFlight / Play internal)").argument("<platform>", "ios | android").option("--ipa <path>", "path to the .ipa (ios; default: newest found in the project)").option("--aab <path>", "path to the .aab (android; default: newest found in the project)").option("--track <track>", "Play track for android uploads", "internal").option("--testflight", "upload iOS build to TestFlight (default and only destination)").option("--yes", "skip the confirmation prompt (required in CI)").action(
2821
+ async (platformArg, options) => {
2822
+ const projectRoot = process.cwd();
2823
+ if (platformArg !== "ios" && platformArg !== "android") {
2824
+ console.error(pc6.red(`\u2717 Unknown platform "${platformArg}". Use ios or android.`));
2825
+ process.exitCode = 1;
2826
+ return;
2827
+ }
2828
+ const platform = platformArg;
2829
+ let config;
2830
+ try {
2831
+ config = await loadConfig(projectRoot);
2832
+ } catch (error) {
2833
+ if (error instanceof ConfigError) {
2834
+ console.error(pc6.red(`\u2717 ${error.message}`));
2835
+ process.exitCode = 1;
2836
+ return;
2837
+ }
2838
+ throw error;
2839
+ }
2840
+ const platformConfigured = platform === "ios" ? config.platforms.ios !== void 0 : config.platforms.android !== void 0;
2841
+ if (!platformConfigured) {
2842
+ console.error(pc6.red(`\u2717 No ${platform} platform configured in appship.yml.`));
2843
+ process.exitCode = 1;
2844
+ return;
2845
+ }
2846
+ const explicit = platform === "ios" ? options.ipa : options.aab;
2847
+ const artifact = explicit ?? await findArtifact(projectRoot, platform);
2848
+ if (explicit && !existsSync9(join17(projectRoot, explicit)) && !existsSync9(explicit)) {
2849
+ console.error(pc6.red(`\u2717 Artifact not found: ${explicit}`));
2850
+ process.exitCode = 1;
2851
+ return;
2852
+ }
2853
+ if (!artifact) {
2854
+ console.error(
2855
+ pc6.red(`\u2717 No ${platform === "ios" ? ".ipa" : ".aab"} found in the project. `) + BUILD_HINTS[platform]
2856
+ );
2857
+ process.exitCode = 1;
2858
+ return;
2859
+ }
2860
+ try {
2861
+ await assertUploadLane(projectRoot, platform);
2862
+ } catch (error) {
2863
+ if (error instanceof UploadError) {
2864
+ console.error(pc6.red(`\u2717 ${error.message}`));
2865
+ process.exitCode = 1;
2866
+ return;
2867
+ }
2868
+ throw error;
2869
+ }
2870
+ const fastlane = resolveFastlaneCommand(projectRoot, await hasGlobalFastlane());
2871
+ if (!fastlane) {
2872
+ console.error(
2873
+ pc6.red("\u2717 fastlane not found. ") + "Install it (`brew install fastlane` or `gem install fastlane`), or add it to your Gemfile."
2874
+ );
2875
+ process.exitCode = 1;
2876
+ return;
2877
+ }
2878
+ const plan = buildUploadPlan(platform, artifact, options.track);
2879
+ console.log(`App: ${config.project.name}`);
2880
+ console.log(
2881
+ `Identifier: ${platform === "ios" ? config.platforms.ios.bundle_id : config.platforms.android.package_name}`
2882
+ );
2883
+ console.log(`Artifact: ${plan.artifact}`);
2884
+ console.log(`Destination: ${plan.destination}`);
2885
+ console.log(
2886
+ `Command: ${fastlane.command} ${[...fastlane.prefixArgs, ...plan.fastlaneArgs].join(" ")}`
2887
+ );
2888
+ console.log();
2889
+ if (!options.yes) {
2890
+ if (!process.stdout.isTTY) {
2891
+ console.error(pc6.red("\u2717 Refusing to upload without confirmation. Pass --yes in CI."));
2892
+ process.exitCode = 1;
2893
+ return;
2894
+ }
2895
+ const proceed = await p3.confirm({ message: "Proceed with the upload?", initialValue: false });
2896
+ if (p3.isCancel(proceed) || !proceed) {
2897
+ console.log(pc6.yellow("Cancelled \u2014 nothing was uploaded."));
2898
+ process.exitCode = 1;
2899
+ return;
2900
+ }
2901
+ }
2902
+ const exitCode = await runUpload(projectRoot, fastlane, plan);
2903
+ if (exitCode === 0) {
2904
+ console.log(pc6.green(`\u2713 Upload finished (${plan.destination}).`));
2905
+ } else {
2906
+ console.error(pc6.red(`\u2717 fastlane exited with code ${exitCode}.`));
2907
+ process.exitCode = exitCode;
2908
+ }
2909
+ }
2910
+ );
2911
+
2912
+ // src/cli/screenshots.ts
2913
+ import { execFile as execFile2 } from "child_process";
2914
+ import { promisify as promisify2 } from "util";
2915
+ import { Command as Command7 } from "commander";
2916
+ import pc7 from "picocolors";
2917
+
2918
+ // src/core/screenshots/index.ts
2919
+ import { mkdir as mkdir8, readdir as readdir3, readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
2920
+ import { existsSync as existsSync10 } from "fs";
2921
+ import { join as join18 } from "path";
2922
+ import { parse as parse6 } from "yaml";
2923
+ import { z as z8 } from "zod";
2924
+ var ScreenshotsError = class extends Error {
2925
+ };
2926
+ var FLOWS_DIR = join18(".maestro", "appship");
2927
+ var RAW_OUTPUT_DIR = join18(".appship", "screenshots", "raw");
2928
+ var NAVIGATION_TODO = "TODO(appship): navigate to this screen";
2929
+ var planSchema = z8.object({
2930
+ screens: z8.array(
2931
+ z8.object({
2932
+ screen: z8.string().min(1),
2933
+ headline: z8.string(),
2934
+ source_route: z8.string().optional()
2935
+ })
2936
+ ).default([]),
2937
+ note: z8.string().optional()
2938
+ });
2939
+ var PLAN_LOCATIONS = [
2940
+ join18(".appship", "app-store", "screenshots", "screenshot-plan.yml"),
2941
+ join18(".appship", "google-play", "screenshots", "screenshot-plan.yml")
2942
+ ];
2943
+ async function loadScreenshotPlan(projectRoot) {
2944
+ for (const location of PLAN_LOCATIONS) {
2945
+ const path = join18(projectRoot, location);
2946
+ if (!existsSync10(path)) continue;
2947
+ const parsed = planSchema.safeParse(parse6(await readFile11(path, "utf8")));
2948
+ if (!parsed.success) {
2949
+ throw new ScreenshotsError(`Invalid screenshot plan (${location}): ${parsed.error.message}`);
2950
+ }
2951
+ return parsed.data;
2952
+ }
2953
+ throw new ScreenshotsError(
2954
+ "No screenshot plan found under .appship/. Run `appship generate` first."
2955
+ );
2956
+ }
2957
+ function slugify(name) {
2958
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2959
+ }
2960
+ function flowContent(appId, index, screen) {
2961
+ const number = String(index + 1).padStart(2, "0");
2962
+ const slug = slugify(screen.screen);
2963
+ const lines = [
2964
+ `# Maestro flow for the "${screen.screen}" screen \u2014 generated by appship.`,
2965
+ `# Store headline: ${screen.headline}`,
2966
+ ...screen.source_route ? [`# Screen source: ${screen.source_route}`] : [],
2967
+ `appId: ${appId}`,
2968
+ "---",
2969
+ "- launchApp",
2970
+ `# ${NAVIGATION_TODO} (then delete this comment)`,
2971
+ "# Examples:",
2972
+ '# - tapOn: "Some button text"',
2973
+ `# - openLink: yourapp://${slug}`,
2974
+ `- takeScreenshot: ${RAW_OUTPUT_DIR}/${number}-${slug}`
2975
+ ];
2976
+ return lines.join("\n") + "\n";
2977
+ }
2978
+ async function generateFlows(projectRoot, config, plan, options = {}) {
2979
+ if (plan.screens.length === 0) {
2980
+ throw new ScreenshotsError(
2981
+ "The screenshot plan has no screens. Add entries to screenshot-plan.yml (or add screen components under src/screens/ and re-run `appship generate`)."
2982
+ );
2983
+ }
2984
+ const appId = config.platforms.android?.package_name ?? config.platforms.ios?.bundle_id ?? null;
2985
+ if (!appId) {
2986
+ throw new ScreenshotsError("No platform identifier in appship.yml (platforms.ios/android).");
2987
+ }
2988
+ const written = [];
2989
+ const skipped = [];
2990
+ await mkdir8(join18(projectRoot, FLOWS_DIR), { recursive: true });
2991
+ for (const [index, screen] of plan.screens.entries()) {
2992
+ const number = String(index + 1).padStart(2, "0");
2993
+ const path = join18(FLOWS_DIR, `${number}-${slugify(screen.screen)}.yaml`);
2994
+ const absolute = join18(projectRoot, path);
2995
+ if (existsSync10(absolute) && !options.force) {
2996
+ skipped.push(path);
2997
+ continue;
2998
+ }
2999
+ await writeFile9(absolute, flowContent(appId, index, screen), "utf8");
3000
+ written.push(path);
3001
+ }
3002
+ return { written, skipped };
3003
+ }
3004
+ async function preflightCapture(projectRoot) {
3005
+ const flowsDir = join18(projectRoot, FLOWS_DIR);
3006
+ if (!existsSync10(flowsDir)) {
3007
+ throw new ScreenshotsError(
3008
+ `No Maestro flows found (${FLOWS_DIR}). Run \`appship screenshots flows\` first.`
3009
+ );
3010
+ }
3011
+ const flowFiles = (await readdir3(flowsDir)).filter((f) => f.endsWith(".yaml")).sort();
3012
+ if (flowFiles.length === 0) {
3013
+ throw new ScreenshotsError(
3014
+ `No Maestro flows found (${FLOWS_DIR}). Run \`appship screenshots flows\` first.`
3015
+ );
3016
+ }
3017
+ const pendingTodos = [];
3018
+ for (const file of flowFiles) {
3019
+ const content = await readFile11(join18(flowsDir, file), "utf8");
3020
+ if (content.includes(NAVIGATION_TODO)) pendingTodos.push(join18(FLOWS_DIR, file));
3021
+ }
3022
+ return { flowFiles, pendingTodos };
3023
+ }
3024
+ async function runCapture(projectRoot, runner, options = {}) {
3025
+ await mkdir8(join18(projectRoot, RAW_OUTPUT_DIR), { recursive: true });
3026
+ const args = ["test", ...options.device ? ["--device", options.device] : [], FLOWS_DIR];
3027
+ return runner("maestro", args, projectRoot);
3028
+ }
3029
+
3030
+ // src/cli/screenshots.ts
3031
+ var execFileAsync2 = promisify2(execFile2);
3032
+ async function hasMaestro() {
3033
+ try {
3034
+ await execFileAsync2("maestro", ["--version"]);
3035
+ return true;
3036
+ } catch {
3037
+ return false;
3038
+ }
3039
+ }
3040
+ function reportAndExit(error) {
3041
+ if (error instanceof ScreenshotsError || error instanceof ConfigError) {
3042
+ console.error(pc7.red(`\u2717 ${error.message}`));
3043
+ process.exitCode = 1;
3044
+ return true;
3045
+ }
3046
+ return false;
3047
+ }
3048
+ var flowsSubcommand = new Command7("flows").description("Generate Maestro flow files from the screenshot plan").option("--force", "overwrite existing flow files (discards your navigation edits)").action(async (options) => {
3049
+ const projectRoot = process.cwd();
3050
+ try {
3051
+ const config = await loadConfig(projectRoot);
3052
+ const plan = await loadScreenshotPlan(projectRoot);
3053
+ const result = await generateFlows(projectRoot, config, plan, {
3054
+ ...options.force ? { force: true } : {}
3055
+ });
3056
+ for (const path of result.written) console.log(`${pc7.green("\u2713")} ${path}`);
3057
+ for (const path of result.skipped) {
3058
+ console.log(`${pc7.dim("\u2022")} ${path} ${pc7.dim("(exists \u2014 kept; use --force to overwrite)")}`);
3059
+ }
3060
+ console.log();
3061
+ console.log(
3062
+ `Next: fill in the navigation TODO in each flow, then run ${pc7.cyan("appship screenshots capture")}.`
3063
+ );
3064
+ } catch (error) {
3065
+ if (!reportAndExit(error)) throw error;
3066
+ }
3067
+ });
3068
+ var captureSubcommand = new Command7("capture").description("Run the Maestro flows and capture screenshots").option("--device <id>", "Maestro device id to run on").action(async (options) => {
3069
+ const projectRoot = process.cwd();
3070
+ try {
3071
+ await loadConfig(projectRoot);
3072
+ const preflight = await preflightCapture(projectRoot);
3073
+ if (preflight.pendingTodos.length > 0) {
3074
+ console.error(
3075
+ pc7.red(`\u2717 ${preflight.pendingTodos.length} flow(s) still contain the navigation TODO:`)
3076
+ );
3077
+ for (const path of preflight.pendingTodos) console.error(pc7.dim(` - ${path}`));
3078
+ console.error("Fill in how to reach each screen, then re-run capture.");
3079
+ process.exitCode = 1;
3080
+ return;
3081
+ }
3082
+ if (!await hasMaestro()) {
3083
+ console.error(
3084
+ pc7.red("\u2717 maestro not found. ") + 'Install it: `curl -fsSL "https://get.maestro.mobile.dev" | bash` (see maestro.dev).'
3085
+ );
3086
+ process.exitCode = 1;
3087
+ return;
3088
+ }
3089
+ console.log(
3090
+ `Running ${preflight.flowFiles.length} flow(s) from ${FLOWS_DIR} \u2192 ${RAW_OUTPUT_DIR}/ ...`
3091
+ );
3092
+ const { spawnRunner: spawnRunner2 } = await import("./upload-FMEKKZSD.js");
3093
+ const exitCode = await runCapture(projectRoot, spawnRunner2, {
3094
+ ...options.device ? { device: options.device } : {}
3095
+ });
3096
+ if (exitCode === 0) {
3097
+ console.log(pc7.green(`\u2713 Screenshots captured into ${RAW_OUTPUT_DIR}/`));
3098
+ } else {
3099
+ console.error(pc7.red(`\u2717 maestro exited with code ${exitCode}.`));
3100
+ process.exitCode = exitCode;
3101
+ }
3102
+ } catch (error) {
3103
+ if (!reportAndExit(error)) throw error;
3104
+ }
3105
+ });
3106
+ var screenshotsCommand = new Command7("screenshots").description("Generate Maestro flows and capture store screenshots").addCommand(flowsSubcommand).addCommand(captureSubcommand);
3107
+
3108
+ // src/cli/submit.ts
3109
+ import { execFile as execFile3 } from "child_process";
3110
+ import { promisify as promisify3 } from "util";
3111
+ import { Command as Command8 } from "commander";
3112
+ import pc8 from "picocolors";
3113
+ import * as p4 from "@clack/prompts";
3114
+
3115
+ // src/core/submit/index.ts
3116
+ import { existsSync as existsSync11 } from "fs";
3117
+ import { readFile as readFile12 } from "fs/promises";
3118
+ import { join as join19 } from "path";
3119
+ var SubmitError = class extends Error {
3120
+ };
3121
+ var SUBMIT_LANE = "submit_review";
3122
+ function platformBlock(fastfile, platform) {
3123
+ const marker = `platform :${platform} do`;
3124
+ const start = fastfile.indexOf(marker);
3125
+ if (start === -1) return null;
3126
+ const next = fastfile.indexOf("platform :", start + marker.length);
3127
+ return next === -1 ? fastfile.slice(start) : fastfile.slice(start, next);
3128
+ }
3129
+ async function assertSubmitLane(projectRoot, platform) {
3130
+ const fastfilePath = join19(projectRoot, "fastlane", "Fastfile");
3131
+ if (!existsSync11(fastfilePath)) {
3132
+ throw new SubmitError("fastlane/Fastfile not found. Run `appship export fastlane` first.");
3133
+ }
3134
+ const block = platformBlock(await readFile12(fastfilePath, "utf8"), platform);
3135
+ if (!block || !block.includes(`lane :${SUBMIT_LANE}`)) {
3136
+ throw new SubmitError(
3137
+ `fastlane/Fastfile has no ${platform} :${SUBMIT_LANE} lane. Re-export with \`appship export fastlane --force\` (or add the lane yourself).`
3138
+ );
3139
+ }
3140
+ }
3141
+ function buildSubmitPlan(platform, options) {
3142
+ if (platform === "ios") {
3143
+ const args = ["ios", SUBMIT_LANE];
3144
+ if (options.buildNumber) args.push(`build_number:${options.buildNumber}`);
3145
+ return {
3146
+ platform,
3147
+ destination: "App Store review",
3148
+ details: [
3149
+ ["Build", options.buildNumber ?? "latest uploaded to App Store Connect"],
3150
+ ["Release", "manual (after approval)"]
3151
+ ],
3152
+ fastlaneArgs: args
3153
+ };
3154
+ }
3155
+ return {
3156
+ platform,
3157
+ destination: `Play track "${options.track}"`,
3158
+ details: [["Promote", `"${options.fromTrack}" \u2192 "${options.track}"`]],
3159
+ fastlaneArgs: [
3160
+ "android",
3161
+ SUBMIT_LANE,
3162
+ `from_track:${options.fromTrack}`,
3163
+ `track:${options.track}`
3164
+ ]
3165
+ };
3166
+ }
3167
+ async function runSubmit(projectRoot, fastlane, plan, runner = spawnRunner) {
3168
+ return runner(fastlane.command, [...fastlane.prefixArgs, ...plan.fastlaneArgs], projectRoot);
3169
+ }
3170
+
3171
+ // src/cli/submit.ts
3172
+ var execFileAsync3 = promisify3(execFile3);
3173
+ async function hasGlobalFastlane2() {
3174
+ try {
3175
+ await execFileAsync3("fastlane", ["--version"]);
3176
+ return true;
3177
+ } catch {
3178
+ return false;
3179
+ }
3180
+ }
3181
+ var DOCTOR_STORE = {
3182
+ ios: "app-store",
3183
+ android: "google-play"
3184
+ };
3185
+ var submitCommand = new Command8("submit").description("Submit the app for store review via fastlane (confirmation required)").argument("<platform>", "ios | android").option("--build-number <n>", "ios: build number to submit (default: latest uploaded)").option("--from-track <track>", "android: track holding the tested build", "internal").option("--track <track>", "android: track to promote into", "production").option("--force", "submit even when doctor reports blocking errors").option("--yes", "skip the confirmation prompt (required in CI)").action(
3186
+ async (platformArg, options) => {
3187
+ const projectRoot = process.cwd();
3188
+ if (platformArg !== "ios" && platformArg !== "android") {
3189
+ console.error(pc8.red(`\u2717 Unknown platform "${platformArg}". Use ios or android.`));
3190
+ process.exitCode = 1;
3191
+ return;
3192
+ }
3193
+ const platform = platformArg;
3194
+ let config;
3195
+ let scan;
3196
+ try {
3197
+ config = await loadConfig(projectRoot);
3198
+ scan = await scanProject(projectRoot);
3199
+ } catch (error) {
3200
+ if (error instanceof ConfigError || error instanceof UnsupportedProjectError) {
3201
+ console.error(pc8.red(`\u2717 ${error.message}`));
3202
+ process.exitCode = 1;
3203
+ return;
3204
+ }
3205
+ throw error;
3206
+ }
3207
+ const platformConfigured = platform === "ios" ? config.platforms.ios !== void 0 : config.platforms.android !== void 0;
3208
+ if (!platformConfigured) {
3209
+ console.error(pc8.red(`\u2717 No ${platform} platform configured in appship.yml.`));
3210
+ process.exitCode = 1;
3211
+ return;
3212
+ }
3213
+ try {
3214
+ await assertSubmitLane(projectRoot, platform);
3215
+ } catch (error) {
3216
+ if (error instanceof SubmitError) {
3217
+ console.error(pc8.red(`\u2717 ${error.message}`));
3218
+ process.exitCode = 1;
3219
+ return;
3220
+ }
3221
+ throw error;
3222
+ }
3223
+ const fastlane = resolveFastlaneCommand(projectRoot, await hasGlobalFastlane2());
3224
+ if (!fastlane) {
3225
+ console.error(
3226
+ pc8.red("\u2717 fastlane not found. ") + "Install it (`brew install fastlane` or `gem install fastlane`), or add it to your Gemfile."
3227
+ );
3228
+ process.exitCode = 1;
3229
+ return;
3230
+ }
3231
+ await mergePreviousConfirmations(projectRoot, scan);
3232
+ const store = DOCTOR_STORE[platform];
3233
+ const [report] = await runDoctor({ projectRoot, config, scan }, await loadRulesWithCache(), [store]);
3234
+ const blocking = report.results.filter(
3235
+ (r) => r.status === "fail" && r.severity === "error"
3236
+ );
3237
+ const warnings = report.results.filter(
3238
+ (r) => r.status === "fail" && r.severity === "warning"
3239
+ );
3240
+ console.log(
3241
+ `Doctor: ${report.score}% ready` + (warnings.length > 0 ? pc8.yellow(` (${warnings.length} warning(s))`) : "")
3242
+ );
3243
+ if (blocking.length > 0) {
3244
+ console.log();
3245
+ for (const result of blocking) {
3246
+ console.log(`${pc8.red("\u2717")} ${result.message}`);
3247
+ }
3248
+ console.log();
3249
+ if (!options.force) {
3250
+ console.error(
3251
+ pc8.red(`\u2717 ${blocking.length} blocking error(s). `) + "Fix them (see `appship doctor`) or pass --force to submit anyway."
3252
+ );
3253
+ process.exitCode = 1;
3254
+ return;
3255
+ }
3256
+ console.log(pc8.yellow("\u26A0 Continuing despite blocking errors (--force)."));
3257
+ console.log();
3258
+ }
3259
+ const plan = buildSubmitPlan(platform, options);
3260
+ console.log(`App: ${config.project.name}`);
3261
+ console.log(
3262
+ `Identifier: ${platform === "ios" ? config.platforms.ios.bundle_id : config.platforms.android.package_name}`
3263
+ );
3264
+ console.log(`Destination: ${plan.destination}`);
3265
+ for (const [label, value] of plan.details) {
3266
+ console.log(`${(label + ":").padEnd(13)}${value}`);
3267
+ }
3268
+ console.log(
3269
+ `Command: ${fastlane.command} ${[...fastlane.prefixArgs, ...plan.fastlaneArgs].join(" ")}`
3270
+ );
3271
+ console.log();
3272
+ if (!options.yes) {
3273
+ if (!process.stdout.isTTY) {
3274
+ console.error(pc8.red("\u2717 Refusing to submit without confirmation. Pass --yes in CI."));
3275
+ process.exitCode = 1;
3276
+ return;
3277
+ }
3278
+ const proceed = await p4.confirm({
3279
+ message: `Submit ${config.project.name} for review? This starts a store review.`,
3280
+ initialValue: false
3281
+ });
3282
+ if (p4.isCancel(proceed) || !proceed) {
3283
+ console.log(pc8.yellow("Cancelled \u2014 nothing was submitted."));
3284
+ process.exitCode = 1;
3285
+ return;
3286
+ }
3287
+ }
3288
+ const exitCode = await runSubmit(projectRoot, fastlane, plan);
3289
+ if (exitCode === 0) {
3290
+ console.log(pc8.green(`\u2713 Submitted (${plan.destination}).`));
3291
+ } else {
3292
+ console.error(pc8.red(`\u2717 fastlane exited with code ${exitCode}.`));
3293
+ process.exitCode = exitCode;
3294
+ }
3295
+ }
3296
+ );
3297
+
3298
+ // src/cli/review.ts
3299
+ import { readFile as readFile13 } from "fs/promises";
3300
+ import { Command as Command9 } from "commander";
3301
+ import pc9 from "picocolors";
3302
+
3303
+ // src/core/review/index.ts
3304
+ import { mkdir as mkdir9, writeFile as writeFile10 } from "fs/promises";
3305
+ import { join as join20 } from "path";
3306
+ import { z as z9 } from "zod";
3307
+ var ReviewAnalyzeError = class extends Error {
3308
+ };
3309
+ var reviewStoreSchema = z9.enum(["app-store", "google-play"]);
3310
+ var reviewIssueSchema = z9.object({
3311
+ guideline: z9.string().nullable(),
3312
+ title: z9.string(),
3313
+ summary: z9.string(),
3314
+ category: z9.enum([
3315
+ "metadata",
3316
+ "privacy",
3317
+ "permissions",
3318
+ "account",
3319
+ "content",
3320
+ "functionality",
3321
+ "payments",
3322
+ "legal",
3323
+ "design",
3324
+ "other"
3325
+ ]),
3326
+ severity: z9.enum(["blocker", "clarification"]),
3327
+ fixSteps: z9.array(z9.string()),
3328
+ appshipCommands: z9.array(z9.string()),
3329
+ responseDraft: z9.string().nullable()
3330
+ });
3331
+ var reviewAnalysisSchema = z9.object({
3332
+ store: z9.enum(["app-store", "google-play", "unknown"]),
3333
+ issues: z9.array(reviewIssueSchema).min(1),
3334
+ overallPlan: z9.array(z9.string())
3335
+ });
3336
+ var ANALYSIS_JSON_SCHEMA = {
3337
+ type: "object",
3338
+ additionalProperties: false,
3339
+ required: ["store", "issues", "overallPlan"],
3340
+ properties: {
3341
+ store: {
3342
+ type: "string",
3343
+ enum: ["app-store", "google-play", "unknown"],
3344
+ description: "Which store the rejection message is from, judged from its wording"
3345
+ },
3346
+ issues: {
3347
+ type: "array",
3348
+ description: "One entry per distinct problem the reviewer raised",
3349
+ items: {
3350
+ type: "object",
3351
+ additionalProperties: false,
3352
+ required: [
3353
+ "guideline",
3354
+ "title",
3355
+ "summary",
3356
+ "category",
3357
+ "severity",
3358
+ "fixSteps",
3359
+ "appshipCommands",
3360
+ "responseDraft"
3361
+ ],
3362
+ properties: {
3363
+ guideline: {
3364
+ type: ["string", "null"],
3365
+ description: 'Exact guideline/policy cited in the message (e.g. "Guideline 5.1.1", "User Data policy"), or null if none is cited. Never invent one.'
3366
+ },
3367
+ title: { type: "string", description: "Short label for the issue" },
3368
+ summary: {
3369
+ type: "string",
3370
+ description: "What the reviewer is objecting to, in plain language"
3371
+ },
3372
+ category: {
3373
+ type: "string",
3374
+ enum: [
3375
+ "metadata",
3376
+ "privacy",
3377
+ "permissions",
3378
+ "account",
3379
+ "content",
3380
+ "functionality",
3381
+ "payments",
3382
+ "legal",
3383
+ "design",
3384
+ "other"
3385
+ ]
3386
+ },
3387
+ severity: {
3388
+ type: "string",
3389
+ enum: ["blocker", "clarification"],
3390
+ description: "blocker: the app/metadata must change. clarification: replying with information may resolve it."
3391
+ },
3392
+ fixSteps: {
3393
+ type: "array",
3394
+ items: { type: "string" },
3395
+ description: "Concrete ordered steps the developer should take"
3396
+ },
3397
+ appshipCommands: {
3398
+ type: "array",
3399
+ items: { type: "string" },
3400
+ description: "AppShip commands (from the provided list only) that help apply the fix; empty if none apply"
3401
+ },
3402
+ responseDraft: {
3403
+ type: ["string", "null"],
3404
+ description: "For clarification issues: a draft reply to the review team. null for blockers."
3405
+ }
3406
+ }
3407
+ }
3408
+ },
3409
+ overallPlan: {
3410
+ type: "array",
3411
+ items: { type: "string" },
3412
+ description: "Recommended order of actions across all issues, ending with resubmission"
3413
+ }
3414
+ }
3415
+ };
3416
+ var APPSHIP_COMMANDS = [
3417
+ "appship generate",
3418
+ "appship localize",
3419
+ "appship export fastlane",
3420
+ "appship screenshots flows",
3421
+ "appship screenshots capture",
3422
+ "appship upload ios",
3423
+ "appship upload android",
3424
+ "appship doctor",
3425
+ "appship submit ios",
3426
+ "appship submit android"
3427
+ ];
3428
+ var SYSTEM_PROMPT2 = `You are AppShip, an assistant that analyzes app store rejection messages and produces a concrete fix plan.
3429
+
3430
+ Rules you must never break:
3431
+ - Ground every issue in the rejection message itself. Never invent problems the reviewer did not raise.
3432
+ - Only cite a guideline or policy name if the message cites it, or if you are certain it is the standard reference for exactly what the message describes; otherwise set guideline to null.
3433
+ - The project summary is the only source of truth about the app. Never assume features, permissions, or SDKs it does not list.
3434
+ - appshipCommands may only contain commands from this list (with arguments where noted): ${APPSHIP_COMMANDS.join(", ")}. Use an empty array when none genuinely helps.
3435
+ - Fix steps must be actions the developer can take, not restatements of the problem.
3436
+ - Write responseDraft in a professional, factual tone; never promise changes the developer has not decided to make.`;
3437
+ function buildAnalyzePrompt(rejectionText, payload, options = {}) {
3438
+ const parts = [
3439
+ "Analyze this store rejection message and produce a fix plan.",
3440
+ options.storeHint ? `The developer says it came from: ${options.storeHint}.` : "",
3441
+ payload ? `Project summary (the only source of truth about the app):
3442
+ ${JSON.stringify(payload, null, 2)}` : "No project summary is available \u2014 do not assume anything about the app beyond the message.",
3443
+ `Rejection message:
3444
+ """
3445
+ ${rejectionText}
3446
+ """`
3447
+ ];
3448
+ return parts.filter(Boolean).join("\n\n");
3449
+ }
3450
+ async function analyzeRejection(provider, rejectionText, payload, options = {}) {
3451
+ const trimmed = rejectionText.trim();
3452
+ if (trimmed.length === 0) {
3453
+ throw new ReviewAnalyzeError("The rejection message is empty.");
3454
+ }
3455
+ const raw = await provider.generateObject({
3456
+ system: SYSTEM_PROMPT2,
3457
+ prompt: buildAnalyzePrompt(trimmed, payload, options),
3458
+ jsonSchema: ANALYSIS_JSON_SCHEMA
3459
+ });
3460
+ const parsed = reviewAnalysisSchema.safeParse(raw);
3461
+ if (!parsed.success) {
3462
+ throw new ReviewAnalyzeError(
3463
+ `The model returned an invalid analysis: ${parsed.error.issues[0]?.message ?? "unknown error"}`
3464
+ );
3465
+ }
3466
+ if (options.storeHint && parsed.data.store === "unknown") {
3467
+ return { ...parsed.data, store: options.storeHint };
3468
+ }
3469
+ return parsed.data;
3470
+ }
3471
+ var STORE_LABELS2 = {
3472
+ "app-store": "App Store",
3473
+ "google-play": "Google Play",
3474
+ unknown: "Unknown store"
3475
+ };
3476
+ function renderAnalysisMarkdown(analysis, appName) {
3477
+ const lines = [
3478
+ `# Rejection analysis \u2014 ${appName}`,
3479
+ "",
3480
+ `Store: ${STORE_LABELS2[analysis.store]}`,
3481
+ ""
3482
+ ];
3483
+ analysis.issues.forEach((issue, index) => {
3484
+ const guideline = issue.guideline ? ` (${issue.guideline})` : "";
3485
+ lines.push(`## ${index + 1}. ${issue.title}${guideline}`);
3486
+ lines.push("");
3487
+ lines.push(`- Category: ${issue.category}`);
3488
+ lines.push(`- Severity: ${issue.severity}`);
3489
+ lines.push("");
3490
+ lines.push(issue.summary);
3491
+ lines.push("");
3492
+ if (issue.fixSteps.length > 0) {
3493
+ lines.push("### Fix");
3494
+ lines.push(...issue.fixSteps.map((step, i) => `${i + 1}. ${step}`));
3495
+ lines.push("");
3496
+ }
3497
+ if (issue.appshipCommands.length > 0) {
3498
+ lines.push("### Relevant appship commands");
3499
+ lines.push(...issue.appshipCommands.map((c) => `- \`${c}\``));
3500
+ lines.push("");
3501
+ }
3502
+ if (issue.responseDraft) {
3503
+ lines.push("### Draft reply to the review team");
3504
+ lines.push("");
3505
+ lines.push("> " + issue.responseDraft.split("\n").join("\n> "));
3506
+ lines.push("");
3507
+ }
3508
+ });
3509
+ if (analysis.overallPlan.length > 0) {
3510
+ lines.push("## Recommended order");
3511
+ lines.push(...analysis.overallPlan.map((step, i) => `${i + 1}. ${step}`));
3512
+ lines.push("");
3513
+ }
3514
+ return lines.join("\n");
3515
+ }
3516
+ async function writeAnalysis2(projectRoot, analysis, appName) {
3517
+ const dir = join20(projectRoot, ".appship", "review");
3518
+ await mkdir9(dir, { recursive: true });
3519
+ const markdownPath = join20(".appship", "review", "rejection-analysis.md");
3520
+ const jsonPath = join20(".appship", "review", "rejection-analysis.json");
3521
+ await writeFile10(
3522
+ join20(projectRoot, markdownPath),
3523
+ renderAnalysisMarkdown(analysis, appName),
3524
+ "utf8"
3525
+ );
3526
+ await writeFile10(join20(projectRoot, jsonPath), JSON.stringify(analysis, null, 2) + "\n", "utf8");
3527
+ return { markdownPath, jsonPath };
3528
+ }
3529
+
3530
+ // src/cli/review.ts
3531
+ function renderIssues(analysis) {
3532
+ analysis.issues.forEach((issue, index) => {
3533
+ const icon = issue.severity === "blocker" ? pc9.red("\u2717") : pc9.yellow("\u25C6");
3534
+ const guideline = issue.guideline ? pc9.dim(` (${issue.guideline})`) : "";
3535
+ console.log(`${icon} ${pc9.bold(issue.title)}${guideline}`);
3536
+ console.log(` ${issue.summary}`);
3537
+ for (const step of issue.fixSteps) {
3538
+ console.log(pc9.dim(` \u2192 ${step}`));
3539
+ }
3540
+ for (const command of issue.appshipCommands) {
3541
+ console.log(pc9.cyan(` $ ${command}`));
3542
+ }
3543
+ if (issue.responseDraft) {
3544
+ console.log(pc9.dim(" (a draft reply to the review team is in the report)"));
3545
+ }
3546
+ if (index < analysis.issues.length - 1) console.log();
3547
+ });
3548
+ }
3549
+ var analyzeSubcommand = new Command9("analyze").description("Analyze a store rejection message and produce a fix plan").argument("<file>", "text file containing the rejection message (copy it from the store console)").option("--store <store>", "app-store | google-play (hint when the message is ambiguous)").action(async (file, options) => {
3550
+ const projectRoot = process.cwd();
3551
+ let storeHint;
3552
+ if (options.store !== void 0) {
3553
+ const parsed = reviewStoreSchema.safeParse(options.store);
3554
+ if (!parsed.success) {
3555
+ console.error(pc9.red(`\u2717 Unknown store "${options.store}". Use app-store or google-play.`));
3556
+ process.exitCode = 1;
3557
+ return;
3558
+ }
3559
+ storeHint = parsed.data;
3560
+ }
3561
+ let rejectionText;
3562
+ try {
3563
+ rejectionText = await readFile13(file, "utf8");
3564
+ } catch {
3565
+ console.error(pc9.red(`\u2717 Could not read "${file}".`));
3566
+ process.exitCode = 1;
3567
+ return;
3568
+ }
3569
+ if (rejectionText.trim().length === 0) {
3570
+ console.error(pc9.red(`\u2717 "${file}" is empty.`));
3571
+ process.exitCode = 1;
3572
+ return;
3573
+ }
3574
+ let config;
3575
+ try {
3576
+ config = await loadConfig(projectRoot);
3577
+ } catch (error) {
3578
+ if (error instanceof ConfigError) {
3579
+ console.error(pc9.red(`\u2717 ${error.message}`));
3580
+ process.exitCode = 1;
3581
+ return;
3582
+ }
3583
+ throw error;
3584
+ }
3585
+ let payload = null;
3586
+ try {
3587
+ const scan = await scanProject(projectRoot);
3588
+ await mergePreviousConfirmations(projectRoot, scan);
3589
+ payload = buildSummaryPayload(scan, config);
3590
+ } catch (error) {
3591
+ if (!(error instanceof UnsupportedProjectError)) throw error;
3592
+ console.log(pc9.yellow("\u26A0 Project scan unavailable \u2014 analyzing the message without app context."));
3593
+ }
3594
+ let provider;
3595
+ try {
3596
+ provider = createProvider(config);
3597
+ } catch (error) {
3598
+ if (error instanceof AIProviderError) {
3599
+ console.error(pc9.red(`\u2717 ${error.message}`));
3600
+ process.exitCode = 1;
3601
+ return;
3602
+ }
3603
+ throw error;
3604
+ }
3605
+ console.log(`Analyzing the rejection message with ${provider.name} ...`);
3606
+ console.log();
3607
+ let analysis;
3608
+ try {
3609
+ analysis = await analyzeRejection(
3610
+ provider,
3611
+ rejectionText,
3612
+ payload,
3613
+ storeHint ? { storeHint } : {}
3614
+ );
3615
+ } catch (error) {
3616
+ if (error instanceof ReviewAnalyzeError || error instanceof AIProviderError) {
3617
+ console.error(pc9.red(`\u2717 ${error.message}`));
3618
+ process.exitCode = 1;
3619
+ return;
3620
+ }
3621
+ throw error;
3622
+ }
3623
+ renderIssues(analysis);
3624
+ if (analysis.overallPlan.length > 0) {
3625
+ console.log();
3626
+ console.log(pc9.bold("Recommended order:"));
3627
+ analysis.overallPlan.forEach((step, i) => console.log(` ${i + 1}. ${step}`));
3628
+ }
3629
+ const written = await writeAnalysis2(projectRoot, analysis, config.project.name);
3630
+ console.log();
3631
+ console.log(pc9.dim(`Full report written to ${written.markdownPath}`));
3632
+ });
3633
+ var reviewCommand = new Command9("review").description("Work with store review feedback").addCommand(analyzeSubcommand);
3634
+
3635
+ // src/cli/rules.ts
3636
+ import { Command as Command10 } from "commander";
3637
+ import pc10 from "picocolors";
3638
+ var updateSubcommand = new Command10("update").description("Download the latest doctor rules and SDK signatures (no new CLI release needed)").option("--source <url>", "base URL to download the data/ files from").action(async (options) => {
3639
+ let result;
3640
+ try {
3641
+ result = await updateRules(options.source ? { source: options.source } : {});
3642
+ } catch (error) {
3643
+ if (error instanceof RulesUpdateError) {
3644
+ console.error(pc10.red(`\u2717 ${error.message}`));
3645
+ process.exitCode = 1;
3646
+ return;
3647
+ }
3648
+ throw error;
3649
+ }
3650
+ for (const file of result.files) {
3651
+ const marker = file.changed ? pc10.green("\u2713") : pc10.dim("\u2022");
3652
+ const note = file.changed ? "updated" : "already up to date";
3653
+ console.log(`${marker} ${file.path} ${pc10.dim(`(${file.entries} entries, ${note})`)}`);
3654
+ }
3655
+ console.log();
3656
+ console.log(
3657
+ pc10.dim(`Cached in ${result.cacheDir} \u2014 doctor and the scanner now prefer these files.`)
3658
+ );
3659
+ });
3660
+ var statusSubcommand = new Command10("status").description("Show whether bundled or updated rules are in use").action(async () => {
3661
+ const meta = await readCacheMeta();
3662
+ if (!meta) {
3663
+ console.log("Using the rules and SDK signatures bundled with this appship version.");
3664
+ console.log(pc10.dim("Run `appship rules update` to fetch the latest."));
3665
+ return;
3666
+ }
3667
+ console.log(`Using updated rules from ${meta.source}`);
3668
+ console.log(`Fetched: ${meta.updatedAt}`);
3669
+ console.log(`Cache: ${dataCacheDir()}`);
3670
+ console.log(pc10.dim("Run `appship rules reset` to go back to the bundled rules."));
3671
+ });
3672
+ var resetSubcommand = new Command10("reset").description("Delete the downloaded rules cache and return to the bundled rules").action(async () => {
3673
+ const removed = await resetRules();
3674
+ if (removed) {
3675
+ console.log(pc10.green("\u2713") + " Cache removed \u2014 bundled rules are in use again.");
3676
+ } else {
3677
+ console.log("No rules cache to remove \u2014 bundled rules are already in use.");
3678
+ }
3679
+ });
3680
+ var rulesCommand = new Command10("rules").description("Manage the store policy rules and SDK signature database").addCommand(updateSubcommand).addCommand(statusSubcommand).addCommand(resetSubcommand);
3681
+
3682
+ // src/index.ts
3683
+ var program = new Command11();
3684
+ program.name("appship").description(
3685
+ "AI release assistant that analyzes your mobile app and prepares everything required for App Store and Google Play submission"
3686
+ ).version("0.1.0");
3687
+ program.addCommand(initCommand);
3688
+ program.addCommand(generateCommand);
3689
+ program.addCommand(localizeCommand);
3690
+ program.addCommand(exportCommand);
3691
+ program.addCommand(uploadCommand);
3692
+ program.addCommand(screenshotsCommand);
3693
+ program.addCommand(submitCommand);
3694
+ program.addCommand(reviewCommand);
3695
+ program.addCommand(doctorCommand);
3696
+ program.addCommand(rulesCommand);
3697
+ program.parseAsync(process.argv);