runcloud 0.1.20 → 0.1.22
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/commands/appStore.js +162 -0
- package/dist/commands/appStoreEvaluate.js +515 -0
- package/dist/commands/sandbox.js +4 -2
- package/dist/config.js +3 -1
- package/dist/index.js +2 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { evaluateCompliance, renderEvaluation } from './appStoreEvaluate.js';
|
|
3
|
+
const STATUSES = new Set(['pass', 'warning', 'fail', 'info']);
|
|
4
|
+
const EVIDENCE_REQUIRED = new Set(['pass', 'warning', 'fail']);
|
|
5
|
+
function activeItems(manifest) {
|
|
6
|
+
return manifest.items.filter((item) => !item.deferred && item.resolvedBy !== 'card');
|
|
7
|
+
}
|
|
8
|
+
export function loadComplianceManifest(path) {
|
|
9
|
+
const manifestPath = path ?? process.env.NEWLY_APP_STORE_COMPLIANCE_MANIFEST;
|
|
10
|
+
if (!manifestPath) {
|
|
11
|
+
throw new Error('Missing compliance manifest. Pass --manifest or set NEWLY_APP_STORE_COMPLIANCE_MANIFEST.');
|
|
12
|
+
}
|
|
13
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
export function renderComplianceCheck(manifest) {
|
|
16
|
+
const lines = activeItems(manifest).map((item) => {
|
|
17
|
+
const bits = [item.category, item.platform.join('+'), item.check];
|
|
18
|
+
if (item.guideline)
|
|
19
|
+
bits.push(`§${item.guideline}`);
|
|
20
|
+
if (item.critical)
|
|
21
|
+
bits.push('CRITICAL');
|
|
22
|
+
if (item.limit)
|
|
23
|
+
bits.push(`limit ${item.limit}`);
|
|
24
|
+
if (item.source)
|
|
25
|
+
bits.push(item.source);
|
|
26
|
+
if (item.authorable)
|
|
27
|
+
bits.push('authorable');
|
|
28
|
+
if (item.autofill)
|
|
29
|
+
bits.push(`autofill:${item.autofill}`);
|
|
30
|
+
return `[${item.priority}] ${item.id} (${bits.join(', ')}) - ${item.label}`;
|
|
31
|
+
});
|
|
32
|
+
return (`Compliance manifest v${manifest.manifest_version}. The item list and completeness gate are ` +
|
|
33
|
+
`deterministic manifest contracts. Start local deterministic rows with: newly app-store ` +
|
|
34
|
+
`compliance evaluate --manifest <compliance_manifest.json> --project . --json. Use Markdown ` +
|
|
35
|
+
`skills only for unresolved judgment/live-store rows and listing authoring. Determine a status ` +
|
|
36
|
+
`(pass/warning/fail/info) for EVERY item below and submit them ALL in one report_compliance ` +
|
|
37
|
+
`call, each with a short message and evidence (a file:line or command output). Carry each ` +
|
|
38
|
+
`item's guideline / critical / authorable / autofill flags through into the report.\n\n${lines.join('\n')}`);
|
|
39
|
+
}
|
|
40
|
+
export function validateComplianceReport(rawItems, manifest) {
|
|
41
|
+
const list = Array.isArray(rawItems) ? rawItems : [];
|
|
42
|
+
const byId = new Map();
|
|
43
|
+
for (const item of list) {
|
|
44
|
+
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
|
45
|
+
byId.set(item.id, item);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const missing = [];
|
|
49
|
+
const invalid = [];
|
|
50
|
+
for (const item of activeItems(manifest)) {
|
|
51
|
+
const reported = byId.get(item.id);
|
|
52
|
+
if (!reported) {
|
|
53
|
+
missing.push(item.id);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const status = reported.status;
|
|
57
|
+
if (typeof status !== 'string' || !STATUSES.has(status)) {
|
|
58
|
+
invalid.push(`${item.id} (status must be pass/warning/fail/info)`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const evidence = reported.evidence;
|
|
62
|
+
if (EVIDENCE_REQUIRED.has(status) &&
|
|
63
|
+
(typeof evidence !== 'string' || evidence.trim() === '')) {
|
|
64
|
+
invalid.push(`${item.id} (a ${status} verdict needs evidence)`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (missing.length || invalid.length) {
|
|
68
|
+
const parts = ['report_compliance rejected: the report is incomplete or invalid.'];
|
|
69
|
+
if (missing.length)
|
|
70
|
+
parts.push(`Missing ${missing.length} item(s): ${missing.join(', ')}.`);
|
|
71
|
+
if (invalid.length)
|
|
72
|
+
parts.push(`Invalid item(s): ${invalid.join('; ')}.`);
|
|
73
|
+
parts.push('Re-call report_compliance with the complete, corrected item list.');
|
|
74
|
+
return { ok: false, error: parts.join(' '), missing, invalid };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
items: activeItems(manifest).map((item) => {
|
|
79
|
+
const reported = byId.get(item.id);
|
|
80
|
+
return {
|
|
81
|
+
id: item.id,
|
|
82
|
+
label: typeof reported.label === 'string' ? reported.label : item.label,
|
|
83
|
+
status: reported.status,
|
|
84
|
+
message: typeof reported.message === 'string' ? reported.message : '',
|
|
85
|
+
evidence: typeof reported.evidence === 'string' ? reported.evidence : undefined,
|
|
86
|
+
category: typeof reported.category === 'string' ? reported.category : item.category,
|
|
87
|
+
priority: typeof reported.priority === 'string' ? reported.priority : item.priority,
|
|
88
|
+
platform: item.platform,
|
|
89
|
+
authorable: item.authorable === true || undefined,
|
|
90
|
+
autofill: item.autofill,
|
|
91
|
+
guideline: item.guideline,
|
|
92
|
+
critical: item.critical === true || undefined,
|
|
93
|
+
};
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function registerAppStore(program) {
|
|
98
|
+
const appStore = program.command('app-store').description('App Store / Google Play workflows');
|
|
99
|
+
const compliance = appStore
|
|
100
|
+
.command('compliance')
|
|
101
|
+
.description('App Store / Google Play readiness manifest contracts');
|
|
102
|
+
const printContract = (opts) => {
|
|
103
|
+
console.log(renderComplianceCheck(loadComplianceManifest(opts.manifest)));
|
|
104
|
+
};
|
|
105
|
+
const validateReport = (opts) => {
|
|
106
|
+
const manifest = loadComplianceManifest(opts.manifest);
|
|
107
|
+
const report = JSON.parse(readFileSync(opts.report, 'utf8'));
|
|
108
|
+
const items = Array.isArray(report) ? report : report.items;
|
|
109
|
+
const result = validateComplianceReport(items, manifest);
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
console.error(result.error);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(JSON.stringify({ items: result.items }, null, 2));
|
|
116
|
+
};
|
|
117
|
+
const evaluateProject = (opts) => {
|
|
118
|
+
const manifest = loadComplianceManifest(opts.manifest);
|
|
119
|
+
const result = evaluateCompliance(manifest, opts.project ?? process.cwd());
|
|
120
|
+
if (opts.json) {
|
|
121
|
+
console.log(JSON.stringify(result, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
console.log(renderEvaluation(result));
|
|
125
|
+
};
|
|
126
|
+
compliance
|
|
127
|
+
.command('contract')
|
|
128
|
+
.description('Print the canonical manifest contract; this does not evaluate the app')
|
|
129
|
+
.requiredOption('--manifest <path>', 'path to compliance_manifest.json')
|
|
130
|
+
.action(printContract);
|
|
131
|
+
compliance
|
|
132
|
+
.command('check')
|
|
133
|
+
.description('Alias for contract')
|
|
134
|
+
.requiredOption('--manifest <path>', 'path to compliance_manifest.json')
|
|
135
|
+
.action(printContract);
|
|
136
|
+
compliance
|
|
137
|
+
.command('validate-report')
|
|
138
|
+
.description('Validate a report_compliance JSON payload against the manifest')
|
|
139
|
+
.requiredOption('--manifest <path>', 'path to compliance_manifest.json')
|
|
140
|
+
.requiredOption('--report <path>', 'path to JSON report: either an array or { "items": [...] }')
|
|
141
|
+
.action(validateReport);
|
|
142
|
+
compliance
|
|
143
|
+
.command('validate')
|
|
144
|
+
.description('Alias for validate-report')
|
|
145
|
+
.requiredOption('--manifest <path>', 'path to compliance_manifest.json')
|
|
146
|
+
.requiredOption('--report <path>', 'path to JSON report: either an array or { "items": [...] }')
|
|
147
|
+
.action(validateReport);
|
|
148
|
+
compliance
|
|
149
|
+
.command('evaluate')
|
|
150
|
+
.description('Evaluate deterministic local checks and list unresolved manual/live-store rows')
|
|
151
|
+
.requiredOption('--manifest <path>', 'path to compliance_manifest.json')
|
|
152
|
+
.option('--project <path>', 'project directory to evaluate', process.cwd())
|
|
153
|
+
.option('--json', 'emit machine-readable JSON')
|
|
154
|
+
.action(evaluateProject);
|
|
155
|
+
compliance
|
|
156
|
+
.command('run')
|
|
157
|
+
.description('Alias for evaluate')
|
|
158
|
+
.requiredOption('--manifest <path>', 'path to compliance_manifest.json')
|
|
159
|
+
.option('--project <path>', 'project directory to evaluate', process.cwd())
|
|
160
|
+
.option('--json', 'emit machine-readable JSON')
|
|
161
|
+
.action(evaluateProject);
|
|
162
|
+
}
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const CONFIG_RULES = {
|
|
4
|
+
app_name: { path: ['expo', 'name'], absent: 'fail' },
|
|
5
|
+
slug: { path: ['expo', 'slug'], absent: 'fail' },
|
|
6
|
+
ios_bundle_id: { path: ['expo', 'ios', 'bundleIdentifier'], absent: 'fail' },
|
|
7
|
+
android_package: { path: ['expo', 'android', 'package'], absent: 'fail' },
|
|
8
|
+
version: { path: ['expo', 'version'], absent: 'warning' },
|
|
9
|
+
scheme: { path: ['expo', 'scheme'], absent: 'info' },
|
|
10
|
+
ios_encryption: { path: ['expo', 'ios', 'infoPlist', 'ITSAppUsesNonExemptEncryption'], absent: 'warning' },
|
|
11
|
+
user_interface_style: { path: ['expo', 'userInterfaceStyle'], absent: 'info' },
|
|
12
|
+
orientation: { path: ['expo', 'orientation'], absent: 'info' },
|
|
13
|
+
ios_supports_tablet: { path: ['expo', 'ios', 'supportsTablet'], absent: 'info' },
|
|
14
|
+
};
|
|
15
|
+
const JUDGMENT_IDS = new Set([
|
|
16
|
+
'apple_sign_in_order',
|
|
17
|
+
'permission_string_quality',
|
|
18
|
+
'account_deletion',
|
|
19
|
+
'iap_screen_compliance',
|
|
20
|
+
'background_mode_justification',
|
|
21
|
+
'push_notification_setup',
|
|
22
|
+
'deep_link_config',
|
|
23
|
+
]);
|
|
24
|
+
const LIVE_STORE_IDS = new Set([
|
|
25
|
+
'apple_privacy_policy',
|
|
26
|
+
'apple_support_url',
|
|
27
|
+
'apple_description',
|
|
28
|
+
'apple_store_name',
|
|
29
|
+
'apple_subtitle',
|
|
30
|
+
'apple_keywords',
|
|
31
|
+
'apple_promotional_text',
|
|
32
|
+
'apple_whats_new',
|
|
33
|
+
'apple_marketing_url',
|
|
34
|
+
'apple_copyright',
|
|
35
|
+
'apple_app_category',
|
|
36
|
+
'apple_secondary_category',
|
|
37
|
+
'apple_content_rights',
|
|
38
|
+
'apple_age_rating',
|
|
39
|
+
'apple_privacy_label',
|
|
40
|
+
'google_play_title',
|
|
41
|
+
'google_play_short_description',
|
|
42
|
+
'google_play_description',
|
|
43
|
+
'google_play_contact_email',
|
|
44
|
+
'google_play_contact_website',
|
|
45
|
+
'apple_screenshots',
|
|
46
|
+
'google_play_screenshots',
|
|
47
|
+
'google_play_feature_graphic',
|
|
48
|
+
]);
|
|
49
|
+
const STORE_REMINDER_IDS = new Set([
|
|
50
|
+
'android_privacy_policy',
|
|
51
|
+
'android_content_rating',
|
|
52
|
+
'android_app_category',
|
|
53
|
+
'google_data_safety',
|
|
54
|
+
'google_app_access',
|
|
55
|
+
'google_ads_declaration',
|
|
56
|
+
'google_target_audience',
|
|
57
|
+
'google_government_apps',
|
|
58
|
+
'google_financial_features',
|
|
59
|
+
'google_health',
|
|
60
|
+
]);
|
|
61
|
+
const TEXT_EXTENSIONS = new Set([
|
|
62
|
+
'.c',
|
|
63
|
+
'.cc',
|
|
64
|
+
'.cpp',
|
|
65
|
+
'.cxx',
|
|
66
|
+
'.h',
|
|
67
|
+
'.hpp',
|
|
68
|
+
'.java',
|
|
69
|
+
'.js',
|
|
70
|
+
'.jsx',
|
|
71
|
+
'.json',
|
|
72
|
+
'.kt',
|
|
73
|
+
'.m',
|
|
74
|
+
'.md',
|
|
75
|
+
'.mm',
|
|
76
|
+
'.mjs',
|
|
77
|
+
'.plist',
|
|
78
|
+
'.swift',
|
|
79
|
+
'.ts',
|
|
80
|
+
'.tsx',
|
|
81
|
+
'.xml',
|
|
82
|
+
]);
|
|
83
|
+
const SKIP_DIRS = new Set([
|
|
84
|
+
'.expo',
|
|
85
|
+
'.git',
|
|
86
|
+
'.next',
|
|
87
|
+
'build',
|
|
88
|
+
'dist',
|
|
89
|
+
'node_modules',
|
|
90
|
+
'Pods',
|
|
91
|
+
]);
|
|
92
|
+
export function evaluateCompliance(manifest, projectDir) {
|
|
93
|
+
const root = path.resolve(projectDir);
|
|
94
|
+
const appJson = readJson(path.join(root, 'app.json'));
|
|
95
|
+
const targetPlatforms = targetPlatformsFromAppJson(appJson);
|
|
96
|
+
const items = [];
|
|
97
|
+
const unresolved = [];
|
|
98
|
+
for (const item of activeItems(manifest)) {
|
|
99
|
+
if (!targetsItem(item, targetPlatforms)) {
|
|
100
|
+
items.push(makeItem(item, 'info', `Not targeting ${platformNames(item.platform)}.`, 'app.json target platform detection'));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const evaluated = evaluateLocalItem(root, appJson, item);
|
|
104
|
+
if (evaluated) {
|
|
105
|
+
items.push(evaluated);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
unresolved.push({
|
|
109
|
+
id: item.id,
|
|
110
|
+
label: item.label,
|
|
111
|
+
category: item.category,
|
|
112
|
+
priority: item.priority,
|
|
113
|
+
platform: item.platform,
|
|
114
|
+
check: item.check,
|
|
115
|
+
reason: unresolvedReason(item),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
manifestVersion: manifest.manifest_version,
|
|
120
|
+
projectDir: root,
|
|
121
|
+
targetPlatforms,
|
|
122
|
+
items,
|
|
123
|
+
unresolved,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function renderEvaluation(result) {
|
|
127
|
+
const lines = [
|
|
128
|
+
`Compliance evaluation draft for ${result.projectDir}`,
|
|
129
|
+
`Manifest v${result.manifestVersion}; target platforms: ${result.targetPlatforms.join(', ')}`,
|
|
130
|
+
'',
|
|
131
|
+
`Deterministic items (${result.items.length}):`,
|
|
132
|
+
];
|
|
133
|
+
for (const item of result.items) {
|
|
134
|
+
lines.push(`- ${item.status.toUpperCase()} ${item.id}: ${item.message ?? ''}`);
|
|
135
|
+
if (item.evidence)
|
|
136
|
+
lines.push(` evidence: ${item.evidence}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push('', `Unresolved items (${result.unresolved.length}):`);
|
|
139
|
+
for (const item of result.unresolved) {
|
|
140
|
+
lines.push(`- ${item.id}: ${item.reason}`);
|
|
141
|
+
}
|
|
142
|
+
lines.push('', 'JSON shape: { "items": [...deterministic report_compliance rows...], "unresolved": [...] }');
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
function activeItems(manifest) {
|
|
146
|
+
return manifest.items.filter((item) => !item.deferred && item.resolvedBy !== 'card');
|
|
147
|
+
}
|
|
148
|
+
function evaluateLocalItem(root, appJson, item) {
|
|
149
|
+
if (CONFIG_RULES[item.id])
|
|
150
|
+
return evaluateConfigItem(appJson, item, CONFIG_RULES[item.id]);
|
|
151
|
+
switch (item.id) {
|
|
152
|
+
case 'hardcoded_secrets':
|
|
153
|
+
return patternItem(root, item, /sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN.*PRIVATE KEY|api[_-]?key\s*[:=]\s*['"][A-Za-z0-9]{16,}/i, 'fail', 'Hardcoded secret-like value found.', 'No secret-like values found.');
|
|
154
|
+
case 'http_endpoints':
|
|
155
|
+
return patternItem(root, item, /http:\/\/(?!localhost|127\.0\.0\.1)/i, 'warning', 'Non-local HTTP endpoint found.', 'No non-local HTTP endpoints found.');
|
|
156
|
+
case 'placeholder_content':
|
|
157
|
+
return patternItem(root, item, /lorem ipsum|example\.com|\bTBD\b|\bTODO\b|Coming soon|placeholder/i, 'warning', 'Placeholder text found.', 'No placeholder text found.');
|
|
158
|
+
case 'competitor_reference':
|
|
159
|
+
return patternItem(root, item, /google play|\.apk\b|samsung|android app/i, 'warning', 'Possible platform/competitor reference found in user-facing copy.', 'No competitor platform references found.', { dirs: ['app', 'src', 'components'] });
|
|
160
|
+
case 'private_api_usage':
|
|
161
|
+
return patternItem(root, item, /dlopen|dlsym|NSSelectorFromString|performSelector|valueForKey.*private|_UI[A-Z]/, 'fail', 'Private API or dynamic lookup pattern found.', 'No private API patterns found.', { dirs: ['ios', 'app', 'src'] });
|
|
162
|
+
case 'dynamic_code_execution':
|
|
163
|
+
return patternItem(root, item, /JSPatch|rollout\.io|CodePush.*eval|eval\(.*fetch|new Function\(.*http/i, 'fail', 'Dynamic code execution pattern found.', 'No dynamic code execution patterns found.');
|
|
164
|
+
case 'uiwebview':
|
|
165
|
+
return patternItem(root, item, /\bUIWebView\b/, 'fail', 'UIWebView reference found.', 'No UIWebView references found.', { dirs: ['ios', 'app', 'src'] });
|
|
166
|
+
case 'crypto_mining':
|
|
167
|
+
return patternItem(root, item, /coinhive|cryptonight|miner|monero|stratum\+tcp/i, 'fail', 'Cryptocurrency mining pattern found.', 'No cryptocurrency mining patterns found.');
|
|
168
|
+
case 'hardcoded_ipv4':
|
|
169
|
+
return patternItem(root, item, /\b((25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\b/, 'warning', 'Non-local hardcoded IPv4 address found.', 'No non-local hardcoded IPv4 addresses found.', {
|
|
170
|
+
dirs: ['app', 'src'],
|
|
171
|
+
filter: (match) => !/\b(127\.0\.0\.1|0\.0\.0\.0)\b/.test(match.text),
|
|
172
|
+
});
|
|
173
|
+
case 'restore_purchases':
|
|
174
|
+
return evaluateRestorePurchases(root, item);
|
|
175
|
+
case 'tracking_without_att':
|
|
176
|
+
return evaluateTrackingWithoutAtt(root, item);
|
|
177
|
+
case 'external_payment_digital':
|
|
178
|
+
return evaluateExternalPayment(root, item);
|
|
179
|
+
case 'expo_layout_missing':
|
|
180
|
+
return evaluateExpoLayout(root, item);
|
|
181
|
+
case 'expo_duplicate_route':
|
|
182
|
+
return evaluateDuplicateRoutes(root, item);
|
|
183
|
+
case 'plugin_permission_missing':
|
|
184
|
+
return evaluatePluginPermissions(root, appJson, item);
|
|
185
|
+
case 'permission_descriptions':
|
|
186
|
+
return evaluatePermissionDescriptions(root, appJson, item);
|
|
187
|
+
case 'privacy_manifest':
|
|
188
|
+
return evaluatePrivacyManifest(root, item);
|
|
189
|
+
default:
|
|
190
|
+
if (STORE_REMINDER_IDS.has(item.id)) {
|
|
191
|
+
return makeItem(item, 'info', 'Complete this declaration in Google Play Console.', 'Play Console questionnaire');
|
|
192
|
+
}
|
|
193
|
+
if (JUDGMENT_IDS.has(item.id) || LIVE_STORE_IDS.has(item.id))
|
|
194
|
+
return null;
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function evaluateConfigItem(appJson, item, rule) {
|
|
199
|
+
const value = getPath(appJson, rule.path);
|
|
200
|
+
const present = hasValue(value);
|
|
201
|
+
const evidence = `app.json ${rule.path.join('.')} = ${value === undefined ? 'undefined' : JSON.stringify(value)}`;
|
|
202
|
+
if (present)
|
|
203
|
+
return makeItem(item, 'pass', `${item.label} is set.`, evidence);
|
|
204
|
+
return makeItem(item, rule.absent, `${item.label} is not set.`, evidence);
|
|
205
|
+
}
|
|
206
|
+
function evaluateRestorePurchases(root, item) {
|
|
207
|
+
const iap = scan(root, /react-native-purchases|expo-in-app-purchases|StoreKit/i);
|
|
208
|
+
if (!iap.length)
|
|
209
|
+
return makeItem(item, 'pass', 'No in-app purchase APIs found.', 'scan: no IAP APIs found');
|
|
210
|
+
const restore = scan(root, /restorePurchases|restoreTransactions/i);
|
|
211
|
+
if (restore.length)
|
|
212
|
+
return makeItem(item, 'pass', 'Restore purchases flow appears in source.', evidence(restore));
|
|
213
|
+
return makeItem(item, 'fail', 'IAP APIs found but no restore purchases flow was found.', evidence(iap));
|
|
214
|
+
}
|
|
215
|
+
function evaluateTrackingWithoutAtt(root, item) {
|
|
216
|
+
const tracking = scan(root, /react-native-firebase\/analytics|facebook|appsflyer|amplitude|segment/i);
|
|
217
|
+
if (!tracking.length)
|
|
218
|
+
return makeItem(item, 'pass', 'No tracking SDK patterns found.', 'scan: no tracking SDK patterns found');
|
|
219
|
+
const att = scan(root, /expo-tracking-transparency|requestTrackingPermission/i);
|
|
220
|
+
if (att.length)
|
|
221
|
+
return makeItem(item, 'pass', 'Tracking SDK and ATT permission request were both found.', evidence(att));
|
|
222
|
+
return makeItem(item, 'fail', 'Tracking SDK found without ATT permission request.', evidence(tracking));
|
|
223
|
+
}
|
|
224
|
+
function evaluateExternalPayment(root, item) {
|
|
225
|
+
const matches = scan(root, /@stripe\/stripe-react-native|paypal|braintree/i);
|
|
226
|
+
if (!matches.length)
|
|
227
|
+
return makeItem(item, 'pass', 'No external payment SDK patterns found.', 'scan: no Stripe/PayPal/Braintree patterns found');
|
|
228
|
+
return makeItem(item, 'warning', 'External payment SDK found; verify it is not used for digital goods on iOS.', evidence(matches));
|
|
229
|
+
}
|
|
230
|
+
function evaluateExpoLayout(root, item) {
|
|
231
|
+
const appDir = path.join(root, 'app');
|
|
232
|
+
if (!existsSync(appDir))
|
|
233
|
+
return makeItem(item, 'pass', 'No Expo Router app directory found.', 'scan: app/ directory absent');
|
|
234
|
+
const dirs = walkDirs(appDir).map((dir) => path.relative(root, dir) || 'app');
|
|
235
|
+
const missing = dirs.filter((dir) => !hasLayoutFile(path.join(root, dir)));
|
|
236
|
+
if (!missing.length)
|
|
237
|
+
return makeItem(item, 'pass', 'Every app route directory has a layout file.', 'scan: app route directories');
|
|
238
|
+
return makeItem(item, 'fail', 'Expo Router directories without _layout files found.', missing.slice(0, 8).join(', '));
|
|
239
|
+
}
|
|
240
|
+
function evaluateDuplicateRoutes(root, item) {
|
|
241
|
+
const appDir = path.join(root, 'app');
|
|
242
|
+
if (!existsSync(appDir))
|
|
243
|
+
return makeItem(item, 'pass', 'No Expo Router app directory found.', 'scan: app/ directory absent');
|
|
244
|
+
const routes = new Map();
|
|
245
|
+
for (const file of walkFiles(appDir)) {
|
|
246
|
+
if (!/\.(tsx|ts|jsx|js)$/.test(file) || /(^|\/)_layout\.(tsx|ts|jsx|js)$/.test(toRel(root, file)))
|
|
247
|
+
continue;
|
|
248
|
+
const route = routePath(root, file);
|
|
249
|
+
const current = routes.get(route) ?? [];
|
|
250
|
+
current.push(toRel(root, file));
|
|
251
|
+
routes.set(route, current);
|
|
252
|
+
}
|
|
253
|
+
const duplicates = [...routes.entries()].filter(([, files]) => files.length > 1);
|
|
254
|
+
if (!duplicates.length)
|
|
255
|
+
return makeItem(item, 'pass', 'No duplicate Expo routes found.', 'scan: app route files');
|
|
256
|
+
return makeItem(item, 'fail', 'Duplicate Expo route files found.', duplicates.slice(0, 5).map(([route, files]) => `${route}: ${files.join(', ')}`).join('\n'));
|
|
257
|
+
}
|
|
258
|
+
function evaluatePluginPermissions(root, appJson, item) {
|
|
259
|
+
const missing = missingPermissionRequirements(root, appJson);
|
|
260
|
+
if (!missing.length)
|
|
261
|
+
return makeItem(item, 'pass', 'Used native capabilities have matching plugin/permission declarations.', 'app.json permission cross-check');
|
|
262
|
+
return makeItem(item, 'fail', 'Native capability usage is missing a matching permission declaration.', missing.join('; '));
|
|
263
|
+
}
|
|
264
|
+
function evaluatePermissionDescriptions(root, appJson, item) {
|
|
265
|
+
const missing = missingIosUsageDescriptions(root, appJson);
|
|
266
|
+
if (!missing.length)
|
|
267
|
+
return makeItem(item, 'pass', 'Used iOS capabilities have usage descriptions.', 'app.json ios.infoPlist cross-check');
|
|
268
|
+
return makeItem(item, 'fail', 'Used iOS capability is missing an NS*UsageDescription.', missing.join('; '));
|
|
269
|
+
}
|
|
270
|
+
function evaluatePrivacyManifest(root, item) {
|
|
271
|
+
const hasManifest = findByName(root, 'PrivacyInfo.xcprivacy').length > 0;
|
|
272
|
+
const requiredReasonApi = scan(root, /NSUserDefaults|systemUptime|fileModificationDate|diskSpace|activeInputModes/i);
|
|
273
|
+
const tracking = scan(root, /react-native-firebase\/analytics|facebook|appsflyer|amplitude|segment/i);
|
|
274
|
+
if (hasManifest)
|
|
275
|
+
return makeItem(item, 'pass', 'PrivacyInfo.xcprivacy is present.', 'scan: PrivacyInfo.xcprivacy found');
|
|
276
|
+
if (tracking.length)
|
|
277
|
+
return makeItem(item, 'fail', 'Tracking SDK pattern found but no PrivacyInfo.xcprivacy file was found.', evidence(tracking));
|
|
278
|
+
if (requiredReasonApi.length)
|
|
279
|
+
return makeItem(item, 'warning', 'Required-reason API pattern found but no PrivacyInfo.xcprivacy file was found.', evidence(requiredReasonApi));
|
|
280
|
+
return makeItem(item, 'pass', 'No required-reason API patterns found and no tracking SDK patterns found.', 'scan: no required privacy manifest signals found');
|
|
281
|
+
}
|
|
282
|
+
function patternItem(root, item, regex, statusOnHit, messageOnHit, messageOnMiss, options = {}) {
|
|
283
|
+
const matches = scan(root, regex, options);
|
|
284
|
+
if (matches.length)
|
|
285
|
+
return makeItem(item, statusOnHit, messageOnHit, evidence(matches));
|
|
286
|
+
return makeItem(item, 'pass', messageOnMiss, `scan: no matches for ${regex}`);
|
|
287
|
+
}
|
|
288
|
+
function missingPermissionRequirements(root, appJson) {
|
|
289
|
+
const missing = [...missingIosUsageDescriptions(root, appJson)];
|
|
290
|
+
const permissions = arrayOfStrings(getPath(appJson, ['expo', 'android', 'permissions']));
|
|
291
|
+
const source = sourceText(root, ['app', 'src', 'components']);
|
|
292
|
+
const androidChecks = [
|
|
293
|
+
{ name: 'camera', used: /expo-camera|CameraView|requestCameraPermissions/i, permissions: ['CAMERA'] },
|
|
294
|
+
{ name: 'location', used: /expo-location|getCurrentPositionAsync|watchPositionAsync/i, permissions: ['ACCESS_FINE_LOCATION', 'ACCESS_COARSE_LOCATION'] },
|
|
295
|
+
{ name: 'photos', used: /expo-image-picker|expo-media-library|MediaLibrary/i, permissions: ['READ_MEDIA_IMAGES', 'READ_EXTERNAL_STORAGE'] },
|
|
296
|
+
{ name: 'microphone', used: /expo-av|expo-audio|Audio\.|requestMicrophonePermissions/i, permissions: ['RECORD_AUDIO'] },
|
|
297
|
+
{ name: 'notifications', used: /expo-notifications/i, permissions: ['POST_NOTIFICATIONS'] },
|
|
298
|
+
];
|
|
299
|
+
for (const check of androidChecks) {
|
|
300
|
+
if (!check.used.test(source))
|
|
301
|
+
continue;
|
|
302
|
+
if (!check.permissions.some((permission) => permissions.includes(permission))) {
|
|
303
|
+
missing.push(`android ${check.name} permission (${check.permissions.join(' or ')})`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return missing;
|
|
307
|
+
}
|
|
308
|
+
function missingIosUsageDescriptions(root, appJson) {
|
|
309
|
+
const infoPlist = objectValue(getPath(appJson, ['expo', 'ios', 'infoPlist']));
|
|
310
|
+
const source = sourceText(root, ['app', 'src', 'components']);
|
|
311
|
+
const checks = [
|
|
312
|
+
{ name: 'camera', used: /expo-camera|CameraView|requestCameraPermissions/i, keys: ['NSCameraUsageDescription'] },
|
|
313
|
+
{ name: 'location', used: /expo-location|getCurrentPositionAsync|watchPositionAsync/i, keys: ['NSLocationWhenInUseUsageDescription', 'NSLocationAlwaysAndWhenInUseUsageDescription'] },
|
|
314
|
+
{ name: 'photos', used: /expo-image-picker|expo-media-library|MediaLibrary/i, keys: ['NSPhotoLibraryUsageDescription'] },
|
|
315
|
+
{ name: 'microphone', used: /expo-av|expo-audio|Audio\.|requestMicrophonePermissions/i, keys: ['NSMicrophoneUsageDescription'] },
|
|
316
|
+
];
|
|
317
|
+
const missing = [];
|
|
318
|
+
for (const check of checks) {
|
|
319
|
+
if (!check.used.test(source))
|
|
320
|
+
continue;
|
|
321
|
+
if (!check.keys.some((key) => hasValue(infoPlist[key]))) {
|
|
322
|
+
missing.push(`ios ${check.name} usage description (${check.keys.join(' or ')})`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return missing;
|
|
326
|
+
}
|
|
327
|
+
function unresolvedReason(item) {
|
|
328
|
+
if (JUDGMENT_IDS.has(item.id))
|
|
329
|
+
return 'requires app-specific judgment over UI/copy behavior';
|
|
330
|
+
if (LIVE_STORE_IDS.has(item.id))
|
|
331
|
+
return 'requires live store metadata/media state or listing authoring workflow';
|
|
332
|
+
return `no deterministic evaluator is registered for ${item.check} item`;
|
|
333
|
+
}
|
|
334
|
+
function targetPlatformsFromAppJson(appJson) {
|
|
335
|
+
const ios = stringValue(getPath(appJson, ['expo', 'ios', 'bundleIdentifier']));
|
|
336
|
+
const android = stringValue(getPath(appJson, ['expo', 'android', 'package']));
|
|
337
|
+
const platforms = [];
|
|
338
|
+
if (ios)
|
|
339
|
+
platforms.push('ios');
|
|
340
|
+
if (android)
|
|
341
|
+
platforms.push('android');
|
|
342
|
+
return platforms.length ? platforms : ['ios', 'android'];
|
|
343
|
+
}
|
|
344
|
+
function targetsItem(item, targetPlatforms) {
|
|
345
|
+
return item.platform.some((platform) => targetPlatforms.includes(platform));
|
|
346
|
+
}
|
|
347
|
+
function makeItem(manifestItem, status, message, evidenceText) {
|
|
348
|
+
return {
|
|
349
|
+
id: manifestItem.id,
|
|
350
|
+
label: manifestItem.label,
|
|
351
|
+
status,
|
|
352
|
+
message,
|
|
353
|
+
evidence: evidenceText,
|
|
354
|
+
category: manifestItem.category,
|
|
355
|
+
priority: manifestItem.priority,
|
|
356
|
+
platform: manifestItem.platform,
|
|
357
|
+
authorable: manifestItem.authorable === true || undefined,
|
|
358
|
+
autofill: manifestItem.autofill,
|
|
359
|
+
guideline: manifestItem.guideline,
|
|
360
|
+
critical: manifestItem.critical === true || undefined,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function scan(root, regex, options = {}) {
|
|
364
|
+
const files = collectScanFiles(root, options.dirs);
|
|
365
|
+
const matches = [];
|
|
366
|
+
for (const file of files) {
|
|
367
|
+
const content = readText(file);
|
|
368
|
+
if (content == null)
|
|
369
|
+
continue;
|
|
370
|
+
const rel = toRel(root, file);
|
|
371
|
+
const lines = content.split(/\r?\n/);
|
|
372
|
+
lines.forEach((line, index) => {
|
|
373
|
+
regex.lastIndex = 0;
|
|
374
|
+
if (!regex.test(line))
|
|
375
|
+
return;
|
|
376
|
+
const match = { file: rel, line: index + 1, text: compact(line) };
|
|
377
|
+
if (options.filter && !options.filter(match))
|
|
378
|
+
return;
|
|
379
|
+
matches.push(match);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return matches.slice(0, 30);
|
|
383
|
+
}
|
|
384
|
+
function collectScanFiles(root, dirs) {
|
|
385
|
+
const roots = dirs?.length ? dirs : ['app', 'src', 'components', 'ios', 'android', 'app.json', 'package.json'];
|
|
386
|
+
const files = [];
|
|
387
|
+
for (const entry of roots) {
|
|
388
|
+
const full = path.join(root, entry);
|
|
389
|
+
if (!existsSync(full))
|
|
390
|
+
continue;
|
|
391
|
+
const stat = safeStat(full);
|
|
392
|
+
if (!stat)
|
|
393
|
+
continue;
|
|
394
|
+
if (stat.isDirectory())
|
|
395
|
+
files.push(...walkFiles(full));
|
|
396
|
+
else if (shouldScanFile(full))
|
|
397
|
+
files.push(full);
|
|
398
|
+
}
|
|
399
|
+
return files;
|
|
400
|
+
}
|
|
401
|
+
function walkFiles(dir) {
|
|
402
|
+
const files = [];
|
|
403
|
+
for (const entry of safeReadDir(dir)) {
|
|
404
|
+
if (SKIP_DIRS.has(entry.name))
|
|
405
|
+
continue;
|
|
406
|
+
const full = path.join(dir, entry.name);
|
|
407
|
+
if (entry.isDirectory()) {
|
|
408
|
+
files.push(...walkFiles(full));
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (entry.isFile() && shouldScanFile(full))
|
|
412
|
+
files.push(full);
|
|
413
|
+
}
|
|
414
|
+
return files;
|
|
415
|
+
}
|
|
416
|
+
function walkDirs(dir) {
|
|
417
|
+
const dirs = [dir];
|
|
418
|
+
for (const entry of safeReadDir(dir)) {
|
|
419
|
+
if (SKIP_DIRS.has(entry.name))
|
|
420
|
+
continue;
|
|
421
|
+
const full = path.join(dir, entry.name);
|
|
422
|
+
if (entry.isDirectory())
|
|
423
|
+
dirs.push(...walkDirs(full));
|
|
424
|
+
}
|
|
425
|
+
return dirs;
|
|
426
|
+
}
|
|
427
|
+
function shouldScanFile(file) {
|
|
428
|
+
return TEXT_EXTENSIONS.has(path.extname(file));
|
|
429
|
+
}
|
|
430
|
+
function findByName(root, name) {
|
|
431
|
+
return walkFiles(root).filter((file) => path.basename(file) === name);
|
|
432
|
+
}
|
|
433
|
+
function sourceText(root, dirs) {
|
|
434
|
+
return collectScanFiles(root, dirs).map((file) => readText(file) ?? '').join('\n');
|
|
435
|
+
}
|
|
436
|
+
function hasLayoutFile(dir) {
|
|
437
|
+
return ['_layout.tsx', '_layout.ts', '_layout.jsx', '_layout.js'].some((name) => existsSync(path.join(dir, name)));
|
|
438
|
+
}
|
|
439
|
+
function routePath(root, file) {
|
|
440
|
+
const rel = toRel(root, file).replace(/^app\//, '').replace(/\.(tsx|ts|jsx|js)$/, '');
|
|
441
|
+
const parts = rel.split('/').filter((part) => part && !/^\(.+\)$/.test(part));
|
|
442
|
+
if (parts[parts.length - 1] === 'index')
|
|
443
|
+
parts.pop();
|
|
444
|
+
return `/${parts.join('/')}`;
|
|
445
|
+
}
|
|
446
|
+
function evidence(matches) {
|
|
447
|
+
return matches.slice(0, 5).map((match) => `${match.file}:${match.line}: ${match.text}`).join('\n');
|
|
448
|
+
}
|
|
449
|
+
function readJson(file) {
|
|
450
|
+
try {
|
|
451
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function readText(file) {
|
|
458
|
+
try {
|
|
459
|
+
return readFileSync(file, 'utf8');
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function safeReadDir(dir) {
|
|
466
|
+
try {
|
|
467
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function safeStat(file) {
|
|
474
|
+
try {
|
|
475
|
+
return statSync(file);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function getPath(value, keys) {
|
|
482
|
+
let current = value;
|
|
483
|
+
for (const key of keys) {
|
|
484
|
+
if (!current || typeof current !== 'object' || Array.isArray(current))
|
|
485
|
+
return undefined;
|
|
486
|
+
current = current[key];
|
|
487
|
+
}
|
|
488
|
+
return current;
|
|
489
|
+
}
|
|
490
|
+
function objectValue(value) {
|
|
491
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
492
|
+
}
|
|
493
|
+
function arrayOfStrings(value) {
|
|
494
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
495
|
+
}
|
|
496
|
+
function stringValue(value) {
|
|
497
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
498
|
+
}
|
|
499
|
+
function hasValue(value) {
|
|
500
|
+
if (value === undefined || value === null)
|
|
501
|
+
return false;
|
|
502
|
+
if (typeof value === 'string')
|
|
503
|
+
return value.trim().length > 0 && value !== 'null';
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
function platformNames(platforms) {
|
|
507
|
+
return platforms.map((platform) => (platform === 'ios' ? 'iOS' : 'Android')).join('/');
|
|
508
|
+
}
|
|
509
|
+
function toRel(root, file) {
|
|
510
|
+
return path.relative(root, file).split(path.sep).join('/');
|
|
511
|
+
}
|
|
512
|
+
function compact(line) {
|
|
513
|
+
const trimmed = line.trim().replace(/\s+/g, ' ');
|
|
514
|
+
return trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed;
|
|
515
|
+
}
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -10,6 +10,7 @@ import { printJson, StatusLine, style } from '../terminal.js';
|
|
|
10
10
|
import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
|
|
11
11
|
import { renderSandbox, renderSandboxList, } from './sandboxOutput.js';
|
|
12
12
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
13
|
+
const DEFAULT_SANDBOX_TIMEOUT_SECONDS = 300;
|
|
13
14
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
14
15
|
const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
|
|
15
16
|
const SANDBOX_POLL_INTERVAL_MS = 1_000;
|
|
@@ -283,7 +284,7 @@ export function registerSandbox(program) {
|
|
|
283
284
|
.option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
|
|
284
285
|
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
|
|
285
286
|
.option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
|
|
286
|
-
.option('--timeout <seconds>', 'maximum sandbox lifetime; 0
|
|
287
|
+
.option('--timeout <seconds>', 'maximum sandbox lifetime; defaults to 300, or 0 for persistent sandboxes', parseTimeoutSeconds)
|
|
287
288
|
.option('--persistent', 'never pause when idle (same as --idle-pause 0)', false)
|
|
288
289
|
.option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)
|
|
289
290
|
.option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
|
|
@@ -315,7 +316,8 @@ export function registerSandbox(program) {
|
|
|
315
316
|
body.memory = memory;
|
|
316
317
|
if (opts.disk !== undefined)
|
|
317
318
|
body.disk = opts.disk;
|
|
318
|
-
|
|
319
|
+
const persistent = exposed || opts.persistent || opts.idlePause === 0;
|
|
320
|
+
body.timeoutSeconds = opts.timeout ?? (persistent ? 0 : DEFAULT_SANDBOX_TIMEOUT_SECONDS);
|
|
319
321
|
if (exposed || opts.persistent)
|
|
320
322
|
body.idlePauseSeconds = 0;
|
|
321
323
|
else if (opts.idlePause !== undefined)
|
package/dist/config.js
CHANGED
|
@@ -32,7 +32,9 @@ export function saveCredentials(creds) {
|
|
|
32
32
|
}
|
|
33
33
|
export function loadCredentials() {
|
|
34
34
|
const envToken = isRunCloud()
|
|
35
|
-
? process.env.RUN_CLOUD_API_TOKEN ??
|
|
35
|
+
? process.env.RUN_CLOUD_API_TOKEN ??
|
|
36
|
+
process.env.RUN_CLOUD_API_KEY ??
|
|
37
|
+
process.env.RUN_CLOUD_CI_ENVIRONMENT_TOKEN
|
|
36
38
|
: process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
|
|
37
39
|
const envApiUrl = isRunCloud()
|
|
38
40
|
? process.env.RUN_CLOUD_API_URL
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { registerDns } from './commands/dns.js';
|
|
|
18
18
|
import { registerDiagnose } from './diagnose.js';
|
|
19
19
|
import { registerDesktop } from './commands/desktop.js';
|
|
20
20
|
import { registerEnvironment } from './commands/environment.js';
|
|
21
|
+
import { registerAppStore } from './commands/appStore.js';
|
|
21
22
|
const program = new Command('newly')
|
|
22
23
|
.description('Develop, provision, and deploy Newly backends')
|
|
23
24
|
.version('0.1.0');
|
|
@@ -39,4 +40,5 @@ registerDns(program);
|
|
|
39
40
|
registerDiagnose(program);
|
|
40
41
|
registerDesktop(program);
|
|
41
42
|
registerEnvironment(program);
|
|
43
|
+
registerAppStore(program);
|
|
42
44
|
await program.parseAsync(process.argv);
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.1.
|
|
1
|
+
export const CLI_VERSION = '0.1.22';
|