reis-mobile 0.3.5
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/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +12 -0
- package/CHANGELOG.md +103 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/THIRD_PARTY_NOTICES.md +105 -0
- package/agents/flutter-architect.md +92 -0
- package/agents/flutter-performance-engineer.md +103 -0
- package/agents/flutter-test-engineer.md +100 -0
- package/agents/lead-mobile.md +107 -0
- package/agents/mobile-code-reviewer.md +66 -0
- package/agents/mobile-staff-engineer.md +85 -0
- package/agents/plugin-native-expert.md +97 -0
- package/bin/reis-mobile.mjs +256 -0
- package/commands/debate.md +135 -0
- package/commands/doctor.md +23 -0
- package/commands/reis-mobile.md +50 -0
- package/commands/review.md +53 -0
- package/core/config/language.mjs +83 -0
- package/core/context/context-engine.mjs +113 -0
- package/core/detection/project-probe.mjs +104 -0
- package/core/detection/stack-detector.mjs +204 -0
- package/core/diagnostics/doctor.mjs +181 -0
- package/core/install/claude-plugin.mjs +76 -0
- package/core/paths.mjs +4 -0
- package/core/registry/frontmatter.mjs +81 -0
- package/core/registry/registry.mjs +140 -0
- package/core/router/intent-detector.mjs +75 -0
- package/core/router/intents.mjs +91 -0
- package/core/router/router.mjs +83 -0
- package/core/security/redact.mjs +24 -0
- package/package.json +35 -0
- package/skills/architecture-feature-first/SKILL.md +116 -0
- package/skills/bloc/SKILL.md +354 -0
- package/skills/code-review/SKILL.md +89 -0
- package/skills/dart-3-updates/SKILL.md +223 -0
- package/skills/dart-add-unit-test/SKILL.md +126 -0
- package/skills/dart-collect-coverage/SKILL.md +145 -0
- package/skills/dart-fix-runtime-errors/SKILL.md +170 -0
- package/skills/dart-generate-test-mocks/SKILL.md +159 -0
- package/skills/dart-migrate-to-checks-package/SKILL.md +532 -0
- package/skills/dart-resolve-package-conflicts/SKILL.md +120 -0
- package/skills/dart-run-static-analysis/SKILL.md +108 -0
- package/skills/dart-use-pattern-matching/SKILL.md +240 -0
- package/skills/dart-use-pattern-matching/examples/json_patterns.dart +85 -0
- package/skills/detect-mobile-stack/SKILL.md +33 -0
- package/skills/effective-dart/SKILL.md +187 -0
- package/skills/firebase-ai/SKILL.md +93 -0
- package/skills/firebase-analytics/SKILL.md +129 -0
- package/skills/firebase-app-check/SKILL.md +124 -0
- package/skills/firebase-auth/SKILL.md +224 -0
- package/skills/firebase-cloud-firestore/SKILL.md +165 -0
- package/skills/firebase-cloud-functions/SKILL.md +148 -0
- package/skills/firebase-crashlytics/SKILL.md +182 -0
- package/skills/firebase-data-connect/SKILL.md +80 -0
- package/skills/firebase-database/SKILL.md +224 -0
- package/skills/firebase-in-app-messaging/SKILL.md +111 -0
- package/skills/firebase-messaging/SKILL.md +201 -0
- package/skills/firebase-remote-config/SKILL.md +131 -0
- package/skills/firebase-storage/SKILL.md +158 -0
- package/skills/flutter-add-integration-test/SKILL.md +167 -0
- package/skills/flutter-add-widget-preview/SKILL.md +149 -0
- package/skills/flutter-add-widget-test/SKILL.md +158 -0
- package/skills/flutter-adding-home-screen-widgets/SKILL.md +205 -0
- package/skills/flutter-animating-apps/SKILL.md +185 -0
- package/skills/flutter-app-architecture/SKILL.md +80 -0
- package/skills/flutter-apply-architecture-best-practices/SKILL.md +166 -0
- package/skills/flutter-build-responsive-layout/SKILL.md +143 -0
- package/skills/flutter-building-forms/SKILL.md +125 -0
- package/skills/flutter-building-layouts/SKILL.md +131 -0
- package/skills/flutter-building-plugins/SKILL.md +209 -0
- package/skills/flutter-caching-data/SKILL.md +171 -0
- package/skills/flutter-change-notifier/SKILL.md +138 -0
- package/skills/flutter-embedding-native-views/SKILL.md +200 -0
- package/skills/flutter-errors/SKILL.md +126 -0
- package/skills/flutter-fix-layout-issues/SKILL.md +134 -0
- package/skills/flutter-handling-concurrency/SKILL.md +182 -0
- package/skills/flutter-implement-json-serialization/SKILL.md +157 -0
- package/skills/flutter-improving-accessibility/SKILL.md +113 -0
- package/skills/flutter-interoperating-with-native-apis/SKILL.md +130 -0
- package/skills/flutter-login-usecase/SKILL.md +38 -0
- package/skills/flutter-managing-state/SKILL.md +206 -0
- package/skills/flutter-project-audit/SKILL.md +66 -0
- package/skills/flutter-reducing-app-size/SKILL.md +103 -0
- package/skills/flutter-secure-token-store/SKILL.md +35 -0
- package/skills/flutter-setting-up-on-macos/SKILL.md +78 -0
- package/skills/flutter-setup-declarative-routing/SKILL.md +259 -0
- package/skills/flutter-setup-localization/SKILL.md +214 -0
- package/skills/flutter-theming-apps/SKILL.md +163 -0
- package/skills/flutter-use-http-package/SKILL.md +178 -0
- package/skills/flutter-widget-review/SKILL.md +54 -0
- package/skills/flutter-working-with-databases/SKILL.md +203 -0
- package/skills/flutterfire-configure/SKILL.md +143 -0
- package/skills/mobile-security-audit/SKILL.md +101 -0
- package/skills/mockito/SKILL.md +149 -0
- package/skills/mocktail/SKILL.md +122 -0
- package/skills/patrol-e2e-testing/SKILL.md +224 -0
- package/skills/provider/SKILL.md +139 -0
- package/skills/riverpod/SKILL.md +246 -0
- package/skills/testing/SKILL.md +50 -0
- package/stacks/android/stack.json +8 -0
- package/stacks/flutter/stack.json +8 -0
- package/stacks/ios/stack.json +8 -0
- package/stacks/kotlin-multiplatform/stack.json +8 -0
- package/stacks/react-native/stack.json +8 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Language the /reis-mobile:* commands answer in. Agents, skills and commands stay in
|
|
7
|
+
* English; this only picks the language of reports, syntheses and debate files.
|
|
8
|
+
*/
|
|
9
|
+
export const LANGUAGES = {
|
|
10
|
+
en: 'English',
|
|
11
|
+
pt: 'Portuguese (Brazil)',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ALIASES = {
|
|
15
|
+
en: 'en',
|
|
16
|
+
eng: 'en',
|
|
17
|
+
english: 'en',
|
|
18
|
+
'en-us': 'en',
|
|
19
|
+
pt: 'pt',
|
|
20
|
+
'pt-br': 'pt',
|
|
21
|
+
ptbr: 'pt',
|
|
22
|
+
portuguese: 'pt',
|
|
23
|
+
portugues: 'pt',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Returns `en`, `pt` or null for an unknown value. */
|
|
27
|
+
export function normalizeLanguage(value) {
|
|
28
|
+
if (typeof value !== 'string') return null;
|
|
29
|
+
return ALIASES[value.trim().toLowerCase().replace('_', '-')] ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configPath({ env = process.env, platform = process.platform, home = homedir() } = {}) {
|
|
33
|
+
if (env.REIS_MOBILE_CONFIG) return env.REIS_MOBILE_CONFIG;
|
|
34
|
+
if (platform === 'win32' && env.APPDATA) return join(env.APPDATA, 'reis-mobile', 'config.json');
|
|
35
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'reis-mobile', 'config.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readConfig(path = configPath()) {
|
|
39
|
+
let source;
|
|
40
|
+
try {
|
|
41
|
+
source = await readFile(path, 'utf8');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') return {};
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(source);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`Invalid JSON in ${path}. Fix or delete the file and run \`reis-mobile lang <en|pt>\` again.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function saveLanguage(language, path = configPath()) {
|
|
54
|
+
const normalized = normalizeLanguage(language);
|
|
55
|
+
if (!normalized) throw new Error(unknownLanguageMessage(language));
|
|
56
|
+
const config = { ...(await readConfig(path)), language: normalized };
|
|
57
|
+
await mkdir(dirname(path), { recursive: true });
|
|
58
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function removeConfig(path = configPath()) {
|
|
63
|
+
await rm(path, { force: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Precedence: explicit flag, then REIS_MOBILE_LANG, then the config file.
|
|
68
|
+
* Returns null when nothing is set, meaning "answer in the user's language".
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveLanguage({ flag, env = process.env, path = configPath({ env }) } = {}) {
|
|
71
|
+
for (const [value, origin] of [[flag, '--lang'], [env.REIS_MOBILE_LANG, 'REIS_MOBILE_LANG']]) {
|
|
72
|
+
if (value === undefined || value === '') continue;
|
|
73
|
+
const language = normalizeLanguage(value);
|
|
74
|
+
if (!language) throw new Error(`${origin}: ${unknownLanguageMessage(value)}`);
|
|
75
|
+
return language;
|
|
76
|
+
}
|
|
77
|
+
const { language } = await readConfig(path);
|
|
78
|
+
return normalizeLanguage(language);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function unknownLanguageMessage(value) {
|
|
82
|
+
return `Unknown language "${value}". Use one of: ${Object.keys(LANGUAGES).join(', ')}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { redactSecrets } from '../security/redact.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_DIFF_BYTES = 80_000;
|
|
7
|
+
|
|
8
|
+
// Generated and lock files are listed as changed but kept out of the diff body.
|
|
9
|
+
const DIFF_EXCLUDES = [
|
|
10
|
+
':(exclude,glob)**/pubspec.lock',
|
|
11
|
+
':(exclude,glob)**/Podfile.lock',
|
|
12
|
+
':(exclude,glob)**/package-lock.json',
|
|
13
|
+
':(exclude,glob)**/yarn.lock',
|
|
14
|
+
':(exclude,glob)**/pnpm-lock.yaml',
|
|
15
|
+
':(exclude,glob)**/Package.resolved',
|
|
16
|
+
':(exclude,glob)**/*.g.dart',
|
|
17
|
+
':(exclude,glob)**/*.freezed.dart',
|
|
18
|
+
':(exclude,glob)**/*.pbxproj',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collects what a reviewer needs: which files changed and the (redacted, truncated) diff.
|
|
23
|
+
*
|
|
24
|
+
* Paths are relative to the repository root; changes are limited to `projectDir`, so a
|
|
25
|
+
* mobile app inside a monorepo only sees its own files.
|
|
26
|
+
*
|
|
27
|
+
* Modes:
|
|
28
|
+
* - `range`: `--base <ref>` given, compares `<ref>...HEAD` (pull request style).
|
|
29
|
+
* - `working-tree`: uncommitted changes against HEAD, plus untracked files.
|
|
30
|
+
* - `project`: nothing changed (or not a git repo); the reviewer audits the project itself.
|
|
31
|
+
*/
|
|
32
|
+
export function collectReviewContext({ projectDir = process.cwd(), base, maxDiffBytes = DEFAULT_MAX_DIFF_BYTES } = {}) {
|
|
33
|
+
const cwd = resolve(projectDir);
|
|
34
|
+
const topLevel = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
35
|
+
if (!topLevel.ok) {
|
|
36
|
+
return emptyContext(cwd, 'project', 'Not a git repository; reviewing the project as a whole.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const hasHead = git(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD']).ok;
|
|
40
|
+
let nameStatus;
|
|
41
|
+
let diff;
|
|
42
|
+
let mode;
|
|
43
|
+
|
|
44
|
+
if (base) {
|
|
45
|
+
const range = `${base}...HEAD`;
|
|
46
|
+
nameStatus = git(cwd, ['diff', '--name-status', range, '--', '.']);
|
|
47
|
+
if (!nameStatus.ok) throw new Error(`git diff ${range} failed: ${nameStatus.stderr.trim()}`);
|
|
48
|
+
diff = git(cwd, ['diff', range, '--', '.', ...DIFF_EXCLUDES]);
|
|
49
|
+
mode = 'range';
|
|
50
|
+
} else if (hasHead) {
|
|
51
|
+
nameStatus = git(cwd, ['diff', '--name-status', 'HEAD', '--', '.']);
|
|
52
|
+
diff = git(cwd, ['diff', 'HEAD', '--', '.', ...DIFF_EXCLUDES]);
|
|
53
|
+
mode = 'working-tree';
|
|
54
|
+
} else {
|
|
55
|
+
nameStatus = git(cwd, ['diff', '--cached', '--name-status', '--', '.']);
|
|
56
|
+
diff = git(cwd, ['diff', '--cached', '--', '.', ...DIFF_EXCLUDES]);
|
|
57
|
+
mode = 'working-tree';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const files = parseNameStatus(nameStatus.stdout);
|
|
61
|
+
if (mode === 'working-tree') {
|
|
62
|
+
const untracked = git(cwd, ['ls-files', '--others', '--exclude-standard', '--full-name']).stdout;
|
|
63
|
+
for (const path of untracked.split('\n').filter(Boolean)) files.push({ status: 'untracked', path });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!files.length) {
|
|
67
|
+
return emptyContext(topLevel.stdout.trim(), 'project', 'No changes found; reviewing the project as a whole.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fullDiff = redactSecrets(diff.stdout);
|
|
71
|
+
const truncated = Buffer.byteLength(fullDiff) > maxDiffBytes;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
repoRoot: topLevel.stdout.trim(),
|
|
75
|
+
mode,
|
|
76
|
+
base: base ?? null,
|
|
77
|
+
files,
|
|
78
|
+
diff: truncated ? truncateUtf8(fullDiff, maxDiffBytes) : fullDiff,
|
|
79
|
+
diffTruncated: truncated,
|
|
80
|
+
note: truncated
|
|
81
|
+
? `Diff truncated to ${maxDiffBytes} bytes; read the remaining changed files directly.`
|
|
82
|
+
: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function emptyContext(repoRoot, mode, note) {
|
|
87
|
+
return { repoRoot, mode, base: null, files: [], diff: '', diffTruncated: false, note };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const STATUS_NAMES = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' };
|
|
91
|
+
|
|
92
|
+
function parseNameStatus(output) {
|
|
93
|
+
return output
|
|
94
|
+
.split('\n')
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map((line) => {
|
|
97
|
+
const [code, ...paths] = line.split('\t');
|
|
98
|
+
return { status: STATUS_NAMES[code[0]] ?? code, path: paths.at(-1) };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function git(cwd, args) {
|
|
103
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
104
|
+
return {
|
|
105
|
+
ok: result.status === 0,
|
|
106
|
+
stdout: result.stdout ?? '',
|
|
107
|
+
stderr: result.stderr ?? String(result.error ?? ''),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function truncateUtf8(text, maxBytes) {
|
|
112
|
+
return Buffer.from(text).subarray(0, maxBytes).toString('utf8').replace(/�$/, '');
|
|
113
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const IGNORED_DIRS = new Set([
|
|
5
|
+
'.git', '.gradle', '.idea', '.vscode', '.dart_tool', '.expo', '.build', '.swiftpm',
|
|
6
|
+
'node_modules', 'Pods', 'DerivedData', 'build', 'dist', 'vendor', 'Carthage',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const MAX_READ_BYTES = 512 * 1024;
|
|
10
|
+
|
|
11
|
+
const LANGUAGE_BY_EXTENSION = {
|
|
12
|
+
'.dart': 'dart',
|
|
13
|
+
'.kt': 'kotlin',
|
|
14
|
+
'.java': 'java',
|
|
15
|
+
'.swift': 'swift',
|
|
16
|
+
'.m': 'objective-c',
|
|
17
|
+
'.mm': 'objective-c',
|
|
18
|
+
'.ts': 'typescript',
|
|
19
|
+
'.tsx': 'typescript',
|
|
20
|
+
'.js': 'javascript',
|
|
21
|
+
'.jsx': 'javascript',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Read-only, bounded view over a project directory. All paths are relative to `root`. */
|
|
25
|
+
export function createProbe(root) {
|
|
26
|
+
return {
|
|
27
|
+
root,
|
|
28
|
+
|
|
29
|
+
async exists(path) {
|
|
30
|
+
return (await statOrNull(join(root, path))) !== null;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async isDir(path) {
|
|
34
|
+
return (await statOrNull(join(root, path)))?.isDirectory() ?? false;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async read(path) {
|
|
38
|
+
const info = await statOrNull(join(root, path));
|
|
39
|
+
if (!info?.isFile() || info.size > MAX_READ_BYTES) return null;
|
|
40
|
+
return readFile(join(root, path), 'utf8');
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async list(path = '.') {
|
|
44
|
+
try {
|
|
45
|
+
return await readdir(join(root, path), { withFileTypes: true });
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/** Breadth-first search for files, skipping build output and dependency folders. */
|
|
52
|
+
async findFiles(predicate, { from = '.', maxDepth = 4, limit = 50 } = {}) {
|
|
53
|
+
const found = [];
|
|
54
|
+
let queue = [join(root, from)];
|
|
55
|
+
for (let depth = 0; depth <= maxDepth && queue.length && found.length < limit; depth++) {
|
|
56
|
+
const next = [];
|
|
57
|
+
for (const dir of queue) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const full = join(dir, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (!IGNORED_DIRS.has(entry.name) && !entry.name.endsWith('.xcassets')) next.push(full);
|
|
68
|
+
} else if (predicate(entry.name)) {
|
|
69
|
+
found.push(relative(root, full));
|
|
70
|
+
if (found.length >= limit) break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (found.length >= limit) break;
|
|
74
|
+
}
|
|
75
|
+
queue = next;
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/** Source languages present under `from`, ordered by file count. */
|
|
81
|
+
async languages(from = '.', { maxDepth = 10 } = {}) {
|
|
82
|
+
if (!(await this.isDir(from))) return [];
|
|
83
|
+
const files = await this.findFiles((name) => extname(name) in LANGUAGE_BY_EXTENSION, {
|
|
84
|
+
from,
|
|
85
|
+
maxDepth,
|
|
86
|
+
limit: 400,
|
|
87
|
+
});
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const language = LANGUAGE_BY_EXTENSION[extname(file)];
|
|
91
|
+
counts.set(language, (counts.get(language) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([language]) => language);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function statOrNull(path) {
|
|
99
|
+
try {
|
|
100
|
+
return await stat(path);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { createProbe } from './project-probe.mjs';
|
|
4
|
+
|
|
5
|
+
const FLUTTER_PLATFORMS = ['android', 'ios', 'web', 'macos', 'linux', 'windows'];
|
|
6
|
+
const GRADLE_ROOT_FILES = ['settings.gradle.kts', 'settings.gradle', 'build.gradle.kts', 'build.gradle'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detection rules in priority order. Cross-platform stacks come first because their
|
|
10
|
+
* projects also contain native Android/iOS folders.
|
|
11
|
+
*/
|
|
12
|
+
const RULES = [
|
|
13
|
+
{ id: 'flutter', detect: detectFlutter },
|
|
14
|
+
{ id: 'react-native', detect: detectReactNative },
|
|
15
|
+
{ id: 'kotlin-multiplatform', detect: detectKotlinMultiplatform },
|
|
16
|
+
{ id: 'android', detect: detectAndroid },
|
|
17
|
+
{ id: 'ios', detect: detectIos },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const DETECTABLE_STACKS = RULES.map((rule) => rule.id);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {Promise<{ projectDir: string, stack: string, language: string|null,
|
|
24
|
+
* languages: string[], platforms: string[], variant: string|null, evidence: string[],
|
|
25
|
+
* candidates: object[] }>}
|
|
26
|
+
*/
|
|
27
|
+
export async function detectStack(projectDir = process.cwd()) {
|
|
28
|
+
const root = resolve(projectDir);
|
|
29
|
+
const probe = createProbe(root);
|
|
30
|
+
const candidates = [];
|
|
31
|
+
|
|
32
|
+
for (const rule of RULES) {
|
|
33
|
+
const result = await rule.detect(probe);
|
|
34
|
+
if (result) candidates.push({ stack: rule.id, variant: null, ...result });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const primary = candidates[0];
|
|
38
|
+
if (!primary) {
|
|
39
|
+
return {
|
|
40
|
+
projectDir: root,
|
|
41
|
+
stack: 'unknown',
|
|
42
|
+
language: null,
|
|
43
|
+
languages: [],
|
|
44
|
+
platforms: [],
|
|
45
|
+
variant: null,
|
|
46
|
+
evidence: [],
|
|
47
|
+
candidates,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
projectDir: root,
|
|
53
|
+
stack: primary.stack,
|
|
54
|
+
language: primary.languages[0] ?? null,
|
|
55
|
+
languages: primary.languages,
|
|
56
|
+
platforms: primary.platforms,
|
|
57
|
+
variant: primary.variant,
|
|
58
|
+
evidence: primary.evidence,
|
|
59
|
+
candidates,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function detectFlutter(probe) {
|
|
64
|
+
const pubspec = await probe.read('pubspec.yaml');
|
|
65
|
+
if (!pubspec) return null;
|
|
66
|
+
// A pure Dart package has a pubspec too; only the Flutter SDK dependency makes it mobile.
|
|
67
|
+
if (!/^\s*sdk:\s*flutter\s*$/m.test(pubspec) && !/^flutter:\s*$/m.test(pubspec)) return null;
|
|
68
|
+
|
|
69
|
+
const platforms = [];
|
|
70
|
+
for (const platform of FLUTTER_PLATFORMS) {
|
|
71
|
+
if (await probe.isDir(platform)) platforms.push(platform);
|
|
72
|
+
}
|
|
73
|
+
const native = unique([...(await probe.languages('android')), ...(await probe.languages('ios'))]);
|
|
74
|
+
const variant = /^\s*plugin:\s*$/m.test(pubspec) ? 'plugin' : null;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
languages: unique(['dart', ...native]),
|
|
78
|
+
platforms,
|
|
79
|
+
variant,
|
|
80
|
+
evidence: ['pubspec.yaml declares the Flutter SDK', ...platforms.map((p) => `${p}/ directory`)],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function detectReactNative(probe) {
|
|
85
|
+
const manifest = parseJson(await probe.read('package.json'));
|
|
86
|
+
if (!manifest) return null;
|
|
87
|
+
const dependencies = { ...manifest.dependencies, ...manifest.devDependencies, ...manifest.peerDependencies };
|
|
88
|
+
if (!dependencies['react-native']) return null;
|
|
89
|
+
|
|
90
|
+
const isExpo = Boolean(dependencies.expo);
|
|
91
|
+
const platforms = [];
|
|
92
|
+
for (const platform of ['android', 'ios']) {
|
|
93
|
+
if (await probe.isDir(platform)) platforms.push(platform);
|
|
94
|
+
}
|
|
95
|
+
const evidence = [`package.json depends on react-native ${dependencies['react-native']}`];
|
|
96
|
+
if (isExpo && platforms.length === 0) {
|
|
97
|
+
platforms.push('android', 'ios');
|
|
98
|
+
evidence.push('Expo managed workflow (native folders are generated by prebuild)');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const script = (await probe.exists('tsconfig.json')) ? 'typescript' : 'javascript';
|
|
102
|
+
const native = unique([...(await probe.languages('android')), ...(await probe.languages('ios'))]);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
languages: unique([script, ...native]),
|
|
106
|
+
platforms,
|
|
107
|
+
variant: isExpo ? 'expo' : null,
|
|
108
|
+
evidence,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function detectKotlinMultiplatform(probe) {
|
|
113
|
+
const scripts = await probe.findFiles((name) => name === 'build.gradle.kts', { maxDepth: 1, limit: 20 });
|
|
114
|
+
let multiplatformScript = null;
|
|
115
|
+
const targets = new Set();
|
|
116
|
+
|
|
117
|
+
for (const path of scripts) {
|
|
118
|
+
const content = (await probe.read(path)) ?? '';
|
|
119
|
+
if (/kotlin\("multiplatform"\)|org\.jetbrains\.kotlin\.multiplatform|kotlinMultiplatform/.test(content)) {
|
|
120
|
+
multiplatformScript ??= path;
|
|
121
|
+
}
|
|
122
|
+
if (/androidTarget\s*\(|\bandroid\s*\(\s*\)/.test(content)) targets.add('android');
|
|
123
|
+
if (/ios(X64|Arm64|SimulatorArm64)\s*\(/.test(content)) targets.add('ios');
|
|
124
|
+
if (/\bjvm\s*\(/.test(content)) targets.add('desktop');
|
|
125
|
+
if (/\b(js|wasmJs)\s*\(/.test(content)) targets.add('web');
|
|
126
|
+
}
|
|
127
|
+
if (!multiplatformScript) return null;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
languages: unique(['kotlin', ...(await probe.languages('iosApp'))]),
|
|
131
|
+
platforms: [...targets],
|
|
132
|
+
evidence: [`${multiplatformScript} applies the Kotlin Multiplatform plugin`],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function detectAndroid(probe) {
|
|
137
|
+
const gradleFile = await firstExisting(probe, GRADLE_ROOT_FILES);
|
|
138
|
+
if (!gradleFile) return null;
|
|
139
|
+
|
|
140
|
+
const manifests = await probe.findFiles((name) => name === 'AndroidManifest.xml', { maxDepth: 4, limit: 1 });
|
|
141
|
+
const gradleScripts = await probe.findFiles((name) => /^build\.gradle(\.kts)?$/.test(name), {
|
|
142
|
+
maxDepth: 2,
|
|
143
|
+
limit: 20,
|
|
144
|
+
});
|
|
145
|
+
let androidPluginScript = null;
|
|
146
|
+
for (const path of gradleScripts) {
|
|
147
|
+
if (/com\.android\.(application|library)|android\.application|android\.library/.test((await probe.read(path)) ?? '')) {
|
|
148
|
+
androidPluginScript = path;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!manifests.length && !androidPluginScript) return null;
|
|
153
|
+
|
|
154
|
+
const languages = (await probe.languages('.')).filter((language) => language === 'kotlin' || language === 'java');
|
|
155
|
+
return {
|
|
156
|
+
languages: languages.length ? languages : ['kotlin'],
|
|
157
|
+
platforms: ['android'],
|
|
158
|
+
evidence: [
|
|
159
|
+
`${gradleFile} at project root`,
|
|
160
|
+
...(androidPluginScript ? [`${androidPluginScript} applies the Android Gradle plugin`] : []),
|
|
161
|
+
...manifests.map((path) => `${path} found`),
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function detectIos(probe) {
|
|
167
|
+
const entries = await probe.list('.');
|
|
168
|
+
const xcodeEntry = entries.find((entry) => /\.(xcodeproj|xcworkspace)$/.test(entry.name));
|
|
169
|
+
const hasPodfile = await probe.exists('Podfile');
|
|
170
|
+
const packageSwift = await probe.read('Package.swift');
|
|
171
|
+
const iosPackage = packageSwift && /\.iOS\s*\(/.test(packageSwift);
|
|
172
|
+
if (!xcodeEntry && !hasPodfile && !iosPackage) return null;
|
|
173
|
+
|
|
174
|
+
const languages = (await probe.languages('.')).filter((language) => language === 'swift' || language === 'objective-c');
|
|
175
|
+
return {
|
|
176
|
+
languages: languages.length ? languages : ['swift'],
|
|
177
|
+
platforms: ['ios'],
|
|
178
|
+
evidence: [
|
|
179
|
+
...(xcodeEntry ? [`${xcodeEntry.name} at project root`] : []),
|
|
180
|
+
...(hasPodfile ? ['Podfile at project root'] : []),
|
|
181
|
+
...(iosPackage ? ['Package.swift declares an iOS platform'] : []),
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function firstExisting(probe, paths) {
|
|
187
|
+
for (const path of paths) {
|
|
188
|
+
if (await probe.exists(path)) return path;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseJson(text) {
|
|
194
|
+
if (!text) return null;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(text);
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function unique(values) {
|
|
203
|
+
return [...new Set(values)];
|
|
204
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import { createProbe } from '../detection/project-probe.mjs';
|
|
8
|
+
import { detectStack } from '../detection/stack-detector.mjs';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const TOOL_TIMEOUT_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
const ANDROID_STACKS = ['flutter', 'android', 'react-native', 'kotlin-multiplatform'];
|
|
14
|
+
const IOS_STACKS = ['flutter', 'ios', 'react-native', 'kotlin-multiplatform'];
|
|
15
|
+
|
|
16
|
+
// `relevant` decides whether a tool matters for the detected project; `--all` ignores it.
|
|
17
|
+
const TOOLS = [
|
|
18
|
+
{ id: 'git', label: 'Git', section: 'environment', cmd: 'git', args: ['--version'], relevant: () => true },
|
|
19
|
+
{ id: 'node', label: 'Node', section: 'environment', cmd: 'node', args: ['--version'], relevant: () => true },
|
|
20
|
+
{ id: 'flutter', label: 'Flutter', section: 'environment', cmd: 'flutter', args: ['--version'], relevant: ({ stack }) => stack === 'flutter' },
|
|
21
|
+
{ id: 'dart', label: 'Dart', section: 'environment', cmd: 'dart', args: ['--version'], relevant: ({ stack }) => stack === 'flutter' },
|
|
22
|
+
{ id: 'java', label: 'Java', section: 'environment', cmd: 'java', args: ['-version'], relevant: targetsAndroid },
|
|
23
|
+
{ id: 'android-sdk', label: 'Android SDK', section: 'environment', check: checkAndroidSdk, relevant: targetsAndroid },
|
|
24
|
+
{ id: 'xcode', label: 'Xcode', section: 'environment', cmd: 'xcodebuild', args: ['-version'], macOnly: true, relevant: targetsIos },
|
|
25
|
+
{ id: 'cocoapods', label: 'CocoaPods', section: 'environment', cmd: 'pod', args: ['--version'], macOnly: true, relevant: ({ files }) => files.podfile },
|
|
26
|
+
{ id: 'swift', label: 'Swift', section: 'environment', cmd: 'swift', args: ['--version'], relevant: ({ stack }) => stack === 'ios' },
|
|
27
|
+
{ id: 'npm', label: 'npm', section: 'environment', cmd: 'npm', args: ['--version'], relevant: ({ files }) => files.packageManager === 'npm' },
|
|
28
|
+
{ id: 'yarn', label: 'Yarn', section: 'environment', cmd: 'yarn', args: ['--version'], relevant: ({ files }) => files.packageManager === 'yarn' },
|
|
29
|
+
{ id: 'pnpm', label: 'pnpm', section: 'environment', cmd: 'pnpm', args: ['--version'], relevant: ({ files }) => files.packageManager === 'pnpm' },
|
|
30
|
+
{ id: 'fastlane', label: 'Fastlane', section: 'environment', cmd: 'fastlane', args: ['--version'], relevant: ({ files }) => files.fastlane },
|
|
31
|
+
{ id: 'gh', label: 'GitHub CLI', section: 'integrations', optional: true, cmd: 'gh', args: ['--version'], relevant: () => true },
|
|
32
|
+
{ id: 'firebase', label: 'Firebase CLI', section: 'integrations', optional: true, cmd: 'firebase', args: ['--version'], relevant: ({ files }) => files.firebase },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Environment and project diagnostics.
|
|
37
|
+
*
|
|
38
|
+
* Status per check: `ok`, `warning` (missing or misconfigured), `info` (optional tool
|
|
39
|
+
* absent) or `skipped` (not applicable on this OS).
|
|
40
|
+
*/
|
|
41
|
+
export async function runDoctor({ projectDir = process.cwd(), all = false, platform = process.platform } = {}) {
|
|
42
|
+
const detection = await detectStack(projectDir);
|
|
43
|
+
const probe = createProbe(detection.projectDir);
|
|
44
|
+
const files = await inspectProjectFiles(probe);
|
|
45
|
+
const context = { stack: detection.stack, platforms: detection.platforms, files };
|
|
46
|
+
|
|
47
|
+
const tools = TOOLS.filter((tool) => all || tool.relevant(context));
|
|
48
|
+
const toolChecks = await Promise.all(tools.map((tool) => checkTool(tool, platform)));
|
|
49
|
+
const projectChecks = await checkProject(probe, detection, files);
|
|
50
|
+
|
|
51
|
+
const checks = [...toolChecks, ...projectChecks];
|
|
52
|
+
const warnings = checks.filter((check) => check.status === 'warning').length;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
status: warnings ? 'warning' : 'ok',
|
|
56
|
+
warnings,
|
|
57
|
+
platform,
|
|
58
|
+
detection,
|
|
59
|
+
checks,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function targetsAndroid({ stack, platforms }) {
|
|
64
|
+
return ANDROID_STACKS.includes(stack) && platforms.includes('android');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function targetsIos({ stack, platforms }) {
|
|
68
|
+
return IOS_STACKS.includes(stack) && platforms.includes('ios');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function checkTool(tool, platform) {
|
|
72
|
+
const base = { id: tool.id, label: tool.label, section: tool.section };
|
|
73
|
+
if (tool.macOnly && platform !== 'darwin') {
|
|
74
|
+
return { ...base, status: 'skipped', detail: 'requires macOS' };
|
|
75
|
+
}
|
|
76
|
+
if (tool.check) return { ...base, ...(await tool.check()) };
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const { stdout, stderr } = await run(tool.cmd, tool.args, { timeout: TOOL_TIMEOUT_MS });
|
|
80
|
+
return { ...base, status: 'ok', detail: extractVersion(`${stdout}\n${stderr}`) };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const missing = error.code === 'ENOENT';
|
|
83
|
+
const detail = missing ? 'not found' : error.killed ? 'timed out' : firstLine(error.stderr) || 'failed';
|
|
84
|
+
return { ...base, status: tool.optional ? 'info' : 'warning', detail };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function checkAndroidSdk() {
|
|
89
|
+
const candidates = [
|
|
90
|
+
process.env.ANDROID_HOME,
|
|
91
|
+
process.env.ANDROID_SDK_ROOT,
|
|
92
|
+
join(homedir(), 'Library', 'Android', 'sdk'),
|
|
93
|
+
join(homedir(), 'Android', 'Sdk'),
|
|
94
|
+
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Android', 'Sdk'),
|
|
95
|
+
].filter(Boolean);
|
|
96
|
+
const sdk = candidates.find((path) => existsSync(join(path, 'platform-tools')) || existsSync(join(path, 'platforms')));
|
|
97
|
+
return sdk
|
|
98
|
+
? { status: 'ok', detail: sdk }
|
|
99
|
+
: { status: 'warning', detail: 'not found (set ANDROID_HOME)' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function inspectProjectFiles(probe) {
|
|
103
|
+
const lockfiles = [
|
|
104
|
+
['pnpm', 'pnpm-lock.yaml'],
|
|
105
|
+
['yarn', 'yarn.lock'],
|
|
106
|
+
['npm', 'package-lock.json'],
|
|
107
|
+
];
|
|
108
|
+
let packageManager = null;
|
|
109
|
+
for (const [manager, lockfile] of lockfiles) {
|
|
110
|
+
if (await probe.exists(lockfile)) {
|
|
111
|
+
packageManager = manager;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!packageManager && (await probe.exists('package.json'))) packageManager = 'npm';
|
|
116
|
+
|
|
117
|
+
const firebaseConfigs = await probe.findFiles(
|
|
118
|
+
(name) => name === 'firebase.json' || name === 'google-services.json' || name === 'GoogleService-Info.plist',
|
|
119
|
+
{ maxDepth: 4, limit: 1 },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
packageManager,
|
|
124
|
+
podfile: (await probe.exists('Podfile')) || (await probe.exists('ios/Podfile')),
|
|
125
|
+
fastlane: (await probe.isDir('fastlane')) || (await probe.isDir('android/fastlane')) || (await probe.isDir('ios/fastlane')),
|
|
126
|
+
firebase: firebaseConfigs.length > 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function checkProject(probe, detection, files) {
|
|
131
|
+
const checks = [
|
|
132
|
+
{
|
|
133
|
+
id: 'stack',
|
|
134
|
+
label: 'Stack',
|
|
135
|
+
section: 'project',
|
|
136
|
+
status: detection.stack === 'unknown' ? 'warning' : 'ok',
|
|
137
|
+
detail:
|
|
138
|
+
detection.stack === 'unknown'
|
|
139
|
+
? 'no mobile project detected'
|
|
140
|
+
: [detection.stack, detection.variant && `(${detection.variant})`, detection.languages.length && `· ${detection.languages.join(', ')}`]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(' '),
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
if (detection.stack === 'unknown') return checks;
|
|
146
|
+
|
|
147
|
+
checks.push({ id: 'platforms', label: 'Platforms', section: 'project', status: 'ok', detail: detection.platforms.join(', ') || '-' });
|
|
148
|
+
|
|
149
|
+
const add = (id, label, ok, okDetail, warningDetail) =>
|
|
150
|
+
checks.push({ id, label, section: 'project', status: ok ? 'ok' : 'warning', detail: ok ? okDetail : warningDetail });
|
|
151
|
+
|
|
152
|
+
if (detection.stack === 'flutter') {
|
|
153
|
+
add('pubspec-lock', 'pubspec.lock', await probe.exists('pubspec.lock'), 'present', 'missing (run flutter pub get)');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (detection.stack === 'react-native') {
|
|
157
|
+
add('node-modules', 'node_modules', await probe.isDir('node_modules'), `installed (${files.packageManager})`, `missing (run ${files.packageManager} install)`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const gradleDir = detection.stack === 'android' || detection.stack === 'kotlin-multiplatform' ? '.' : 'android';
|
|
161
|
+
if (detection.platforms.includes('android') && (await probe.isDir(gradleDir))) {
|
|
162
|
+
const wrapper = await probe.read(join(gradleDir, 'gradle', 'wrapper', 'gradle-wrapper.properties'));
|
|
163
|
+
const version = /gradle-([\d.]+)-(?:all|bin)\.zip/.exec(wrapper ?? '')?.[1];
|
|
164
|
+
add('gradle-wrapper', 'Gradle wrapper', Boolean(wrapper), version ?? 'present', 'missing gradle-wrapper.properties');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const iosDir = detection.stack === 'ios' ? '.' : 'ios';
|
|
168
|
+
if (detection.platforms.includes('ios') && (await probe.exists(join(iosDir, 'Podfile')))) {
|
|
169
|
+
add('podfile-lock', 'Podfile.lock', await probe.exists(join(iosDir, 'Podfile.lock')), 'present', 'missing (run pod install)');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return checks;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractVersion(output) {
|
|
176
|
+
return /(\d+\.\d+(?:\.\d+)?)/.exec(output)?.[1] ?? firstLine(output);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function firstLine(text) {
|
|
180
|
+
return String(text ?? '').trim().split('\n')[0]?.trim() ?? '';
|
|
181
|
+
}
|