@ceraph/react-native-mcp 0.2.1 → 0.3.1
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/LICENSE +116 -15
- package/README.md +79 -77
- package/assets/default.png +0 -0
- package/dist/app-lifecycle.d.ts +50 -0
- package/dist/app-lifecycle.js +487 -0
- package/dist/camera-image-writer.d.ts +43 -0
- package/dist/camera-image-writer.js +280 -0
- package/dist/camera-registry-sync.d.ts +18 -0
- package/dist/camera-registry-sync.js +117 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +41 -9
- package/dist/device-autonomy.d.ts +30 -0
- package/dist/device-autonomy.js +117 -0
- package/dist/error-parser.d.ts +6 -26
- package/dist/error-parser.js +4 -74
- package/dist/expo-manager.d.ts +2 -74
- package/dist/expo-manager.js +11 -125
- package/dist/index.d.ts +0 -7
- package/dist/index.js +1266 -56
- package/dist/init/ast-camera.d.ts +29 -0
- package/dist/init/ast-camera.js +267 -0
- package/dist/init/ast-layout.d.ts +15 -0
- package/dist/init/ast-layout.js +167 -0
- package/dist/init/claude-hook-constants.d.ts +9 -0
- package/dist/init/claude-hook-constants.js +91 -0
- package/dist/init/lan-ip.d.ts +11 -0
- package/dist/init/lan-ip.js +51 -0
- package/dist/init/monorepo.d.ts +13 -0
- package/dist/init/monorepo.js +185 -0
- package/dist/init/oauth.d.ts +52 -0
- package/dist/init/oauth.js +220 -0
- package/dist/init/package-manager.d.ts +11 -0
- package/dist/init/package-manager.js +60 -0
- package/dist/init/prompt.d.ts +12 -0
- package/dist/init/prompt.js +68 -0
- package/dist/init/shell-profile.d.ts +22 -0
- package/dist/init/shell-profile.js +85 -0
- package/dist/init/steps.d.ts +135 -0
- package/dist/init/steps.js +399 -0
- package/dist/init/url-scheme.d.ts +42 -0
- package/dist/init/url-scheme.js +187 -0
- package/dist/init/walkthrough.d.ts +76 -0
- package/dist/init/walkthrough.js +340 -0
- package/dist/init.d.ts +7 -7
- package/dist/init.js +280 -120
- package/dist/iproxy-manager.d.ts +32 -0
- package/dist/iproxy-manager.js +216 -0
- package/dist/mac-caffeinate.d.ts +10 -0
- package/dist/mac-caffeinate.js +56 -0
- package/dist/permission-interceptor.d.ts +29 -0
- package/dist/permission-interceptor.js +185 -0
- package/dist/prebuild-detector.d.ts +0 -30
- package/dist/prebuild-detector.js +1 -42
- package/dist/preflight.d.ts +34 -0
- package/dist/preflight.js +847 -0
- package/dist/screen.d.ts +132 -43
- package/dist/screen.js +668 -94
- package/dist/shim/boot.d.ts +41 -0
- package/dist/shim/boot.js +141 -0
- package/dist/shim/camera.d.ts +22 -0
- package/dist/shim/camera.js +62 -0
- package/dist/shim/config.d.ts +6 -0
- package/dist/shim/config.js +56 -0
- package/dist/shim/deep-link.d.ts +1 -0
- package/dist/shim/deep-link.js +25 -0
- package/dist/shim/dev-guard.d.ts +1 -0
- package/dist/shim/dev-guard.js +3 -0
- package/dist/shim/error-handler.d.ts +20 -0
- package/dist/shim/error-handler.js +66 -0
- package/dist/shim/fetch-interceptor.d.ts +13 -0
- package/dist/shim/fetch-interceptor.js +93 -0
- package/dist/shim/index.d.ts +6 -0
- package/dist/shim/index.js +6 -0
- package/dist/shim/keep-awake.d.ts +13 -0
- package/dist/shim/keep-awake.js +118 -0
- package/dist/shim/reload.d.ts +23 -0
- package/dist/shim/reload.js +76 -0
- package/dist/shim/signal-capture.d.ts +11 -0
- package/dist/shim/signal-capture.js +15 -0
- package/dist/shim/signal-transport.d.ts +17 -0
- package/dist/shim/signal-transport.js +43 -0
- package/dist/signal-listener.d.ts +27 -0
- package/dist/signal-listener.js +135 -0
- package/dist/simulator-boot.d.ts +52 -0
- package/dist/simulator-boot.js +227 -0
- package/dist/target.d.ts +48 -0
- package/dist/target.js +267 -0
- package/dist/uninstall/cli-runner.d.ts +32 -0
- package/dist/uninstall/cli-runner.js +223 -0
- package/dist/uninstall/footprint.d.ts +40 -0
- package/dist/uninstall/footprint.js +288 -0
- package/dist/uninstall/mcp-tools.d.ts +14 -0
- package/dist/uninstall/mcp-tools.js +175 -0
- package/dist/uninstall/revert-auth.d.ts +22 -0
- package/dist/uninstall/revert-auth.js +31 -0
- package/dist/uninstall/revert-boot.d.ts +24 -0
- package/dist/uninstall/revert-boot.js +242 -0
- package/dist/uninstall/revert-camera.d.ts +12 -0
- package/dist/uninstall/revert-camera.js +199 -0
- package/dist/uninstall/revert-ceraph-dir.d.ts +27 -0
- package/dist/uninstall/revert-ceraph-dir.js +38 -0
- package/dist/uninstall/revert-claude-hooks.d.ts +19 -0
- package/dist/uninstall/revert-claude-hooks.js +191 -0
- package/dist/uninstall/revert-gitignore.d.ts +17 -0
- package/dist/uninstall/revert-gitignore.js +43 -0
- package/dist/uninstall/revert-mcp-clients.d.ts +57 -0
- package/dist/uninstall/revert-mcp-clients.js +194 -0
- package/dist/uninstall/revert-package.d.ts +34 -0
- package/dist/uninstall/revert-package.js +98 -0
- package/dist/uninstall/revert-scheme.d.ts +36 -0
- package/dist/uninstall/revert-scheme.js +139 -0
- package/dist/uninstall/revert-signal-host-env.d.ts +31 -0
- package/dist/uninstall/revert-signal-host-env.js +61 -0
- package/dist/uninstall/walkthrough.d.ts +80 -0
- package/dist/uninstall/walkthrough.js +1244 -0
- package/dist/utils/atomic-write.d.ts +1 -0
- package/dist/utils/atomic-write.js +30 -0
- package/dist/wait-for-device.d.ts +68 -0
- package/dist/wait-for-device.js +368 -0
- package/dist/wda-manager.d.ts +38 -0
- package/dist/wda-manager.js +186 -0
- package/dist/wda-simulator.d.ts +28 -0
- package/dist/wda-simulator.js +257 -0
- package/package.json +38 -5
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
const SENTINEL_HEAD = "# Ceraph signal host — auto-set by @ceraph/react-native-mcp init";
|
|
5
|
+
const SENTINEL_TAIL = "# End Ceraph signal host";
|
|
6
|
+
function isValidIpv4(input) {
|
|
7
|
+
if (typeof input !== "string")
|
|
8
|
+
return false;
|
|
9
|
+
const parts = input.split(".");
|
|
10
|
+
if (parts.length !== 4)
|
|
11
|
+
return false;
|
|
12
|
+
for (const part of parts) {
|
|
13
|
+
if (!/^[0-9]+$/.test(part))
|
|
14
|
+
return false;
|
|
15
|
+
if (part.length > 1 && part.startsWith("0"))
|
|
16
|
+
return false;
|
|
17
|
+
const n = Number(part);
|
|
18
|
+
if (!Number.isInteger(n))
|
|
19
|
+
return false;
|
|
20
|
+
if (n < 0 || n > 255)
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
export function pickShellProfile(deps = {}) {
|
|
26
|
+
const env = deps.env ?? process.env;
|
|
27
|
+
const home = deps.home ?? homedir();
|
|
28
|
+
const shellPath = env.SHELL ?? "";
|
|
29
|
+
if (shellPath.endsWith("/bash")) {
|
|
30
|
+
return { path: join(home, ".bashrc"), shell: "bash" };
|
|
31
|
+
}
|
|
32
|
+
return { path: join(home, ".zshrc"), shell: "zsh" };
|
|
33
|
+
}
|
|
34
|
+
export async function writeSignalHostEnv(ip, deps = {}) {
|
|
35
|
+
if (!isValidIpv4(ip)) {
|
|
36
|
+
throw new Error(`writeSignalHostEnv: refusing non-IPv4 input ${JSON.stringify(ip)} — ` +
|
|
37
|
+
`expected a dotted-quad like 192.168.1.42 with each octet 0-255`);
|
|
38
|
+
}
|
|
39
|
+
const target = deps.target ?? pickShellProfile();
|
|
40
|
+
const read = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
41
|
+
const write = deps.writeFile ?? ((p, c) => writeFile(p, c, "utf-8"));
|
|
42
|
+
const ensureDir = deps.mkdir ?? ((p, o) => mkdir(p, o));
|
|
43
|
+
let body = "";
|
|
44
|
+
try {
|
|
45
|
+
body = await read(target.path);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
body = "";
|
|
49
|
+
}
|
|
50
|
+
const block = `${SENTINEL_HEAD}\n` +
|
|
51
|
+
`export CERAPH_SIGNAL_HOST=${ip}\n` +
|
|
52
|
+
`${SENTINEL_TAIL}\n`;
|
|
53
|
+
const existingBlock = extractSentinelBlock(body);
|
|
54
|
+
if (existingBlock) {
|
|
55
|
+
if (existingBlock.value === ip) {
|
|
56
|
+
return { path: target.path, shell: target.shell, state: "unchanged" };
|
|
57
|
+
}
|
|
58
|
+
const next = body.slice(0, existingBlock.start) +
|
|
59
|
+
block +
|
|
60
|
+
body.slice(existingBlock.end);
|
|
61
|
+
await ensureDir(dirname(target.path), { recursive: true });
|
|
62
|
+
await write(target.path, next);
|
|
63
|
+
return { path: target.path, shell: target.shell, state: "updated" };
|
|
64
|
+
}
|
|
65
|
+
const prefix = body === "" || body.endsWith("\n") ? "" : "\n";
|
|
66
|
+
const separator = body === "" ? "" : "\n";
|
|
67
|
+
const next = body + prefix + separator + block;
|
|
68
|
+
await ensureDir(dirname(target.path), { recursive: true });
|
|
69
|
+
await write(target.path, next);
|
|
70
|
+
return { path: target.path, shell: target.shell, state: "added" };
|
|
71
|
+
}
|
|
72
|
+
function extractSentinelBlock(body) {
|
|
73
|
+
const headIdx = body.indexOf(SENTINEL_HEAD);
|
|
74
|
+
if (headIdx === -1)
|
|
75
|
+
return null;
|
|
76
|
+
const tailIdx = body.indexOf(SENTINEL_TAIL, headIdx);
|
|
77
|
+
if (tailIdx === -1)
|
|
78
|
+
return null;
|
|
79
|
+
const afterTail = tailIdx + SENTINEL_TAIL.length;
|
|
80
|
+
const end = body[afterTail] === "\n" ? afterTail + 1 : afterTail;
|
|
81
|
+
const between = body.slice(headIdx, end);
|
|
82
|
+
const exportMatch = between.match(/export\s+CERAPH_SIGNAL_HOST=(\S+)/);
|
|
83
|
+
const value = exportMatch ? exportMatch[1] : "";
|
|
84
|
+
return { start: headIdx, end, value };
|
|
85
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { type PackageManager } from "./package-manager.js";
|
|
2
|
+
import { type RootComponentTarget } from "./ast-layout.js";
|
|
3
|
+
import { type SchemeAction } from "./url-scheme.js";
|
|
4
|
+
import { type MonorepoCandidate } from "./monorepo.js";
|
|
5
|
+
import { syncCameraRegistry, relativeRegistryPath } from "../camera-registry-sync.js";
|
|
6
|
+
import { type PreflightCheck } from "../preflight.js";
|
|
7
|
+
import { ScreenManager } from "../screen.js";
|
|
8
|
+
import { AppLifecycle } from "../app-lifecycle.js";
|
|
9
|
+
import { DeviceAutonomy } from "../device-autonomy.js";
|
|
10
|
+
import type { TargetResolver } from "../target.js";
|
|
11
|
+
export interface InitStatus {
|
|
12
|
+
workingDir: string;
|
|
13
|
+
packageInstalled: boolean;
|
|
14
|
+
authenticated: boolean;
|
|
15
|
+
authLogin?: string;
|
|
16
|
+
schemeRegistered: boolean | null;
|
|
17
|
+
projectKind: "expo-json" | "expo-config" | "bare-rn" | "none";
|
|
18
|
+
cameraScanResults: Array<{
|
|
19
|
+
relPath: string;
|
|
20
|
+
line: number;
|
|
21
|
+
alreadyHasImageKey: boolean;
|
|
22
|
+
suggestedKey: string;
|
|
23
|
+
}>;
|
|
24
|
+
bootInjected: boolean | null;
|
|
25
|
+
bootTarget: RootComponentTarget | null;
|
|
26
|
+
mcpClientsPresent: {
|
|
27
|
+
claudeCode: boolean;
|
|
28
|
+
cursor: boolean;
|
|
29
|
+
codex: boolean;
|
|
30
|
+
vscode: boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export declare function getInitStatus(projectDir: string): Promise<InitStatus>;
|
|
34
|
+
export interface InstallPackageResult {
|
|
35
|
+
already: boolean;
|
|
36
|
+
packageManager: PackageManager;
|
|
37
|
+
exitCode?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function installCeraphPackage(projectDir: string, deps?: {
|
|
40
|
+
spawnInstall?: (input: {
|
|
41
|
+
bin: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
cwd: string;
|
|
44
|
+
}) => Promise<{
|
|
45
|
+
exitCode: number;
|
|
46
|
+
}>;
|
|
47
|
+
}): Promise<InstallPackageResult>;
|
|
48
|
+
export interface AuthStartResult {
|
|
49
|
+
verificationUri: string;
|
|
50
|
+
userCode: string;
|
|
51
|
+
pollUrl: string;
|
|
52
|
+
expiresIn: number;
|
|
53
|
+
intervalSeconds: number;
|
|
54
|
+
}
|
|
55
|
+
export declare function startAuthFlow(): Promise<AuthStartResult>;
|
|
56
|
+
export type AuthPollResult = {
|
|
57
|
+
status: "pending";
|
|
58
|
+
} | {
|
|
59
|
+
status: "slow_down";
|
|
60
|
+
intervalSeconds: number;
|
|
61
|
+
} | {
|
|
62
|
+
status: "complete";
|
|
63
|
+
login: string;
|
|
64
|
+
} | {
|
|
65
|
+
status: "failed";
|
|
66
|
+
reason: "expired" | "denied" | "unknown-poll-url";
|
|
67
|
+
};
|
|
68
|
+
export declare function pollAuthFlow(pollUrl: string): Promise<AuthPollResult>;
|
|
69
|
+
export interface ScanCameraUsage {
|
|
70
|
+
filePath: string;
|
|
71
|
+
relPath: string;
|
|
72
|
+
line: number;
|
|
73
|
+
suggestedKey: string;
|
|
74
|
+
alreadyHasImageKey: boolean;
|
|
75
|
+
surroundingCode: string;
|
|
76
|
+
}
|
|
77
|
+
export declare function scanCameraUsages(projectDir: string): Promise<{
|
|
78
|
+
usages: ScanCameraUsage[];
|
|
79
|
+
filesScanned: number;
|
|
80
|
+
}>;
|
|
81
|
+
export interface ReplaceCameraInput {
|
|
82
|
+
filePath: string;
|
|
83
|
+
line: number;
|
|
84
|
+
imageKey: string;
|
|
85
|
+
}
|
|
86
|
+
export interface ReplaceCameraResult {
|
|
87
|
+
success: boolean;
|
|
88
|
+
already?: boolean;
|
|
89
|
+
newContent?: string;
|
|
90
|
+
reason?: string;
|
|
91
|
+
}
|
|
92
|
+
export declare function replaceCameraView(projectDir: string, input: ReplaceCameraInput): Promise<ReplaceCameraResult>;
|
|
93
|
+
export interface InjectBootResult {
|
|
94
|
+
applied: boolean;
|
|
95
|
+
filePath?: string;
|
|
96
|
+
already?: boolean;
|
|
97
|
+
reason?: string;
|
|
98
|
+
}
|
|
99
|
+
export declare function injectBoot(projectDir: string): Promise<InjectBootResult>;
|
|
100
|
+
export interface RegisterSchemeResult {
|
|
101
|
+
action: SchemeAction;
|
|
102
|
+
instructions?: string;
|
|
103
|
+
}
|
|
104
|
+
export declare function registerScheme(projectDir: string): Promise<RegisterSchemeResult>;
|
|
105
|
+
export interface SetupImagesDirResult {
|
|
106
|
+
dirPath: string;
|
|
107
|
+
readmeWritten: boolean;
|
|
108
|
+
gitignoreUpdated: boolean;
|
|
109
|
+
}
|
|
110
|
+
export declare function setupImagesDir(projectDir: string): Promise<SetupImagesDirResult>;
|
|
111
|
+
export interface SetupMcpClientsResult {
|
|
112
|
+
written: string[];
|
|
113
|
+
skipped: string[];
|
|
114
|
+
}
|
|
115
|
+
export declare function setupMcpClients(projectDir: string): Promise<SetupMcpClientsResult>;
|
|
116
|
+
export interface PreflightToolResult {
|
|
117
|
+
ok: boolean;
|
|
118
|
+
passedCount: number;
|
|
119
|
+
totalCount: number;
|
|
120
|
+
checks: PreflightCheck[];
|
|
121
|
+
}
|
|
122
|
+
export declare function preflightForMcp(projectDir: string, deps?: {
|
|
123
|
+
screen?: ScreenManager;
|
|
124
|
+
apps?: AppLifecycle;
|
|
125
|
+
autonomy?: DeviceAutonomy;
|
|
126
|
+
target?: TargetResolver;
|
|
127
|
+
iproxyManager?: import("../iproxy-manager.js").IproxyManager | null;
|
|
128
|
+
}): Promise<PreflightToolResult>;
|
|
129
|
+
export interface MonorepoStatusResult {
|
|
130
|
+
isMonorepo: boolean;
|
|
131
|
+
rootIsRnApp: boolean;
|
|
132
|
+
matches: MonorepoCandidate[];
|
|
133
|
+
}
|
|
134
|
+
export declare function detectMonorepoStatus(projectDir: string): Promise<MonorepoStatusResult>;
|
|
135
|
+
export { syncCameraRegistry, relativeRegistryPath };
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, writeFile, access } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { detectPackageManager, installArgv, isPackageInstalled, } from "./package-manager.js";
|
|
6
|
+
import { finalizeAndPersist, pollDeviceFlow, readExistingAuth, startDeviceFlow, } from "./oauth.js";
|
|
7
|
+
import { applyCameraEdits, scanCameraViews, setImageKeyOnExistingTag, } from "./ast-camera.js";
|
|
8
|
+
import { detectRootComponent, injectInstallCeraph, } from "./ast-layout.js";
|
|
9
|
+
import { ensureCeraphUrlScheme, bareRnInstructions, expoDynamicConfigInstructions, } from "./url-scheme.js";
|
|
10
|
+
import { detectMonorepoSubpackages, } from "./monorepo.js";
|
|
11
|
+
import { syncCameraRegistry, relativeRegistryPath, } from "../camera-registry-sync.js";
|
|
12
|
+
import { runPreflight } from "../preflight.js";
|
|
13
|
+
import { ScreenManager } from "../screen.js";
|
|
14
|
+
import { AppLifecycle } from "../app-lifecycle.js";
|
|
15
|
+
import { DeviceAutonomy } from "../device-autonomy.js";
|
|
16
|
+
export async function getInitStatus(projectDir) {
|
|
17
|
+
const [pkgInstalled, auth, scheme, scan, bootTarget] = await Promise.all([
|
|
18
|
+
isPackageInstalled(projectDir),
|
|
19
|
+
readExistingAuth(),
|
|
20
|
+
ensureCeraphUrlSchemeReadOnly(projectDir),
|
|
21
|
+
scanCameraViews(projectDir),
|
|
22
|
+
detectRootComponent(projectDir),
|
|
23
|
+
]);
|
|
24
|
+
const bootInjected = bootTarget
|
|
25
|
+
? (await readFile(bootTarget.filePath, "utf-8")).includes("installCeraph(")
|
|
26
|
+
: null;
|
|
27
|
+
const schemeRegistered = scheme.kind === "expo-json-already-registered"
|
|
28
|
+
? true
|
|
29
|
+
: scheme.kind === "expo-json-added"
|
|
30
|
+
?
|
|
31
|
+
false
|
|
32
|
+
: null;
|
|
33
|
+
const projectKind = scheme.kind === "expo-json-added" ||
|
|
34
|
+
scheme.kind === "expo-json-already-registered"
|
|
35
|
+
? "expo-json"
|
|
36
|
+
: scheme.kind === "expo-config-needs-manual"
|
|
37
|
+
? "expo-config"
|
|
38
|
+
: scheme.kind === "bare-rn-needs-manual"
|
|
39
|
+
? "bare-rn"
|
|
40
|
+
: "none";
|
|
41
|
+
return {
|
|
42
|
+
workingDir: projectDir,
|
|
43
|
+
packageInstalled: pkgInstalled,
|
|
44
|
+
authenticated: auth != null,
|
|
45
|
+
authLogin: auth?.githubUser.login,
|
|
46
|
+
schemeRegistered,
|
|
47
|
+
projectKind,
|
|
48
|
+
cameraScanResults: scan.edits.map((e) => ({
|
|
49
|
+
relPath: e.relPath,
|
|
50
|
+
line: e.line,
|
|
51
|
+
alreadyHasImageKey: e.alreadyHasImageKey,
|
|
52
|
+
suggestedKey: e.suggestedKey,
|
|
53
|
+
})),
|
|
54
|
+
bootInjected,
|
|
55
|
+
bootTarget,
|
|
56
|
+
mcpClientsPresent: {
|
|
57
|
+
claudeCode: await fileExists(join(projectDir, ".mcp.json")),
|
|
58
|
+
cursor: await fileExists(join(projectDir, ".cursor", "mcp.json")),
|
|
59
|
+
codex: await fileExists(join(projectDir, ".codex", "config.toml")),
|
|
60
|
+
vscode: await fileExists(join(projectDir, ".vscode", "mcp.json")),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function ensureCeraphUrlSchemeReadOnly(projectDir) {
|
|
65
|
+
return ensureCeraphUrlScheme(projectDir, {
|
|
66
|
+
writeFile: async () => undefined,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function installCeraphPackage(projectDir, deps = {}) {
|
|
70
|
+
const pm = await detectPackageManager(projectDir);
|
|
71
|
+
if (await isPackageInstalled(projectDir)) {
|
|
72
|
+
return { already: true, packageManager: pm };
|
|
73
|
+
}
|
|
74
|
+
const argv = installArgv(pm);
|
|
75
|
+
const installer = deps.spawnInstall ?? defaultSpawnInstall;
|
|
76
|
+
const res = await installer({ bin: argv.bin, args: argv.args, cwd: projectDir });
|
|
77
|
+
return { already: false, packageManager: pm, exitCode: res.exitCode };
|
|
78
|
+
}
|
|
79
|
+
function defaultSpawnInstall(input) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const child = spawn(input.bin, input.args, {
|
|
82
|
+
cwd: input.cwd,
|
|
83
|
+
stdio: "inherit",
|
|
84
|
+
});
|
|
85
|
+
child.on("error", () => resolve({ exitCode: 127 }));
|
|
86
|
+
child.on("exit", (code) => resolve({ exitCode: code ?? 1 }));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const deviceFlows = new Map();
|
|
90
|
+
export async function startAuthFlow() {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
for (const [key, entry] of deviceFlows) {
|
|
93
|
+
if (now - entry.createdAt > 15 * 60_000)
|
|
94
|
+
deviceFlows.delete(key);
|
|
95
|
+
}
|
|
96
|
+
const start = await startDeviceFlow();
|
|
97
|
+
const pollUrl = randomBytes(16).toString("hex");
|
|
98
|
+
deviceFlows.set(pollUrl, {
|
|
99
|
+
start,
|
|
100
|
+
deviceCode: start.deviceCode,
|
|
101
|
+
createdAt: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
verificationUri: start.verificationUri,
|
|
105
|
+
userCode: start.userCode,
|
|
106
|
+
pollUrl,
|
|
107
|
+
expiresIn: start.expiresIn,
|
|
108
|
+
intervalSeconds: start.intervalSeconds,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export async function pollAuthFlow(pollUrl) {
|
|
112
|
+
const entry = deviceFlows.get(pollUrl);
|
|
113
|
+
if (!entry)
|
|
114
|
+
return { status: "failed", reason: "unknown-poll-url" };
|
|
115
|
+
const result = await pollDeviceFlow(entry.deviceCode);
|
|
116
|
+
if (result.kind === "pending")
|
|
117
|
+
return { status: "pending" };
|
|
118
|
+
if (result.kind === "slow_down")
|
|
119
|
+
return { status: "slow_down", intervalSeconds: result.intervalSeconds };
|
|
120
|
+
if (result.kind === "expired") {
|
|
121
|
+
deviceFlows.delete(pollUrl);
|
|
122
|
+
return { status: "failed", reason: "expired" };
|
|
123
|
+
}
|
|
124
|
+
if (result.kind === "denied") {
|
|
125
|
+
deviceFlows.delete(pollUrl);
|
|
126
|
+
return { status: "failed", reason: "denied" };
|
|
127
|
+
}
|
|
128
|
+
const stored = await finalizeAndPersist(result.accessToken);
|
|
129
|
+
deviceFlows.delete(pollUrl);
|
|
130
|
+
return { status: "complete", login: stored.githubUser.login };
|
|
131
|
+
}
|
|
132
|
+
export async function scanCameraUsages(projectDir) {
|
|
133
|
+
const scan = await scanCameraViews(projectDir);
|
|
134
|
+
const usages = [];
|
|
135
|
+
const cache = new Map();
|
|
136
|
+
for (const edit of scan.edits) {
|
|
137
|
+
let body = cache.get(edit.filePath);
|
|
138
|
+
if (body === undefined) {
|
|
139
|
+
body = await readFile(edit.filePath, "utf-8").catch(() => "");
|
|
140
|
+
cache.set(edit.filePath, body);
|
|
141
|
+
}
|
|
142
|
+
usages.push({
|
|
143
|
+
filePath: edit.filePath,
|
|
144
|
+
relPath: edit.relPath,
|
|
145
|
+
line: edit.line,
|
|
146
|
+
suggestedKey: edit.suggestedKey,
|
|
147
|
+
alreadyHasImageKey: edit.alreadyHasImageKey,
|
|
148
|
+
surroundingCode: extractWindow(body, edit.line, 15),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return { usages, filesScanned: scan.filesScanned };
|
|
152
|
+
}
|
|
153
|
+
function extractWindow(body, centerLine, half) {
|
|
154
|
+
const lines = body.split(/\r?\n/);
|
|
155
|
+
const start = Math.max(0, centerLine - 1 - half);
|
|
156
|
+
const end = Math.min(lines.length, centerLine - 1 + half + 1);
|
|
157
|
+
return lines.slice(start, end).join("\n");
|
|
158
|
+
}
|
|
159
|
+
export async function replaceCameraView(projectDir, input) {
|
|
160
|
+
const scan = await scanCameraViews(projectDir);
|
|
161
|
+
const match = scan.edits.find((e) => e.filePath === input.filePath && e.line === input.line);
|
|
162
|
+
if (!match) {
|
|
163
|
+
const body = await readFile(input.filePath, "utf-8").catch(() => null);
|
|
164
|
+
if (body == null) {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
reason: `File not found: ${input.filePath}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (body.includes("CeraphCamera")) {
|
|
171
|
+
const res = await setImageKeyOnExistingTag(input.filePath, input.line, input.imageKey);
|
|
172
|
+
if (res.applied) {
|
|
173
|
+
const newContent = await readFile(input.filePath, "utf-8");
|
|
174
|
+
return { success: true, newContent };
|
|
175
|
+
}
|
|
176
|
+
if (res.reason === "imageKey unchanged") {
|
|
177
|
+
return { success: true, already: true, newContent: body };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
reason: res.reason ?? `No <CeraphCamera> at ${input.filePath}:${input.line}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
reason: `No <CameraView> at ${input.filePath}:${input.line}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const edit = {
|
|
190
|
+
filePath: match.filePath,
|
|
191
|
+
relPath: match.relPath,
|
|
192
|
+
line: match.line,
|
|
193
|
+
suggestedKey: input.imageKey,
|
|
194
|
+
alreadyHasImageKey: match.alreadyHasImageKey,
|
|
195
|
+
};
|
|
196
|
+
await applyCameraEdits(projectDir, [edit]);
|
|
197
|
+
const newContent = await readFile(input.filePath, "utf-8");
|
|
198
|
+
return { success: true, newContent };
|
|
199
|
+
}
|
|
200
|
+
export async function injectBoot(projectDir) {
|
|
201
|
+
const target = await detectRootComponent(projectDir);
|
|
202
|
+
if (!target) {
|
|
203
|
+
return {
|
|
204
|
+
applied: false,
|
|
205
|
+
reason: "Could not find app/_layout.tsx or App.tsx — add `useEffect(() => { installCeraph(); }, [])` manually.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const body = await readFile(target.filePath, "utf-8").catch(() => "");
|
|
209
|
+
const alreadyCalled = body.includes("installCeraph(");
|
|
210
|
+
const result = await injectInstallCeraph(target);
|
|
211
|
+
return {
|
|
212
|
+
applied: result.applied,
|
|
213
|
+
filePath: target.filePath,
|
|
214
|
+
already: alreadyCalled,
|
|
215
|
+
reason: result.reason,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export async function registerScheme(projectDir) {
|
|
219
|
+
const action = await ensureCeraphUrlScheme(projectDir);
|
|
220
|
+
let instructions;
|
|
221
|
+
if (action.kind === "expo-config-needs-manual") {
|
|
222
|
+
instructions = expoDynamicConfigInstructions(action.path);
|
|
223
|
+
}
|
|
224
|
+
else if (action.kind === "bare-rn-needs-manual") {
|
|
225
|
+
instructions = bareRnInstructions();
|
|
226
|
+
}
|
|
227
|
+
return { action, instructions };
|
|
228
|
+
}
|
|
229
|
+
const CAMERA_IMAGES_README_CONTENT = `# Ceraph camera test images
|
|
230
|
+
|
|
231
|
+
Per-screen test images for \`<CeraphCamera />\` from
|
|
232
|
+
\`@ceraph/react-native-mcp/shim\`. Three valid imageKey shapes:
|
|
233
|
+
|
|
234
|
+
- \`<CeraphCamera />\` (no imageKey) → uninitialized; the bundled
|
|
235
|
+
1024x1024 black PNG renders. \`rn_preflight\` lists these so the
|
|
236
|
+
agent prompts you to pick a deliberate value.
|
|
237
|
+
- \`imageKey="default"\` → explicit acknowledgment of the bundled
|
|
238
|
+
black PNG. Best when image content doesn't matter (most form /
|
|
239
|
+
upload / permission flows).
|
|
240
|
+
- \`imageKey="<scenario>"\` → a file in this folder. Drop
|
|
241
|
+
\`<scenario>.{jpg,png,webp,heic}\` here.
|
|
242
|
+
- \`imageKey="@runtime"\` → the flow planner picks the image
|
|
243
|
+
per-step at test time. Use for cameras that serve multiple
|
|
244
|
+
scenarios across flows.
|
|
245
|
+
|
|
246
|
+
## Naming
|
|
247
|
+
|
|
248
|
+
Lowercase + hyphen-separated, descriptive of what the image represents:
|
|
249
|
+
|
|
250
|
+
- \`profile.jpg\` — face photo for a profile picture screen
|
|
251
|
+
- \`id-card.png\` — government-issued ID for a KYC scan screen
|
|
252
|
+
- \`product-photo.jpg\` — a product image for an upload screen
|
|
253
|
+
|
|
254
|
+
Supported extensions: \`.jpg\`, \`.jpeg\`, \`.png\`, \`.webp\`, \`.heic\`.
|
|
255
|
+
Extension priority on collision: jpg > jpeg > png > webp > heic.
|
|
256
|
+
`;
|
|
257
|
+
export async function setupImagesDir(projectDir) {
|
|
258
|
+
const dir = join(projectDir, ".ceraph", "camera-images");
|
|
259
|
+
await mkdir(dir, { recursive: true });
|
|
260
|
+
const readmePath = join(dir, "README.md");
|
|
261
|
+
const readmeWritten = !(await fileExists(readmePath));
|
|
262
|
+
if (readmeWritten) {
|
|
263
|
+
await writeFile(readmePath, CAMERA_IMAGES_README_CONTENT, "utf-8");
|
|
264
|
+
}
|
|
265
|
+
const gitignorePath = join(projectDir, ".gitignore");
|
|
266
|
+
let content = "";
|
|
267
|
+
try {
|
|
268
|
+
content = await readFile(gitignorePath, "utf-8");
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
}
|
|
272
|
+
const entries = [".rn-errors.json", ".rn-flow-progress.json", ".rn-mcp-cache/"];
|
|
273
|
+
const missing = entries.filter((e) => !content.includes(e));
|
|
274
|
+
let gitignoreUpdated = false;
|
|
275
|
+
if (missing.length > 0) {
|
|
276
|
+
const prefix = content === "" || content.endsWith("\n") ? "" : "\n";
|
|
277
|
+
await writeFile(gitignorePath, content + prefix + missing.join("\n") + "\n", "utf-8");
|
|
278
|
+
gitignoreUpdated = true;
|
|
279
|
+
}
|
|
280
|
+
return { dirPath: dir, readmeWritten, gitignoreUpdated };
|
|
281
|
+
}
|
|
282
|
+
const MCP_ENTRY = {
|
|
283
|
+
"mobile-mcp": {
|
|
284
|
+
command: "npx",
|
|
285
|
+
args: ["-y", "@mobilenext/mobile-mcp@latest"],
|
|
286
|
+
},
|
|
287
|
+
"react-native-mcp": {
|
|
288
|
+
command: "npx",
|
|
289
|
+
args: ["-y", "@ceraph/react-native-mcp@latest"],
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
export async function setupMcpClients(projectDir) {
|
|
293
|
+
const written = [];
|
|
294
|
+
const skipped = [];
|
|
295
|
+
const targets = [
|
|
296
|
+
{ name: "Claude Code", path: join(projectDir, ".mcp.json") },
|
|
297
|
+
{ name: "Cursor", path: join(projectDir, ".cursor", "mcp.json") },
|
|
298
|
+
{
|
|
299
|
+
name: "Codex",
|
|
300
|
+
path: join(projectDir, ".codex", "config.toml"),
|
|
301
|
+
toml: true,
|
|
302
|
+
},
|
|
303
|
+
{ name: "VS Code", path: join(projectDir, ".vscode", "mcp.json") },
|
|
304
|
+
];
|
|
305
|
+
for (const t of targets) {
|
|
306
|
+
const did = t.toml
|
|
307
|
+
? await setupCodexToml(t.path)
|
|
308
|
+
: await setupJsonMcp(t.path);
|
|
309
|
+
if (did)
|
|
310
|
+
written.push(t.name);
|
|
311
|
+
else
|
|
312
|
+
skipped.push(t.name);
|
|
313
|
+
}
|
|
314
|
+
return { written, skipped };
|
|
315
|
+
}
|
|
316
|
+
async function setupJsonMcp(configPath) {
|
|
317
|
+
const existing = await readJsonOrEmpty(configPath);
|
|
318
|
+
const servers = (existing.mcpServers ?? {});
|
|
319
|
+
if (servers["react-native-mcp"])
|
|
320
|
+
return false;
|
|
321
|
+
servers["mobile-mcp"] = MCP_ENTRY["mobile-mcp"];
|
|
322
|
+
servers["react-native-mcp"] = MCP_ENTRY["react-native-mcp"];
|
|
323
|
+
existing.mcpServers = servers;
|
|
324
|
+
await mkdir(join(configPath, ".."), { recursive: true });
|
|
325
|
+
await writeFile(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
async function setupCodexToml(configPath) {
|
|
329
|
+
let content = "";
|
|
330
|
+
try {
|
|
331
|
+
content = await readFile(configPath, "utf-8");
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
}
|
|
335
|
+
if (content.includes("react-native-mcp"))
|
|
336
|
+
return false;
|
|
337
|
+
const tomlBlock = `
|
|
338
|
+
[mcp_servers.mobile-mcp]
|
|
339
|
+
command = "npx"
|
|
340
|
+
args = ["-y", "@mobilenext/mobile-mcp@latest"]
|
|
341
|
+
|
|
342
|
+
[mcp_servers.react-native-mcp]
|
|
343
|
+
command = "npx"
|
|
344
|
+
args = ["-y", "@ceraph/react-native-mcp@latest"]
|
|
345
|
+
`;
|
|
346
|
+
await mkdir(join(configPath, ".."), { recursive: true });
|
|
347
|
+
await writeFile(configPath, content + tomlBlock, "utf-8");
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
async function readJsonOrEmpty(path) {
|
|
351
|
+
try {
|
|
352
|
+
const raw = await readFile(path, "utf-8");
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
if (parsed && typeof parsed === "object") {
|
|
355
|
+
return parsed;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
}
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
export async function preflightForMcp(projectDir, deps = {}) {
|
|
363
|
+
const screen = deps.screen ?? new ScreenManager(deps.target ? { targetResolver: deps.target } : {});
|
|
364
|
+
const apps = deps.apps ?? new AppLifecycle(screen, deps.target);
|
|
365
|
+
const autonomy = deps.autonomy ?? new DeviceAutonomy(screen);
|
|
366
|
+
const res = await runPreflight({
|
|
367
|
+
screen,
|
|
368
|
+
apps,
|
|
369
|
+
autonomy,
|
|
370
|
+
projectDir,
|
|
371
|
+
target: deps.target,
|
|
372
|
+
iproxyManager: deps.iproxyManager ?? null,
|
|
373
|
+
});
|
|
374
|
+
const passedCount = res.checks.filter((c) => c.ok).length;
|
|
375
|
+
return {
|
|
376
|
+
ok: res.ok,
|
|
377
|
+
passedCount,
|
|
378
|
+
totalCount: res.checks.length,
|
|
379
|
+
checks: res.checks,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
export async function detectMonorepoStatus(projectDir) {
|
|
383
|
+
const detection = await detectMonorepoSubpackages(projectDir);
|
|
384
|
+
return {
|
|
385
|
+
isMonorepo: detection.isMonorepo,
|
|
386
|
+
rootIsRnApp: detection.rootIsRnApp,
|
|
387
|
+
matches: detection.matches,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async function fileExists(path) {
|
|
391
|
+
try {
|
|
392
|
+
await access(path);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
export { syncCameraRegistry, relativeRegistryPath };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type SchemeAction = {
|
|
2
|
+
kind: "expo-json-added";
|
|
3
|
+
previousScheme: SchemeShape;
|
|
4
|
+
nextScheme: string[];
|
|
5
|
+
path: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "expo-json-already-registered";
|
|
8
|
+
scheme: SchemeShape;
|
|
9
|
+
path: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "expo-config-needs-manual";
|
|
12
|
+
path: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "bare-rn-needs-manual";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "no-rn-project";
|
|
17
|
+
};
|
|
18
|
+
export type SchemeShape = string | string[] | undefined;
|
|
19
|
+
export interface UrlSchemeDeps {
|
|
20
|
+
readFile?: (p: string) => Promise<string>;
|
|
21
|
+
writeFile?: (p: string, content: string) => Promise<void>;
|
|
22
|
+
fileExists?: (p: string) => Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
export declare const CERAPH_SCHEME = "ceraph";
|
|
25
|
+
export declare function ensureCeraphUrlScheme(projectDir: string, deps?: UrlSchemeDeps): Promise<SchemeAction>;
|
|
26
|
+
interface ParsedAppJson {
|
|
27
|
+
expo?: {
|
|
28
|
+
scheme?: SchemeShape;
|
|
29
|
+
[k: string]: unknown;
|
|
30
|
+
};
|
|
31
|
+
[k: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
interface InjectResult {
|
|
34
|
+
action: "added" | "already";
|
|
35
|
+
scheme: SchemeShape;
|
|
36
|
+
previousScheme: SchemeShape;
|
|
37
|
+
nextScheme: string[];
|
|
38
|
+
}
|
|
39
|
+
export declare function injectSchemeIntoAppJson(parsed: ParsedAppJson, schemeToAdd: string): InjectResult;
|
|
40
|
+
export declare function bareRnInstructions(): string;
|
|
41
|
+
export declare function expoDynamicConfigInstructions(configPath: string): string;
|
|
42
|
+
export {};
|