@mindmakr/reins 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/adapters.d.ts +25 -0
- package/dist/adapters.js +24 -0
- package/dist/bridge/args.d.ts +15 -0
- package/dist/bridge/args.js +59 -0
- package/dist/bridge/audit.d.ts +13 -0
- package/dist/bridge/audit.js +13 -0
- package/dist/bridge/clipboard.d.ts +2 -0
- package/dist/bridge/clipboard.js +31 -0
- package/dist/bridge/hub.d.ts +56 -0
- package/dist/bridge/hub.js +162 -0
- package/dist/bridge/index.d.ts +11 -0
- package/dist/bridge/index.js +11 -0
- package/dist/bridge/mcp.d.ts +5 -0
- package/dist/bridge/mcp.js +22 -0
- package/dist/bridge/origin.d.ts +7 -0
- package/dist/bridge/origin.js +23 -0
- package/dist/bridge/report-cli.d.ts +13 -0
- package/dist/bridge/report-cli.js +82 -0
- package/dist/bridge/session.d.ts +24 -0
- package/dist/bridge/session.js +116 -0
- package/dist/bridge/snap.d.ts +28 -0
- package/dist/bridge/snap.js +65 -0
- package/dist/bridge/token.d.ts +10 -0
- package/dist/bridge/token.js +35 -0
- package/dist/bridge/tools.d.ts +18 -0
- package/dist/bridge/tools.js +174 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/dom.d.ts +2 -0
- package/dist/dom.js +48 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +6 -0
- package/dist/expo.d.ts +27 -0
- package/dist/expo.js +48 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +267 -0
- package/dist/metro.d.ts +16 -0
- package/dist/metro.js +45 -0
- package/dist/network.d.ts +14 -0
- package/dist/network.js +50 -0
- package/dist/next-token-route.d.ts +9 -0
- package/dist/next-token-route.js +17 -0
- package/dist/next.d.ts +13 -0
- package/dist/next.js +27 -0
- package/dist/node.d.ts +80 -0
- package/dist/node.js +414 -0
- package/dist/react.d.ts +281 -0
- package/dist/react.js +33 -0
- package/dist/record-stub.d.ts +18 -0
- package/dist/record-stub.js +6 -0
- package/dist/record.d.ts +28 -0
- package/dist/record.js +104 -0
- package/dist/redact.d.ts +7 -0
- package/dist/redact.js +30 -0
- package/dist/report.d.ts +121 -0
- package/dist/report.js +353 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +27 -0
- package/dist/stub.d.ts +88 -0
- package/dist/stub.js +38 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +1 -0
- package/dist/verify-cli.d.ts +2 -0
- package/dist/verify-cli.js +3 -0
- package/dist/verify.d.ts +17 -0
- package/dist/verify.js +60 -0
- package/dist/vite.d.ts +32 -0
- package/dist/vite.js +33 -0
- package/dist/webmcp.d.ts +3 -0
- package/dist/webmcp.js +20 -0
- package/package.json +59 -4
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { VERSION } from '@mindmakr/reins';
|
|
4
|
+
import { buildSession, mergeServers, renderMarkdown, sessionBase } from '@mindmakr/reins/report';
|
|
5
|
+
import { reinsDir } from './token.js';
|
|
6
|
+
/** Asks every paired `@mindmakr/reins/node` server for its report. Servers that do not answer are skipped. */
|
|
7
|
+
export async function serverReports(hub) {
|
|
8
|
+
const all = await Promise.all(hub.nodeClients().map((c) => hub.request('server', {}, c).catch(() => null)));
|
|
9
|
+
return all.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
/** How much of the end of the audit log a report reads. The log is never trimmed, so it can be far larger. */
|
|
12
|
+
export const AUDIT_TAIL_BYTES = 1024 * 1024;
|
|
13
|
+
/** The last `maxBytes` of a file as text. A first line cut in half fails to parse and is skipped by the caller. */
|
|
14
|
+
function readTail(file, maxBytes) {
|
|
15
|
+
const fd = openSync(file, 'r');
|
|
16
|
+
try {
|
|
17
|
+
const size = fstatSync(fd).size;
|
|
18
|
+
const length = Math.min(size, maxBytes);
|
|
19
|
+
const buf = Buffer.alloc(length);
|
|
20
|
+
readSync(fd, buf, 0, length, size - length);
|
|
21
|
+
return buf.toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Audit log lines written since `since` (ms), oldest first. Missing log: none. Reads only the end of the file. */
|
|
28
|
+
export function readAudit(projectDir, since, max = 500, tailBytes = AUDIT_TAIL_BYTES) {
|
|
29
|
+
const file = join(projectDir, '.reins', 'audit.log');
|
|
30
|
+
if (!existsSync(file))
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of readTail(file, tailBytes).split('\n')) {
|
|
34
|
+
if (!line.trim())
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const e = JSON.parse(line);
|
|
38
|
+
if (Date.parse(e.at) >= since - 1000)
|
|
39
|
+
out.push({ at: e.at, source: e.source, event: e.event, name: e.name, via: e.via, ok: e.ok, error: e.error, ms: e.ms });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// skip a torn line
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out.slice(-max);
|
|
46
|
+
}
|
|
47
|
+
const safe = (p) => p.catch(() => null);
|
|
48
|
+
/** Gathers a session from the paired app and servers: everything `reins_report` puts in the files. */
|
|
49
|
+
export async function gatherSession(ctx, title) {
|
|
50
|
+
const hub = ctx.hub;
|
|
51
|
+
const c = hub.current();
|
|
52
|
+
const server = mergeServers(await serverReports(hub));
|
|
53
|
+
if (!c && !server)
|
|
54
|
+
throw new Error('No app is paired. Open the app in dev mode (it must call reins.connect()) and make sure the dev server serves .reins/token.');
|
|
55
|
+
let list = null;
|
|
56
|
+
let state = null;
|
|
57
|
+
let network = null;
|
|
58
|
+
let errors = null;
|
|
59
|
+
let perf = null;
|
|
60
|
+
const dom = {};
|
|
61
|
+
if (c) {
|
|
62
|
+
[list, state, network, errors, perf] = await Promise.all([
|
|
63
|
+
safe(hub.request('list')),
|
|
64
|
+
safe(hub.request('state')),
|
|
65
|
+
safe(hub.request('network', { limit: 100 })),
|
|
66
|
+
safe(hub.request('errors', { limit: 100 })),
|
|
67
|
+
safe(hub.request('perf')),
|
|
68
|
+
]);
|
|
69
|
+
for (const id of Array.isArray(list?.panels) ? list.panels : [])
|
|
70
|
+
dom[id] = await safe(hub.request('dom', { panel: id, depth: 8, maxNodes: 400 }));
|
|
71
|
+
}
|
|
72
|
+
return buildSession({
|
|
73
|
+
title: title || (c?.title ? `${c.title}` : 'session'),
|
|
74
|
+
source: 'bridge',
|
|
75
|
+
reinsVersion: VERSION,
|
|
76
|
+
app: c ? { url: c.url, title: c.title, platform: c.meta?.platform || 'web', meta: c.meta } : { platform: 'node' },
|
|
77
|
+
actions: [...(list?.actions || []), ...(server?.actions || [])],
|
|
78
|
+
state,
|
|
79
|
+
network: network || [],
|
|
80
|
+
errors: errors || [],
|
|
81
|
+
perf,
|
|
82
|
+
dom,
|
|
83
|
+
server,
|
|
84
|
+
snaps: ctx.snaps || [],
|
|
85
|
+
audit: readAudit(ctx.dir, hub.startedAt),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Snap links relative to where the Markdown file lives. */
|
|
89
|
+
export const linkFrom = (projectDir, mdFile) => (p) => relative(dirname(mdFile), resolve(projectDir, p)).split('\\').join('/');
|
|
90
|
+
/** Writes `.reins/sessions/<time>-<slug>.json` and `.md`. Returns the paths and the Markdown. */
|
|
91
|
+
export function writeSession(projectDir, session) {
|
|
92
|
+
const folder = join(reinsDir(projectDir), 'sessions');
|
|
93
|
+
mkdirSync(folder, { recursive: true });
|
|
94
|
+
const base = join(folder, sessionBase(session.createdAt, session.title));
|
|
95
|
+
const md = base + '.md';
|
|
96
|
+
const json = base + '.json';
|
|
97
|
+
const markdown = renderMarkdown(session, { link: linkFrom(projectDir, md) });
|
|
98
|
+
writeFileSync(json, JSON.stringify(session, null, 2) + '\n');
|
|
99
|
+
writeFileSync(md, markdown);
|
|
100
|
+
return { md, json, markdown };
|
|
101
|
+
}
|
|
102
|
+
/** Reads a session JSON and checks its schema version. */
|
|
103
|
+
export function readSession(file) {
|
|
104
|
+
const s = JSON.parse(readFileSync(file, 'utf8'));
|
|
105
|
+
if (!s || s.schemaVersion !== 1)
|
|
106
|
+
throw new Error(`${file} is not a reins session (schemaVersion 1)`);
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
/** The newest session JSON under `<projectDir>/.reins/sessions`, if any. */
|
|
110
|
+
export function latestSession(projectDir) {
|
|
111
|
+
const folder = join(projectDir, '.reins', 'sessions');
|
|
112
|
+
if (!existsSync(folder))
|
|
113
|
+
return undefined;
|
|
114
|
+
const files = readdirSync(folder).filter((f) => f.endsWith('.json')).sort();
|
|
115
|
+
return files.length ? join(folder, files[files.length - 1]) : undefined;
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Box } from '@mindmakr/reins';
|
|
2
|
+
import type { Client } from './hub.js';
|
|
3
|
+
export interface SnapOptions {
|
|
4
|
+
dir: string;
|
|
5
|
+
/** Chrome DevTools endpoint, for example http://127.0.0.1:9222. Needed for browser snaps. */
|
|
6
|
+
cdp?: string;
|
|
7
|
+
copy?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface SnapResult {
|
|
10
|
+
box: Box;
|
|
11
|
+
file?: string;
|
|
12
|
+
copied?: boolean;
|
|
13
|
+
/** For full-screen device captures: the panel in device pixels, to crop with. */
|
|
14
|
+
pixelBox?: {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
note?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Takes a pixel-exact picture of a panel.
|
|
24
|
+
* Browser: Playwright over CDP, clipped to the box (viewport CSS pixels).
|
|
25
|
+
* Android (Expo): a full-screen `adb screencap` plus the box in device pixels.
|
|
26
|
+
* Otherwise it returns the box so the agent's own driver can clip.
|
|
27
|
+
*/
|
|
28
|
+
export declare function snap(box: Box, client: Client | undefined, opts: SnapOptions): Promise<SnapResult>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { copyImage } from './clipboard.js';
|
|
5
|
+
async function loadPlaywright() {
|
|
6
|
+
for (const name of ['playwright', 'playwright-core']) {
|
|
7
|
+
try {
|
|
8
|
+
return await import(name);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// try the next one
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const stripQuery = (url = '') => url.split(/[?#]/)[0];
|
|
17
|
+
function snapFile(dir, id) {
|
|
18
|
+
const folder = join(dir, '.reins', 'snaps');
|
|
19
|
+
mkdirSync(folder, { recursive: true });
|
|
20
|
+
return join(folder, `${id.replace(/[^A-Za-z0-9._-]/g, '_')}-${Date.now()}.png`);
|
|
21
|
+
}
|
|
22
|
+
const pixels = (b) => ({
|
|
23
|
+
x: Math.round(b.x * b.devicePixelRatio),
|
|
24
|
+
y: Math.round(b.y * b.devicePixelRatio),
|
|
25
|
+
width: Math.round(b.width * b.devicePixelRatio),
|
|
26
|
+
height: Math.round(b.height * b.devicePixelRatio),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Takes a pixel-exact picture of a panel.
|
|
30
|
+
* Browser: Playwright over CDP, clipped to the box (viewport CSS pixels).
|
|
31
|
+
* Android (Expo): a full-screen `adb screencap` plus the box in device pixels.
|
|
32
|
+
* Otherwise it returns the box so the agent's own driver can clip.
|
|
33
|
+
*/
|
|
34
|
+
export async function snap(box, client, opts) {
|
|
35
|
+
if (client && client.meta && client.meta.platform === 'android') {
|
|
36
|
+
try {
|
|
37
|
+
const png = execFileSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
|
|
38
|
+
const file = snapFile(opts.dir, box.id);
|
|
39
|
+
writeFileSync(file, png);
|
|
40
|
+
return { box, file, pixelBox: pixels(box), note: 'Full screen; crop to pixelBox.', copied: opts.copy ? copyImage(file) : undefined };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { box, pixelBox: pixels(box), note: 'adb screencap failed; is adb on PATH and a device attached?' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const pw = await loadPlaywright();
|
|
47
|
+
if (!pw || !opts.cdp) {
|
|
48
|
+
return { box, note: pw ? 'Start the bridge with --cdp <url> to take snaps.' : 'Playwright is not installed; clip your own screenshot to this box (viewport CSS pixels).' };
|
|
49
|
+
}
|
|
50
|
+
const browser = await pw.chromium.connectOverCDP(opts.cdp);
|
|
51
|
+
try {
|
|
52
|
+
const pages = browser.contexts().flatMap((c) => c.pages());
|
|
53
|
+
const want = stripQuery(client && client.url);
|
|
54
|
+
const page = pages.find((p) => stripQuery(p.url()) === want) || pages[0];
|
|
55
|
+
if (!page)
|
|
56
|
+
return { box, note: 'No page found over CDP.' };
|
|
57
|
+
const file = snapFile(opts.dir, box.id);
|
|
58
|
+
await page.screenshot({ path: file, clip: { x: box.x, y: box.y, width: box.width, height: box.height } });
|
|
59
|
+
return { box, file, copied: opts.copy ? copyImage(file) : undefined };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
// For CDP connections this disconnects; it does not close the person's browser.
|
|
63
|
+
await browser.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A fresh random pairing token (192 bits). */
|
|
2
|
+
export declare function newToken(): string;
|
|
3
|
+
/** Compares tokens in constant time. */
|
|
4
|
+
export declare function tokensMatch(given: unknown, expected: string): boolean;
|
|
5
|
+
/** The project's `.reins` folder. It holds its own `.gitignore` so nothing in it is ever committed. */
|
|
6
|
+
export declare function reinsDir(projectDir: string): string;
|
|
7
|
+
export declare function tokenPath(projectDir: string): string;
|
|
8
|
+
/** Writes the token where the dev server's token route reads it. Readable by the owner only. */
|
|
9
|
+
export declare function writeToken(projectDir: string, token: string): string;
|
|
10
|
+
export declare function removeToken(projectDir: string): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** A fresh random pairing token (192 bits). */
|
|
5
|
+
export function newToken() {
|
|
6
|
+
return randomBytes(24).toString('base64url');
|
|
7
|
+
}
|
|
8
|
+
/** Compares tokens in constant time. */
|
|
9
|
+
export function tokensMatch(given, expected) {
|
|
10
|
+
if (typeof given !== 'string' || !expected)
|
|
11
|
+
return false;
|
|
12
|
+
const a = Buffer.from(given);
|
|
13
|
+
const b = Buffer.from(expected);
|
|
14
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
15
|
+
}
|
|
16
|
+
/** The project's `.reins` folder. It holds its own `.gitignore` so nothing in it is ever committed. */
|
|
17
|
+
export function reinsDir(projectDir) {
|
|
18
|
+
const dir = join(projectDir, '.reins');
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
writeFileSync(join(dir, '.gitignore'), '*\n');
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
export function tokenPath(projectDir) {
|
|
24
|
+
return join(projectDir, '.reins', 'token');
|
|
25
|
+
}
|
|
26
|
+
/** Writes the token where the dev server's token route reads it. Readable by the owner only. */
|
|
27
|
+
export function writeToken(projectDir, token) {
|
|
28
|
+
reinsDir(projectDir);
|
|
29
|
+
const file = tokenPath(projectDir);
|
|
30
|
+
writeFileSync(file, token + '\n', { mode: 0o600 });
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
export function removeToken(projectDir) {
|
|
34
|
+
rmSync(tokenPath(projectDir), { force: true });
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Audit } from './audit.js';
|
|
2
|
+
import type { Hub } from './hub.js';
|
|
3
|
+
export interface Tool {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputSchema: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export declare const TOOLS: Tool[];
|
|
9
|
+
export interface ToolContext {
|
|
10
|
+
hub: Hub;
|
|
11
|
+
dir: string;
|
|
12
|
+
cdp?: string;
|
|
13
|
+
audit?: Audit;
|
|
14
|
+
/** Project-relative paths of the snaps taken this session (filled in by reins_snap). */
|
|
15
|
+
snaps?: string[];
|
|
16
|
+
}
|
|
17
|
+
/** Runs one MCP tool against the paired app (and server). Every call is audited. */
|
|
18
|
+
export declare function callTool(ctx: ToolContext, name: string, args?: Record<string, any>): Promise<unknown>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { findings, mergeServers, renderMarkdown, withTimeline } from '@mindmakr/reins/report';
|
|
3
|
+
import { gatherSession, serverReports, writeSession } from './session.js';
|
|
4
|
+
import { snap } from './snap.js';
|
|
5
|
+
const obj = (properties = {}, required = []) => ({ type: 'object', properties, required });
|
|
6
|
+
export const TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: 'reins_list_actions',
|
|
9
|
+
description: 'List the actions (with input schemas), state sources and panels the paired app registered, plus the actions of any paired @mindmakr/reins/node server. Call this first.',
|
|
10
|
+
inputSchema: obj(),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'reins_run_action',
|
|
14
|
+
description: 'Run a registered action by name (app or server). Actions marked consequential are refused unless confirm is true; ask the person before confirming. Use target to pick "app" or "server" when both have the name.',
|
|
15
|
+
inputSchema: obj({ name: { type: 'string' }, input: { type: 'object' }, confirm: { type: 'boolean' }, target: { type: 'string', enum: ['app', 'server'] } }, ['name']),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'reins_state',
|
|
19
|
+
description: 'Read a redacted snapshot of the app state: one source by name, or all of them.',
|
|
20
|
+
inputSchema: obj({ name: { type: 'string' } }),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'reins_network',
|
|
24
|
+
description: 'Recent fetch calls: method, url, status, ms, startMs, trace id, redacted headers and bodies.',
|
|
25
|
+
inputSchema: obj({ limit: { type: 'number' }, url: { type: 'string', description: 'Only calls whose URL contains this' } }),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'reins_errors',
|
|
29
|
+
description: 'Recent console errors, uncaught errors and unhandled promise rejections.',
|
|
30
|
+
inputSchema: obj({ limit: { type: 'number' } }),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'reins_panel_box',
|
|
34
|
+
description: 'Bounding box of a panel (data-reins attribute or registered RN panel) in CSS pixels, with devicePixelRatio.',
|
|
35
|
+
inputSchema: obj({ id: { type: 'string' }, scroll: { type: 'boolean', description: 'Scroll it into view first' } }, ['id']),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'reins_snap',
|
|
39
|
+
description: 'Pixel-exact picture of a panel, saved under .reins/snaps. Uses Playwright over CDP (bridge --cdp) or adb for Android; otherwise returns the box.',
|
|
40
|
+
inputSchema: obj({ id: { type: 'string' }, copy: { type: 'boolean', description: 'Also copy the image to the clipboard' } }, ['id']),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'reins_perf',
|
|
44
|
+
description: 'Page lifecycle timings (TTFB, FCP, DOM interactive, load), component/panel mount times and render counts, API call latency stats, and a unified millisecond timeline. With a paired @mindmakr/reins/node server it adds a server section and the server half of each API call (kind "server", with db time and spans), joined by trace id.',
|
|
45
|
+
inputSchema: obj(),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'reins_dom',
|
|
49
|
+
description: "Inspect a panel or CSS selector's DOM subtree, bounding boxes, computed CSS styles, and automatic layout issue flags (horizontal-clip, vertical-clip, zero-size).",
|
|
50
|
+
inputSchema: obj({
|
|
51
|
+
panel: { type: 'string', description: 'Panel id (data-reins attribute) to inspect' },
|
|
52
|
+
selector: { type: 'string', description: 'CSS selector inside the panel (or document if no panel)' },
|
|
53
|
+
depth: { type: 'number', description: 'Max child tree depth (default 3)' },
|
|
54
|
+
maxNodes: { type: 'number', description: 'Max elements to return (default 40)' },
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'reins_server',
|
|
59
|
+
description: 'The server side (needs @mindmakr/reins/node in the dev server): recent requests with their spans (db.* time) and outbound fetches, trace ids that match reins_network, the slowest routes, and runtime stats (event-loop delay p50/p99/max, memory). Call it when an API call is slow or fails.',
|
|
60
|
+
inputSchema: obj({
|
|
61
|
+
limit: { type: 'number', description: 'Requests to return, newest last (default 50)' },
|
|
62
|
+
route: { type: 'string', description: 'Only requests whose route or URL contains this' },
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'reins_report',
|
|
67
|
+
description: 'Gather everything into one session report: actions, redacted state, network, errors, perf with the server joined, layout issues per panel, snaps and actions run this session, with plain-language findings (slow or failed calls, duplicate calls, N+1-looking server spans, re-renders, layout issues). Saves .reins/sessions/<time>-<slug>.md and .json unless save is false. Returns the Markdown, the file paths and the findings.',
|
|
68
|
+
inputSchema: obj({
|
|
69
|
+
title: { type: 'string', description: 'Short name, e.g. "checkout before fix"' },
|
|
70
|
+
save: { type: 'boolean', description: 'Write the files (default true)' },
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
const METHODS = {
|
|
75
|
+
reins_state: 'state',
|
|
76
|
+
reins_network: 'network',
|
|
77
|
+
reins_errors: 'errors',
|
|
78
|
+
reins_panel_box: 'panel',
|
|
79
|
+
reins_dom: 'dom',
|
|
80
|
+
};
|
|
81
|
+
const hasAction = (l, name) => Array.isArray(l?.actions) && l.actions.some((a) => a && a.name === name);
|
|
82
|
+
/** Which client runs an action: the page or app, unless only a paired server has it (or `target` says so). */
|
|
83
|
+
async function actionTarget(hub, name, target) {
|
|
84
|
+
const nodes = hub.nodeClients();
|
|
85
|
+
if (!nodes.length || target === 'app')
|
|
86
|
+
return undefined;
|
|
87
|
+
if (target !== 'server' && hub.current() && hasAction(await hub.request('list').catch(() => null), name))
|
|
88
|
+
return undefined;
|
|
89
|
+
for (const c of nodes)
|
|
90
|
+
if (hasAction(await hub.request('list', {}, c).catch(() => null), name))
|
|
91
|
+
return c;
|
|
92
|
+
if (target === 'server')
|
|
93
|
+
throw new Error(`no paired server has an action "${name}"`);
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
async function listAll(hub) {
|
|
97
|
+
const c = hub.current();
|
|
98
|
+
const nodes = hub.nodeClients();
|
|
99
|
+
const app = c || !nodes.length ? await hub.request('list') : { actions: [], states: [], panels: [] };
|
|
100
|
+
const out = { app: c && { url: c.url, title: c.title, meta: c.meta }, ...app };
|
|
101
|
+
if (nodes.length)
|
|
102
|
+
out.servers = await Promise.all(nodes.map(async (n) => ({ client: n.id, title: n.title, meta: n.meta, actions: (await hub.request('list', {}, n).catch(() => null))?.actions || [] })));
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
async function perfJoined(hub) {
|
|
106
|
+
const perf = await hub.request('perf');
|
|
107
|
+
const server = mergeServers(await serverReports(hub));
|
|
108
|
+
const out = withTimeline(perf, server?.requests);
|
|
109
|
+
if (server)
|
|
110
|
+
out.server = { requests: server.requests.length, slowest: server.slowest.slice(0, 5), runtime: server.runtime };
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
async function serverTool(hub, args) {
|
|
114
|
+
if (!hub.nodeClients().length)
|
|
115
|
+
throw new Error('No server is paired. In the dev server, call reinsNode.connect() from @mindmakr/reins/node (dev only) and add reinsNode.middleware().');
|
|
116
|
+
const server = mergeServers(await serverReports(hub));
|
|
117
|
+
if (!server)
|
|
118
|
+
throw new Error('the paired server did not answer');
|
|
119
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : 50;
|
|
120
|
+
const route = typeof args.route === 'string' ? args.route : '';
|
|
121
|
+
return { ...server, requests: server.requests.filter((r) => !route || r.route.includes(route) || r.url.includes(route)).slice(-limit) };
|
|
122
|
+
}
|
|
123
|
+
/** Runs one MCP tool against the paired app (and server). Every call is audited. */
|
|
124
|
+
export async function callTool(ctx, name, args = {}) {
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
const entry = {
|
|
127
|
+
source: 'agent',
|
|
128
|
+
event: name,
|
|
129
|
+
name: args.name ?? args.id ?? args.panel ?? args.selector ?? args.title ?? args.route,
|
|
130
|
+
args: name === 'reins_run_action' ? args.input : undefined,
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
let result;
|
|
134
|
+
if (name === 'reins_snap') {
|
|
135
|
+
const box = await ctx.hub.request('panel', { id: args.id, scroll: true });
|
|
136
|
+
if (!box)
|
|
137
|
+
throw new Error(`no panel "${args.id}"`);
|
|
138
|
+
const shot = await snap(box, ctx.hub.current(), { dir: ctx.dir, cdp: ctx.cdp, copy: args.copy === true });
|
|
139
|
+
if (shot.file)
|
|
140
|
+
(ctx.snaps ?? (ctx.snaps = [])).push(relative(ctx.dir, shot.file).split('\\').join('/'));
|
|
141
|
+
result = shot;
|
|
142
|
+
}
|
|
143
|
+
else if (name === 'reins_list_actions') {
|
|
144
|
+
result = await listAll(ctx.hub);
|
|
145
|
+
}
|
|
146
|
+
else if (name === 'reins_run_action') {
|
|
147
|
+
const target = await actionTarget(ctx.hub, args.name, args.target);
|
|
148
|
+
result = await ctx.hub.request('run', { name: args.name, input: args.input, confirm: args.confirm === true }, target);
|
|
149
|
+
}
|
|
150
|
+
else if (name === 'reins_perf') {
|
|
151
|
+
result = await perfJoined(ctx.hub);
|
|
152
|
+
}
|
|
153
|
+
else if (name === 'reins_server') {
|
|
154
|
+
result = await serverTool(ctx.hub, args);
|
|
155
|
+
}
|
|
156
|
+
else if (name === 'reins_report') {
|
|
157
|
+
const session = await gatherSession(ctx, typeof args.title === 'string' ? args.title : undefined);
|
|
158
|
+
const files = args.save === false ? null : writeSession(ctx.dir, session);
|
|
159
|
+
result = { markdown: files ? files.markdown : renderMarkdown(session), files: files && { md: files.md, json: files.json }, findings: findings(session) };
|
|
160
|
+
}
|
|
161
|
+
else if (METHODS[name]) {
|
|
162
|
+
result = await ctx.hub.request(METHODS[name], args);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
throw new Error(`unknown tool "${name}"`);
|
|
166
|
+
}
|
|
167
|
+
ctx.audit?.({ ...entry, ok: true, ms: Date.now() - started });
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
ctx.audit?.({ ...entry, ok: false, error: err.message, ms: Date.now() - started });
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseArgs, portInUse, USAGE } from './bridge/args.js';
|
|
4
|
+
import { fileAudit } from './bridge/audit.js';
|
|
5
|
+
import { DEFAULT_PORT, Hub } from './bridge/hub.js';
|
|
6
|
+
import { startMcp } from './bridge/mcp.js';
|
|
7
|
+
import { reportMain } from './bridge/report-cli.js';
|
|
8
|
+
import { tokenPath } from './bridge/token.js';
|
|
9
|
+
import { main as verifyMain } from './verify.js';
|
|
10
|
+
const err = (m) => process.stderr.write(m + '\n');
|
|
11
|
+
async function main() {
|
|
12
|
+
const argv = process.argv.slice(2);
|
|
13
|
+
if (argv[0] === 'verify') {
|
|
14
|
+
process.exitCode = verifyMain(argv.slice(1));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (argv[0] === 'report') {
|
|
18
|
+
process.exitCode = reportMain(argv.slice(1));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let args;
|
|
22
|
+
try {
|
|
23
|
+
args = parseArgs(argv, process.cwd());
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
err(`reins: ${e.message}\n\n${USAGE}`);
|
|
27
|
+
process.exitCode = 2;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (args.command === 'help') {
|
|
31
|
+
err(USAGE);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const dir = resolve(args.dir);
|
|
35
|
+
const audit = fileAudit(dir);
|
|
36
|
+
let hub;
|
|
37
|
+
try {
|
|
38
|
+
hub = await Hub.start({ dir, port: args.port, origins: args.origins, rotate: args.rotate, allowNoOrigin: args.allowNoOrigin, audit });
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (e && e.code === 'EADDRINUSE') {
|
|
42
|
+
err(portInUse(args.port ?? DEFAULT_PORT));
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
err(`[reins] bridge on ws://127.0.0.1:${hub.port}`);
|
|
49
|
+
// The token itself is not printed: stderr ends up in the agent host's MCP logs. It is in the file.
|
|
50
|
+
err(`[reins] pairing token written to ${tokenPath(dir)}${args.rotate ? ' (renewed after each pairing)' : ''}`);
|
|
51
|
+
audit({ source: 'bridge', event: 'start', ok: true, port: hub.port });
|
|
52
|
+
if (args.mcp)
|
|
53
|
+
await startMcp({ hub, dir, cdp: args.cdp, audit });
|
|
54
|
+
const stop = async () => {
|
|
55
|
+
audit({ source: 'bridge', event: 'stop', ok: true });
|
|
56
|
+
await hub.close();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
};
|
|
59
|
+
process.on('SIGINT', stop);
|
|
60
|
+
process.on('SIGTERM', stop);
|
|
61
|
+
if (args.mcp)
|
|
62
|
+
process.stdin.on('end', stop);
|
|
63
|
+
}
|
|
64
|
+
main().catch((e) => {
|
|
65
|
+
err(`reins: ${e && e.message ? e.message : e}`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
});
|
package/dist/dom.d.ts
ADDED
package/dist/dom.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The DOM and CSS inspector behind `reins.dom()` and `reins_dom`. The core loads this file on the first call.
|
|
2
|
+
import { isSensitive, REDACTED } from './redact.js';
|
|
3
|
+
const STYLE_KEYS = 'display,position,flexDirection,justifyContent,alignItems,gap,overflowX,overflowY,direction,color,backgroundColor,fontSize,fontWeight,opacity,visibility,zIndex'.split(',');
|
|
4
|
+
const R = (n) => Math.round(n || 0);
|
|
5
|
+
export function inspect(g, o = {}) {
|
|
6
|
+
const d = g.document, findPanel = (id) => d.querySelector?.(`[data-reins="${id.replace(/["\\]/g, '\\$&')}"]`), boxOf = (r) => ({ x: R(r.left), y: R(r.top), width: R(r.width), height: R(r.height) });
|
|
7
|
+
let root = d && (o.panel ? findPanel(o.panel) : o.selector ? d : d.body);
|
|
8
|
+
if (root && o.selector)
|
|
9
|
+
root = root.querySelector?.(o.selector);
|
|
10
|
+
if (!root)
|
|
11
|
+
return null;
|
|
12
|
+
let count = 0;
|
|
13
|
+
const walk = (el, depth) => {
|
|
14
|
+
count++;
|
|
15
|
+
const r = el.getBoundingClientRect?.() || {}, cs = g.getComputedStyle?.(el) || {}, styles = {}, issues = [], clip = (v) => /^hidden|clip$/.test(v), attr = (k) => el.getAttribute?.(k) || undefined, cls = el.className?.trim?.(), children = [];
|
|
16
|
+
let raw = '';
|
|
17
|
+
for (const n of el.childNodes || [])
|
|
18
|
+
if (n.nodeType == 3)
|
|
19
|
+
raw += n.textContent;
|
|
20
|
+
raw = raw.trim().slice(0, 80);
|
|
21
|
+
for (const k of STYLE_KEYS)
|
|
22
|
+
if (cs[k])
|
|
23
|
+
styles[k] = '' + cs[k];
|
|
24
|
+
if (el.scrollWidth > el.clientWidth + 1 && (clip(cs.overflowX) || cs.textOverflow == 'ellipsis'))
|
|
25
|
+
issues.push('horizontal-clip');
|
|
26
|
+
if (el.scrollHeight > el.clientHeight + 1 && clip(cs.overflowY))
|
|
27
|
+
issues.push('vertical-clip');
|
|
28
|
+
if ((!r.width || !r.height) && !/^(none|contents)$/.test(cs.display))
|
|
29
|
+
issues.push('zero-size');
|
|
30
|
+
if (depth < (o.depth ?? 3))
|
|
31
|
+
for (const ch of el.children || [])
|
|
32
|
+
if (count < (o.maxNodes ?? 40))
|
|
33
|
+
children.push(walk(ch, depth + 1));
|
|
34
|
+
return {
|
|
35
|
+
tag: (el.tagName || 'div').toLowerCase(),
|
|
36
|
+
id: el.id || undefined,
|
|
37
|
+
classes: cls ? cls.split(/\s+/) : undefined,
|
|
38
|
+
panel: attr('data-reins'),
|
|
39
|
+
role: attr('role'),
|
|
40
|
+
text: raw ? (isSensitive([el.id, attr('name'), attr('type')].join(' ')) ? REDACTED : raw) : undefined,
|
|
41
|
+
box: boxOf(r),
|
|
42
|
+
styles,
|
|
43
|
+
issues,
|
|
44
|
+
children,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
return walk(root, 0);
|
|
48
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function devEnabled(): boolean;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function devEnabled() {
|
|
2
|
+
const d = import.meta.env, r = globalThis.__DEV__, p = typeof process < 'u' && process.env;
|
|
3
|
+
return (['', 'VITE_', 'NEXT_PUBLIC_', 'EXPO_PUBLIC_'].some((k) => /^(1|true)$/.test(d?.[k + 'REINS_ENABLE'] || p?.[k + 'REINS_ENABLE'])) ||
|
|
4
|
+
r === true ||
|
|
5
|
+
(r !== false && p?.NODE_ENV != 'production' && d?.DEV !== false && !!(d?.DEV || p?.NODE_ENV)));
|
|
6
|
+
}
|
package/dist/expo.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type Box, type ConnectOptions } from '@mindmakr/reins';
|
|
3
|
+
export declare const TOKEN_ROUTE = "/__reins/token";
|
|
4
|
+
/** `http://host:port` of the Metro dev server that served this bundle, if known. */
|
|
5
|
+
export declare function devServerOrigin(scriptURL?: string | undefined): string | null;
|
|
6
|
+
/**
|
|
7
|
+
* Pairs the app with the bridge. Fetches the token from the Metro dev server (see `withReins` in `@mindmakr/reins/metro`)
|
|
8
|
+
* and connects to the bridge on localhost. Android emulators and USB devices need `adb reverse tcp:7357 tcp:7357`.
|
|
9
|
+
* Does nothing in production builds.
|
|
10
|
+
*/
|
|
11
|
+
export declare function connectExpo(opts?: ConnectOptions): Promise<boolean>;
|
|
12
|
+
/** Measures a native view in window coordinates (dp) with the pixel ratio. */
|
|
13
|
+
export declare function measureView(id: string, view: any): Promise<Box | null>;
|
|
14
|
+
/** Returns a ref to put on a View; the agent can then measure and snap it as panel `id`. */
|
|
15
|
+
export declare function useReinsPanel(id: string): import("react").MutableRefObject<any>;
|
|
16
|
+
export interface ReinsPanelProps {
|
|
17
|
+
id: string;
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
[prop: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** A View with `testID={id}` that the agent can measure and snap. */
|
|
22
|
+
export declare function ReinsPanel({ id, children, ...rest }: ReinsPanelProps): import("react").CElement<{
|
|
23
|
+
ref: import("react").MutableRefObject<any>;
|
|
24
|
+
testID: string;
|
|
25
|
+
collapsable: boolean;
|
|
26
|
+
}, any>;
|
|
27
|
+
export { useReinsAction, useReinsState } from '@mindmakr/reins/react';
|
package/dist/expo.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createElement, useEffect, useRef } from 'react';
|
|
2
|
+
import { NativeModules, PixelRatio, Platform, View } from 'react-native';
|
|
3
|
+
import { reins } from '@mindmakr/reins';
|
|
4
|
+
export const TOKEN_ROUTE = '/__reins/token';
|
|
5
|
+
/** `http://host:port` of the Metro dev server that served this bundle, if known. */
|
|
6
|
+
export function devServerOrigin(scriptURL = NativeModules.SourceCode && NativeModules.SourceCode.scriptURL) {
|
|
7
|
+
if (!scriptURL)
|
|
8
|
+
return null;
|
|
9
|
+
const m = /^(https?:\/\/[^/]+)/.exec(scriptURL);
|
|
10
|
+
return m ? m[1] : null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pairs the app with the bridge. Fetches the token from the Metro dev server (see `withReins` in `@mindmakr/reins/metro`)
|
|
14
|
+
* and connects to the bridge on localhost. Android emulators and USB devices need `adb reverse tcp:7357 tcp:7357`.
|
|
15
|
+
* Does nothing in production builds.
|
|
16
|
+
*/
|
|
17
|
+
export function connectExpo(opts = {}) {
|
|
18
|
+
const origin = devServerOrigin();
|
|
19
|
+
return reins.connect({
|
|
20
|
+
tokenUrl: origin ? origin + TOKEN_ROUTE : undefined,
|
|
21
|
+
url: 'ws://localhost:7357',
|
|
22
|
+
...opts,
|
|
23
|
+
meta: { platform: Platform.OS, ...(opts.meta || {}) },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Measures a native view in window coordinates (dp) with the pixel ratio. */
|
|
27
|
+
export function measureView(id, view) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
if (!view || typeof view.measureInWindow !== 'function')
|
|
30
|
+
return resolve(null);
|
|
31
|
+
view.measureInWindow((x, y, width, height) => resolve({ id, x, y, width, height, scrollX: 0, scrollY: 0, devicePixelRatio: PixelRatio.get() }));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Returns a ref to put on a View; the agent can then measure and snap it as panel `id`. */
|
|
35
|
+
export function useReinsPanel(id) {
|
|
36
|
+
const ref = useRef(null);
|
|
37
|
+
useEffect(() => reins.registerPanel(id, () => measureView(id, ref.current)), [id]);
|
|
38
|
+
return ref;
|
|
39
|
+
}
|
|
40
|
+
/** A View with `testID={id}` that the agent can measure and snap. */
|
|
41
|
+
export function ReinsPanel({ id, children, ...rest }) {
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
reins.markComponent(id);
|
|
44
|
+
});
|
|
45
|
+
const ref = useReinsPanel(id);
|
|
46
|
+
return createElement(View, { testID: id, collapsable: false, ...rest, ref }, children);
|
|
47
|
+
}
|
|
48
|
+
export { useReinsAction, useReinsState } from '@mindmakr/reins/react';
|