@forvibe/cli 2.0.0 → 2.0.2
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/engine/data/bundle.appstore.json +22 -13
- package/dist/index.js +1356 -555
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -4,11 +4,8 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/analyze.ts
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import { basename as basename3, extname as extname4, relative as relative5 } from "path";
|
|
10
|
-
import chalk from "chalk";
|
|
11
|
-
import ora from "ora";
|
|
7
|
+
import chalk3 from "chalk";
|
|
8
|
+
import ora3 from "ora";
|
|
12
9
|
|
|
13
10
|
// src/analyzers/tech-detector.ts
|
|
14
11
|
import { existsSync as existsSync2, readdirSync as readdirSync2 } from "fs";
|
|
@@ -129,11 +126,12 @@ function findFiles(rootDir, extensions, maxDepth = 5) {
|
|
|
129
126
|
(f) => join(rootDir, f)
|
|
130
127
|
);
|
|
131
128
|
}
|
|
132
|
-
|
|
129
|
+
var DEFAULT_MAX_READ_BYTES = 1024 * 1024;
|
|
130
|
+
function readFileSafe(filePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
133
131
|
try {
|
|
134
132
|
if (!existsSync(filePath)) return null;
|
|
135
133
|
const stat = statSync(filePath);
|
|
136
|
-
if (stat.size >
|
|
134
|
+
if (stat.size > maxBytes) return null;
|
|
137
135
|
return readFileSync(filePath, "utf-8");
|
|
138
136
|
} catch {
|
|
139
137
|
return null;
|
|
@@ -1745,11 +1743,11 @@ function extractExpoColors(rootDir) {
|
|
|
1745
1743
|
const hexColors = [];
|
|
1746
1744
|
let primary = null;
|
|
1747
1745
|
let secondary = null;
|
|
1748
|
-
const
|
|
1746
|
+
const HEX2 = /^#[0-9a-fA-F]{6}$/;
|
|
1749
1747
|
const normalize = (raw) => {
|
|
1750
1748
|
if (!raw) return null;
|
|
1751
1749
|
const v = raw.trim();
|
|
1752
|
-
if (
|
|
1750
|
+
if (HEX2.test(v)) return v.toLowerCase();
|
|
1753
1751
|
if (/^#[0-9a-fA-F]{8}$/.test(v)) return `#${v.slice(3).toLowerCase()}`;
|
|
1754
1752
|
return null;
|
|
1755
1753
|
};
|
|
@@ -1803,11 +1801,11 @@ function extractMauiColors(rootDir) {
|
|
|
1803
1801
|
const name = match[1].toLowerCase();
|
|
1804
1802
|
let hex = match[2];
|
|
1805
1803
|
if (hex.length === 8) hex = hex.substring(2);
|
|
1806
|
-
const
|
|
1804
|
+
const color2 = `#${hex.toLowerCase()}`;
|
|
1807
1805
|
if (name.includes("primary") && !name.includes("dark") && !name.includes("light")) {
|
|
1808
|
-
hexColors.unshift(
|
|
1806
|
+
hexColors.unshift(color2);
|
|
1809
1807
|
} else {
|
|
1810
|
-
hexColors.push(
|
|
1808
|
+
hexColors.push(color2);
|
|
1811
1809
|
}
|
|
1812
1810
|
}
|
|
1813
1811
|
const brushMatches = content.matchAll(
|
|
@@ -1967,8 +1965,8 @@ function extractSwiftColors(rootDir) {
|
|
|
1967
1965
|
const entries = readdirSync5(catalog);
|
|
1968
1966
|
for (const entry of entries) {
|
|
1969
1967
|
if (entry.endsWith(".colorset") && entry !== "AccentColor.colorset") {
|
|
1970
|
-
const
|
|
1971
|
-
if (
|
|
1968
|
+
const color2 = parseColorSet(join5(catalog, entry));
|
|
1969
|
+
if (color2) hexColors.push(color2);
|
|
1972
1970
|
}
|
|
1973
1971
|
}
|
|
1974
1972
|
} catch {
|
|
@@ -1997,9 +1995,9 @@ function extractSwiftColors(rootDir) {
|
|
|
1997
1995
|
const namedColorMatches = content.matchAll(/Color\s*\(\s*"([^"]+)"\s*\)/g);
|
|
1998
1996
|
for (const match of namedColorMatches) {
|
|
1999
1997
|
for (const catalog of assetsCatalogs) {
|
|
2000
|
-
const
|
|
2001
|
-
if (
|
|
2002
|
-
hexColors.push(
|
|
1998
|
+
const color2 = parseColorSetInCatalog(catalog, match[1]);
|
|
1999
|
+
if (color2) {
|
|
2000
|
+
hexColors.push(color2);
|
|
2003
2001
|
break;
|
|
2004
2002
|
}
|
|
2005
2003
|
}
|
|
@@ -2849,7 +2847,20 @@ function getPriorityPatternsForStack(techStack) {
|
|
|
2849
2847
|
];
|
|
2850
2848
|
switch (techStack) {
|
|
2851
2849
|
case "flutter":
|
|
2852
|
-
return [
|
|
2850
|
+
return [
|
|
2851
|
+
...common,
|
|
2852
|
+
"widget",
|
|
2853
|
+
"screen",
|
|
2854
|
+
"page",
|
|
2855
|
+
"bloc",
|
|
2856
|
+
"provider",
|
|
2857
|
+
"controller",
|
|
2858
|
+
"view",
|
|
2859
|
+
"viewmodel",
|
|
2860
|
+
"settings",
|
|
2861
|
+
"auth",
|
|
2862
|
+
"onboarding"
|
|
2863
|
+
];
|
|
2853
2864
|
case "swift":
|
|
2854
2865
|
return [...common, "view", "controller", "manager", "delegate", "contentview"];
|
|
2855
2866
|
case "kotlin":
|
|
@@ -2885,21 +2896,41 @@ function getPriorityPatternsForStack(techStack) {
|
|
|
2885
2896
|
return common;
|
|
2886
2897
|
}
|
|
2887
2898
|
}
|
|
2899
|
+
var MAX_PER_DIR = 4;
|
|
2900
|
+
function scoreFile(rel, patterns) {
|
|
2901
|
+
const lower = rel.toLowerCase();
|
|
2902
|
+
const slash = Math.max(lower.lastIndexOf("/"), lower.lastIndexOf("\\"));
|
|
2903
|
+
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
|
|
2904
|
+
const dir = slash >= 0 ? lower.slice(0, slash) : "";
|
|
2905
|
+
let score = 0;
|
|
2906
|
+
for (const p of patterns) {
|
|
2907
|
+
if (base.includes(p)) score += 3;
|
|
2908
|
+
else if (dir.includes(p)) score += 1;
|
|
2909
|
+
}
|
|
2910
|
+
return score - Math.min(rel.split(/[/\\]/).length - 1, 6);
|
|
2911
|
+
}
|
|
2888
2912
|
function sortByPriority(files, patterns) {
|
|
2889
|
-
|
|
2890
|
-
const
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
0
|
|
2895
|
-
);
|
|
2896
|
-
const bScore = patterns.reduce(
|
|
2897
|
-
(score, pattern) => bLower.includes(pattern) ? score + 1 : score,
|
|
2898
|
-
0
|
|
2899
|
-
);
|
|
2900
|
-
if (aScore !== bScore) return bScore - aScore;
|
|
2901
|
-
return a.length - b.length;
|
|
2913
|
+
const ranked = [...files].sort((a, b) => {
|
|
2914
|
+
const d = scoreFile(b, patterns) - scoreFile(a, patterns);
|
|
2915
|
+
if (d !== 0) return d;
|
|
2916
|
+
if (a.length !== b.length) return a.length - b.length;
|
|
2917
|
+
return a.localeCompare(b);
|
|
2902
2918
|
});
|
|
2919
|
+
const perDir = /* @__PURE__ */ new Map();
|
|
2920
|
+
const primary = [];
|
|
2921
|
+
const overflow = [];
|
|
2922
|
+
for (const file of ranked) {
|
|
2923
|
+
const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"));
|
|
2924
|
+
const dir = slash >= 0 ? file.slice(0, slash) : "";
|
|
2925
|
+
const used = perDir.get(dir) ?? 0;
|
|
2926
|
+
if (used < MAX_PER_DIR) {
|
|
2927
|
+
perDir.set(dir, used + 1);
|
|
2928
|
+
primary.push(file);
|
|
2929
|
+
} else {
|
|
2930
|
+
overflow.push(file);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
return [...primary, ...overflow];
|
|
2903
2934
|
}
|
|
2904
2935
|
function isTestFile(file) {
|
|
2905
2936
|
const lower = file.toLowerCase();
|
|
@@ -2978,12 +3009,57 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
2978
3009
|
// src/analyzers/asset-scanner.ts
|
|
2979
3010
|
import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync as readdirSync8, statSync as statSync5 } from "fs";
|
|
2980
3011
|
import { join as join8, extname as extname3, relative as relative4, basename as basename2 } from "path";
|
|
3012
|
+
|
|
3013
|
+
// src/utils/image-size.ts
|
|
3014
|
+
function imageSize(buffer) {
|
|
3015
|
+
try {
|
|
3016
|
+
if (buffer.length >= 24 && buffer.readUInt32BE(0) === 2303741511 && buffer.toString("ascii", 12, 16) === "IHDR") {
|
|
3017
|
+
return valid(buffer.readUInt32BE(16), buffer.readUInt32BE(20));
|
|
3018
|
+
}
|
|
3019
|
+
if (buffer.length >= 4 && buffer[0] === 255 && buffer[1] === 216) {
|
|
3020
|
+
let offset = 2;
|
|
3021
|
+
while (offset + 9 < buffer.length) {
|
|
3022
|
+
if (buffer[offset] !== 255) return null;
|
|
3023
|
+
const marker = buffer[offset + 1];
|
|
3024
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
3025
|
+
const isFrame = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
3026
|
+
if (isFrame) return valid(buffer.readUInt16BE(offset + 7), buffer.readUInt16BE(offset + 5));
|
|
3027
|
+
offset += 2 + length;
|
|
3028
|
+
}
|
|
3029
|
+
return null;
|
|
3030
|
+
}
|
|
3031
|
+
if (buffer.length >= 30 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
|
|
3032
|
+
const chunk = buffer.toString("ascii", 12, 16);
|
|
3033
|
+
if (chunk === "VP8X") return valid(1 + buffer.readUIntLE(24, 3), 1 + buffer.readUIntLE(27, 3));
|
|
3034
|
+
if (chunk === "VP8 ") return valid(buffer.readUInt16LE(26) & 16383, buffer.readUInt16LE(28) & 16383);
|
|
3035
|
+
if (chunk === "VP8L") {
|
|
3036
|
+
const bits = buffer.readUInt32LE(21);
|
|
3037
|
+
return valid((bits & 16383) + 1, (bits >> 14 & 16383) + 1);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
} catch {
|
|
3041
|
+
return null;
|
|
3042
|
+
}
|
|
3043
|
+
return null;
|
|
3044
|
+
}
|
|
3045
|
+
function valid(width, height) {
|
|
3046
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// src/analyzers/asset-scanner.ts
|
|
2981
3050
|
var MAX_ASSET_SIZE = 5 * 1024 * 1024;
|
|
2982
3051
|
var MAX_TOTAL_ASSETS = 10;
|
|
2983
3052
|
var MAX_SCREENSHOTS = 5;
|
|
2984
3053
|
var MIN_ASSET_SIZE = 500;
|
|
2985
3054
|
var MIN_SCREENSHOT_SIZE = 1e4;
|
|
2986
3055
|
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
3056
|
+
var NOT_A_SCREEN = /(^|[-_. ])(icons?|logos?|splash|favicon|adaptive|foreground|background|banner|feature[-_ ]?graphic|promo)([-_. ]|$)/i;
|
|
3057
|
+
function looksLikeScreen(fileName, size) {
|
|
3058
|
+
if (NOT_A_SCREEN.test(fileName.replace(/\.[a-z0-9]+$/i, ""))) return false;
|
|
3059
|
+
if (!size) return true;
|
|
3060
|
+
const ratio = size.height / size.width;
|
|
3061
|
+
return ratio >= 1.25 && ratio <= 2.4;
|
|
3062
|
+
}
|
|
2987
3063
|
function getMimeType(filePath) {
|
|
2988
3064
|
const ext = extname3(filePath).toLowerCase();
|
|
2989
3065
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
@@ -3001,13 +3077,15 @@ function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
|
|
|
3001
3077
|
if (stat.size < minSize) return null;
|
|
3002
3078
|
if (stat.size > MAX_ASSET_SIZE) return null;
|
|
3003
3079
|
const buffer = readFileSync4(filePath);
|
|
3080
|
+
const size = imageSize(buffer);
|
|
3081
|
+
if (assetType === "screenshot" && !looksLikeScreen(basename2(filePath), size)) return null;
|
|
3004
3082
|
return {
|
|
3005
3083
|
asset_type: assetType,
|
|
3006
3084
|
file_name: basename2(filePath),
|
|
3007
3085
|
mime_type: getMimeType(filePath),
|
|
3008
3086
|
base64_data: buffer.toString("base64"),
|
|
3009
|
-
width: null,
|
|
3010
|
-
height: null,
|
|
3087
|
+
width: size?.width ?? null,
|
|
3088
|
+
height: size?.height ?? null,
|
|
3011
3089
|
source_path: relative4(rootDir, filePath)
|
|
3012
3090
|
};
|
|
3013
3091
|
} catch {
|
|
@@ -3023,7 +3101,7 @@ function collectImagesRecursive(rootDir, dirPath, assetType, assets, maxCount, m
|
|
|
3023
3101
|
if (assets.filter((a) => a.asset_type === assetType).length >= maxCount) return;
|
|
3024
3102
|
if (assets.length >= MAX_TOTAL_ASSETS) return;
|
|
3025
3103
|
const fullPath = join8(dirPath, entry);
|
|
3026
|
-
if (isImageFile(entry)) {
|
|
3104
|
+
if (isImageFile(entry) && !assets.some((existing) => existing.source_path === relative4(rootDir, fullPath))) {
|
|
3027
3105
|
const asset = tryReadImage(rootDir, fullPath, assetType, minSize);
|
|
3028
3106
|
if (asset) assets.push(asset);
|
|
3029
3107
|
}
|
|
@@ -3301,18 +3379,19 @@ var ForvibeClient = class {
|
|
|
3301
3379
|
sessionToken = null;
|
|
3302
3380
|
sessionId = null;
|
|
3303
3381
|
storeConfig = null;
|
|
3382
|
+
flowType = "appstore";
|
|
3304
3383
|
constructor(baseUrl) {
|
|
3305
3384
|
this.baseUrl = (baseUrl || process.env.FORVIBE_API_URL || DEFAULT_API_URL).replace(/\/$/, "");
|
|
3306
3385
|
}
|
|
3307
3386
|
/**
|
|
3308
3387
|
* Validate OTC code and get session token
|
|
3309
3388
|
*/
|
|
3310
|
-
async validateOTC(code) {
|
|
3389
|
+
async validateOTC(code, expectedFlow) {
|
|
3311
3390
|
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
3312
3391
|
const response = await fetch(`${this.baseUrl}/api/agent/validate-otc`, {
|
|
3313
3392
|
method: "POST",
|
|
3314
3393
|
headers: { "Content-Type": "application/json" },
|
|
3315
|
-
body: JSON.stringify({ code: normalizedCode })
|
|
3394
|
+
body: JSON.stringify(expectedFlow ? { code: normalizedCode, flow: expectedFlow } : { code: normalizedCode })
|
|
3316
3395
|
});
|
|
3317
3396
|
if (!response.ok) {
|
|
3318
3397
|
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
|
@@ -3325,14 +3404,40 @@ var ForvibeClient = class {
|
|
|
3325
3404
|
if (response.status === 404) {
|
|
3326
3405
|
throw new Error("Invalid connection code. Please check and try again.");
|
|
3327
3406
|
}
|
|
3407
|
+
if (response.status === 409 && error.error) {
|
|
3408
|
+
throw new Error(error.error);
|
|
3409
|
+
}
|
|
3328
3410
|
throw new Error(error.error || `Validation failed (${response.status})`);
|
|
3329
3411
|
}
|
|
3330
3412
|
const data = await response.json();
|
|
3331
3413
|
this.sessionToken = data.session_token;
|
|
3332
3414
|
this.sessionId = data.session_id;
|
|
3333
3415
|
this.storeConfig = data.store_config || null;
|
|
3416
|
+
this.flowType = data.flow_type ?? "appstore";
|
|
3334
3417
|
return data;
|
|
3335
3418
|
}
|
|
3419
|
+
/**
|
|
3420
|
+
* Submit the Screenshot Studio brief (flow_type "screenshots")
|
|
3421
|
+
*/
|
|
3422
|
+
async submitScreenshotBrief(brief) {
|
|
3423
|
+
if (!this.sessionToken) {
|
|
3424
|
+
throw new Error("Not connected. Please validate OTC code first.");
|
|
3425
|
+
}
|
|
3426
|
+
const response = await fetchWithAuth(`${this.baseUrl}/api/agent/screenshot-brief`, {
|
|
3427
|
+
method: "POST",
|
|
3428
|
+
headers: {
|
|
3429
|
+
"Content-Type": "application/json",
|
|
3430
|
+
Authorization: `Bearer ${this.sessionToken}`
|
|
3431
|
+
},
|
|
3432
|
+
body: JSON.stringify({ brief })
|
|
3433
|
+
});
|
|
3434
|
+
if (!response.ok) {
|
|
3435
|
+
const error = await response.json().catch(() => ({}));
|
|
3436
|
+
if (response.status === 401) throw new Error("Session expired. Please generate a new connection code in Screenshot Studio.");
|
|
3437
|
+
throw new Error(error.error || `Could not send the brief (${response.status})`);
|
|
3438
|
+
}
|
|
3439
|
+
return await response.json();
|
|
3440
|
+
}
|
|
3336
3441
|
/**
|
|
3337
3442
|
* Submit the CLI project report
|
|
3338
3443
|
*/
|
|
@@ -3469,16 +3574,326 @@ var ForvibeClient = class {
|
|
|
3469
3574
|
}
|
|
3470
3575
|
};
|
|
3471
3576
|
|
|
3577
|
+
// src/commands/upload-assets.ts
|
|
3578
|
+
import { readFileSync as readFileSync5, statSync as statSync6 } from "fs";
|
|
3579
|
+
import { basename as basename3, extname as extname4, relative as relative5 } from "path";
|
|
3580
|
+
function mimeForPath(p) {
|
|
3581
|
+
const ext = extname4(p).toLowerCase();
|
|
3582
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
3583
|
+
if (ext === ".webp") return "image/webp";
|
|
3584
|
+
return "image/png";
|
|
3585
|
+
}
|
|
3586
|
+
async function uploadAssetsToStorage(client, rootDir, assets, appIconPath) {
|
|
3587
|
+
const pending = [];
|
|
3588
|
+
for (const asset of assets) {
|
|
3589
|
+
if (!asset.base64_data) continue;
|
|
3590
|
+
pending.push({
|
|
3591
|
+
asset,
|
|
3592
|
+
buffer: Buffer.from(asset.base64_data, "base64")
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
if (appIconPath && extname4(appIconPath).toLowerCase() !== ".svg") {
|
|
3596
|
+
try {
|
|
3597
|
+
const stat = statSync6(appIconPath);
|
|
3598
|
+
if (stat.isFile() && stat.size <= 5 * 1024 * 1024) {
|
|
3599
|
+
const buffer = readFileSync5(appIconPath);
|
|
3600
|
+
pending.push({
|
|
3601
|
+
asset: {
|
|
3602
|
+
asset_type: "app_icon",
|
|
3603
|
+
file_name: basename3(appIconPath),
|
|
3604
|
+
mime_type: mimeForPath(appIconPath),
|
|
3605
|
+
width: null,
|
|
3606
|
+
height: null,
|
|
3607
|
+
source_path: relative5(rootDir, appIconPath)
|
|
3608
|
+
},
|
|
3609
|
+
buffer
|
|
3610
|
+
});
|
|
3611
|
+
}
|
|
3612
|
+
} catch {
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
if (pending.length === 0) return [];
|
|
3616
|
+
const { uploads } = await client.getAssetUploadUrls(
|
|
3617
|
+
pending.map((p) => ({
|
|
3618
|
+
asset_type: p.asset.asset_type,
|
|
3619
|
+
file_name: p.asset.file_name,
|
|
3620
|
+
mime_type: p.asset.mime_type
|
|
3621
|
+
}))
|
|
3622
|
+
);
|
|
3623
|
+
if (uploads.length !== pending.length) {
|
|
3624
|
+
throw new Error(
|
|
3625
|
+
`Upload slot count mismatch: requested ${pending.length}, got ${uploads.length}`
|
|
3626
|
+
);
|
|
3627
|
+
}
|
|
3628
|
+
const concurrency = 3;
|
|
3629
|
+
for (let i = 0; i < pending.length; i += concurrency) {
|
|
3630
|
+
const chunk = pending.slice(i, i + concurrency);
|
|
3631
|
+
const slots = uploads.slice(i, i + concurrency);
|
|
3632
|
+
await Promise.all(
|
|
3633
|
+
chunk.map(
|
|
3634
|
+
(p, idx) => client.uploadAssetToSignedUrl(
|
|
3635
|
+
slots[idx].signed_url,
|
|
3636
|
+
p.buffer,
|
|
3637
|
+
p.asset.mime_type
|
|
3638
|
+
)
|
|
3639
|
+
)
|
|
3640
|
+
);
|
|
3641
|
+
}
|
|
3642
|
+
return pending.map((p, i) => ({
|
|
3643
|
+
asset_type: p.asset.asset_type,
|
|
3644
|
+
file_name: uploads[i].file_name,
|
|
3645
|
+
mime_type: p.asset.mime_type,
|
|
3646
|
+
width: p.asset.width,
|
|
3647
|
+
height: p.asset.height,
|
|
3648
|
+
source_path: p.asset.source_path,
|
|
3649
|
+
storage_bucket: uploads[i].bucket,
|
|
3650
|
+
storage_path: uploads[i].storage_path
|
|
3651
|
+
}));
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
// src/commands/screenshots.ts
|
|
3655
|
+
import chalk2 from "chalk";
|
|
3656
|
+
import ora2 from "ora";
|
|
3657
|
+
|
|
3658
|
+
// src/engine/redactor.ts
|
|
3659
|
+
function buildSimpleRules() {
|
|
3660
|
+
return [
|
|
3661
|
+
{
|
|
3662
|
+
kind: "pem",
|
|
3663
|
+
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
|
|
3664
|
+
},
|
|
3665
|
+
{ kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{16,}/g },
|
|
3666
|
+
{
|
|
3667
|
+
kind: "openai",
|
|
3668
|
+
regex: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}/g
|
|
3669
|
+
},
|
|
3670
|
+
{ kind: "stripe", regex: /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
|
|
3671
|
+
{ kind: "google", regex: /AIza[0-9A-Za-z_-]{35}/g },
|
|
3672
|
+
{ kind: "aws", regex: /AKIA[0-9A-Z]{16}/g },
|
|
3673
|
+
{ kind: "github", regex: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
3674
|
+
{ kind: "slack", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
3675
|
+
{
|
|
3676
|
+
kind: "jwt",
|
|
3677
|
+
regex: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
|
|
3678
|
+
}
|
|
3679
|
+
];
|
|
3680
|
+
}
|
|
3681
|
+
function buildGenericRule() {
|
|
3682
|
+
return /(api[_-]?key|apikey|secret|token|passwd|password|client[_-]?secret)(\s*[:=]\s*)(["'])([^"'\n]{12,})(["'])/gi;
|
|
3683
|
+
}
|
|
3684
|
+
var ALREADY_REDACTED = /^\[REDACTED:[A-Za-z0-9_]+\]$/;
|
|
3685
|
+
function redactSecrets(source) {
|
|
3686
|
+
let text2 = source;
|
|
3687
|
+
let redactedCount = 0;
|
|
3688
|
+
for (const { kind, regex } of buildSimpleRules()) {
|
|
3689
|
+
text2 = text2.replace(regex, () => {
|
|
3690
|
+
redactedCount++;
|
|
3691
|
+
return `[REDACTED:${kind}]`;
|
|
3692
|
+
});
|
|
3693
|
+
}
|
|
3694
|
+
text2 = text2.replace(
|
|
3695
|
+
buildGenericRule(),
|
|
3696
|
+
(full, key, delim, openQuote, value, closeQuote) => {
|
|
3697
|
+
if (ALREADY_REDACTED.test(value)) return full;
|
|
3698
|
+
redactedCount++;
|
|
3699
|
+
return `${key}${delim}${openQuote}[REDACTED:generic]${closeQuote}`;
|
|
3700
|
+
}
|
|
3701
|
+
);
|
|
3702
|
+
return { text: text2, redactedCount };
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
// src/ai/screenshot-brief-generator.ts
|
|
3706
|
+
var SYSTEM_PROMPT = `You are the creative director of a mobile app's App Store screenshots. You read the app's code to understand what users can actually do and how the product looks. Forvibe will plan the screenshot set from your brief: one opening "hook" page with artwork, then one page per feature showing the real app.
|
|
3707
|
+
|
|
3708
|
+
Return ONLY a JSON object:
|
|
3709
|
+
{
|
|
3710
|
+
"description": "One or two sentences: what the app does for its user.",
|
|
3711
|
+
"category": "App Store style category, e.g. Education, Health & Fitness, Productivity",
|
|
3712
|
+
"features": [
|
|
3713
|
+
{ "name": "Short user-facing feature name (2-5 words)", "description": "The user benefit in one sentence", "priority": 0-100 }
|
|
3714
|
+
],
|
|
3715
|
+
"brand_personality": ["3-5 adjectives, e.g. calm, playful, premium"],
|
|
3716
|
+
"visual_keywords": ["3-8 concrete visual cues from the UI, e.g. rounded cards, soft gradients, dense data tables"],
|
|
3717
|
+
"ui_colors": { "accent": "#RRGGBB", "background": "#RRGGBB", "surface": "#RRGGBB", "text_primary": "#RRGGBB", "text_secondary": "#RRGGBB" },
|
|
3718
|
+
"typography": { "style": "e.g. geometric sans", "heading_style": "e.g. bold, tight", "body_style": "e.g. regular, comfortable" },
|
|
3719
|
+
"style": { "theme": "light|dark|mixed", "radius_style": "sharp|small|medium|large|pill", "density": "compact|balanced|spacious", "shadow_style": "none|subtle|medium|strong", "characteristics": ["short phrases"] },
|
|
3720
|
+
"screenshot_style_idea": {
|
|
3721
|
+
"concept": "A short name for an app-specific campaign idea",
|
|
3722
|
+
"metaphor": "How the product's real behavior could become a visual",
|
|
3723
|
+
"recurringMotifs": ["2-4 motifs that could repeat across pages"],
|
|
3724
|
+
"backgroundTreatment": "How backgrounds should feel",
|
|
3725
|
+
"accentTreatment": "Where the brand accent should appear",
|
|
3726
|
+
"pageRhythm": "How the set should progress"
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
RULES
|
|
3731
|
+
- features: 3 to 7 real, user-facing capabilities you can see implemented in the code (screens, views, flows). Order by importance with priority. No settings, onboarding, login, or generic items like "Beautiful design". Never invent features.
|
|
3732
|
+
- Colors: only hex values found in the code or assets, or clearly implied by them. Omit a color you cannot ground.
|
|
3733
|
+
- Ground everything in the supplied evidence. Omit a field rather than guess.
|
|
3734
|
+
- Never include secrets, keys, credentials, personal data, or code in the output.`;
|
|
3735
|
+
var HEX = /^#[0-9a-fA-F]{6}$/;
|
|
3736
|
+
var THEMES = ["light", "dark", "mixed"];
|
|
3737
|
+
var RADII = ["sharp", "small", "medium", "large", "pill"];
|
|
3738
|
+
var DENSITIES = ["compact", "balanced", "spacious"];
|
|
3739
|
+
var SHADOWS = ["none", "subtle", "medium", "strong"];
|
|
3740
|
+
function text(value, max) {
|
|
3741
|
+
if (typeof value !== "string") return void 0;
|
|
3742
|
+
const cleaned = value.replace(/\s+/g, " ").trim();
|
|
3743
|
+
return cleaned ? cleaned.slice(0, max) : void 0;
|
|
3744
|
+
}
|
|
3745
|
+
function list(value, maxItems, maxLength) {
|
|
3746
|
+
if (!Array.isArray(value)) return void 0;
|
|
3747
|
+
const items = value.map((item) => text(item, maxLength)).filter((item) => Boolean(item)).slice(0, maxItems);
|
|
3748
|
+
return items.length > 0 ? items : void 0;
|
|
3749
|
+
}
|
|
3750
|
+
function color(value) {
|
|
3751
|
+
return typeof value === "string" && HEX.test(value.trim()) ? value.trim().toUpperCase() : void 0;
|
|
3752
|
+
}
|
|
3753
|
+
function oneOf(value, allowed) {
|
|
3754
|
+
return typeof value === "string" && allowed.includes(value) ? value : void 0;
|
|
3755
|
+
}
|
|
3756
|
+
function record(value) {
|
|
3757
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3758
|
+
}
|
|
3759
|
+
function compact(value) {
|
|
3760
|
+
const entries = Object.entries(value).filter(([, item]) => item !== void 0);
|
|
3761
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
3762
|
+
}
|
|
3763
|
+
function buildScreenshotBriefPrompt(input) {
|
|
3764
|
+
const readme = input.readmeContent ? redactSecrets(input.readmeContent).text.slice(0, 8e3) : null;
|
|
3765
|
+
const source = redactSecrets(input.sourceCode).text.slice(0, 4e4);
|
|
3766
|
+
return [
|
|
3767
|
+
`APP NAME: ${input.config.app_name ?? "unknown"}`,
|
|
3768
|
+
`PLATFORMS: ${input.techStack.platforms.join(", ")} (${input.techStack.label})`,
|
|
3769
|
+
`BRAND COLORS FOUND: ${[input.branding.primary_color, input.branding.secondary_color, ...input.branding.brand_colors].filter(Boolean).join(", ") || "none"}`,
|
|
3770
|
+
`FONTS FOUND: ${input.detectedFonts.join(", ") || "none (system fonts)"}`,
|
|
3771
|
+
`EXISTING SCREENSHOTS IN REPO: ${input.screenshotCount}`,
|
|
3772
|
+
"",
|
|
3773
|
+
readme ? `README:
|
|
3774
|
+
${readme}` : "README: none",
|
|
3775
|
+
"",
|
|
3776
|
+
input.projectTree ? `PROJECT TREE:
|
|
3777
|
+
${input.projectTree.slice(0, 6e3)}` : "",
|
|
3778
|
+
"",
|
|
3779
|
+
`SOURCE EXCERPTS (secrets redacted):
|
|
3780
|
+
${source}`
|
|
3781
|
+
].join("\n");
|
|
3782
|
+
}
|
|
3783
|
+
function parseJson(response) {
|
|
3784
|
+
try {
|
|
3785
|
+
return JSON.parse(response);
|
|
3786
|
+
} catch {
|
|
3787
|
+
const match = response.match(/\{[\s\S]*\}/);
|
|
3788
|
+
if (!match) throw new Error("The AI response did not contain JSON");
|
|
3789
|
+
return JSON.parse(match[0].replace(/,\s*([}\]])/g, "$1"));
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
function structuredDirection(value) {
|
|
3793
|
+
const raw = record(value);
|
|
3794
|
+
const concept = text(raw.concept, 160);
|
|
3795
|
+
if (!concept) return void 0;
|
|
3796
|
+
return compact({
|
|
3797
|
+
concept,
|
|
3798
|
+
metaphor: text(raw.metaphor, 500),
|
|
3799
|
+
recurringMotifs: list(raw.recurringMotifs, 12, 120),
|
|
3800
|
+
backgroundTreatment: text(raw.backgroundTreatment, 500),
|
|
3801
|
+
accentTreatment: text(raw.accentTreatment, 500),
|
|
3802
|
+
pageRhythm: text(raw.pageRhythm, 500)
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
function normalizeScreenshotBrief(raw, input) {
|
|
3806
|
+
const data = record(raw);
|
|
3807
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3808
|
+
const features = (Array.isArray(data.features) ? data.features : []).map((item, index) => {
|
|
3809
|
+
const feature = record(item);
|
|
3810
|
+
const name = text(feature.name, 120);
|
|
3811
|
+
if (!name || seen.has(name.toLowerCase())) return null;
|
|
3812
|
+
seen.add(name.toLowerCase());
|
|
3813
|
+
const priority = typeof feature.priority === "number" && Number.isFinite(feature.priority) ? Math.max(0, Math.min(100, Math.round(feature.priority))) : Math.max(0, 100 - index * 10);
|
|
3814
|
+
return compact({ name, description: text(feature.description, 500), priority });
|
|
3815
|
+
}).filter((feature) => feature !== null).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)).slice(0, 7);
|
|
3816
|
+
if (features.length < 3) {
|
|
3817
|
+
throw new Error(`The AI found ${features.length} feature${features.length === 1 ? "" : "s"}; Screenshot Studio needs at least 3. Add a README that lists what the app does, then run the CLI again.`);
|
|
3818
|
+
}
|
|
3819
|
+
const uiColors = record(data.ui_colors);
|
|
3820
|
+
const typography = record(data.typography);
|
|
3821
|
+
const style = record(data.style);
|
|
3822
|
+
const design = compact({
|
|
3823
|
+
brand_personality: list(data.brand_personality, 12, 60),
|
|
3824
|
+
visual_keywords: list(data.visual_keywords, 20, 60),
|
|
3825
|
+
ui_colors: compact({
|
|
3826
|
+
accent: color(uiColors.accent),
|
|
3827
|
+
background: color(uiColors.background),
|
|
3828
|
+
surface: color(uiColors.surface),
|
|
3829
|
+
text_primary: color(uiColors.text_primary),
|
|
3830
|
+
text_secondary: color(uiColors.text_secondary)
|
|
3831
|
+
}),
|
|
3832
|
+
typography: compact({
|
|
3833
|
+
style: text(typography.style, 120),
|
|
3834
|
+
heading_style: text(typography.heading_style, 160),
|
|
3835
|
+
body_style: text(typography.body_style, 160)
|
|
3836
|
+
}),
|
|
3837
|
+
style: compact({
|
|
3838
|
+
theme: oneOf(style.theme, THEMES),
|
|
3839
|
+
radius_style: oneOf(style.radius_style, RADII),
|
|
3840
|
+
density: oneOf(style.density, DENSITIES),
|
|
3841
|
+
shadow_style: oneOf(style.shadow_style, SHADOWS),
|
|
3842
|
+
characteristics: list(style.characteristics, 20, 80)
|
|
3843
|
+
})
|
|
3844
|
+
});
|
|
3845
|
+
const platforms = input.techStack.platforms.filter((platform) => platform === "ios" || platform === "android");
|
|
3846
|
+
const brandColors = [...new Set(input.branding.brand_colors.map(color).filter((value) => Boolean(value)))].slice(0, 8);
|
|
3847
|
+
return compact({
|
|
3848
|
+
app_name: text(input.config.app_name, 120) ?? "Untitled App",
|
|
3849
|
+
bundle_id: text(input.config.bundle_id, 200),
|
|
3850
|
+
description: text(data.description, 1200),
|
|
3851
|
+
category: text(data.category, 120),
|
|
3852
|
+
platforms: platforms.length > 0 ? platforms : void 0,
|
|
3853
|
+
tech_stack: text(input.techStack.stack, 60),
|
|
3854
|
+
features,
|
|
3855
|
+
screenshot_style_idea: structuredDirection(data.screenshot_style_idea),
|
|
3856
|
+
screenshot_design: design,
|
|
3857
|
+
primary_color: color(input.branding.primary_color),
|
|
3858
|
+
secondary_color: color(input.branding.secondary_color),
|
|
3859
|
+
brand_colors: brandColors.length > 0 ? brandColors : void 0,
|
|
3860
|
+
detected_fonts: input.detectedFonts.length > 0 ? input.detectedFonts.slice(0, 8) : void 0
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
async function generateScreenshotBrief(input, provider) {
|
|
3864
|
+
const response = await provider.generateJSON(SYSTEM_PROMPT, buildScreenshotBriefPrompt(input), 0.4);
|
|
3865
|
+
return normalizeScreenshotBrief(parseJson(response.replace(/^```(?:json)?\s*|\s*```\s*$/g, "")), input);
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
// src/utils/version.ts
|
|
3869
|
+
import { createRequire } from "module";
|
|
3870
|
+
var FALLBACK_VERSION = "0.0.0";
|
|
3871
|
+
function getCliVersion() {
|
|
3872
|
+
const require2 = createRequire(import.meta.url);
|
|
3873
|
+
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
3874
|
+
try {
|
|
3875
|
+
const pkg = require2(candidate);
|
|
3876
|
+
if (pkg?.version && pkg.name === "@forvibe/cli") return pkg.version;
|
|
3877
|
+
} catch {
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
return FALLBACK_VERSION;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
// src/commands/connect.ts
|
|
3884
|
+
import { createInterface } from "readline";
|
|
3885
|
+
import chalk from "chalk";
|
|
3886
|
+
import ora from "ora";
|
|
3887
|
+
|
|
3472
3888
|
// src/ai/providers.ts
|
|
3473
3889
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
3474
|
-
function createGeminiProvider(apiKey
|
|
3475
|
-
const modelId = deep ? "gemini-3.1-pro-preview" : "gemini-3-flash-preview";
|
|
3890
|
+
function createGeminiProvider(apiKey) {
|
|
3476
3891
|
return {
|
|
3477
|
-
name:
|
|
3892
|
+
name: "Gemini 3.8 Flash",
|
|
3478
3893
|
async generateJSON(systemPrompt, userPrompt, temperature) {
|
|
3479
3894
|
const genAI = new GoogleGenerativeAI(apiKey);
|
|
3480
3895
|
const model = genAI.getGenerativeModel({
|
|
3481
|
-
model:
|
|
3896
|
+
model: "gemini-3.8-flash",
|
|
3482
3897
|
generationConfig: {
|
|
3483
3898
|
temperature,
|
|
3484
3899
|
responseMimeType: "application/json"
|
|
@@ -3494,8 +3909,8 @@ function createGeminiProvider(apiKey, deep) {
|
|
|
3494
3909
|
}
|
|
3495
3910
|
function createOpenAIProvider(apiKey) {
|
|
3496
3911
|
return {
|
|
3497
|
-
name: "OpenAI",
|
|
3498
|
-
async generateJSON(systemPrompt, userPrompt,
|
|
3912
|
+
name: "OpenAI (GPT-5.6 Luna)",
|
|
3913
|
+
async generateJSON(systemPrompt, userPrompt, _temperature) {
|
|
3499
3914
|
const response = await fetch(
|
|
3500
3915
|
"https://api.openai.com/v1/chat/completions",
|
|
3501
3916
|
{
|
|
@@ -3505,13 +3920,13 @@ function createOpenAIProvider(apiKey) {
|
|
|
3505
3920
|
Authorization: `Bearer ${apiKey}`
|
|
3506
3921
|
},
|
|
3507
3922
|
body: JSON.stringify({
|
|
3508
|
-
model: "gpt-
|
|
3923
|
+
model: "gpt-5.6-luna",
|
|
3509
3924
|
messages: [
|
|
3510
3925
|
{ role: "system", content: systemPrompt },
|
|
3511
3926
|
{ role: "user", content: userPrompt }
|
|
3512
3927
|
],
|
|
3513
3928
|
response_format: { type: "json_object" },
|
|
3514
|
-
|
|
3929
|
+
reasoning_effort: "low"
|
|
3515
3930
|
})
|
|
3516
3931
|
}
|
|
3517
3932
|
);
|
|
@@ -3532,8 +3947,8 @@ function createOpenAIProvider(apiKey) {
|
|
|
3532
3947
|
}
|
|
3533
3948
|
function createClaudeProvider(apiKey) {
|
|
3534
3949
|
return {
|
|
3535
|
-
name: "Claude",
|
|
3536
|
-
async generateJSON(systemPrompt, userPrompt,
|
|
3950
|
+
name: "Claude (Sonnet 5)",
|
|
3951
|
+
async generateJSON(systemPrompt, userPrompt, _temperature) {
|
|
3537
3952
|
const response = await fetch(
|
|
3538
3953
|
"https://api.anthropic.com/v1/messages",
|
|
3539
3954
|
{
|
|
@@ -3544,11 +3959,11 @@ function createClaudeProvider(apiKey) {
|
|
|
3544
3959
|
"anthropic-version": "2023-06-01"
|
|
3545
3960
|
},
|
|
3546
3961
|
body: JSON.stringify({
|
|
3547
|
-
model: "claude-sonnet-
|
|
3548
|
-
max_tokens:
|
|
3962
|
+
model: "claude-sonnet-5",
|
|
3963
|
+
max_tokens: 16e3,
|
|
3549
3964
|
system: systemPrompt,
|
|
3550
3965
|
messages: [{ role: "user", content: userPrompt }],
|
|
3551
|
-
|
|
3966
|
+
output_config: { effort: "medium" }
|
|
3552
3967
|
})
|
|
3553
3968
|
}
|
|
3554
3969
|
);
|
|
@@ -3559,21 +3974,21 @@ function createClaudeProvider(apiKey) {
|
|
|
3559
3974
|
);
|
|
3560
3975
|
}
|
|
3561
3976
|
const data = await response.json();
|
|
3562
|
-
const
|
|
3563
|
-
if (!
|
|
3977
|
+
const text2 = data.content?.find((b) => b.type === "text")?.text;
|
|
3978
|
+
if (!text2) {
|
|
3564
3979
|
throw new Error("Empty response from Claude");
|
|
3565
3980
|
}
|
|
3566
|
-
return
|
|
3981
|
+
return text2;
|
|
3567
3982
|
}
|
|
3568
3983
|
};
|
|
3569
3984
|
}
|
|
3570
|
-
function getAvailableProviders(
|
|
3985
|
+
function getAvailableProviders() {
|
|
3571
3986
|
const providers = [];
|
|
3572
3987
|
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
3573
3988
|
if (anthropicKey) {
|
|
3574
3989
|
providers.push({
|
|
3575
3990
|
id: "claude",
|
|
3576
|
-
name: "Claude",
|
|
3991
|
+
name: "Claude (Sonnet 5)",
|
|
3577
3992
|
recommended: true,
|
|
3578
3993
|
create: () => createClaudeProvider(anthropicKey)
|
|
3579
3994
|
});
|
|
@@ -3582,7 +3997,7 @@ function getAvailableProviders(deep = false) {
|
|
|
3582
3997
|
if (openaiKey) {
|
|
3583
3998
|
providers.push({
|
|
3584
3999
|
id: "openai",
|
|
3585
|
-
name: "OpenAI",
|
|
4000
|
+
name: "OpenAI (GPT-5.6 Luna)",
|
|
3586
4001
|
recommended: false,
|
|
3587
4002
|
create: () => createOpenAIProvider(openaiKey)
|
|
3588
4003
|
});
|
|
@@ -3591,116 +4006,37 @@ function getAvailableProviders(deep = false) {
|
|
|
3591
4006
|
if (geminiKey) {
|
|
3592
4007
|
providers.push({
|
|
3593
4008
|
id: "gemini",
|
|
3594
|
-
name:
|
|
4009
|
+
name: "Gemini 3.8 Flash",
|
|
3595
4010
|
recommended: false,
|
|
3596
|
-
create: () => createGeminiProvider(geminiKey
|
|
4011
|
+
create: () => createGeminiProvider(geminiKey)
|
|
3597
4012
|
});
|
|
3598
4013
|
}
|
|
3599
4014
|
return providers;
|
|
3600
4015
|
}
|
|
3601
4016
|
|
|
3602
|
-
// src/commands/
|
|
3603
|
-
function
|
|
3604
|
-
const
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
if (!asset.base64_data) continue;
|
|
3613
|
-
pending.push({
|
|
3614
|
-
asset,
|
|
3615
|
-
buffer: Buffer.from(asset.base64_data, "base64")
|
|
4017
|
+
// src/commands/connect.ts
|
|
4018
|
+
function askQuestion(question) {
|
|
4019
|
+
const rl = createInterface({
|
|
4020
|
+
input: process.stdin,
|
|
4021
|
+
output: process.stdout
|
|
4022
|
+
});
|
|
4023
|
+
return new Promise((resolve) => {
|
|
4024
|
+
rl.question(question, (answer) => {
|
|
4025
|
+
rl.close();
|
|
4026
|
+
resolve(answer.trim());
|
|
3616
4027
|
});
|
|
3617
|
-
}
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
height: null,
|
|
3630
|
-
source_path: relative5(rootDir, appIconPath)
|
|
3631
|
-
},
|
|
3632
|
-
buffer
|
|
3633
|
-
});
|
|
3634
|
-
}
|
|
3635
|
-
} catch {
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
if (pending.length === 0) return [];
|
|
3639
|
-
const { uploads } = await client.getAssetUploadUrls(
|
|
3640
|
-
pending.map((p) => ({
|
|
3641
|
-
asset_type: p.asset.asset_type,
|
|
3642
|
-
file_name: p.asset.file_name,
|
|
3643
|
-
mime_type: p.asset.mime_type
|
|
3644
|
-
}))
|
|
3645
|
-
);
|
|
3646
|
-
if (uploads.length !== pending.length) {
|
|
3647
|
-
throw new Error(
|
|
3648
|
-
`Upload slot count mismatch: requested ${pending.length}, got ${uploads.length}`
|
|
3649
|
-
);
|
|
3650
|
-
}
|
|
3651
|
-
const concurrency = 3;
|
|
3652
|
-
for (let i = 0; i < pending.length; i += concurrency) {
|
|
3653
|
-
const chunk = pending.slice(i, i + concurrency);
|
|
3654
|
-
const slots = uploads.slice(i, i + concurrency);
|
|
3655
|
-
await Promise.all(
|
|
3656
|
-
chunk.map(
|
|
3657
|
-
(p, idx) => client.uploadAssetToSignedUrl(
|
|
3658
|
-
slots[idx].signed_url,
|
|
3659
|
-
p.buffer,
|
|
3660
|
-
p.asset.mime_type
|
|
3661
|
-
)
|
|
3662
|
-
)
|
|
3663
|
-
);
|
|
3664
|
-
}
|
|
3665
|
-
return pending.map((p, i) => ({
|
|
3666
|
-
asset_type: p.asset.asset_type,
|
|
3667
|
-
file_name: uploads[i].file_name,
|
|
3668
|
-
mime_type: p.asset.mime_type,
|
|
3669
|
-
width: p.asset.width,
|
|
3670
|
-
height: p.asset.height,
|
|
3671
|
-
source_path: p.asset.source_path,
|
|
3672
|
-
storage_bucket: uploads[i].bucket,
|
|
3673
|
-
storage_path: uploads[i].storage_path
|
|
3674
|
-
}));
|
|
3675
|
-
}
|
|
3676
|
-
function askQuestion(question) {
|
|
3677
|
-
const rl = createInterface({
|
|
3678
|
-
input: process.stdin,
|
|
3679
|
-
output: process.stdout
|
|
3680
|
-
});
|
|
3681
|
-
return new Promise((resolve) => {
|
|
3682
|
-
rl.question(question, (answer) => {
|
|
3683
|
-
rl.close();
|
|
3684
|
-
resolve(answer.trim());
|
|
3685
|
-
});
|
|
3686
|
-
});
|
|
3687
|
-
}
|
|
3688
|
-
async function analyzeCommand(options) {
|
|
3689
|
-
const rootDir = options.dir || process.cwd();
|
|
3690
|
-
console.log();
|
|
3691
|
-
console.log(
|
|
3692
|
-
chalk.bold(" Forvibe CLI") + chalk.gray(" \u2014 AI-powered App Store automation")
|
|
3693
|
-
);
|
|
3694
|
-
console.log();
|
|
3695
|
-
const availableProviders = getAvailableProviders();
|
|
3696
|
-
if (availableProviders.length === 0) {
|
|
3697
|
-
console.log(chalk.red(" \u2717 No AI API key found. Set one of the following:\n"));
|
|
3698
|
-
console.log(chalk.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk.gray(" https://console.anthropic.com/settings/keys") + chalk.green(" (recommended)"));
|
|
3699
|
-
console.log(chalk.cyan(" export OPENAI_API_KEY=your-key") + chalk.gray(" https://platform.openai.com/api-keys"));
|
|
3700
|
-
console.log(chalk.cyan(" export GEMINI_API_KEY=your-key") + chalk.gray(" https://aistudio.google.com/apikey"));
|
|
3701
|
-
console.log();
|
|
3702
|
-
console.log(chalk.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n"));
|
|
3703
|
-
process.exit(1);
|
|
4028
|
+
});
|
|
4029
|
+
}
|
|
4030
|
+
async function chooseProvider() {
|
|
4031
|
+
const availableProviders = getAvailableProviders();
|
|
4032
|
+
if (availableProviders.length === 0) {
|
|
4033
|
+
console.log(chalk.red(" \u2717 No AI API key found. Set one of the following:\n"));
|
|
4034
|
+
console.log(chalk.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk.gray(" https://console.anthropic.com/settings/keys") + chalk.green(" (recommended)"));
|
|
4035
|
+
console.log(chalk.cyan(" export OPENAI_API_KEY=your-key") + chalk.gray(" https://platform.openai.com/api-keys"));
|
|
4036
|
+
console.log(chalk.cyan(" export GEMINI_API_KEY=your-key") + chalk.gray(" https://aistudio.google.com/apikey"));
|
|
4037
|
+
console.log();
|
|
4038
|
+
console.log(chalk.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n"));
|
|
4039
|
+
process.exit(1);
|
|
3704
4040
|
}
|
|
3705
4041
|
let provider;
|
|
3706
4042
|
if (availableProviders.length === 1) {
|
|
@@ -3724,6 +4060,9 @@ async function analyzeCommand(options) {
|
|
|
3724
4060
|
}
|
|
3725
4061
|
}
|
|
3726
4062
|
console.log(chalk.gray(` AI Provider: ${provider.name} \u2713`));
|
|
4063
|
+
return provider;
|
|
4064
|
+
}
|
|
4065
|
+
async function connectWithCode(client, expectedFlow) {
|
|
3727
4066
|
const otcCode = await askQuestion(
|
|
3728
4067
|
chalk.cyan(" \u{1F517} Enter your Forvibe connection code: ")
|
|
3729
4068
|
);
|
|
@@ -3737,9 +4076,8 @@ async function analyzeCommand(options) {
|
|
|
3737
4076
|
text: "Connecting to Forvibe...",
|
|
3738
4077
|
prefixText: " "
|
|
3739
4078
|
}).start();
|
|
3740
|
-
const client = new ForvibeClient(options.apiUrl);
|
|
3741
4079
|
try {
|
|
3742
|
-
await client.validateOTC(otcCode);
|
|
4080
|
+
await client.validateOTC(otcCode, expectedFlow);
|
|
3743
4081
|
connectSpinner.succeed(chalk.green("Connected to Forvibe!"));
|
|
3744
4082
|
} catch (error) {
|
|
3745
4083
|
connectSpinner.fail(
|
|
@@ -3748,23 +4086,135 @@ async function analyzeCommand(options) {
|
|
|
3748
4086
|
process.exit(1);
|
|
3749
4087
|
}
|
|
3750
4088
|
console.log();
|
|
3751
|
-
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
// src/commands/screenshots.ts
|
|
4092
|
+
async function runScreenshotsFlow(options) {
|
|
4093
|
+
const { client, provider, rootDir } = options;
|
|
4094
|
+
console.log(chalk2.white(" Screenshot Studio: reading your app to plan store screenshots.\n"));
|
|
4095
|
+
const scan = ora2({ text: "Detecting tech stack...", prefixText: " " }).start();
|
|
4096
|
+
const techStack = detectTechStack(rootDir);
|
|
4097
|
+
if (techStack.stack === "unknown") {
|
|
4098
|
+
scan.fail(chalk2.red("No supported project found. Run the CLI in your app's folder (Swift, Flutter, React Native, Expo, Kotlin, Capacitor, .NET MAUI)."));
|
|
4099
|
+
process.exit(1);
|
|
4100
|
+
}
|
|
4101
|
+
scan.succeed(`Tech stack: ${chalk2.bold(techStack.label)} ${chalk2.gray(`(${techStack.platforms.join(", ")})`)}`);
|
|
4102
|
+
const brandSpinner = ora2({ text: "Reading config, brand colors, and fonts...", prefixText: " " }).start();
|
|
4103
|
+
const config = parseConfig(rootDir, techStack.stack);
|
|
4104
|
+
const branding = extractBranding(rootDir, techStack.stack);
|
|
4105
|
+
const detectedFonts = detectFonts(rootDir, techStack.stack);
|
|
4106
|
+
brandSpinner.succeed(
|
|
4107
|
+
`Brand: ${chalk2.gray([
|
|
4108
|
+
config.app_name,
|
|
4109
|
+
branding.primary_color && `primary ${branding.primary_color}`,
|
|
4110
|
+
branding.app_icon_path ? "icon found" : "no icon",
|
|
4111
|
+
detectedFonts.length > 0 && detectedFonts.join(", ")
|
|
4112
|
+
].filter(Boolean).join(" \xB7 "))}`
|
|
4113
|
+
);
|
|
4114
|
+
const assetSpinner = ora2({ text: "Looking for app screenshots in the repo...", prefixText: " " }).start();
|
|
4115
|
+
const screenshots = scanAppAssets(rootDir, techStack.stack).filter((asset) => asset.asset_type === "screenshot");
|
|
4116
|
+
assetSpinner.succeed(
|
|
4117
|
+
screenshots.length > 0 ? `Screenshots: ${chalk2.bold(String(screenshots.length))} found` : `Screenshots: ${chalk2.gray("none found. You can upload them in Screenshot Studio.")}`
|
|
4118
|
+
);
|
|
4119
|
+
const sourceSpinner = ora2({ text: "Reading source code...", prefixText: " " }).start();
|
|
4120
|
+
const readmeContent = readReadme(rootDir);
|
|
4121
|
+
const sourceCode = readSourceCode(rootDir, techStack.stack);
|
|
4122
|
+
const projectTree = generateProjectTree(rootDir);
|
|
4123
|
+
sourceSpinner.succeed(`Source code: ${chalk2.bold(String(sourceCode.split("\n").length))} lines ${readmeContent ? chalk2.gray("(README found)") : ""}`);
|
|
4124
|
+
console.log();
|
|
4125
|
+
const aiSpinner = ora2({
|
|
4126
|
+
text: `Finding features and brand style with ${provider.name} (your source code never leaves your machine)...`,
|
|
4127
|
+
prefixText: " "
|
|
4128
|
+
}).start();
|
|
4129
|
+
let brief;
|
|
4130
|
+
try {
|
|
4131
|
+
brief = await generateScreenshotBrief(
|
|
4132
|
+
{ techStack, config, branding, detectedFonts, readmeContent, sourceCode, projectTree, screenshotCount: screenshots.length },
|
|
4133
|
+
provider
|
|
4134
|
+
);
|
|
4135
|
+
aiSpinner.succeed(chalk2.green(`Brief ready: ${brief.features.length} features`));
|
|
4136
|
+
} catch (error) {
|
|
4137
|
+
aiSpinner.fail(chalk2.red(`AI analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`));
|
|
4138
|
+
process.exit(1);
|
|
4139
|
+
}
|
|
4140
|
+
for (const feature of brief.features) {
|
|
4141
|
+
console.log(chalk2.gray(` \u2022 ${feature.name}${feature.description ? ` \u2014 ${feature.description}` : ""}`));
|
|
4142
|
+
}
|
|
4143
|
+
console.log();
|
|
4144
|
+
let appAssets = [];
|
|
4145
|
+
if (screenshots.length > 0 || branding.app_icon_path) {
|
|
4146
|
+
const uploadSpinner = ora2({ text: "Uploading app icon and screenshots...", prefixText: " " }).start();
|
|
4147
|
+
try {
|
|
4148
|
+
appAssets = await uploadAssetsToStorage(client, rootDir, screenshots, branding.app_icon_path);
|
|
4149
|
+
uploadSpinner.succeed(`Uploaded ${appAssets.length} image${appAssets.length === 1 ? "" : "s"}`);
|
|
4150
|
+
} catch (error) {
|
|
4151
|
+
uploadSpinner.warn(chalk2.yellow(`Images were not uploaded: ${error instanceof Error ? error.message : "Unknown error"}`));
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
const sendSpinner = ora2({ text: "Sending the brief to Screenshot Studio...", prefixText: " " }).start();
|
|
4155
|
+
try {
|
|
4156
|
+
const result = await client.submitScreenshotBrief({
|
|
4157
|
+
...brief,
|
|
4158
|
+
app_assets: appAssets.length > 0 ? appAssets : void 0,
|
|
4159
|
+
cli_version: getCliVersion()
|
|
4160
|
+
});
|
|
4161
|
+
sendSpinner.succeed(chalk2.green("Brief sent! Forvibe is planning your screenshots."));
|
|
4162
|
+
console.log();
|
|
4163
|
+
console.log(chalk2.bold(" \u{1F310} Review the plan at: ") + chalk2.cyan.underline(result.web_url));
|
|
4164
|
+
console.log();
|
|
4165
|
+
} catch (error) {
|
|
4166
|
+
sendSpinner.fail(chalk2.red(error instanceof Error ? error.message : "Could not send the brief"));
|
|
4167
|
+
process.exit(1);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
async function screenshotsCommand(options) {
|
|
4171
|
+
const rootDir = options.dir || process.cwd();
|
|
4172
|
+
console.log();
|
|
4173
|
+
console.log(chalk2.bold(" Forvibe CLI") + chalk2.gray(" \u2014 Screenshot Studio"));
|
|
4174
|
+
console.log();
|
|
4175
|
+
const provider = await chooseProvider();
|
|
4176
|
+
const client = new ForvibeClient(options.apiUrl);
|
|
4177
|
+
await connectWithCode(client, "screenshots");
|
|
4178
|
+
await runScreenshotsFlow({ client, provider, rootDir });
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
// src/commands/analyze.ts
|
|
4182
|
+
async function analyzeCommand(options) {
|
|
4183
|
+
const rootDir = options.dir || process.cwd();
|
|
4184
|
+
console.log();
|
|
4185
|
+
console.log(
|
|
4186
|
+
chalk3.bold(" Forvibe CLI") + chalk3.gray(" \u2014 AI-powered App Store automation")
|
|
4187
|
+
);
|
|
4188
|
+
console.log();
|
|
4189
|
+
const provider = await chooseProvider();
|
|
4190
|
+
const client = new ForvibeClient(options.apiUrl);
|
|
4191
|
+
await connectWithCode(client);
|
|
4192
|
+
if (client.flowType === "screenshots") {
|
|
4193
|
+
await runScreenshotsFlow({ client, provider, rootDir });
|
|
4194
|
+
console.log(chalk3.gray(" Tip: Screenshot Studio codes have their own command: npx @forvibe/cli screenshots\n"));
|
|
4195
|
+
return;
|
|
4196
|
+
}
|
|
4197
|
+
if (client.flowType === "review") {
|
|
4198
|
+
console.log(chalk3.yellow(" This code is for the review simulator. Run: npx @forvibe/cli review --send\n"));
|
|
4199
|
+
process.exit(1);
|
|
4200
|
+
}
|
|
4201
|
+
const techSpinner = ora3({
|
|
3752
4202
|
text: "Detecting tech stack...",
|
|
3753
4203
|
prefixText: " "
|
|
3754
4204
|
}).start();
|
|
3755
4205
|
const techStack = detectTechStack(rootDir);
|
|
3756
4206
|
if (techStack.stack === "unknown") {
|
|
3757
4207
|
techSpinner.fail(
|
|
3758
|
-
|
|
4208
|
+
chalk3.red(
|
|
3759
4209
|
"No supported project found. Supported: Swift, Flutter, React Native, Expo, Kotlin, Capacitor, .NET MAUI"
|
|
3760
4210
|
)
|
|
3761
4211
|
);
|
|
3762
4212
|
process.exit(1);
|
|
3763
4213
|
}
|
|
3764
4214
|
techSpinner.succeed(
|
|
3765
|
-
`Tech stack: ${
|
|
4215
|
+
`Tech stack: ${chalk3.bold(techStack.label)} ${chalk3.gray(`(${techStack.platforms.join(", ")})`)}`
|
|
3766
4216
|
);
|
|
3767
|
-
const configSpinner =
|
|
4217
|
+
const configSpinner = ora3({
|
|
3768
4218
|
text: "Reading project configuration...",
|
|
3769
4219
|
prefixText: " "
|
|
3770
4220
|
}).start();
|
|
@@ -3775,17 +4225,17 @@ async function analyzeCommand(options) {
|
|
|
3775
4225
|
config.version && `v${config.version}`
|
|
3776
4226
|
].filter(Boolean).join(" \xB7 ");
|
|
3777
4227
|
configSpinner.succeed(
|
|
3778
|
-
`Config parsed: ${
|
|
4228
|
+
`Config parsed: ${chalk3.gray(configDetails || "partial data")}`
|
|
3779
4229
|
);
|
|
3780
|
-
const sdkSpinner =
|
|
4230
|
+
const sdkSpinner = ora3({
|
|
3781
4231
|
text: "Scanning dependencies...",
|
|
3782
4232
|
prefixText: " "
|
|
3783
4233
|
}).start();
|
|
3784
4234
|
const sdkScan = scanSDKs(rootDir, techStack.stack);
|
|
3785
4235
|
sdkSpinner.succeed(
|
|
3786
|
-
`Dependencies: ${
|
|
4236
|
+
`Dependencies: ${chalk3.bold(String(sdkScan.detected_sdks.length))} known SDKs detected`
|
|
3787
4237
|
);
|
|
3788
|
-
const brandingSpinner =
|
|
4238
|
+
const brandingSpinner = ora3({
|
|
3789
4239
|
text: "Extracting branding...",
|
|
3790
4240
|
prefixText: " "
|
|
3791
4241
|
}).start();
|
|
@@ -3795,17 +4245,17 @@ async function analyzeCommand(options) {
|
|
|
3795
4245
|
branding.app_icon_path && "Icon found"
|
|
3796
4246
|
].filter(Boolean).join(" \xB7 ");
|
|
3797
4247
|
brandingSpinner.succeed(
|
|
3798
|
-
`Branding: ${
|
|
4248
|
+
`Branding: ${chalk3.gray(brandingDetails || "no colors/icon detected")}`
|
|
3799
4249
|
);
|
|
3800
|
-
const fontSpinner =
|
|
4250
|
+
const fontSpinner = ora3({
|
|
3801
4251
|
text: "Detecting fonts...",
|
|
3802
4252
|
prefixText: " "
|
|
3803
4253
|
}).start();
|
|
3804
4254
|
const detectedFonts = detectFonts(rootDir, techStack.stack);
|
|
3805
4255
|
fontSpinner.succeed(
|
|
3806
|
-
`Fonts: ${detectedFonts.length > 0 ?
|
|
4256
|
+
`Fonts: ${detectedFonts.length > 0 ? chalk3.white(detectedFonts.join(", ")) : chalk3.gray("none detected")}`
|
|
3807
4257
|
);
|
|
3808
|
-
const assetSpinner =
|
|
4258
|
+
const assetSpinner = ora3({
|
|
3809
4259
|
text: "Scanning app assets...",
|
|
3810
4260
|
prefixText: " "
|
|
3811
4261
|
}).start();
|
|
@@ -3817,9 +4267,9 @@ async function analyzeCommand(options) {
|
|
|
3817
4267
|
appAssets.some((a) => a.asset_type === "promotional_image") && "promo image"
|
|
3818
4268
|
].filter(Boolean);
|
|
3819
4269
|
assetSpinner.succeed(
|
|
3820
|
-
`Assets: ${assetParts.length > 0 ? assetParts.join(", ") : "none found"} ${
|
|
4270
|
+
`Assets: ${assetParts.length > 0 ? assetParts.join(", ") : "none found"} ${chalk3.gray(`(${appAssets.length} total)`)}`
|
|
3821
4271
|
);
|
|
3822
|
-
const sourceSpinner =
|
|
4272
|
+
const sourceSpinner = ora3({
|
|
3823
4273
|
text: "Reading source code & project structure...",
|
|
3824
4274
|
prefixText: " "
|
|
3825
4275
|
}).start();
|
|
@@ -3828,11 +4278,11 @@ async function analyzeCommand(options) {
|
|
|
3828
4278
|
const projectTree = generateProjectTree(rootDir);
|
|
3829
4279
|
const sourceLines = sourceCode.split("\n").length;
|
|
3830
4280
|
sourceSpinner.succeed(
|
|
3831
|
-
`Source code: ${
|
|
4281
|
+
`Source code: ${chalk3.bold(String(sourceLines))} lines analyzed ${readmeContent ? chalk3.gray("(README found)") : ""}`
|
|
3832
4282
|
);
|
|
3833
4283
|
console.log();
|
|
3834
4284
|
let report;
|
|
3835
|
-
const aiSpinner =
|
|
4285
|
+
const aiSpinner = ora3({
|
|
3836
4286
|
text: `Analyzing locally with ${provider.name} (your source code never leaves your machine)...`,
|
|
3837
4287
|
prefixText: " "
|
|
3838
4288
|
}).start();
|
|
@@ -3851,14 +4301,14 @@ async function analyzeCommand(options) {
|
|
|
3851
4301
|
if (detectedFonts.length > 0) {
|
|
3852
4302
|
report.detected_fonts = detectedFonts;
|
|
3853
4303
|
}
|
|
3854
|
-
aiSpinner.succeed(
|
|
4304
|
+
aiSpinner.succeed(chalk3.green("Analysis complete!"));
|
|
3855
4305
|
} catch (error) {
|
|
3856
4306
|
aiSpinner.fail(
|
|
3857
|
-
|
|
4307
|
+
chalk3.red(`AI analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`)
|
|
3858
4308
|
);
|
|
3859
4309
|
process.exit(1);
|
|
3860
4310
|
}
|
|
3861
|
-
const asoSpinner =
|
|
4311
|
+
const asoSpinner = ora3({
|
|
3862
4312
|
text: "Generating ASO-optimized store listing...",
|
|
3863
4313
|
prefixText: " "
|
|
3864
4314
|
}).start();
|
|
@@ -3867,56 +4317,56 @@ async function analyzeCommand(options) {
|
|
|
3867
4317
|
const stores = client.storeConfig?.stores || [];
|
|
3868
4318
|
const asoContent = await generateASOContent(report, provider, stores);
|
|
3869
4319
|
report.aso_content = asoContent;
|
|
3870
|
-
asoSpinner.succeed(
|
|
4320
|
+
asoSpinner.succeed(chalk3.green("Store listing content generated!"));
|
|
3871
4321
|
} catch (error) {
|
|
3872
4322
|
asoSpinner.warn(
|
|
3873
|
-
|
|
4323
|
+
chalk3.yellow(`ASO generation skipped: ${error instanceof Error ? error.message : "Unknown error"}`)
|
|
3874
4324
|
);
|
|
3875
4325
|
}
|
|
3876
4326
|
console.log();
|
|
3877
|
-
console.log(
|
|
3878
|
-
console.log(
|
|
3879
|
-
console.log(` App Name: ${
|
|
3880
|
-
console.log(` Bundle ID: ${
|
|
3881
|
-
console.log(` Type: ${
|
|
3882
|
-
console.log(` Category: ${
|
|
4327
|
+
console.log(chalk3.bold(" \u{1F4CB} Report Summary"));
|
|
4328
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4329
|
+
console.log(` App Name: ${chalk3.white(report.app_name)}`);
|
|
4330
|
+
console.log(` Bundle ID: ${chalk3.white(report.bundle_id)}`);
|
|
4331
|
+
console.log(` Type: ${chalk3.white(report.app_type)}`);
|
|
4332
|
+
console.log(` Category: ${chalk3.white(report.app_category_suggestion)}`);
|
|
3883
4333
|
if (report.description) {
|
|
3884
4334
|
const descPreview = report.description.length > 200 ? report.description.substring(0, 200).replace(/\n/g, " ") + "..." : report.description.replace(/\n/g, " ");
|
|
3885
|
-
console.log(` Description: ${
|
|
3886
|
-
}
|
|
3887
|
-
console.log(` Features: ${
|
|
3888
|
-
console.log(` Audience: ${
|
|
3889
|
-
console.log(` SDKs: ${
|
|
3890
|
-
console.log(` Data: ${
|
|
3891
|
-
console.log(` Colors: ${
|
|
3892
|
-
console.log(` Fonts: ${report.detected_fonts?.length ?
|
|
3893
|
-
console.log(` Icon: ${report.app_icon_base64 ?
|
|
3894
|
-
console.log(` Assets: ${report.app_assets?.length ?
|
|
3895
|
-
console.log(
|
|
4335
|
+
console.log(` Description: ${chalk3.white(descPreview)}`);
|
|
4336
|
+
}
|
|
4337
|
+
console.log(` Features: ${chalk3.white(report.key_features.slice(0, 3).join(", "))}${report.key_features.length > 3 ? chalk3.gray(` +${report.key_features.length - 3} more`) : ""}`);
|
|
4338
|
+
console.log(` Audience: ${chalk3.white(report.target_audience.length > 100 ? report.target_audience.substring(0, 100) + "..." : report.target_audience)}`);
|
|
4339
|
+
console.log(` SDKs: ${chalk3.white(String(report.detected_sdks.length))} detected`);
|
|
4340
|
+
console.log(` Data: ${chalk3.white(report.data_collected.join(", ") || "none")}`);
|
|
4341
|
+
console.log(` Colors: ${chalk3.hex(report.primary_color)("\u25A0")} ${report.primary_color} ${report.secondary_color ? `${chalk3.hex(report.secondary_color)("\u25A0")} ${report.secondary_color}` : ""}${report.brand_colors?.length ? chalk3.gray(` +${report.brand_colors.length} brand colors`) : ""}`);
|
|
4342
|
+
console.log(` Fonts: ${report.detected_fonts?.length ? chalk3.white(report.detected_fonts.join(", ")) : chalk3.gray("none detected")}`);
|
|
4343
|
+
console.log(` Icon: ${report.app_icon_base64 ? chalk3.green("\u2713 found") : chalk3.gray("not found")}`);
|
|
4344
|
+
console.log(` Assets: ${report.app_assets?.length ? chalk3.green(`${report.app_assets.length} found`) : chalk3.gray("none")}`);
|
|
4345
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3896
4346
|
if (report.aso_content) {
|
|
3897
4347
|
if (report.aso_content.appstore) {
|
|
3898
4348
|
console.log();
|
|
3899
|
-
console.log(
|
|
3900
|
-
console.log(
|
|
4349
|
+
console.log(chalk3.bold(" \u{1F4F1} App Store Listing Preview"));
|
|
4350
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3901
4351
|
const aso = report.aso_content.appstore;
|
|
3902
|
-
console.log(` Title: ${
|
|
3903
|
-
console.log(` Subtitle: ${
|
|
3904
|
-
console.log(` Keywords: ${
|
|
3905
|
-
console.log(` Promo: ${
|
|
4352
|
+
console.log(` Title: ${chalk3.white(aso.app_name)} ${chalk3.gray(`(${aso.app_name.length}/30)`)}`);
|
|
4353
|
+
console.log(` Subtitle: ${chalk3.white(aso.subtitle)} ${chalk3.gray(`(${aso.subtitle.length}/30)`)}`);
|
|
4354
|
+
console.log(` Keywords: ${chalk3.white(aso.keywords)} ${chalk3.gray(`(${aso.keywords.length}/100)`)}`);
|
|
4355
|
+
console.log(` Promo: ${chalk3.white(aso.promotional_text)} ${chalk3.gray(`(${aso.promotional_text.length}/170)`)}`);
|
|
3906
4356
|
const descPreview = aso.description.substring(0, 150).replace(/\n/g, " ");
|
|
3907
|
-
console.log(` Description: ${
|
|
3908
|
-
console.log(
|
|
4357
|
+
console.log(` Description: ${chalk3.white(descPreview)}${aso.description.length > 150 ? chalk3.gray("...") : ""} ${chalk3.gray(`(${aso.description.length}/4000)`)}`);
|
|
4358
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3909
4359
|
}
|
|
3910
4360
|
if (report.aso_content.playstore) {
|
|
3911
4361
|
console.log();
|
|
3912
|
-
console.log(
|
|
3913
|
-
console.log(
|
|
4362
|
+
console.log(chalk3.bold(" \u{1F916} Play Store Listing Preview"));
|
|
4363
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3914
4364
|
const ps = report.aso_content.playstore;
|
|
3915
|
-
console.log(` Title: ${
|
|
3916
|
-
console.log(` Short Desc: ${
|
|
4365
|
+
console.log(` Title: ${chalk3.white(ps.title)} ${chalk3.gray(`(${ps.title.length}/30)`)}`);
|
|
4366
|
+
console.log(` Short Desc: ${chalk3.white(ps.short_description)} ${chalk3.gray(`(${ps.short_description.length}/80)`)}`);
|
|
3917
4367
|
const psDescPreview = ps.description.substring(0, 150).replace(/\n/g, " ");
|
|
3918
|
-
console.log(` Description: ${
|
|
3919
|
-
console.log(
|
|
4368
|
+
console.log(` Description: ${chalk3.white(psDescPreview)}${ps.description.length > 150 ? chalk3.gray("...") : ""} ${chalk3.gray(`(${ps.description.length}/4000)`)}`);
|
|
4369
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3920
4370
|
}
|
|
3921
4371
|
}
|
|
3922
4372
|
const selectedStores = client.storeConfig?.stores || [];
|
|
@@ -3924,40 +4374,40 @@ async function analyzeCommand(options) {
|
|
|
3924
4374
|
const hasPS = selectedStores.includes("playstore") || selectedStores.length === 0;
|
|
3925
4375
|
if (hasASC) {
|
|
3926
4376
|
console.log();
|
|
3927
|
-
console.log(
|
|
3928
|
-
console.log(
|
|
3929
|
-
console.log(
|
|
3930
|
-
console.log(
|
|
4377
|
+
console.log(chalk3.bold(" \u{1F4DD} App Review Information"));
|
|
4378
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4379
|
+
console.log(chalk3.gray(" Apple's review team may need to sign in to your app."));
|
|
4380
|
+
console.log(chalk3.gray(" You can also provide this later in the Forvibe dashboard."));
|
|
3931
4381
|
console.log();
|
|
3932
4382
|
const hasAuthSDKs = (sdkScan.third_party_services.auth?.length ?? 0) > 0;
|
|
3933
4383
|
if (hasAuthSDKs) {
|
|
3934
4384
|
const signInAnswer = await askQuestion(
|
|
3935
|
-
|
|
4385
|
+
chalk3.cyan(" Does your app require sign-in to use? (y/n): ")
|
|
3936
4386
|
);
|
|
3937
4387
|
const signInRequired = signInAnswer.toLowerCase().startsWith("y");
|
|
3938
4388
|
report.sign_in_required = signInRequired;
|
|
3939
4389
|
if (signInRequired) {
|
|
3940
4390
|
console.log();
|
|
3941
|
-
console.log(
|
|
3942
|
-
console.log(
|
|
4391
|
+
console.log(chalk3.yellow(" \u26A0 Please create a test account in your app for Apple's review team."));
|
|
4392
|
+
console.log(chalk3.gray(" This account should have full access to your app's features."));
|
|
3943
4393
|
console.log();
|
|
3944
|
-
const testEmail = await askQuestion(
|
|
3945
|
-
const testPassword = await askQuestion(
|
|
4394
|
+
const testEmail = await askQuestion(chalk3.cyan(" Test account email: "));
|
|
4395
|
+
const testPassword = await askQuestion(chalk3.cyan(" Test account password: "));
|
|
3946
4396
|
if (testEmail && testPassword) {
|
|
3947
4397
|
report.test_account = { email: testEmail, password: testPassword };
|
|
3948
|
-
console.log(
|
|
4398
|
+
console.log(chalk3.green(" \u2713 Test account saved"));
|
|
3949
4399
|
}
|
|
3950
4400
|
}
|
|
3951
4401
|
} else {
|
|
3952
4402
|
report.sign_in_required = false;
|
|
3953
4403
|
}
|
|
3954
4404
|
console.log();
|
|
3955
|
-
console.log(
|
|
4405
|
+
console.log(chalk3.gray(" Contact information for Apple's review team (press Enter to skip):"));
|
|
3956
4406
|
console.log();
|
|
3957
|
-
const firstName = await askQuestion(
|
|
3958
|
-
const lastName = await askQuestion(
|
|
3959
|
-
const contactEmail = await askQuestion(
|
|
3960
|
-
const contactPhone = await askQuestion(
|
|
4407
|
+
const firstName = await askQuestion(chalk3.cyan(" First name: "));
|
|
4408
|
+
const lastName = await askQuestion(chalk3.cyan(" Last name: "));
|
|
4409
|
+
const contactEmail = await askQuestion(chalk3.cyan(" Email: "));
|
|
4410
|
+
const contactPhone = await askQuestion(chalk3.cyan(" Phone (e.g. +1 555 123 4567): "));
|
|
3961
4411
|
if (firstName && lastName && contactEmail) {
|
|
3962
4412
|
report.review_contact = {
|
|
3963
4413
|
first_name: firstName,
|
|
@@ -3965,18 +4415,18 @@ async function analyzeCommand(options) {
|
|
|
3965
4415
|
email: contactEmail,
|
|
3966
4416
|
phone: contactPhone || ""
|
|
3967
4417
|
};
|
|
3968
|
-
console.log(
|
|
4418
|
+
console.log(chalk3.green(" \u2713 Contact info saved"));
|
|
3969
4419
|
} else {
|
|
3970
|
-
console.log(
|
|
4420
|
+
console.log(chalk3.gray(" \u2139 Skipped \u2014 you can add this later in the dashboard."));
|
|
3971
4421
|
}
|
|
3972
|
-
console.log(
|
|
4422
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3973
4423
|
}
|
|
3974
4424
|
if (hasPS) {
|
|
3975
4425
|
console.log();
|
|
3976
|
-
console.log(
|
|
3977
|
-
console.log(
|
|
3978
|
-
console.log(
|
|
3979
|
-
console.log(
|
|
4426
|
+
console.log(chalk3.bold(" \u{1F916} Play Store Declarations"));
|
|
4427
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4428
|
+
console.log(chalk3.gray(" Google Play requires content declarations before publishing."));
|
|
4429
|
+
console.log(chalk3.gray(" We'll auto-detect what we can and ask you to confirm."));
|
|
3980
4430
|
console.log();
|
|
3981
4431
|
const hasAdSDKs = (sdkScan.third_party_services.ads?.length ?? 0) > 0;
|
|
3982
4432
|
const hasPaymentSDKs = (sdkScan.third_party_services.payment?.length ?? 0) > 0;
|
|
@@ -3995,48 +4445,48 @@ async function analyzeCommand(options) {
|
|
|
3995
4445
|
if (report.test_account) {
|
|
3996
4446
|
appAccessInstructions = `Test account: ${report.test_account.email} / ${report.test_account.password}`;
|
|
3997
4447
|
}
|
|
3998
|
-
console.log(
|
|
4448
|
+
console.log(chalk3.white(" App Access: ") + chalk3.yellow("Restricted (login required)"));
|
|
3999
4449
|
} else {
|
|
4000
4450
|
const accessAnswer = await askQuestion(
|
|
4001
|
-
|
|
4451
|
+
chalk3.cyan(" Does your app have restricted access (login, special permissions, etc.)? (y/n): ")
|
|
4002
4452
|
);
|
|
4003
4453
|
if (accessAnswer.toLowerCase().startsWith("y")) {
|
|
4004
4454
|
appAccess = "restricted_login";
|
|
4005
4455
|
const instrAnswer = await askQuestion(
|
|
4006
|
-
|
|
4456
|
+
chalk3.cyan(" How can a reviewer access all features? (describe or Enter to skip): ")
|
|
4007
4457
|
);
|
|
4008
4458
|
if (instrAnswer) appAccessInstructions = instrAnswer;
|
|
4009
4459
|
} else {
|
|
4010
|
-
console.log(
|
|
4460
|
+
console.log(chalk3.green(" \u2713 App is freely accessible"));
|
|
4011
4461
|
}
|
|
4012
4462
|
}
|
|
4013
4463
|
let containsAds = hasAdSDKs || report.advertising_type !== "none";
|
|
4014
4464
|
if (containsAds) {
|
|
4015
|
-
console.log(
|
|
4465
|
+
console.log(chalk3.white(" Ads: ") + chalk3.yellow(`Detected (${sdkScan.third_party_services.ads?.join(", ") || report.advertising_type})`));
|
|
4016
4466
|
const adsConfirm = await askQuestion(
|
|
4017
|
-
|
|
4467
|
+
chalk3.cyan(" Does your app contain ads? (Y/n): ")
|
|
4018
4468
|
);
|
|
4019
4469
|
if (adsConfirm.toLowerCase() === "n") containsAds = false;
|
|
4020
4470
|
} else {
|
|
4021
4471
|
const adsAnswer = await askQuestion(
|
|
4022
|
-
|
|
4472
|
+
chalk3.cyan(" Does your app contain ads? (y/N): ")
|
|
4023
4473
|
);
|
|
4024
4474
|
containsAds = adsAnswer.toLowerCase().startsWith("y");
|
|
4025
4475
|
}
|
|
4026
|
-
console.log(containsAds ?
|
|
4476
|
+
console.log(containsAds ? chalk3.yellow(" \u2192 Contains ads") : chalk3.green(" \u2713 No ads"));
|
|
4027
4477
|
let targetAgeGroup = "all_ages";
|
|
4028
4478
|
let isAppealToChildren = false;
|
|
4029
4479
|
if (report.is_for_children) {
|
|
4030
|
-
console.log(
|
|
4480
|
+
console.log(chalk3.yellow(" \u26A0 App appears to target children"));
|
|
4031
4481
|
targetAgeGroup = "all_ages";
|
|
4032
4482
|
isAppealToChildren = true;
|
|
4033
4483
|
} else {
|
|
4034
4484
|
console.log();
|
|
4035
|
-
console.log(
|
|
4036
|
-
console.log(
|
|
4037
|
-
console.log(
|
|
4038
|
-
console.log(
|
|
4039
|
-
const ageAnswer = await askQuestion(
|
|
4485
|
+
console.log(chalk3.gray(" Target age groups:"));
|
|
4486
|
+
console.log(chalk3.gray(" 1. All ages (including children under 13)"));
|
|
4487
|
+
console.log(chalk3.gray(" 2. Older users only (13+ or 18+)"));
|
|
4488
|
+
console.log(chalk3.gray(" 3. Mixed (targets both children and older users)"));
|
|
4489
|
+
const ageAnswer = await askQuestion(chalk3.cyan(" Choose target audience (1/2/3): "));
|
|
4040
4490
|
if (ageAnswer === "2") {
|
|
4041
4491
|
targetAgeGroup = "older_users_only";
|
|
4042
4492
|
} else if (ageAnswer === "3") {
|
|
@@ -4045,52 +4495,52 @@ async function analyzeCommand(options) {
|
|
|
4045
4495
|
} else {
|
|
4046
4496
|
targetAgeGroup = "all_ages";
|
|
4047
4497
|
const appealAnswer = await askQuestion(
|
|
4048
|
-
|
|
4498
|
+
chalk3.cyan(" Could the app appeal to children even if not targeted at them? (y/N): ")
|
|
4049
4499
|
);
|
|
4050
4500
|
isAppealToChildren = appealAnswer.toLowerCase().startsWith("y");
|
|
4051
4501
|
}
|
|
4052
4502
|
}
|
|
4053
4503
|
const govAnswer = await askQuestion(
|
|
4054
|
-
|
|
4504
|
+
chalk3.cyan(" Is this a government app? (y/N): ")
|
|
4055
4505
|
);
|
|
4056
4506
|
const isGovernmentApp = govAnswer.toLowerCase().startsWith("y");
|
|
4057
4507
|
let hasFinancialFeatures = isFinanceApp || hasFinanceSDKs;
|
|
4058
4508
|
if (hasFinancialFeatures) {
|
|
4059
|
-
console.log(
|
|
4509
|
+
console.log(chalk3.white(" Financial features: ") + chalk3.yellow("Detected"));
|
|
4060
4510
|
const finConfirm = await askQuestion(
|
|
4061
|
-
|
|
4511
|
+
chalk3.cyan(" Does your app offer financial features (payments, digital wallet, crypto, etc.)? (Y/n): ")
|
|
4062
4512
|
);
|
|
4063
4513
|
if (finConfirm.toLowerCase() === "n") hasFinancialFeatures = false;
|
|
4064
4514
|
} else {
|
|
4065
4515
|
const finAnswer = await askQuestion(
|
|
4066
|
-
|
|
4516
|
+
chalk3.cyan(" Does your app offer financial features (payments, digital wallet, crypto, etc.)? (y/N): ")
|
|
4067
4517
|
);
|
|
4068
4518
|
hasFinancialFeatures = finAnswer.toLowerCase().startsWith("y");
|
|
4069
4519
|
}
|
|
4070
4520
|
let financialDetails;
|
|
4071
4521
|
if (hasFinancialFeatures) {
|
|
4072
4522
|
const finDetailsAnswer = await askQuestion(
|
|
4073
|
-
|
|
4523
|
+
chalk3.cyan(" Describe financial features briefly (or Enter to skip): ")
|
|
4074
4524
|
);
|
|
4075
4525
|
if (finDetailsAnswer) financialDetails = finDetailsAnswer;
|
|
4076
4526
|
}
|
|
4077
4527
|
let isHealthAppDecl = isHealthApp || hasHealthSDKs;
|
|
4078
4528
|
if (isHealthAppDecl) {
|
|
4079
|
-
console.log(
|
|
4529
|
+
console.log(chalk3.white(" Health app: ") + chalk3.yellow("Detected"));
|
|
4080
4530
|
const healthConfirm = await askQuestion(
|
|
4081
|
-
|
|
4531
|
+
chalk3.cyan(" Is this a health or fitness app? (Y/n): ")
|
|
4082
4532
|
);
|
|
4083
4533
|
if (healthConfirm.toLowerCase() === "n") isHealthAppDecl = false;
|
|
4084
4534
|
} else {
|
|
4085
4535
|
const healthAnswer = await askQuestion(
|
|
4086
|
-
|
|
4536
|
+
chalk3.cyan(" Is this a health or fitness app? (y/N): ")
|
|
4087
4537
|
);
|
|
4088
4538
|
isHealthAppDecl = healthAnswer.toLowerCase().startsWith("y");
|
|
4089
4539
|
}
|
|
4090
4540
|
let healthDetails;
|
|
4091
4541
|
if (isHealthAppDecl) {
|
|
4092
4542
|
const healthDetailsAnswer = await askQuestion(
|
|
4093
|
-
|
|
4543
|
+
chalk3.cyan(" Describe health features briefly (or Enter to skip): ")
|
|
4094
4544
|
);
|
|
4095
4545
|
if (healthDetailsAnswer) healthDetails = healthDetailsAnswer;
|
|
4096
4546
|
}
|
|
@@ -4107,14 +4557,14 @@ async function analyzeCommand(options) {
|
|
|
4107
4557
|
health_app_details: healthDetails
|
|
4108
4558
|
};
|
|
4109
4559
|
console.log();
|
|
4110
|
-
console.log(
|
|
4111
|
-
console.log(
|
|
4560
|
+
console.log(chalk3.green(" \u2713 Play Store declarations collected"));
|
|
4561
|
+
console.log(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
4112
4562
|
}
|
|
4113
4563
|
console.log();
|
|
4114
4564
|
try {
|
|
4115
4565
|
const uploadCount = (report.app_assets?.length ?? 0) + (branding.app_icon_path ? 1 : 0);
|
|
4116
4566
|
if (uploadCount > 0) {
|
|
4117
|
-
const uploadSpinner =
|
|
4567
|
+
const uploadSpinner = ora3({
|
|
4118
4568
|
text: `Uploading ${uploadCount} asset${uploadCount === 1 ? "" : "s"}...`,
|
|
4119
4569
|
prefixText: " "
|
|
4120
4570
|
}).start();
|
|
@@ -4127,10 +4577,10 @@ async function analyzeCommand(options) {
|
|
|
4127
4577
|
);
|
|
4128
4578
|
report.app_assets = uploaded;
|
|
4129
4579
|
report.app_icon_base64 = null;
|
|
4130
|
-
uploadSpinner.succeed(
|
|
4580
|
+
uploadSpinner.succeed(chalk3.green(`Uploaded ${uploaded.length} asset${uploaded.length === 1 ? "" : "s"}`));
|
|
4131
4581
|
} catch (uploadError) {
|
|
4132
4582
|
uploadSpinner.fail(
|
|
4133
|
-
|
|
4583
|
+
chalk3.red(
|
|
4134
4584
|
`Asset upload failed: ${uploadError instanceof Error ? uploadError.message : "Unknown error"}`
|
|
4135
4585
|
)
|
|
4136
4586
|
);
|
|
@@ -4138,24 +4588,24 @@ async function analyzeCommand(options) {
|
|
|
4138
4588
|
}
|
|
4139
4589
|
}
|
|
4140
4590
|
} catch (e) {
|
|
4141
|
-
console.error(
|
|
4591
|
+
console.error(chalk3.red(`Asset upload error: ${e instanceof Error ? e.message : String(e)}`));
|
|
4142
4592
|
process.exit(1);
|
|
4143
4593
|
}
|
|
4144
|
-
const sendSpinner =
|
|
4594
|
+
const sendSpinner = ora3({
|
|
4145
4595
|
text: "Sending report to Forvibe...",
|
|
4146
4596
|
prefixText: " "
|
|
4147
4597
|
}).start();
|
|
4148
4598
|
try {
|
|
4149
4599
|
const result = await client.submitReport(report);
|
|
4150
|
-
sendSpinner.succeed(
|
|
4600
|
+
sendSpinner.succeed(chalk3.green("Report sent successfully!"));
|
|
4151
4601
|
console.log();
|
|
4152
4602
|
console.log(
|
|
4153
|
-
|
|
4603
|
+
chalk3.bold(" \u{1F310} Continue setup at: ") + chalk3.cyan.underline(result.web_url)
|
|
4154
4604
|
);
|
|
4155
4605
|
console.log();
|
|
4156
4606
|
} catch (error) {
|
|
4157
4607
|
sendSpinner.fail(
|
|
4158
|
-
|
|
4608
|
+
chalk3.red(
|
|
4159
4609
|
`Failed to send report: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
4160
4610
|
)
|
|
4161
4611
|
);
|
|
@@ -4165,11 +4615,188 @@ async function analyzeCommand(options) {
|
|
|
4165
4615
|
|
|
4166
4616
|
// src/commands/review.ts
|
|
4167
4617
|
import { createInterface as createInterface2 } from "readline";
|
|
4168
|
-
import
|
|
4169
|
-
import
|
|
4618
|
+
import chalk5 from "chalk";
|
|
4619
|
+
import ora4 from "ora";
|
|
4170
4620
|
|
|
4171
|
-
// src/engine/
|
|
4172
|
-
import {
|
|
4621
|
+
// src/engine/source-corpus.ts
|
|
4622
|
+
import { statSync as statSync7 } from "fs";
|
|
4623
|
+
import { join as join9 } from "path";
|
|
4624
|
+
var CORPUS_MAX_FILES = 4e3;
|
|
4625
|
+
var CORPUS_MAX_BYTES = 24 * 1024 * 1024;
|
|
4626
|
+
var CORPUS_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
4627
|
+
function extensionsForStack(stack) {
|
|
4628
|
+
switch (stack) {
|
|
4629
|
+
case "flutter":
|
|
4630
|
+
return [".dart"];
|
|
4631
|
+
case "swift":
|
|
4632
|
+
return [".swift", ".m", ".mm", ".h"];
|
|
4633
|
+
case "kotlin":
|
|
4634
|
+
return [".kt", ".kts"];
|
|
4635
|
+
case "expo":
|
|
4636
|
+
case "react-native":
|
|
4637
|
+
case "capacitor":
|
|
4638
|
+
return [".ts", ".tsx", ".js", ".jsx"];
|
|
4639
|
+
case "dotnet-maui":
|
|
4640
|
+
return [".cs", ".xaml"];
|
|
4641
|
+
case "unity":
|
|
4642
|
+
return [".cs"];
|
|
4643
|
+
case "kmp":
|
|
4644
|
+
return [".kt", ".kts", ".swift"];
|
|
4645
|
+
default:
|
|
4646
|
+
return [".ts", ".js", ".swift", ".dart", ".kt"];
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
function readSourceCorpus(rootDir, stack, opts = {}) {
|
|
4650
|
+
const maxFiles = opts.maxFiles ?? CORPUS_MAX_FILES;
|
|
4651
|
+
const maxBytes = opts.maxBytes ?? CORPUS_MAX_BYTES;
|
|
4652
|
+
const limits = /* @__PURE__ */ new Set();
|
|
4653
|
+
const relPaths = scanDirectory(rootDir, {
|
|
4654
|
+
extensions: extensionsForStack(stack),
|
|
4655
|
+
maxDepth: 8,
|
|
4656
|
+
maxFiles: maxFiles + 1
|
|
4657
|
+
}).sort();
|
|
4658
|
+
let discovered = relPaths.length;
|
|
4659
|
+
let paths = relPaths;
|
|
4660
|
+
if (discovered > maxFiles) {
|
|
4661
|
+
limits.add("walker_saturated");
|
|
4662
|
+
limits.add("max_files");
|
|
4663
|
+
paths = relPaths.slice(0, maxFiles);
|
|
4664
|
+
discovered = maxFiles;
|
|
4665
|
+
}
|
|
4666
|
+
const files = [];
|
|
4667
|
+
let filtered = 0;
|
|
4668
|
+
let totalBytes = 0;
|
|
4669
|
+
for (const rel of paths) {
|
|
4670
|
+
if (totalBytes >= maxBytes) {
|
|
4671
|
+
limits.add("max_bytes");
|
|
4672
|
+
break;
|
|
4673
|
+
}
|
|
4674
|
+
const abs = join9(rootDir, rel);
|
|
4675
|
+
const content = readFileSafe(abs, CORPUS_MAX_FILE_BYTES);
|
|
4676
|
+
if (content === null) {
|
|
4677
|
+
let oversize = false;
|
|
4678
|
+
try {
|
|
4679
|
+
oversize = statSync7(abs).size > CORPUS_MAX_FILE_BYTES;
|
|
4680
|
+
} catch {
|
|
4681
|
+
oversize = false;
|
|
4682
|
+
}
|
|
4683
|
+
limits.add(oversize ? "oversize_file" : "read_error");
|
|
4684
|
+
continue;
|
|
4685
|
+
}
|
|
4686
|
+
if (content.trim().length < 20 || isTestFile(rel) || isGeneratedFile(rel, content)) {
|
|
4687
|
+
filtered += 1;
|
|
4688
|
+
continue;
|
|
4689
|
+
}
|
|
4690
|
+
files.push({ path: rel, content });
|
|
4691
|
+
totalBytes += content.length;
|
|
4692
|
+
}
|
|
4693
|
+
const attempted = files.length + filtered;
|
|
4694
|
+
const complete = limits.size === 0 && attempted === discovered;
|
|
4695
|
+
return {
|
|
4696
|
+
files,
|
|
4697
|
+
coverage: {
|
|
4698
|
+
discovered,
|
|
4699
|
+
read: files.length,
|
|
4700
|
+
filtered,
|
|
4701
|
+
total_bytes: totalBytes,
|
|
4702
|
+
complete,
|
|
4703
|
+
limits_hit: Array.from(limits).sort()
|
|
4704
|
+
}
|
|
4705
|
+
};
|
|
4706
|
+
}
|
|
4707
|
+
|
|
4708
|
+
// src/review/ai-corpus.ts
|
|
4709
|
+
var EXCERPT_RADIUS = 3e3;
|
|
4710
|
+
var EVIDENCE_BUDGET_SHARE = 0.4;
|
|
4711
|
+
function excerptAround(content, offsets, maxChars, radius = EXCERPT_RADIUS) {
|
|
4712
|
+
if (content.length <= maxChars) return content;
|
|
4713
|
+
const windows = offsets.map((o) => ({
|
|
4714
|
+
start: Math.max(0, o - radius),
|
|
4715
|
+
end: Math.min(content.length, o + radius)
|
|
4716
|
+
})).sort((a, b) => a.start - b.start);
|
|
4717
|
+
const merged = [];
|
|
4718
|
+
for (const w of windows) {
|
|
4719
|
+
const last = merged[merged.length - 1];
|
|
4720
|
+
if (last && w.start <= last.end) {
|
|
4721
|
+
last.end = Math.max(last.end, w.end);
|
|
4722
|
+
} else {
|
|
4723
|
+
merged.push({ ...w });
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
const parts = [];
|
|
4727
|
+
let used = 0;
|
|
4728
|
+
let prevEnd = 0;
|
|
4729
|
+
for (const w of merged) {
|
|
4730
|
+
if (used >= maxChars) break;
|
|
4731
|
+
const slice = content.slice(w.start, Math.min(w.end, w.start + (maxChars - used)));
|
|
4732
|
+
if (w.start > prevEnd) {
|
|
4733
|
+
parts.push(`
|
|
4734
|
+
// ... [${w.start - prevEnd} chars omitted] ...
|
|
4735
|
+
`);
|
|
4736
|
+
}
|
|
4737
|
+
parts.push(slice);
|
|
4738
|
+
used += slice.length;
|
|
4739
|
+
prevEnd = w.start + slice.length;
|
|
4740
|
+
}
|
|
4741
|
+
if (prevEnd < content.length) {
|
|
4742
|
+
parts.push(`
|
|
4743
|
+
// ... [${content.length - prevEnd} chars omitted] ...
|
|
4744
|
+
`);
|
|
4745
|
+
}
|
|
4746
|
+
return parts.join("");
|
|
4747
|
+
}
|
|
4748
|
+
function selectAiCorpus(corpus, hits, stack, maxTotalChars, perFileChars = 15e3) {
|
|
4749
|
+
const byPath = new Map(corpus.files.map((f) => [f.path, f]));
|
|
4750
|
+
const inventory = corpus.files.map((f) => f.path);
|
|
4751
|
+
const hitsByPath = /* @__PURE__ */ new Map();
|
|
4752
|
+
for (const hit of hits) {
|
|
4753
|
+
if (!byPath.has(hit.path)) continue;
|
|
4754
|
+
let entry = hitsByPath.get(hit.path);
|
|
4755
|
+
if (!entry) {
|
|
4756
|
+
entry = { signals: /* @__PURE__ */ new Set(), offsets: [] };
|
|
4757
|
+
hitsByPath.set(hit.path, entry);
|
|
4758
|
+
}
|
|
4759
|
+
entry.signals.add(hit.signal);
|
|
4760
|
+
entry.offsets.push(hit.index);
|
|
4761
|
+
}
|
|
4762
|
+
const evidencePaths = Array.from(hitsByPath.entries()).sort((a, b) => {
|
|
4763
|
+
const d = b[1].signals.size - a[1].signals.size;
|
|
4764
|
+
return d !== 0 ? d : a[0].localeCompare(b[0]);
|
|
4765
|
+
}).map(([path2]) => path2);
|
|
4766
|
+
const files = [];
|
|
4767
|
+
const taken = /* @__PURE__ */ new Set();
|
|
4768
|
+
let totalChars = 0;
|
|
4769
|
+
const evidenceBudget = Math.floor(maxTotalChars * EVIDENCE_BUDGET_SHARE);
|
|
4770
|
+
for (const path2 of evidencePaths) {
|
|
4771
|
+
if (totalChars >= evidenceBudget) break;
|
|
4772
|
+
const file = byPath.get(path2);
|
|
4773
|
+
if (!file) continue;
|
|
4774
|
+
const entry = hitsByPath.get(path2);
|
|
4775
|
+
const content = excerptAround(file.content, entry?.offsets ?? [0], perFileChars);
|
|
4776
|
+
files.push({ path: path2, content });
|
|
4777
|
+
taken.add(path2);
|
|
4778
|
+
totalChars += content.length;
|
|
4779
|
+
}
|
|
4780
|
+
const evidenceFileCount = files.length;
|
|
4781
|
+
const remaining = corpus.files.filter((f) => !taken.has(f.path)).map((f) => f.path);
|
|
4782
|
+
const sorted = sortByPriority(remaining, getPriorityPatternsForStack(stack));
|
|
4783
|
+
for (const path2 of sorted) {
|
|
4784
|
+
if (totalChars >= maxTotalChars) break;
|
|
4785
|
+
const file = byPath.get(path2);
|
|
4786
|
+
if (!file) continue;
|
|
4787
|
+
const content = file.content.substring(0, perFileChars);
|
|
4788
|
+
files.push({ path: path2, content });
|
|
4789
|
+
taken.add(path2);
|
|
4790
|
+
totalChars += content.length;
|
|
4791
|
+
}
|
|
4792
|
+
return {
|
|
4793
|
+
files,
|
|
4794
|
+
inventory,
|
|
4795
|
+
totalChars,
|
|
4796
|
+
evidenceFileCount,
|
|
4797
|
+
selectedFrom: corpus.files.length
|
|
4798
|
+
};
|
|
4799
|
+
}
|
|
4173
4800
|
|
|
4174
4801
|
// src/engine/registry.ts
|
|
4175
4802
|
var ECOSYSTEM_KEYS = [
|
|
@@ -4252,7 +4879,7 @@ function matchSDKs(deps, registry) {
|
|
|
4252
4879
|
}
|
|
4253
4880
|
|
|
4254
4881
|
// src/engine/extractors/ios-plist.ts
|
|
4255
|
-
import { join as
|
|
4882
|
+
import { join as join10, relative as relative6 } from "path";
|
|
4256
4883
|
import plistLib from "plist";
|
|
4257
4884
|
|
|
4258
4885
|
// src/engine/extractors/shared.ts
|
|
@@ -4447,7 +5074,7 @@ function extractIosPlist(rootDir) {
|
|
|
4447
5074
|
}
|
|
4448
5075
|
}
|
|
4449
5076
|
let expoData = null;
|
|
4450
|
-
const appJsonContent = readFileSafe(
|
|
5077
|
+
const appJsonContent = readFileSafe(join10(rootDir, "app.json"));
|
|
4451
5078
|
if (appJsonContent) {
|
|
4452
5079
|
try {
|
|
4453
5080
|
const appJson = JSON.parse(appJsonContent);
|
|
@@ -4465,7 +5092,7 @@ function extractIosPlist(rootDir) {
|
|
|
4465
5092
|
}
|
|
4466
5093
|
if (!expoData) {
|
|
4467
5094
|
for (const name of ["app.config.ts", "app.config.js"]) {
|
|
4468
|
-
const content = readFileSafe(
|
|
5095
|
+
const content = readFileSafe(join10(rootDir, name));
|
|
4469
5096
|
if (!content) continue;
|
|
4470
5097
|
const found = readExpoConfigFallback(content);
|
|
4471
5098
|
if (Object.keys(found).length > 0) {
|
|
@@ -4645,16 +5272,16 @@ function extractIosPrivacyManifest(rootDir) {
|
|
|
4645
5272
|
}
|
|
4646
5273
|
|
|
4647
5274
|
// src/engine/extractors/android-manifest.ts
|
|
4648
|
-
import { join as
|
|
5275
|
+
import { join as join11, relative as relative9 } from "path";
|
|
4649
5276
|
var EXCLUDED_SEGMENTS = /* @__PURE__ */ new Set(["build", "test", "androidTest", "debug"]);
|
|
4650
5277
|
function isExcludedManifestPath(relPath) {
|
|
4651
5278
|
return relPath.split(/[\\/]/).some((segment) => EXCLUDED_SEGMENTS.has(segment));
|
|
4652
5279
|
}
|
|
4653
5280
|
function findMainManifest(rootDir) {
|
|
4654
5281
|
const knownCandidates = [
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
5282
|
+
join11(rootDir, "app/src/main/AndroidManifest.xml"),
|
|
5283
|
+
join11(rootDir, "android/app/src/main/AndroidManifest.xml"),
|
|
5284
|
+
join11(rootDir, "src/main/AndroidManifest.xml")
|
|
4658
5285
|
];
|
|
4659
5286
|
for (const candidate of knownCandidates) {
|
|
4660
5287
|
if (fileExists(candidate)) return candidate;
|
|
@@ -4719,16 +5346,16 @@ function extractAndroidManifest(rootDir) {
|
|
|
4719
5346
|
}
|
|
4720
5347
|
|
|
4721
5348
|
// src/engine/extractors/gradle-targets.ts
|
|
4722
|
-
import { join as
|
|
5349
|
+
import { join as join12, relative as relative10 } from "path";
|
|
4723
5350
|
function candidateGradleFiles(rootDir) {
|
|
4724
5351
|
const candidates = [];
|
|
4725
5352
|
const add = (p) => {
|
|
4726
5353
|
if (p && !candidates.includes(p)) candidates.push(p);
|
|
4727
5354
|
};
|
|
4728
|
-
add(findFile(
|
|
4729
|
-
add(findFile(
|
|
4730
|
-
add(findFile(
|
|
4731
|
-
add(findFile(
|
|
5355
|
+
add(findFile(join12(rootDir, "app"), "build.gradle", 2));
|
|
5356
|
+
add(findFile(join12(rootDir, "app"), "build.gradle.kts", 2));
|
|
5357
|
+
add(findFile(join12(rootDir, "android/app"), "build.gradle", 2));
|
|
5358
|
+
add(findFile(join12(rootDir, "android/app"), "build.gradle.kts", 2));
|
|
4732
5359
|
add(findFile(rootDir, "build.gradle", 1));
|
|
4733
5360
|
add(findFile(rootDir, "build.gradle.kts", 1));
|
|
4734
5361
|
for (const f of findFiles(rootDir, ["build.gradle", "build.gradle.kts"], 4)) {
|
|
@@ -4772,9 +5399,9 @@ function extractGradleTargets(rootDir) {
|
|
|
4772
5399
|
}
|
|
4773
5400
|
|
|
4774
5401
|
// src/engine/extractors/unity.ts
|
|
4775
|
-
import { join as
|
|
5402
|
+
import { join as join13 } from "path";
|
|
4776
5403
|
import YAML3 from "yaml";
|
|
4777
|
-
var PROJECT_SETTINGS_REL =
|
|
5404
|
+
var PROJECT_SETTINGS_REL = join13("ProjectSettings", "ProjectSettings.asset");
|
|
4778
5405
|
function toPlainYaml(raw) {
|
|
4779
5406
|
return raw.split("\n").filter((line) => !line.startsWith("%")).map((line) => /^---\s*!u!/.test(line) ? "---" : line).join("\n");
|
|
4780
5407
|
}
|
|
@@ -4792,7 +5419,7 @@ function regexFallbackAppIds(raw) {
|
|
|
4792
5419
|
};
|
|
4793
5420
|
}
|
|
4794
5421
|
function extractUnityProjectSettings(rootDir) {
|
|
4795
|
-
const filePath =
|
|
5422
|
+
const filePath = join13(rootDir, "ProjectSettings", "ProjectSettings.asset");
|
|
4796
5423
|
const raw = readFileSafe(filePath);
|
|
4797
5424
|
if (raw === null) return null;
|
|
4798
5425
|
const evidence = {
|
|
@@ -4833,8 +5460,8 @@ function extractUnityProjectSettings(rootDir) {
|
|
|
4833
5460
|
}
|
|
4834
5461
|
|
|
4835
5462
|
// src/engine/extractors/kmp.ts
|
|
4836
|
-
import { readdirSync as readdirSync9, statSync as
|
|
4837
|
-
import { join as
|
|
5463
|
+
import { readdirSync as readdirSync9, statSync as statSync8, existsSync as existsSync6 } from "fs";
|
|
5464
|
+
import { join as join14 } from "path";
|
|
4838
5465
|
function containsXcodeproj(dir, maxDepth) {
|
|
4839
5466
|
function walk(current, depth) {
|
|
4840
5467
|
let entries;
|
|
@@ -4844,10 +5471,10 @@ function containsXcodeproj(dir, maxDepth) {
|
|
|
4844
5471
|
return false;
|
|
4845
5472
|
}
|
|
4846
5473
|
for (const entry of entries) {
|
|
4847
|
-
const full =
|
|
5474
|
+
const full = join14(current, entry);
|
|
4848
5475
|
let isDirectory = false;
|
|
4849
5476
|
try {
|
|
4850
|
-
isDirectory =
|
|
5477
|
+
isDirectory = statSync8(full).isDirectory();
|
|
4851
5478
|
} catch {
|
|
4852
5479
|
continue;
|
|
4853
5480
|
}
|
|
@@ -4862,11 +5489,11 @@ function containsXcodeproj(dir, maxDepth) {
|
|
|
4862
5489
|
var ANDROID_APPLICATION_MODULE_RE = /applicationId\b|com\.android\.application/;
|
|
4863
5490
|
var KMP_PLUGIN_MARKER_RE2 = /kotlin\(\s*["']multiplatform["']\s*\)|id\(\s*["']org\.jetbrains\.kotlin\.multiplatform["']\s*\)|kotlinMultiplatform/;
|
|
4864
5491
|
function readModuleGradle(moduleDir) {
|
|
4865
|
-
return readFileSafe(
|
|
5492
|
+
return readFileSafe(join14(moduleDir, "build.gradle.kts")) ?? readFileSafe(join14(moduleDir, "build.gradle"));
|
|
4866
5493
|
}
|
|
4867
5494
|
function findKmpLayout(rootDir) {
|
|
4868
5495
|
let iosAppDir = null;
|
|
4869
|
-
const iosAppCandidate =
|
|
5496
|
+
const iosAppCandidate = join14(rootDir, "iosApp");
|
|
4870
5497
|
if (existsSync6(iosAppCandidate)) {
|
|
4871
5498
|
const hasXcodeproj = containsXcodeproj(iosAppCandidate, 3);
|
|
4872
5499
|
const hasInfoPlist = findFile(iosAppCandidate, "Info.plist", 3) !== null;
|
|
@@ -4874,7 +5501,7 @@ function findKmpLayout(rootDir) {
|
|
|
4874
5501
|
}
|
|
4875
5502
|
let androidModuleDir = null;
|
|
4876
5503
|
for (const name of ["androidApp", "composeApp", "app"]) {
|
|
4877
|
-
const candidate =
|
|
5504
|
+
const candidate = join14(rootDir, name);
|
|
4878
5505
|
const content = readModuleGradle(candidate);
|
|
4879
5506
|
if (content && ANDROID_APPLICATION_MODULE_RE.test(content)) {
|
|
4880
5507
|
androidModuleDir = candidate;
|
|
@@ -4885,10 +5512,10 @@ function findKmpLayout(rootDir) {
|
|
|
4885
5512
|
try {
|
|
4886
5513
|
const entries = [...readdirSync9(rootDir)].sort();
|
|
4887
5514
|
for (const entry of entries) {
|
|
4888
|
-
const candidate =
|
|
5515
|
+
const candidate = join14(rootDir, entry);
|
|
4889
5516
|
let isDirectory = false;
|
|
4890
5517
|
try {
|
|
4891
|
-
isDirectory =
|
|
5518
|
+
isDirectory = statSync8(candidate).isDirectory();
|
|
4892
5519
|
} catch {
|
|
4893
5520
|
continue;
|
|
4894
5521
|
}
|
|
@@ -4906,44 +5533,102 @@ function findKmpLayout(rootDir) {
|
|
|
4906
5533
|
|
|
4907
5534
|
// src/engine/profile-builder.ts
|
|
4908
5535
|
var RESTORE_RE = /restorePurchases|restoreCompletedTransactions|restoreTransactions|queryPurchasesAsync|\brestore\s*\(\s*\)/;
|
|
4909
|
-
var ACCOUNT_DELETION_RE =
|
|
5536
|
+
var ACCOUNT_DELETION_RE = /\b(?:delete|remove|close|destroy|purge)(?:My)?(?:Account|User|Profile)s?(?![a-z])|\baccountDeletion\b|["'`][^"'`]{0,20}[Dd]elete (?:my )?[Aa]ccount/;
|
|
4910
5537
|
var ACCOUNT_CREATION_RE = /createAccount|createUser|signUp|register(?:User|Account|WithEmail)/;
|
|
4911
5538
|
var PRIVACY_POLICY_RE = /privacy[\s_-]?policy/i;
|
|
4912
5539
|
var EXTERNAL_CHECKOUT_RE = /checkout\.stripe\.com|buy\.stripe\.com|paypal\.com|lemonsqueezy|gumroad|paddle\.com/i;
|
|
4913
5540
|
var WEBVIEW_RE = /WKWebView|UIWebView|webview_flutter|react-native-webview|InAppWebView|android\.webkit\.WebView/;
|
|
5541
|
+
var UGC_SURFACE_RE = /\b(?:add|post|publish|submit|create|edit|update|delete|fetch|load|get)[A-Za-z]{0,24}(?:Comment|Reply|Thread|Discussion|Post)s?(?![a-z])|\b(?:post|publish|upload|submit|share)(?:Review|Story|Photo|Video|Content|Message)s?(?![a-z])|\buserGenerated\b|(?:[Ff]eed|[Tt]imeline|[Cc]ommunity)(?:ViewModel|Controller|Bloc|Provider|Repository|Service|Screen|Page|View)s?(?![a-z])|\bcommentCount\b|\blikeCount\b|\bupvote|\bsharedBy\b/;
|
|
5542
|
+
var MODERATION_RE = /\b(?:report|flag)(?!Error|Crash|Bug|Exception|Issue|Analytics|Event)[A-Za-z]{0,24}(?:Content|Post|Comment|User|Abuse|Message|Reason|Item|Set|Author)s?(?![a-z])|\b(?:block|mute|ban|unblock)(?:ed)?(?:User|Author|Account|Member|List|Comment|Set)s?(?![a-z])|(?:Block|Unblock|Mute)(?:ed)?(?:User|Author|Account|Member|List|Comment|Set)s?(?![a-z])|[Cc]ontentReport|[Rr]eportReason|\bmoderate(?:Comment|Post|Content|User|Item)|[Cc]ontentModeration\b|[Mm]oderationQueue\b/;
|
|
4914
5543
|
function round2(n) {
|
|
4915
5544
|
return Math.round(n * 100) / 100;
|
|
4916
5545
|
}
|
|
5546
|
+
var ONESHOT_SIGNALS = [
|
|
5547
|
+
{ key: "restore_purchases", re: RESTORE_RE },
|
|
5548
|
+
{ key: "account_deletion", re: ACCOUNT_DELETION_RE },
|
|
5549
|
+
{ key: "account_creation", re: ACCOUNT_CREATION_RE },
|
|
5550
|
+
{ key: "privacy_policy_link", re: PRIVACY_POLICY_RE },
|
|
5551
|
+
{ key: "external_checkout_url", re: EXTERNAL_CHECKOUT_RE }
|
|
5552
|
+
];
|
|
4917
5553
|
function scanSourceSignals(input) {
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
let privacyPolicy = false;
|
|
4922
|
-
let externalCheckout = false;
|
|
5554
|
+
const complete = input.coverageComplete ?? true;
|
|
5555
|
+
const found = /* @__PURE__ */ new Map();
|
|
5556
|
+
const hits = [];
|
|
4923
5557
|
let webviewFiles = 0;
|
|
4924
5558
|
let sourceChars = 0;
|
|
4925
|
-
for (const { content } of input.files) {
|
|
5559
|
+
for (const { path: path2, content } of input.files) {
|
|
4926
5560
|
sourceChars += content.length;
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
5561
|
+
for (const { key, re } of ONESHOT_SIGNALS) {
|
|
5562
|
+
if (found.has(key)) continue;
|
|
5563
|
+
const m = re.exec(content);
|
|
5564
|
+
if (m) {
|
|
5565
|
+
const hit = { path: path2, signal: key, index: m.index };
|
|
5566
|
+
found.set(key, hit);
|
|
5567
|
+
hits.push(hit);
|
|
5568
|
+
}
|
|
5569
|
+
}
|
|
5570
|
+
const ugc = UGC_SURFACE_RE.exec(content);
|
|
5571
|
+
if (ugc && !found.has("ugc_surface")) {
|
|
5572
|
+
const hit = { path: path2, signal: "ugc_surface", index: ugc.index };
|
|
5573
|
+
found.set("ugc_surface", hit);
|
|
5574
|
+
hits.push(hit);
|
|
5575
|
+
}
|
|
5576
|
+
if (ugc && !found.has("moderation_controls")) {
|
|
5577
|
+
const mod = MODERATION_RE.exec(content);
|
|
5578
|
+
if (mod) {
|
|
5579
|
+
const hit = { path: path2, signal: "moderation_controls", index: mod.index };
|
|
5580
|
+
found.set("moderation_controls", hit);
|
|
5581
|
+
hits.push(hit);
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5584
|
+
const wv = WEBVIEW_RE.exec(content);
|
|
5585
|
+
if (wv) {
|
|
5586
|
+
webviewFiles += 1;
|
|
5587
|
+
hits.push({ path: path2, signal: "webview", index: wv.index });
|
|
5588
|
+
}
|
|
4933
5589
|
}
|
|
4934
5590
|
const scannedFiles = input.files.length;
|
|
5591
|
+
const tri = (key) => found.has(key) ? true : complete ? false : void 0;
|
|
5592
|
+
const put = (name, key) => {
|
|
5593
|
+
const v = tri(key);
|
|
5594
|
+
return v === void 0 ? {} : { [name]: v };
|
|
5595
|
+
};
|
|
4935
5596
|
return {
|
|
4936
|
-
restore_purchases_found
|
|
4937
|
-
account_deletion_found
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
5597
|
+
...put("restore_purchases_found", "restore_purchases"),
|
|
5598
|
+
...put("account_deletion_found", "account_deletion"),
|
|
5599
|
+
...put("account_creation_found", "account_creation"),
|
|
5600
|
+
...put("privacy_policy_link_found", "privacy_policy_link"),
|
|
5601
|
+
...put("external_checkout_url_found", "external_checkout_url"),
|
|
5602
|
+
...put("ugc_surface_found", "ugc_surface"),
|
|
5603
|
+
...put("moderation_controls_found", "moderation_controls"),
|
|
5604
|
+
...complete ? { webview_ratio: round2(webviewFiles / Math.max(scannedFiles, 1)) } : {},
|
|
4942
5605
|
webview_files: webviewFiles,
|
|
4943
5606
|
scanned_files: scannedFiles,
|
|
4944
|
-
source_chars: sourceChars
|
|
5607
|
+
source_chars: sourceChars,
|
|
5608
|
+
coverage_complete: complete,
|
|
5609
|
+
hits
|
|
4945
5610
|
};
|
|
4946
5611
|
}
|
|
5612
|
+
var SIGNAL_FACT_PATH = {
|
|
5613
|
+
restore_purchases: "signals.restore_purchases_found",
|
|
5614
|
+
account_deletion: "signals.account_deletion_found",
|
|
5615
|
+
account_creation: "signals.account_creation_found",
|
|
5616
|
+
privacy_policy_link: "signals.privacy_policy_link_found",
|
|
5617
|
+
external_checkout_url: "signals.external_checkout_url_found",
|
|
5618
|
+
ugc_surface: "signals.ugc_surface_found",
|
|
5619
|
+
moderation_controls: "signals.moderation_controls_found",
|
|
5620
|
+
webview: "signals.webview_ratio"
|
|
5621
|
+
};
|
|
5622
|
+
function signalEvidence(signals) {
|
|
5623
|
+
const evidence = {};
|
|
5624
|
+
if (!signals) return evidence;
|
|
5625
|
+
for (const hit of signals.hits) {
|
|
5626
|
+
const factPath = SIGNAL_FACT_PATH[hit.signal];
|
|
5627
|
+
if (!factPath || evidence[factPath]) continue;
|
|
5628
|
+
evidence[factPath] = { file: hit.path, detail: "source scan" };
|
|
5629
|
+
}
|
|
5630
|
+
return evidence;
|
|
5631
|
+
}
|
|
4947
5632
|
function permissionShortName(permission) {
|
|
4948
5633
|
const idx = permission.lastIndexOf(".");
|
|
4949
5634
|
return idx >= 0 ? permission.slice(idx + 1) : permission;
|
|
@@ -4989,10 +5674,13 @@ function deriveCapabilities(input) {
|
|
|
4989
5674
|
}
|
|
4990
5675
|
const signals = input.signals;
|
|
4991
5676
|
if (signals) {
|
|
4992
|
-
if (signals.account_creation_found) caps.add("account_creation");
|
|
4993
|
-
if (signals.account_deletion_found) caps.add("account_deletion");
|
|
4994
|
-
if (signals.
|
|
4995
|
-
if (signals.
|
|
5677
|
+
if (signals.account_creation_found === true) caps.add("account_creation");
|
|
5678
|
+
if (signals.account_deletion_found === true) caps.add("account_deletion");
|
|
5679
|
+
if (signals.external_checkout_url_found === true) caps.add("external_payment");
|
|
5680
|
+
if (signals.ugc_surface_found === true) caps.add("ugc");
|
|
5681
|
+
if (signals.webview_ratio !== void 0 && signals.webview_ratio >= 0.5) {
|
|
5682
|
+
caps.add("webview_heavy");
|
|
5683
|
+
}
|
|
4996
5684
|
}
|
|
4997
5685
|
for (const permission of input.android?.android.permissions ?? []) {
|
|
4998
5686
|
switch (permissionShortName(permission)) {
|
|
@@ -5039,6 +5727,7 @@ function deriveCapabilities(input) {
|
|
|
5039
5727
|
function mergeEvidence(input) {
|
|
5040
5728
|
const evidence = {};
|
|
5041
5729
|
const sources = [
|
|
5730
|
+
signalEvidence(input.signals),
|
|
5042
5731
|
input.ios?.evidence,
|
|
5043
5732
|
input.entitlements?.evidence,
|
|
5044
5733
|
input.privacyManifest?.evidence,
|
|
@@ -5064,19 +5753,28 @@ function buildAppProfile(input) {
|
|
|
5064
5753
|
privacy_manifest: input.privacyManifest?.privacy_manifest ?? null
|
|
5065
5754
|
} : null;
|
|
5066
5755
|
const android = input.android ? input.android.android : null;
|
|
5067
|
-
const
|
|
5068
|
-
restore_purchases_found
|
|
5069
|
-
account_deletion_found
|
|
5070
|
-
privacy_policy_link_found
|
|
5071
|
-
external_checkout_url_found
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5756
|
+
const SIGNAL_FIELDS = [
|
|
5757
|
+
"restore_purchases_found",
|
|
5758
|
+
"account_deletion_found",
|
|
5759
|
+
"privacy_policy_link_found",
|
|
5760
|
+
"external_checkout_url_found",
|
|
5761
|
+
"ugc_surface_found",
|
|
5762
|
+
"moderation_controls_found",
|
|
5763
|
+
"webview_ratio"
|
|
5764
|
+
];
|
|
5765
|
+
const signals = {};
|
|
5766
|
+
if (input.signals) {
|
|
5767
|
+
const src = input.signals;
|
|
5768
|
+
for (const field of SIGNAL_FIELDS) {
|
|
5769
|
+
if (Object.prototype.hasOwnProperty.call(src, field)) {
|
|
5770
|
+
signals[field] = src[field];
|
|
5771
|
+
}
|
|
5772
|
+
}
|
|
5773
|
+
} else {
|
|
5774
|
+
for (const field of SIGNAL_FIELDS) {
|
|
5775
|
+
signals[field] = null;
|
|
5776
|
+
}
|
|
5777
|
+
}
|
|
5080
5778
|
return {
|
|
5081
5779
|
schema_version: 1,
|
|
5082
5780
|
generated_at: input.generatedAt,
|
|
@@ -5100,7 +5798,11 @@ function buildAppProfile(input) {
|
|
|
5100
5798
|
evidence: mergeEvidence(input),
|
|
5101
5799
|
stats: {
|
|
5102
5800
|
files_scanned: input.signals?.scanned_files ?? 0,
|
|
5103
|
-
source_chars_read: input.signals?.source_chars ?? 0
|
|
5801
|
+
source_chars_read: input.signals?.source_chars ?? 0,
|
|
5802
|
+
...input.coverage ? {
|
|
5803
|
+
files_discovered: input.coverage.discovered,
|
|
5804
|
+
coverage_complete: input.coverage.complete
|
|
5805
|
+
} : {}
|
|
5104
5806
|
}
|
|
5105
5807
|
};
|
|
5106
5808
|
}
|
|
@@ -5248,8 +5950,8 @@ function evaluatePredicate(profile, pred, matchedSdkIds) {
|
|
|
5248
5950
|
}
|
|
5249
5951
|
return evaluateFactPredicate(profile, pred.fact, pred.op, pred.value);
|
|
5250
5952
|
}
|
|
5251
|
-
function interpolate(
|
|
5252
|
-
return
|
|
5953
|
+
function interpolate(text2, ctx) {
|
|
5954
|
+
return text2.replace(
|
|
5253
5955
|
/\{\{(\w+)\}\}/g,
|
|
5254
5956
|
(match, key) => Object.prototype.hasOwnProperty.call(ctx, key) ? ctx[key] : match
|
|
5255
5957
|
);
|
|
@@ -5425,47 +6127,6 @@ function evaluateRules(profile, bundle) {
|
|
|
5425
6127
|
}
|
|
5426
6128
|
|
|
5427
6129
|
// src/engine/index.ts
|
|
5428
|
-
function extensionsForStack(stack) {
|
|
5429
|
-
switch (stack) {
|
|
5430
|
-
case "flutter":
|
|
5431
|
-
return [".dart"];
|
|
5432
|
-
case "swift":
|
|
5433
|
-
return [".swift", ".m", ".mm", ".h"];
|
|
5434
|
-
case "kotlin":
|
|
5435
|
-
return [".kt", ".kts"];
|
|
5436
|
-
case "expo":
|
|
5437
|
-
case "react-native":
|
|
5438
|
-
case "capacitor":
|
|
5439
|
-
return [".ts", ".tsx", ".js", ".jsx"];
|
|
5440
|
-
case "dotnet-maui":
|
|
5441
|
-
return [".cs", ".xaml"];
|
|
5442
|
-
case "unity":
|
|
5443
|
-
return [".cs"];
|
|
5444
|
-
case "kmp":
|
|
5445
|
-
return [".kt", ".kts", ".swift"];
|
|
5446
|
-
default:
|
|
5447
|
-
return [".ts", ".js", ".swift", ".dart", ".kt"];
|
|
5448
|
-
}
|
|
5449
|
-
}
|
|
5450
|
-
var SELF_SCAN_MAX_FILES = 300;
|
|
5451
|
-
var SELF_SCAN_MAX_TOTAL_CHARS = 200 * 1024;
|
|
5452
|
-
function selfScanSource(rootDir, stack) {
|
|
5453
|
-
const relPaths = scanDirectory(rootDir, {
|
|
5454
|
-
extensions: extensionsForStack(stack),
|
|
5455
|
-
maxDepth: 8,
|
|
5456
|
-
maxFiles: SELF_SCAN_MAX_FILES
|
|
5457
|
-
});
|
|
5458
|
-
const files = [];
|
|
5459
|
-
let totalChars = 0;
|
|
5460
|
-
for (const rel of relPaths) {
|
|
5461
|
-
if (files.length >= SELF_SCAN_MAX_FILES || totalChars >= SELF_SCAN_MAX_TOTAL_CHARS) break;
|
|
5462
|
-
const content = readFileSafe(join14(rootDir, rel));
|
|
5463
|
-
if (content === null) continue;
|
|
5464
|
-
files.push({ path: rel, content });
|
|
5465
|
-
totalChars += content.length;
|
|
5466
|
-
}
|
|
5467
|
-
return files;
|
|
5468
|
-
}
|
|
5469
6130
|
function runStaticEngine(input) {
|
|
5470
6131
|
const { rootDir, stackResult, bundle } = input;
|
|
5471
6132
|
const { stack, platforms } = stackResult;
|
|
@@ -5495,13 +6156,11 @@ function runStaticEngine(input) {
|
|
|
5495
6156
|
extraEvidence = unityExtraction.evidence;
|
|
5496
6157
|
}
|
|
5497
6158
|
}
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
signals = selfFiles.length > 0 ? scanSourceSignals({ files: selfFiles }) : null;
|
|
5504
|
-
}
|
|
6159
|
+
const corpus = input.sourceCorpus ?? readSourceCorpus(rootDir, stack);
|
|
6160
|
+
const signals = corpus.files.length > 0 ? scanSourceSignals({
|
|
6161
|
+
files: corpus.files,
|
|
6162
|
+
coverageComplete: corpus.coverage.complete
|
|
6163
|
+
}) : null;
|
|
5505
6164
|
const profile = buildAppProfile({
|
|
5506
6165
|
rootDir,
|
|
5507
6166
|
stackResult,
|
|
@@ -5515,13 +6174,20 @@ function runStaticEngine(input) {
|
|
|
5515
6174
|
android,
|
|
5516
6175
|
gradleTargets,
|
|
5517
6176
|
signals,
|
|
6177
|
+
coverage: corpus.coverage,
|
|
5518
6178
|
generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
5519
6179
|
});
|
|
5520
6180
|
if (extraEvidence) {
|
|
5521
6181
|
profile.evidence = { ...profile.evidence, ...extraEvidence };
|
|
5522
6182
|
}
|
|
5523
6183
|
const { checks, findings } = evaluateRules(profile, bundle);
|
|
5524
|
-
return {
|
|
6184
|
+
return {
|
|
6185
|
+
profile,
|
|
6186
|
+
checks,
|
|
6187
|
+
findings,
|
|
6188
|
+
coverage: corpus.coverage,
|
|
6189
|
+
signalHits: signals?.hits ?? []
|
|
6190
|
+
};
|
|
5525
6191
|
}
|
|
5526
6192
|
|
|
5527
6193
|
// src/engine/remote.ts
|
|
@@ -5685,53 +6351,6 @@ function loadBundledSnapshot() {
|
|
|
5685
6351
|
}
|
|
5686
6352
|
}
|
|
5687
6353
|
|
|
5688
|
-
// src/engine/redactor.ts
|
|
5689
|
-
function buildSimpleRules() {
|
|
5690
|
-
return [
|
|
5691
|
-
{
|
|
5692
|
-
kind: "pem",
|
|
5693
|
-
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
|
|
5694
|
-
},
|
|
5695
|
-
{ kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{16,}/g },
|
|
5696
|
-
{
|
|
5697
|
-
kind: "openai",
|
|
5698
|
-
regex: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}/g
|
|
5699
|
-
},
|
|
5700
|
-
{ kind: "stripe", regex: /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
|
|
5701
|
-
{ kind: "google", regex: /AIza[0-9A-Za-z_-]{35}/g },
|
|
5702
|
-
{ kind: "aws", regex: /AKIA[0-9A-Z]{16}/g },
|
|
5703
|
-
{ kind: "github", regex: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
5704
|
-
{ kind: "slack", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
5705
|
-
{
|
|
5706
|
-
kind: "jwt",
|
|
5707
|
-
regex: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
|
|
5708
|
-
}
|
|
5709
|
-
];
|
|
5710
|
-
}
|
|
5711
|
-
function buildGenericRule() {
|
|
5712
|
-
return /(api[_-]?key|apikey|secret|token|passwd|password|client[_-]?secret)(\s*[:=]\s*)(["'])([^"'\n]{12,})(["'])/gi;
|
|
5713
|
-
}
|
|
5714
|
-
var ALREADY_REDACTED = /^\[REDACTED:[A-Za-z0-9_]+\]$/;
|
|
5715
|
-
function redactSecrets(source) {
|
|
5716
|
-
let text = source;
|
|
5717
|
-
let redactedCount = 0;
|
|
5718
|
-
for (const { kind, regex } of buildSimpleRules()) {
|
|
5719
|
-
text = text.replace(regex, () => {
|
|
5720
|
-
redactedCount++;
|
|
5721
|
-
return `[REDACTED:${kind}]`;
|
|
5722
|
-
});
|
|
5723
|
-
}
|
|
5724
|
-
text = text.replace(
|
|
5725
|
-
buildGenericRule(),
|
|
5726
|
-
(full, key, delim, openQuote, value, closeQuote) => {
|
|
5727
|
-
if (ALREADY_REDACTED.test(value)) return full;
|
|
5728
|
-
redactedCount++;
|
|
5729
|
-
return `${key}${delim}${openQuote}[REDACTED:generic]${closeQuote}`;
|
|
5730
|
-
}
|
|
5731
|
-
);
|
|
5732
|
-
return { text, redactedCount };
|
|
5733
|
-
}
|
|
5734
|
-
|
|
5735
6354
|
// src/review/feature-detector.ts
|
|
5736
6355
|
function getFeatureDiscoveryPrompt() {
|
|
5737
6356
|
return `You are an expert app analyst. Your job is to understand WHAT an app does from the USER's perspective by reading its source code. You are NOT reviewing code quality.
|
|
@@ -5958,8 +6577,10 @@ Do not include an approval estimate; the final outcome is computed deterministic
|
|
|
5958
6577
|
- low: Improves chances but not a guaranteed rejection
|
|
5959
6578
|
- info: Best practice suggestion
|
|
5960
6579
|
6. Focus on the TOP issues (max 10 findings). Quality over quantity.
|
|
5961
|
-
7.
|
|
5962
|
-
8.
|
|
6580
|
+
7. EVIDENCE IS MANDATORY. Every finding MUST set "file" to a path that appears in the Source File Inventory, and "evidence" MUST quote the relevant code verbatim. A finding with no file, or an invented file path, is discarded before the user sees it.
|
|
6581
|
+
8. NEVER INFER ABSENCE. You are shown a SUBSET of the source, but the inventory lists EVERY file in the project. If a feature would live in a file you were not given, you have learned nothing about it. Say nothing. "I didn't see it in the provided code" is not evidence.
|
|
6582
|
+
9. The "Resolved By Deterministic Scan" section is GROUND TRUTH, produced by regexing every file in the project. If it says a feature was FOUND, that feature exists: do not report it as missing regardless of what your excerpts show. Only "unknown" entries are open questions.
|
|
6583
|
+
10. Report absence ONLY with positive contradicting evidence, e.g. subscription purchase code in a file you were shown PLUS the deterministic scan reporting restore purchases NOT FOUND.`;
|
|
5963
6584
|
}
|
|
5964
6585
|
function parseAIResponse(response) {
|
|
5965
6586
|
try {
|
|
@@ -6029,6 +6650,53 @@ function dedupeAIFindings(aiFindings, staticFindings) {
|
|
|
6029
6650
|
});
|
|
6030
6651
|
return { kept, droppedCount: aiFindings.length - kept.length };
|
|
6031
6652
|
}
|
|
6653
|
+
var ABSENCE_CLAIM_RE = /\b(missing|absent|no |not (?:present|implemented|found|available)|lacks?|lacking|without)\b/i;
|
|
6654
|
+
var REFUTABLE_CLAIMS = [
|
|
6655
|
+
{
|
|
6656
|
+
signal: "account_deletion",
|
|
6657
|
+
guidelines: ["5.1.1(v)", "5.1.1"],
|
|
6658
|
+
topic: /account\s+deletion|delet\w*\s+(?:my\s+|the\s+|their\s+)?account|remov\w*\s+(?:my\s+|the\s+)?account/i
|
|
6659
|
+
},
|
|
6660
|
+
{
|
|
6661
|
+
signal: "restore_purchases",
|
|
6662
|
+
guidelines: ["3.1.2", "3.1.1"],
|
|
6663
|
+
topic: /restor\w*\s+(?:in-app\s+)?purchase|purchase\s+restor|restore\s+button/i
|
|
6664
|
+
},
|
|
6665
|
+
{
|
|
6666
|
+
signal: "privacy_policy_link",
|
|
6667
|
+
guidelines: ["5.1.1", "5.1.2"],
|
|
6668
|
+
topic: /privacy\s+policy/i
|
|
6669
|
+
},
|
|
6670
|
+
{
|
|
6671
|
+
signal: "moderation_controls",
|
|
6672
|
+
guidelines: ["1.2"],
|
|
6673
|
+
topic: /moderat|report\w*\s+(?:content|posts?|comments?|users?|abuse)|block\w*\s+(?:users?|authors?)|flag\w*\s+content/i
|
|
6674
|
+
}
|
|
6675
|
+
];
|
|
6676
|
+
function filterUnsupportedAIFindings(findings, opts) {
|
|
6677
|
+
const resolved = new Set(opts.resolvedTrue);
|
|
6678
|
+
const kept = [];
|
|
6679
|
+
let downgradedCount = 0;
|
|
6680
|
+
for (const finding of findings) {
|
|
6681
|
+
const text2 = `${finding.title ?? ""} ${finding.description ?? ""}`;
|
|
6682
|
+
const claimsAbsence = ABSENCE_CLAIM_RE.test(text2);
|
|
6683
|
+
if (claimsAbsence) {
|
|
6684
|
+
const guideline = finding.guideline_number ?? "";
|
|
6685
|
+
const refuted = REFUTABLE_CLAIMS.some(
|
|
6686
|
+
(claim) => resolved.has(claim.signal) && claim.guidelines.includes(guideline) && claim.topic.test(text2)
|
|
6687
|
+
);
|
|
6688
|
+
if (refuted) continue;
|
|
6689
|
+
if (!finding.file || !opts.corpusPaths.has(finding.file)) continue;
|
|
6690
|
+
}
|
|
6691
|
+
if (finding.risk_level === "high" && !finding.evidence?.trim()) {
|
|
6692
|
+
kept.push({ ...finding, risk_level: "medium" });
|
|
6693
|
+
downgradedCount += 1;
|
|
6694
|
+
continue;
|
|
6695
|
+
}
|
|
6696
|
+
kept.push(finding);
|
|
6697
|
+
}
|
|
6698
|
+
return { kept, droppedCount: findings.length - kept.length, downgradedCount };
|
|
6699
|
+
}
|
|
6032
6700
|
var FEATURE_CAPABILITY_MAP = {
|
|
6033
6701
|
push_notifications: "push_notifications",
|
|
6034
6702
|
push_notification: "push_notifications",
|
|
@@ -6083,9 +6751,11 @@ function mapFeaturesToCapabilities(analysis) {
|
|
|
6083
6751
|
}
|
|
6084
6752
|
return [...caps].sort();
|
|
6085
6753
|
}
|
|
6086
|
-
function
|
|
6087
|
-
if (value === null) return
|
|
6088
|
-
|
|
6754
|
+
function formatSignalLine(label, value, profile, factPath) {
|
|
6755
|
+
if (value === null || value === void 0) return `- ${label}: unknown`;
|
|
6756
|
+
if (!value) return `- ${label}: NOT FOUND anywhere in the scanned source`;
|
|
6757
|
+
const file = profile.evidence[factPath]?.file;
|
|
6758
|
+
return `- ${label}: FOUND${file ? ` (evidence: ${file})` : ""}`;
|
|
6089
6759
|
}
|
|
6090
6760
|
function formatProfileDigest(profile) {
|
|
6091
6761
|
const lines = [];
|
|
@@ -6123,8 +6793,67 @@ function formatProfileDigest(profile) {
|
|
|
6123
6793
|
lines.push(`- Android permissions: ${profile.android.permissions.join(", ") || "none"}`);
|
|
6124
6794
|
}
|
|
6125
6795
|
const signals = profile.signals;
|
|
6796
|
+
const scanned = profile.stats.files_scanned;
|
|
6797
|
+
const discovered = profile.stats.files_discovered;
|
|
6798
|
+
const complete = profile.stats.coverage_complete;
|
|
6799
|
+
lines.push("");
|
|
6800
|
+
lines.push("### Resolved By Deterministic Scan (ground truth, do not contradict)");
|
|
6801
|
+
if (discovered !== void 0 && complete === true) {
|
|
6802
|
+
lines.push(
|
|
6803
|
+
`The scanner read ALL ${discovered} source files in this project. The results below are facts, not impressions.`
|
|
6804
|
+
);
|
|
6805
|
+
} else if (discovered !== void 0) {
|
|
6806
|
+
lines.push(
|
|
6807
|
+
`The scanner read ${scanned} of ${discovered} source files (coverage incomplete). Anything marked "unknown" was not established either way.`
|
|
6808
|
+
);
|
|
6809
|
+
}
|
|
6126
6810
|
lines.push(
|
|
6127
|
-
|
|
6811
|
+
formatSignalLine(
|
|
6812
|
+
"Restore purchases",
|
|
6813
|
+
signals.restore_purchases_found,
|
|
6814
|
+
profile,
|
|
6815
|
+
"signals.restore_purchases_found"
|
|
6816
|
+
)
|
|
6817
|
+
);
|
|
6818
|
+
lines.push(
|
|
6819
|
+
formatSignalLine(
|
|
6820
|
+
"Account deletion",
|
|
6821
|
+
signals.account_deletion_found,
|
|
6822
|
+
profile,
|
|
6823
|
+
"signals.account_deletion_found"
|
|
6824
|
+
)
|
|
6825
|
+
);
|
|
6826
|
+
lines.push(
|
|
6827
|
+
formatSignalLine(
|
|
6828
|
+
"In-app privacy policy link",
|
|
6829
|
+
signals.privacy_policy_link_found,
|
|
6830
|
+
profile,
|
|
6831
|
+
"signals.privacy_policy_link_found"
|
|
6832
|
+
)
|
|
6833
|
+
);
|
|
6834
|
+
lines.push(
|
|
6835
|
+
formatSignalLine(
|
|
6836
|
+
"External checkout URL",
|
|
6837
|
+
signals.external_checkout_url_found,
|
|
6838
|
+
profile,
|
|
6839
|
+
"signals.external_checkout_url_found"
|
|
6840
|
+
)
|
|
6841
|
+
);
|
|
6842
|
+
lines.push(
|
|
6843
|
+
formatSignalLine(
|
|
6844
|
+
"User-generated content surface",
|
|
6845
|
+
signals.ugc_surface_found,
|
|
6846
|
+
profile,
|
|
6847
|
+
"signals.ugc_surface_found"
|
|
6848
|
+
)
|
|
6849
|
+
);
|
|
6850
|
+
lines.push(
|
|
6851
|
+
formatSignalLine(
|
|
6852
|
+
"Moderation controls (report/block)",
|
|
6853
|
+
signals.moderation_controls_found,
|
|
6854
|
+
profile,
|
|
6855
|
+
"signals.moderation_controls_found"
|
|
6856
|
+
)
|
|
6128
6857
|
);
|
|
6129
6858
|
return lines.join("\n");
|
|
6130
6859
|
}
|
|
@@ -6179,6 +6908,12 @@ ${featureAnalysis.features.map((f) => `- ${f.feature}: ${f.description} (confide
|
|
|
6179
6908
|
**Risk Areas:**
|
|
6180
6909
|
${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}
|
|
6181
6910
|
|
|
6911
|
+
` : "";
|
|
6912
|
+
const inventorySection = input.inventory && input.inventory.length > 0 ? `## Source File Inventory (${input.inventory.length} files, complete)
|
|
6913
|
+
Every source file in this project. Files not reproduced below still EXIST; you simply were not shown them.
|
|
6914
|
+
|
|
6915
|
+
${input.inventory.join("\n")}
|
|
6916
|
+
|
|
6182
6917
|
` : "";
|
|
6183
6918
|
const userPrompt = `## App Information
|
|
6184
6919
|
- Platform: ${input.platform}
|
|
@@ -6198,7 +6933,7 @@ These are REAL cases where Apple rejected similar apps. Use them to calibrate:
|
|
|
6198
6933
|
|
|
6199
6934
|
${storiesText}
|
|
6200
6935
|
|
|
6201
|
-
## Source Code Context
|
|
6936
|
+
${inventorySection}## Source Code Context
|
|
6202
6937
|
|
|
6203
6938
|
${input.sourceContext}
|
|
6204
6939
|
|
|
@@ -6214,6 +6949,8 @@ Now review this app as an Apple reviewer testing it on a device. Focus on behavi
|
|
|
6214
6949
|
featuresDetected,
|
|
6215
6950
|
ragStoriesUsed: relevantStories.length,
|
|
6216
6951
|
dedupedCount: 0,
|
|
6952
|
+
unsupportedCount: 0,
|
|
6953
|
+
downgradedCount: 0,
|
|
6217
6954
|
profile,
|
|
6218
6955
|
checks,
|
|
6219
6956
|
staticFindings
|
|
@@ -6221,12 +6958,24 @@ Now review this app as an Apple reviewer testing it on a device. Focus on behavi
|
|
|
6221
6958
|
}
|
|
6222
6959
|
const converted = convertFindings(parsed);
|
|
6223
6960
|
const { kept, droppedCount } = dedupeAIFindings(converted, staticFindings);
|
|
6961
|
+
const resolvedTrue = [];
|
|
6962
|
+
const sig = profile.signals;
|
|
6963
|
+
if (sig.account_deletion_found === true) resolvedTrue.push("account_deletion");
|
|
6964
|
+
if (sig.restore_purchases_found === true) resolvedTrue.push("restore_purchases");
|
|
6965
|
+
if (sig.privacy_policy_link_found === true) resolvedTrue.push("privacy_policy_link");
|
|
6966
|
+
if (sig.moderation_controls_found === true) resolvedTrue.push("moderation_controls");
|
|
6967
|
+
const guarded = filterUnsupportedAIFindings(kept, {
|
|
6968
|
+
corpusPaths: new Set(input.inventory ?? []),
|
|
6969
|
+
resolvedTrue
|
|
6970
|
+
});
|
|
6224
6971
|
return {
|
|
6225
|
-
aiFindings: kept,
|
|
6972
|
+
aiFindings: guarded.kept,
|
|
6226
6973
|
aiAssessment: parsed.overallAssessment || "AI review completed.",
|
|
6227
6974
|
featuresDetected,
|
|
6228
6975
|
ragStoriesUsed: relevantStories.length,
|
|
6229
6976
|
dedupedCount: droppedCount,
|
|
6977
|
+
unsupportedCount: guarded.droppedCount,
|
|
6978
|
+
downgradedCount: guarded.downgradedCount,
|
|
6230
6979
|
profile,
|
|
6231
6980
|
checks,
|
|
6232
6981
|
staticFindings
|
|
@@ -6293,6 +7042,9 @@ function assembleReportV2(input) {
|
|
|
6293
7042
|
return {
|
|
6294
7043
|
features_detected: input.featuresDetected,
|
|
6295
7044
|
source_files_analyzed: input.sourceFilesAnalyzed,
|
|
7045
|
+
...input.sourceFilesDiscovered !== void 0 ? { source_files_discovered: input.sourceFilesDiscovered } : {},
|
|
7046
|
+
...input.sourceScanComplete !== void 0 ? { source_scan_complete: input.sourceScanComplete } : {},
|
|
7047
|
+
...input.aiContextFiles !== void 0 ? { ai_context_files: input.aiContextFiles } : {},
|
|
6296
7048
|
platform: input.platform,
|
|
6297
7049
|
bundle_id: input.bundleId,
|
|
6298
7050
|
findings,
|
|
@@ -6311,7 +7063,7 @@ function assembleReportV2(input) {
|
|
|
6311
7063
|
}
|
|
6312
7064
|
|
|
6313
7065
|
// src/review/formatters.ts
|
|
6314
|
-
import
|
|
7066
|
+
import chalk4 from "chalk";
|
|
6315
7067
|
var RISK_ICONS = {
|
|
6316
7068
|
high: "\u{1F534}",
|
|
6317
7069
|
medium: "\u{1F7E1}",
|
|
@@ -6325,33 +7077,44 @@ var RISK_LABELS = {
|
|
|
6325
7077
|
info: "INFO"
|
|
6326
7078
|
};
|
|
6327
7079
|
var RISK_COLORS = {
|
|
6328
|
-
high:
|
|
6329
|
-
medium:
|
|
6330
|
-
low:
|
|
6331
|
-
info:
|
|
7080
|
+
high: chalk4.red,
|
|
7081
|
+
medium: chalk4.yellow,
|
|
7082
|
+
low: chalk4.blue,
|
|
7083
|
+
info: chalk4.gray
|
|
6332
7084
|
};
|
|
7085
|
+
function formatCoverage(report) {
|
|
7086
|
+
const analyzed = report.source_files_analyzed;
|
|
7087
|
+
const discovered = report.source_files_discovered;
|
|
7088
|
+
if (discovered === void 0 || discovered <= 0) {
|
|
7089
|
+
return `${analyzed} source files analyzed`;
|
|
7090
|
+
}
|
|
7091
|
+
const pct = Math.round(analyzed / discovered * 100);
|
|
7092
|
+
const ai = report.ai_context_files;
|
|
7093
|
+
const aiPart = ai !== void 0 ? ` \xB7 ${ai} sent to AI` : "";
|
|
7094
|
+
return `${analyzed} of ${discovered} source files scanned (${pct}%)${aiPart}`;
|
|
7095
|
+
}
|
|
6333
7096
|
var STATIC_ONLY_BANNER = "AI behavioral review skipped. Static rule results only.";
|
|
6334
7097
|
function outcomeDisplay(outcome) {
|
|
6335
7098
|
switch (outcome) {
|
|
6336
7099
|
case "likely-approved":
|
|
6337
|
-
return
|
|
7100
|
+
return chalk4.green.bold("LIKELY APPROVED");
|
|
6338
7101
|
case "likely-rejected":
|
|
6339
|
-
return
|
|
7102
|
+
return chalk4.red.bold("LIKELY REJECTED");
|
|
6340
7103
|
case "needs-attention":
|
|
6341
|
-
return
|
|
7104
|
+
return chalk4.yellow.bold("NEEDS ATTENTION");
|
|
6342
7105
|
default:
|
|
6343
|
-
return
|
|
7106
|
+
return chalk4.gray(outcome);
|
|
6344
7107
|
}
|
|
6345
7108
|
}
|
|
6346
7109
|
function approvalBar(chance) {
|
|
6347
7110
|
const width = 20;
|
|
6348
7111
|
const filled = Math.round(chance / 100 * width);
|
|
6349
7112
|
const empty = width - filled;
|
|
6350
|
-
let
|
|
6351
|
-
if (chance >= 70)
|
|
6352
|
-
else if (chance >= 40)
|
|
6353
|
-
else
|
|
6354
|
-
return
|
|
7113
|
+
let color2;
|
|
7114
|
+
if (chance >= 70) color2 = chalk4.green;
|
|
7115
|
+
else if (chance >= 40) color2 = chalk4.yellow;
|
|
7116
|
+
else color2 = chalk4.red;
|
|
7117
|
+
return color2("\u2588".repeat(filled)) + chalk4.gray("\u2591".repeat(empty)) + ` ${chance}%`;
|
|
6355
7118
|
}
|
|
6356
7119
|
function groupChecks(checks) {
|
|
6357
7120
|
return {
|
|
@@ -6362,8 +7125,8 @@ function groupChecks(checks) {
|
|
|
6362
7125
|
};
|
|
6363
7126
|
}
|
|
6364
7127
|
function detectionBadge(finding) {
|
|
6365
|
-
if (finding.detection === "static") return
|
|
6366
|
-
if (finding.detection === "ai") return
|
|
7128
|
+
if (finding.detection === "static") return chalk4.dim("[static]") + " ";
|
|
7129
|
+
if (finding.detection === "ai") return chalk4.cyan("[AI]") + " ";
|
|
6367
7130
|
return "";
|
|
6368
7131
|
}
|
|
6369
7132
|
function versionFooter(report) {
|
|
@@ -6380,57 +7143,64 @@ function versionFooter(report) {
|
|
|
6380
7143
|
function formatTerminal(report) {
|
|
6381
7144
|
const lines = [];
|
|
6382
7145
|
lines.push("");
|
|
6383
|
-
lines.push(
|
|
6384
|
-
lines.push(
|
|
7146
|
+
lines.push(chalk4.bold(" \u{1F6E1}\uFE0F App Store Review Simulation"));
|
|
7147
|
+
lines.push(chalk4.gray(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
6385
7148
|
lines.push("");
|
|
6386
|
-
lines.push(` Platform: ${
|
|
7149
|
+
lines.push(` Platform: ${chalk4.white(report.platform)}`);
|
|
6387
7150
|
if (report.bundle_id) {
|
|
6388
|
-
lines.push(` Bundle ID: ${
|
|
7151
|
+
lines.push(` Bundle ID: ${chalk4.white(report.bundle_id)}`);
|
|
6389
7152
|
}
|
|
6390
|
-
lines.push(` Files: ${
|
|
6391
|
-
|
|
6392
|
-
|
|
7153
|
+
lines.push(` Files: ${chalk4.white(formatCoverage(report))}`);
|
|
7154
|
+
if (report.source_scan_complete === false) {
|
|
7155
|
+
lines.push(
|
|
7156
|
+
chalk4.yellow(
|
|
7157
|
+
` Partial scan. Signals that were not found are reported as unverified, not as violations.`
|
|
7158
|
+
)
|
|
7159
|
+
);
|
|
7160
|
+
}
|
|
7161
|
+
lines.push(` Stories: ${chalk4.white(String(report.rag_stories_used))} rejection cases referenced`);
|
|
7162
|
+
lines.push(` Duration: ${chalk4.white((report.scan_duration_ms / 1e3).toFixed(1) + "s")}`);
|
|
6393
7163
|
lines.push("");
|
|
6394
7164
|
lines.push(` Outcome: ${outcomeDisplay(report.summary.outcome)}`);
|
|
6395
7165
|
lines.push(` Approval: ${approvalBar(report.summary.estimated_approval_chance)}`);
|
|
6396
7166
|
lines.push("");
|
|
6397
7167
|
const counts = [
|
|
6398
|
-
report.summary.high_risk > 0 &&
|
|
6399
|
-
report.summary.medium_risk > 0 &&
|
|
6400
|
-
report.summary.low_risk > 0 &&
|
|
6401
|
-
report.summary.info > 0 &&
|
|
7168
|
+
report.summary.high_risk > 0 && chalk4.red(`${report.summary.high_risk} high risk`),
|
|
7169
|
+
report.summary.medium_risk > 0 && chalk4.yellow(`${report.summary.medium_risk} medium`),
|
|
7170
|
+
report.summary.low_risk > 0 && chalk4.blue(`${report.summary.low_risk} low`),
|
|
7171
|
+
report.summary.info > 0 && chalk4.gray(`${report.summary.info} info`)
|
|
6402
7172
|
].filter(Boolean);
|
|
6403
7173
|
lines.push(
|
|
6404
|
-
` Findings: ${
|
|
7174
|
+
` Findings: ${chalk4.bold(String(report.summary.total_findings))} total` + (counts.length > 0 ? ` (${counts.join(" \xB7 ")})` : "")
|
|
6405
7175
|
);
|
|
6406
7176
|
lines.push("");
|
|
6407
7177
|
if (report.features_detected.length > 0) {
|
|
6408
|
-
lines.push(` Features: ${
|
|
7178
|
+
lines.push(` Features: ${chalk4.gray(report.features_detected.join(", "))}`);
|
|
6409
7179
|
lines.push("");
|
|
6410
7180
|
}
|
|
6411
7181
|
if (report.summary.ai_assessment) {
|
|
6412
|
-
lines.push(
|
|
6413
|
-
lines.push(` ${
|
|
6414
|
-
lines.push(
|
|
7182
|
+
lines.push(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
7183
|
+
lines.push(` ${chalk4.italic(report.summary.ai_assessment)}`);
|
|
7184
|
+
lines.push(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
6415
7185
|
lines.push("");
|
|
6416
7186
|
}
|
|
6417
7187
|
if (report.checks && report.checks.length > 0) {
|
|
6418
7188
|
const groups = groupChecks(report.checks);
|
|
6419
|
-
lines.push(
|
|
7189
|
+
lines.push(chalk4.bold(" COMPLIANCE CHECKS"));
|
|
6420
7190
|
lines.push(
|
|
6421
|
-
` ${
|
|
7191
|
+
` ${chalk4.red(`${groups.failed.length} failed`)}, ${chalk4.yellow(`${groups.unverified.length} verify manually`)}, ${chalk4.green(`${groups.passed.length} passed`)}, ${chalk4.gray(`${groups.na.length} not applicable`)}`
|
|
6422
7192
|
);
|
|
6423
7193
|
lines.push("");
|
|
6424
7194
|
for (const check of groups.failed) {
|
|
6425
|
-
lines.push(` ${
|
|
7195
|
+
lines.push(` ${chalk4.red(`\u2717 [${check.guideline_number}] ${check.title}`)}`);
|
|
6426
7196
|
}
|
|
6427
7197
|
for (const check of groups.unverified) {
|
|
6428
|
-
lines.push(` ${
|
|
7198
|
+
lines.push(` ${chalk4.yellow(`VERIFY: [${check.guideline_number}] ${check.title}`)}`);
|
|
6429
7199
|
}
|
|
6430
7200
|
if (groups.passed.length > 0) {
|
|
6431
7201
|
const noun = groups.passed.length === 1 ? "check" : "checks";
|
|
6432
7202
|
lines.push(
|
|
6433
|
-
|
|
7203
|
+
chalk4.gray(
|
|
6434
7204
|
` ${groups.passed.length} ${noun} passed (run with --format json for the full list)`
|
|
6435
7205
|
)
|
|
6436
7206
|
);
|
|
@@ -6452,38 +7222,38 @@ function formatTerminal(report) {
|
|
|
6452
7222
|
for (const finding of findings) {
|
|
6453
7223
|
const icon = RISK_ICONS[level];
|
|
6454
7224
|
const label = RISK_LABELS[level];
|
|
6455
|
-
const
|
|
6456
|
-
lines.push(
|
|
6457
|
-
lines.push(` ${icon} ${
|
|
6458
|
-
lines.push(
|
|
7225
|
+
const color2 = RISK_COLORS[level];
|
|
7226
|
+
lines.push(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
7227
|
+
lines.push(` ${icon} ${color2(label)} Guideline ${finding.guideline_number} - ${finding.guideline_name}`);
|
|
7228
|
+
lines.push(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
6459
7229
|
lines.push("");
|
|
6460
|
-
lines.push(` ${detectionBadge(finding)}${
|
|
7230
|
+
lines.push(` ${detectionBadge(finding)}${chalk4.bold(finding.title)}`);
|
|
6461
7231
|
lines.push(` ${finding.description}`);
|
|
6462
7232
|
lines.push("");
|
|
6463
|
-
lines.push(` ${
|
|
7233
|
+
lines.push(` ${chalk4.cyan("User Impact:")} ${finding.user_impact}`);
|
|
6464
7234
|
if (finding.file) {
|
|
6465
|
-
lines.push(` ${
|
|
7235
|
+
lines.push(` ${chalk4.cyan("File:")} ${finding.file}`);
|
|
6466
7236
|
}
|
|
6467
|
-
lines.push(` ${
|
|
7237
|
+
lines.push(` ${chalk4.green("Fix:")} ${finding.recommendation}`);
|
|
6468
7238
|
if (finding.related_story_id) {
|
|
6469
|
-
lines.push(` ${
|
|
7239
|
+
lines.push(` ${chalk4.gray("Related case:")} ${finding.related_story_id}`);
|
|
6470
7240
|
}
|
|
6471
7241
|
lines.push("");
|
|
6472
7242
|
}
|
|
6473
7243
|
}
|
|
6474
7244
|
if (report.findings.length === 0) {
|
|
6475
|
-
lines.push(
|
|
7245
|
+
lines.push(chalk4.green(" \u2713 No significant issues found. Your app looks ready for review!"));
|
|
6476
7246
|
lines.push("");
|
|
6477
7247
|
}
|
|
6478
|
-
lines.push(
|
|
7248
|
+
lines.push(chalk4.gray(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
6479
7249
|
if (report.ai_review_ran === false) {
|
|
6480
|
-
lines.push(
|
|
7250
|
+
lines.push(chalk4.yellow(` ${STATIC_ONLY_BANNER}`));
|
|
6481
7251
|
}
|
|
6482
7252
|
const footer = versionFooter(report);
|
|
6483
7253
|
if (footer) {
|
|
6484
|
-
lines.push(
|
|
7254
|
+
lines.push(chalk4.gray(` ${footer}`));
|
|
6485
7255
|
}
|
|
6486
|
-
lines.push(
|
|
7256
|
+
lines.push(chalk4.gray(" Powered by Forvibe CLI (forvibe.app)"));
|
|
6487
7257
|
lines.push("");
|
|
6488
7258
|
return lines.join("\n");
|
|
6489
7259
|
}
|
|
@@ -6498,7 +7268,12 @@ function formatMarkdown(report) {
|
|
|
6498
7268
|
if (report.bundle_id) {
|
|
6499
7269
|
lines.push(`**Bundle ID:** ${report.bundle_id}`);
|
|
6500
7270
|
}
|
|
6501
|
-
lines.push(`**Files Analyzed:** ${report
|
|
7271
|
+
lines.push(`**Files Analyzed:** ${formatCoverage(report)}`);
|
|
7272
|
+
if (report.source_scan_complete === false) {
|
|
7273
|
+
lines.push(
|
|
7274
|
+
"> Partial scan. Signals that were not found are reported as unverified, not as violations."
|
|
7275
|
+
);
|
|
7276
|
+
}
|
|
6502
7277
|
lines.push(`**Rejection Cases Referenced:** ${report.rag_stories_used}`);
|
|
6503
7278
|
lines.push(`**Scan Duration:** ${(report.scan_duration_ms / 1e3).toFixed(1)}s`);
|
|
6504
7279
|
lines.push("");
|
|
@@ -6594,21 +7369,6 @@ function appendMarkdownFooter(lines, report) {
|
|
|
6594
7369
|
lines.push("");
|
|
6595
7370
|
}
|
|
6596
7371
|
|
|
6597
|
-
// src/utils/version.ts
|
|
6598
|
-
import { createRequire } from "module";
|
|
6599
|
-
var FALLBACK_VERSION = "0.0.0";
|
|
6600
|
-
function getCliVersion() {
|
|
6601
|
-
const require2 = createRequire(import.meta.url);
|
|
6602
|
-
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
6603
|
-
try {
|
|
6604
|
-
const pkg = require2(candidate);
|
|
6605
|
-
if (pkg?.version && pkg.name === "@forvibe/cli") return pkg.version;
|
|
6606
|
-
} catch {
|
|
6607
|
-
}
|
|
6608
|
-
}
|
|
6609
|
-
return FALLBACK_VERSION;
|
|
6610
|
-
}
|
|
6611
|
-
|
|
6612
7372
|
// src/commands/review.ts
|
|
6613
7373
|
var DEFAULT_RULEPACK_BASE_URL = "https://forvibe.app";
|
|
6614
7374
|
function askQuestion2(question) {
|
|
@@ -6631,26 +7391,26 @@ async function reviewCommand(options) {
|
|
|
6631
7391
|
const startTime = Date.now();
|
|
6632
7392
|
console.log();
|
|
6633
7393
|
console.log(
|
|
6634
|
-
|
|
7394
|
+
chalk5.bold(" Forvibe CLI") + chalk5.gray(" - App Store Review Simulation") + (deep ? chalk5.cyan(" [DEEP MODE]") : "") + (staticOnly ? chalk5.cyan(" [STATIC ONLY]") : "")
|
|
6635
7395
|
);
|
|
6636
7396
|
console.log();
|
|
6637
|
-
const techSpinner =
|
|
7397
|
+
const techSpinner = ora4({
|
|
6638
7398
|
text: "Detecting tech stack...",
|
|
6639
7399
|
prefixText: " "
|
|
6640
7400
|
}).start();
|
|
6641
7401
|
const techStack = detectTechStack(rootDir);
|
|
6642
7402
|
if (techStack.stack === "unknown") {
|
|
6643
7403
|
techSpinner.fail(
|
|
6644
|
-
|
|
7404
|
+
chalk5.red(
|
|
6645
7405
|
"No supported project found. Supported: Swift, Objective-C, Flutter, React Native, Kotlin, Kotlin Multiplatform, Capacitor, .NET MAUI, Unity"
|
|
6646
7406
|
)
|
|
6647
7407
|
);
|
|
6648
7408
|
process.exit(1);
|
|
6649
7409
|
}
|
|
6650
7410
|
techSpinner.succeed(
|
|
6651
|
-
`Tech stack: ${
|
|
7411
|
+
`Tech stack: ${chalk5.bold(techStack.label)} ${chalk5.gray(`(${techStack.platforms.join(", ")})`)}`
|
|
6652
7412
|
);
|
|
6653
|
-
const configSpinner =
|
|
7413
|
+
const configSpinner = ora4({
|
|
6654
7414
|
text: "Reading project configuration...",
|
|
6655
7415
|
prefixText: " "
|
|
6656
7416
|
}).start();
|
|
@@ -6658,9 +7418,9 @@ async function reviewCommand(options) {
|
|
|
6658
7418
|
const appName = config.app_name || "Unknown App";
|
|
6659
7419
|
const bundleId = config.bundle_id || "";
|
|
6660
7420
|
configSpinner.succeed(
|
|
6661
|
-
`Config: ${
|
|
7421
|
+
`Config: ${chalk5.white(appName)} ${bundleId ? chalk5.gray(`(${bundleId})`) : ""}`
|
|
6662
7422
|
);
|
|
6663
|
-
const rulepackSpinner =
|
|
7423
|
+
const rulepackSpinner = ora4({
|
|
6664
7424
|
text: "Loading review rulepack...",
|
|
6665
7425
|
prefixText: " "
|
|
6666
7426
|
}).start();
|
|
@@ -6670,27 +7430,32 @@ async function reviewCommand(options) {
|
|
|
6670
7430
|
loadedRulepack = await loadRulepackBundle({ baseUrl: rulepackBaseUrl });
|
|
6671
7431
|
} catch (error) {
|
|
6672
7432
|
rulepackSpinner.fail(
|
|
6673
|
-
|
|
7433
|
+
chalk5.red(error instanceof Error ? error.message : "Failed to load rulepack")
|
|
6674
7434
|
);
|
|
6675
7435
|
process.exit(1);
|
|
6676
7436
|
}
|
|
6677
7437
|
const { bundle } = loadedRulepack;
|
|
6678
7438
|
rulepackSpinner.succeed(
|
|
6679
|
-
`Rulepack ${
|
|
7439
|
+
`Rulepack ${chalk5.bold(`v${loadedRulepack.version}`)} ${chalk5.gray(`(${loadedRulepack.source})`)}`
|
|
6680
7440
|
);
|
|
6681
|
-
const sourceSpinner =
|
|
7441
|
+
const sourceSpinner = ora4({
|
|
6682
7442
|
text: "Reading source code...",
|
|
6683
7443
|
prefixText: " "
|
|
6684
7444
|
}).start();
|
|
6685
|
-
const
|
|
6686
|
-
const
|
|
6687
|
-
const
|
|
6688
|
-
const projectTree = generateProjectTree(rootDir);
|
|
6689
|
-
const sourceLines = sourceCode.split("\n").length;
|
|
7445
|
+
const corpus = readSourceCorpus(rootDir, techStack.stack);
|
|
7446
|
+
const projectTree = generateProjectTree(rootDir, 5, 600);
|
|
7447
|
+
const cov = corpus.coverage;
|
|
6690
7448
|
sourceSpinner.succeed(
|
|
6691
|
-
`Source
|
|
7449
|
+
`Source: ${chalk5.bold(String(cov.read))} of ${chalk5.bold(String(cov.discovered))} files scanned ` + chalk5.gray(`(${(cov.total_bytes / 1024 / 1024).toFixed(1)} MB)`)
|
|
6692
7450
|
);
|
|
6693
|
-
|
|
7451
|
+
if (!cov.complete) {
|
|
7452
|
+
console.log(
|
|
7453
|
+
chalk5.yellow(
|
|
7454
|
+
` Partial scan (${cov.limits_hit.join(", ")}). Signals that were not found are reported as unverified, not as violations.`
|
|
7455
|
+
)
|
|
7456
|
+
);
|
|
7457
|
+
}
|
|
7458
|
+
const staticSpinner = ora4({
|
|
6694
7459
|
text: "Running static rule engine...",
|
|
6695
7460
|
prefixText: " "
|
|
6696
7461
|
}).start();
|
|
@@ -6705,58 +7470,66 @@ async function reviewCommand(options) {
|
|
|
6705
7470
|
min_android_sdk: config.min_android_sdk
|
|
6706
7471
|
},
|
|
6707
7472
|
bundle,
|
|
6708
|
-
|
|
7473
|
+
sourceCorpus: corpus
|
|
6709
7474
|
});
|
|
7475
|
+
const maxChars = deep ? 4e5 : 15e4;
|
|
7476
|
+
const aiCorpus = selectAiCorpus(
|
|
7477
|
+
corpus,
|
|
7478
|
+
staticResult.signalHits,
|
|
7479
|
+
techStack.stack,
|
|
7480
|
+
maxChars
|
|
7481
|
+
);
|
|
7482
|
+
const sourceCode = joinSourceFiles(aiCorpus.files);
|
|
6710
7483
|
const checkCount = (status) => staticResult.checks.filter((c) => c.status === status).length;
|
|
6711
7484
|
staticSpinner.succeed(
|
|
6712
|
-
`Static scan: ${
|
|
7485
|
+
`Static scan: ${chalk5.red(`${checkCount("fail")} failed`)}, ${chalk5.yellow(`${checkCount("unverified")} unverified`)}, ${chalk5.green(`${checkCount("pass")} passed`)}, ${chalk5.gray(`${checkCount("na")} n/a`)}`
|
|
6713
7486
|
);
|
|
6714
7487
|
let provider = null;
|
|
6715
7488
|
if (!staticOnly) {
|
|
6716
|
-
const availableProviders = getAvailableProviders(
|
|
7489
|
+
const availableProviders = getAvailableProviders();
|
|
6717
7490
|
if (availableProviders.length === 0) {
|
|
6718
7491
|
console.log();
|
|
6719
7492
|
console.log(
|
|
6720
|
-
|
|
7493
|
+
chalk5.yellow(
|
|
6721
7494
|
" No AI API key found. AI behavioral review skipped. Static rule results below."
|
|
6722
7495
|
)
|
|
6723
7496
|
);
|
|
6724
|
-
console.log(
|
|
7497
|
+
console.log(chalk5.gray(" To enable the AI review pass, set one of the following:"));
|
|
6725
7498
|
console.log(
|
|
6726
|
-
|
|
7499
|
+
chalk5.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk5.gray(" https://console.anthropic.com/settings/keys") + chalk5.green(" (recommended)")
|
|
6727
7500
|
);
|
|
6728
7501
|
console.log(
|
|
6729
|
-
|
|
7502
|
+
chalk5.cyan(" export OPENAI_API_KEY=your-key") + chalk5.gray(" https://platform.openai.com/api-keys")
|
|
6730
7503
|
);
|
|
6731
7504
|
console.log(
|
|
6732
|
-
|
|
7505
|
+
chalk5.cyan(" export GEMINI_API_KEY=your-key") + chalk5.gray(" https://aistudio.google.com/apikey")
|
|
6733
7506
|
);
|
|
6734
7507
|
console.log();
|
|
6735
7508
|
} else if (availableProviders.length === 1) {
|
|
6736
7509
|
provider = availableProviders[0].create();
|
|
6737
7510
|
} else {
|
|
6738
7511
|
console.log();
|
|
6739
|
-
console.log(
|
|
7512
|
+
console.log(chalk5.white(" Multiple AI API keys detected. Choose a provider:\n"));
|
|
6740
7513
|
availableProviders.forEach((p, i) => {
|
|
6741
|
-
const label = `${i + 1}. ${p.name}${p.recommended ?
|
|
7514
|
+
const label = `${i + 1}. ${p.name}${p.recommended ? chalk5.green(" (recommended)") : ""}`;
|
|
6742
7515
|
console.log(` ${label}`);
|
|
6743
7516
|
});
|
|
6744
7517
|
console.log();
|
|
6745
7518
|
const answer = await askQuestion2(
|
|
6746
|
-
|
|
7519
|
+
chalk5.cyan(` Enter choice (1-${availableProviders.length}): `)
|
|
6747
7520
|
);
|
|
6748
7521
|
const index = parseInt(answer, 10) - 1;
|
|
6749
7522
|
if (isNaN(index) || index < 0 || index >= availableProviders.length) {
|
|
6750
7523
|
const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
|
|
6751
7524
|
provider = recommended.create();
|
|
6752
|
-
console.log(
|
|
7525
|
+
console.log(chalk5.gray(` Using ${recommended.name} (default)
|
|
6753
7526
|
`));
|
|
6754
7527
|
} else {
|
|
6755
7528
|
provider = availableProviders[index].create();
|
|
6756
7529
|
}
|
|
6757
7530
|
}
|
|
6758
7531
|
if (provider) {
|
|
6759
|
-
console.log(
|
|
7532
|
+
console.log(chalk5.gray(` AI Provider: ${provider.name} \u2713`));
|
|
6760
7533
|
console.log();
|
|
6761
7534
|
}
|
|
6762
7535
|
}
|
|
@@ -6765,7 +7538,10 @@ async function reviewCommand(options) {
|
|
|
6765
7538
|
report = assembleReportV2({
|
|
6766
7539
|
platform: techStack.label,
|
|
6767
7540
|
bundleId: config.bundle_id,
|
|
6768
|
-
sourceFilesAnalyzed:
|
|
7541
|
+
sourceFilesAnalyzed: cov.read,
|
|
7542
|
+
sourceFilesDiscovered: cov.discovered,
|
|
7543
|
+
sourceScanComplete: cov.complete,
|
|
7544
|
+
aiContextFiles: aiCorpus.files.length,
|
|
6769
7545
|
featuresDetected: [],
|
|
6770
7546
|
staticFindings: staticResult.findings,
|
|
6771
7547
|
aiFindings: [],
|
|
@@ -6790,13 +7566,13 @@ async function reviewCommand(options) {
|
|
|
6790
7566
|
const { text: redactedSource, redactedCount } = redactSecrets(enrichedSource);
|
|
6791
7567
|
if (redactedCount > 0) {
|
|
6792
7568
|
console.log(
|
|
6793
|
-
|
|
7569
|
+
chalk5.yellow(
|
|
6794
7570
|
` Redacted ${redactedCount} secret-like values before sending source to the AI provider.`
|
|
6795
7571
|
)
|
|
6796
7572
|
);
|
|
6797
7573
|
console.log();
|
|
6798
7574
|
}
|
|
6799
|
-
const reviewSpinner =
|
|
7575
|
+
const reviewSpinner = ora4({
|
|
6800
7576
|
text: deep ? `Running deep review with ${provider.name} (Pass 1: feature detection, Pass 2: Apple reviewer simulation)...` : `Running review with ${provider.name} (simulating Apple's review process)...`,
|
|
6801
7577
|
prefixText: " "
|
|
6802
7578
|
}).start();
|
|
@@ -6811,12 +7587,16 @@ async function reviewCommand(options) {
|
|
|
6811
7587
|
profile: staticResult.profile,
|
|
6812
7588
|
staticChecks: staticResult.checks,
|
|
6813
7589
|
staticFindings: staticResult.findings,
|
|
6814
|
-
bundle
|
|
7590
|
+
bundle,
|
|
7591
|
+
inventory: aiCorpus.inventory
|
|
6815
7592
|
});
|
|
6816
7593
|
report = assembleReportV2({
|
|
6817
7594
|
platform: techStack.label,
|
|
6818
7595
|
bundleId: config.bundle_id,
|
|
6819
|
-
sourceFilesAnalyzed:
|
|
7596
|
+
sourceFilesAnalyzed: cov.read,
|
|
7597
|
+
sourceFilesDiscovered: cov.discovered,
|
|
7598
|
+
sourceScanComplete: cov.complete,
|
|
7599
|
+
aiContextFiles: aiCorpus.files.length,
|
|
6820
7600
|
featuresDetected: aiResult.featuresDetected,
|
|
6821
7601
|
staticFindings: aiResult.staticFindings,
|
|
6822
7602
|
aiFindings: aiResult.aiFindings,
|
|
@@ -6834,20 +7614,34 @@ async function reviewCommand(options) {
|
|
|
6834
7614
|
scanDurationMs: Date.now() - startTime
|
|
6835
7615
|
});
|
|
6836
7616
|
reviewSpinner.succeed(
|
|
6837
|
-
|
|
7617
|
+
chalk5.green(
|
|
6838
7618
|
`Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
|
|
6839
7619
|
)
|
|
6840
7620
|
);
|
|
6841
7621
|
if (aiResult.dedupedCount > 0) {
|
|
6842
7622
|
console.log(
|
|
6843
|
-
|
|
7623
|
+
chalk5.gray(
|
|
6844
7624
|
` Deduplicated ${aiResult.dedupedCount} findings already covered by static analysis.`
|
|
6845
7625
|
)
|
|
6846
7626
|
);
|
|
6847
7627
|
}
|
|
7628
|
+
if (aiResult.unsupportedCount > 0) {
|
|
7629
|
+
console.log(
|
|
7630
|
+
chalk5.gray(
|
|
7631
|
+
` Discarded ${aiResult.unsupportedCount} AI findings that claimed a feature was missing without supporting evidence.`
|
|
7632
|
+
)
|
|
7633
|
+
);
|
|
7634
|
+
}
|
|
7635
|
+
if (aiResult.downgradedCount > 0) {
|
|
7636
|
+
console.log(
|
|
7637
|
+
chalk5.gray(
|
|
7638
|
+
` Downgraded ${aiResult.downgradedCount} high-risk AI findings that carried no code evidence.`
|
|
7639
|
+
)
|
|
7640
|
+
);
|
|
7641
|
+
}
|
|
6848
7642
|
} catch (error) {
|
|
6849
7643
|
reviewSpinner.fail(
|
|
6850
|
-
|
|
7644
|
+
chalk5.red(
|
|
6851
7645
|
`Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
6852
7646
|
)
|
|
6853
7647
|
);
|
|
@@ -6868,49 +7662,49 @@ async function reviewCommand(options) {
|
|
|
6868
7662
|
}
|
|
6869
7663
|
if (options.send) {
|
|
6870
7664
|
console.log(
|
|
6871
|
-
|
|
7665
|
+
chalk5.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")
|
|
6872
7666
|
);
|
|
6873
7667
|
console.log();
|
|
6874
7668
|
const otcCode = await askQuestion2(
|
|
6875
|
-
|
|
7669
|
+
chalk5.cyan(" \u{1F517} Enter your Forvibe connection code: ")
|
|
6876
7670
|
);
|
|
6877
7671
|
if (!otcCode || otcCode.length < 6) {
|
|
6878
7672
|
console.log(
|
|
6879
|
-
|
|
7673
|
+
chalk5.red(
|
|
6880
7674
|
"\n \u2717 Invalid code. Please enter the 6-character code from forvibe.app\n"
|
|
6881
7675
|
)
|
|
6882
7676
|
);
|
|
6883
7677
|
process.exit(1);
|
|
6884
7678
|
}
|
|
6885
|
-
const connectSpinner =
|
|
7679
|
+
const connectSpinner = ora4({
|
|
6886
7680
|
text: "Connecting to Forvibe...",
|
|
6887
7681
|
prefixText: " "
|
|
6888
7682
|
}).start();
|
|
6889
7683
|
const client = new ForvibeClient(options.apiUrl);
|
|
6890
7684
|
try {
|
|
6891
7685
|
await client.validateOTC(otcCode);
|
|
6892
|
-
connectSpinner.succeed(
|
|
7686
|
+
connectSpinner.succeed(chalk5.green("Connected to Forvibe!"));
|
|
6893
7687
|
} catch (error) {
|
|
6894
7688
|
connectSpinner.fail(
|
|
6895
|
-
|
|
7689
|
+
chalk5.red(error instanceof Error ? error.message : "Connection failed")
|
|
6896
7690
|
);
|
|
6897
7691
|
process.exit(1);
|
|
6898
7692
|
}
|
|
6899
|
-
const sendSpinner =
|
|
7693
|
+
const sendSpinner = ora4({
|
|
6900
7694
|
text: "Sending review results to Forvibe...",
|
|
6901
7695
|
prefixText: " "
|
|
6902
7696
|
}).start();
|
|
6903
7697
|
try {
|
|
6904
7698
|
const result = await client.submitCodebaseReview(report);
|
|
6905
|
-
sendSpinner.succeed(
|
|
7699
|
+
sendSpinner.succeed(chalk5.green("Review results sent!"));
|
|
6906
7700
|
console.log();
|
|
6907
7701
|
console.log(
|
|
6908
|
-
|
|
7702
|
+
chalk5.bold(" \u{1F310} View combined review at: ") + chalk5.cyan.underline(result.web_url)
|
|
6909
7703
|
);
|
|
6910
7704
|
console.log();
|
|
6911
7705
|
} catch (error) {
|
|
6912
7706
|
sendSpinner.fail(
|
|
6913
|
-
|
|
7707
|
+
chalk5.red(
|
|
6914
7708
|
`Failed to send: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
6915
7709
|
)
|
|
6916
7710
|
);
|
|
@@ -6946,6 +7740,13 @@ program.command("analyze", { isDefault: true }).description(
|
|
|
6946
7740
|
"Forvibe API URL (for development)",
|
|
6947
7741
|
void 0
|
|
6948
7742
|
).action(analyzeCommand);
|
|
7743
|
+
program.command("screenshots").description(
|
|
7744
|
+
"Send your app's features, brand style, icon, and screenshots to Forvibe Screenshot Studio (use the code from Continue with Autopilot)"
|
|
7745
|
+
).option("-d, --dir <path>", "Project directory to analyze", process.cwd()).option(
|
|
7746
|
+
"--api-url <url>",
|
|
7747
|
+
"Forvibe API URL (for development)",
|
|
7748
|
+
void 0
|
|
7749
|
+
).action(screenshotsCommand);
|
|
6949
7750
|
program.command("review").description(
|
|
6950
7751
|
"Simulate Apple's App Store review process on your project"
|
|
6951
7752
|
).option("-d, --dir <path>", "Project directory to review", process.cwd()).option("--deep", "Enable deep 2-pass review (feature detection + reviewer simulation)").option("--format <type>", "Output format: terminal, json, markdown", "terminal").option("--static-only", "Run only the static rule engine, skipping the AI behavioral review (no API key needed)").option("--send", "Send results to Forvibe for combined review with metadata analysis").option(
|