@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mindmakr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mindmakr/reins
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The reins core: actions, state, network and error buffers, panels and WebMCP. No dependencies, under 5 KB min+gzip.
|
|
4
|
+
|
|
5
|
+
See the [main README](https://github.com/mindmakr/reins#readme) for setup and the security model.
|
|
6
|
+
|
|
7
|
+
MIT � mindmakr
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** State source for any store with `getState()`: Zustand, Redux, Redux Toolkit. */
|
|
2
|
+
export declare const fromStore: (store: {
|
|
3
|
+
getState(): unknown;
|
|
4
|
+
}) => () => unknown;
|
|
5
|
+
export declare const zustand: (store: {
|
|
6
|
+
getState(): unknown;
|
|
7
|
+
}) => () => unknown;
|
|
8
|
+
export declare const redux: (store: {
|
|
9
|
+
getState(): unknown;
|
|
10
|
+
}) => () => unknown;
|
|
11
|
+
/** State source for a TanStack (React) Query client: one entry per cached query. */
|
|
12
|
+
export declare const reactQuery: (client: {
|
|
13
|
+
getQueryCache(): {
|
|
14
|
+
getAll(): any[];
|
|
15
|
+
};
|
|
16
|
+
}) => () => {
|
|
17
|
+
key: any;
|
|
18
|
+
status: any;
|
|
19
|
+
fetchStatus: any;
|
|
20
|
+
updatedAt: any;
|
|
21
|
+
error: string | undefined;
|
|
22
|
+
data: any;
|
|
23
|
+
}[];
|
|
24
|
+
/** State source for the current route. Pass a getter for routers that keep their own location. */
|
|
25
|
+
export declare const routerLocation: (get?: () => unknown) => () => unknown;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { redactUrl } from './redact.js';
|
|
2
|
+
/** State source for any store with `getState()`: Zustand, Redux, Redux Toolkit. */
|
|
3
|
+
export const fromStore = (store) => () => store.getState();
|
|
4
|
+
export const zustand = fromStore;
|
|
5
|
+
export const redux = fromStore;
|
|
6
|
+
/** State source for a TanStack (React) Query client: one entry per cached query. */
|
|
7
|
+
export const reactQuery = (client) => () => client
|
|
8
|
+
.getQueryCache()
|
|
9
|
+
.getAll()
|
|
10
|
+
.map((q) => ({
|
|
11
|
+
key: q.queryKey,
|
|
12
|
+
status: q.state.status,
|
|
13
|
+
fetchStatus: q.state.fetchStatus,
|
|
14
|
+
updatedAt: q.state.dataUpdatedAt,
|
|
15
|
+
error: q.state.error ? String(q.state.error) : undefined,
|
|
16
|
+
data: q.state.data,
|
|
17
|
+
}));
|
|
18
|
+
/** State source for the current route. Pass a getter for routers that keep their own location. */
|
|
19
|
+
export const routerLocation = (get) => () => {
|
|
20
|
+
if (get)
|
|
21
|
+
return get();
|
|
22
|
+
const l = globalThis.location;
|
|
23
|
+
return l ? { pathname: l.pathname, search: redactUrl(l.search), hash: l.hash } : null;
|
|
24
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BridgeArgs {
|
|
2
|
+
command: 'bridge' | 'help';
|
|
3
|
+
port?: number;
|
|
4
|
+
dir: string;
|
|
5
|
+
origins: string[];
|
|
6
|
+
cdp?: string;
|
|
7
|
+
rotate: boolean;
|
|
8
|
+
mcp: boolean;
|
|
9
|
+
allowNoOrigin: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const USAGE = "usage:\n reins bridge [options]\n reins verify <dir> [<dir>...]\n reins report [--from <session.json>] [--compare <before.json> <after.json>] [--out <dir|file.md>]\n\nStarts the reins bridge on 127.0.0.1 and an MCP server on stdio, checks a production bundle for reins dev code, or renders saved session reports.\n\n --port <n> WebSocket port (default 7357)\n --dir <path> project folder for .reins/ (default: current folder)\n --origin <origin> also allow this exact page origin (repeatable); localhost is always allowed\n --cdp <url> Chrome DevTools endpoint for reins_snap, e.g. http://127.0.0.1:9222\n --no-rotate keep the same token after a page pairs\n --no-mcp do not start the MCP server (WebSocket only)\n --browser-only refuse clients without an Origin header (no React Native)\n";
|
|
12
|
+
/** What to tell the person when the bridge port is taken, which happens when two agent sessions each start a bridge. */
|
|
13
|
+
export declare const portInUse: (port: number) => string;
|
|
14
|
+
/** Parses `reins` arguments. Throws on unknown flags. */
|
|
15
|
+
export declare function parseArgs(argv: string[], cwd: string): BridgeArgs;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const USAGE = `usage:
|
|
2
|
+
reins bridge [options]
|
|
3
|
+
reins verify <dir> [<dir>...]
|
|
4
|
+
reins report [--from <session.json>] [--compare <before.json> <after.json>] [--out <dir|file.md>]
|
|
5
|
+
|
|
6
|
+
Starts the reins bridge on 127.0.0.1 and an MCP server on stdio, checks a production bundle for reins dev code, or renders saved session reports.
|
|
7
|
+
|
|
8
|
+
--port <n> WebSocket port (default 7357)
|
|
9
|
+
--dir <path> project folder for .reins/ (default: current folder)
|
|
10
|
+
--origin <origin> also allow this exact page origin (repeatable); localhost is always allowed
|
|
11
|
+
--cdp <url> Chrome DevTools endpoint for reins_snap, e.g. http://127.0.0.1:9222
|
|
12
|
+
--no-rotate keep the same token after a page pairs
|
|
13
|
+
--no-mcp do not start the MCP server (WebSocket only)
|
|
14
|
+
--browser-only refuse clients without an Origin header (no React Native)
|
|
15
|
+
`;
|
|
16
|
+
/** What to tell the person when the bridge port is taken, which happens when two agent sessions each start a bridge. */
|
|
17
|
+
export const portInUse = (port) => `reins: port ${port} is already in use. Another reins bridge is probably running (a second agent session or terminal, in this project or another).
|
|
18
|
+
reins: stop that one, or start this one with --port <n> and give the app the same port: reins.connect({ url: 'ws://127.0.0.1:<n>' }).`;
|
|
19
|
+
/** Parses `reins` arguments. Throws on unknown flags. */
|
|
20
|
+
export function parseArgs(argv, cwd) {
|
|
21
|
+
const out = { command: 'help', dir: cwd, origins: [], rotate: true, mcp: true, allowNoOrigin: true };
|
|
22
|
+
const args = [...argv];
|
|
23
|
+
const cmd = args.shift();
|
|
24
|
+
if (cmd !== 'bridge')
|
|
25
|
+
return out;
|
|
26
|
+
out.command = 'bridge';
|
|
27
|
+
const value = (flag) => {
|
|
28
|
+
const v = args.shift();
|
|
29
|
+
if (!v || v.startsWith('--'))
|
|
30
|
+
throw new Error(`${flag} needs a value`);
|
|
31
|
+
return v;
|
|
32
|
+
};
|
|
33
|
+
while (args.length) {
|
|
34
|
+
const a = args.shift();
|
|
35
|
+
if (a === '--port') {
|
|
36
|
+
const n = Number(value(a));
|
|
37
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535)
|
|
38
|
+
throw new Error('--port must be 0-65535');
|
|
39
|
+
out.port = n;
|
|
40
|
+
}
|
|
41
|
+
else if (a === '--dir')
|
|
42
|
+
out.dir = value(a);
|
|
43
|
+
else if (a === '--origin')
|
|
44
|
+
out.origins.push(value(a));
|
|
45
|
+
else if (a === '--cdp')
|
|
46
|
+
out.cdp = value(a);
|
|
47
|
+
else if (a === '--no-rotate')
|
|
48
|
+
out.rotate = false;
|
|
49
|
+
else if (a === '--no-mcp')
|
|
50
|
+
out.mcp = false;
|
|
51
|
+
else if (a === '--browser-only')
|
|
52
|
+
out.allowNoOrigin = false;
|
|
53
|
+
else if (a === '--help' || a === '-h')
|
|
54
|
+
out.command = 'help';
|
|
55
|
+
else
|
|
56
|
+
throw new Error(`unknown option ${a}`);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AuditEntry {
|
|
2
|
+
source: 'agent' | 'page' | 'bridge';
|
|
3
|
+
event: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
args?: unknown;
|
|
6
|
+
ok?: boolean;
|
|
7
|
+
error?: string;
|
|
8
|
+
ms?: number;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type Audit = (entry: AuditEntry) => void;
|
|
12
|
+
/** Appends one JSON line per event to `.reins/audit.log` and echoes it to stderr. Arguments are redacted. */
|
|
13
|
+
export declare function fileAudit(projectDir: string, echo?: (line: string) => void): Audit;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { redactDeep } from '@mindmakr/reins';
|
|
4
|
+
import { reinsDir } from './token.js';
|
|
5
|
+
/** Appends one JSON line per event to `.reins/audit.log` and echoes it to stderr. Arguments are redacted. */
|
|
6
|
+
export function fileAudit(projectDir, echo = (l) => process.stderr.write(l + '\n')) {
|
|
7
|
+
const file = join(reinsDir(projectDir), 'audit.log');
|
|
8
|
+
return (entry) => {
|
|
9
|
+
const line = JSON.stringify({ at: new Date().toISOString(), ...entry, args: entry.args === undefined ? undefined : redactDeep(entry.args) });
|
|
10
|
+
appendFileSync(file, line + '\n');
|
|
11
|
+
echo(`[reins] ${entry.source} ${entry.event}${entry.name ? ' ' + entry.name : ''}${entry.ok === false ? ' failed: ' + entry.error : ''}`);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
/** Copies a PNG file to the OS clipboard. Returns false when no clipboard tool is available. No shell strings: the path goes through an env var or argv. */
|
|
4
|
+
export function copyImage(file) {
|
|
5
|
+
const env = { ...process.env, REINS_SNAP: file };
|
|
6
|
+
try {
|
|
7
|
+
if (process.platform === 'win32') {
|
|
8
|
+
execFileSync('powershell', [
|
|
9
|
+
'-NoProfile',
|
|
10
|
+
'-STA',
|
|
11
|
+
'-Command',
|
|
12
|
+
'Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; [System.Windows.Forms.Clipboard]::SetImage([System.Drawing.Image]::FromFile($env:REINS_SNAP))',
|
|
13
|
+
], { env, stdio: 'ignore' });
|
|
14
|
+
}
|
|
15
|
+
else if (process.platform === 'darwin') {
|
|
16
|
+
execFileSync('osascript', ['-e', 'set the clipboard to (read (POSIX file (system attribute "REINS_SNAP")) as «class PNGf»)'], { env, stdio: 'ignore' });
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
try {
|
|
20
|
+
execFileSync('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-i', file], { stdio: 'ignore' });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
execFileSync('wl-copy', ['--type', 'image/png'], { input: readFileSync(file), stdio: ['pipe', 'ignore', 'ignore'] });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type WebSocket } from 'ws';
|
|
2
|
+
import type { Audit } from './audit.js';
|
|
3
|
+
export declare const DEFAULT_PORT = 7357;
|
|
4
|
+
/** The bridge listens on loopback only. There is no option to change this. */
|
|
5
|
+
export declare const HOST = "127.0.0.1";
|
|
6
|
+
export interface HubOptions {
|
|
7
|
+
/** Project folder; the token goes to `<dir>/.reins/token`. */
|
|
8
|
+
dir: string;
|
|
9
|
+
/** Default 7357. Use 0 for a free port (tests). */
|
|
10
|
+
port?: number;
|
|
11
|
+
/** Extra allowed origins, matched exactly. Localhost origins are always allowed. */
|
|
12
|
+
origins?: string[];
|
|
13
|
+
/** Allow clients that send no Origin (React Native). Default true; the token is still required. */
|
|
14
|
+
allowNoOrigin?: boolean;
|
|
15
|
+
/** Issue a new token after each successful pairing. Default true. */
|
|
16
|
+
rotate?: boolean;
|
|
17
|
+
audit?: Audit;
|
|
18
|
+
log?: (msg: string) => void;
|
|
19
|
+
handshakeMs?: number;
|
|
20
|
+
callMs?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface Client {
|
|
23
|
+
id: number;
|
|
24
|
+
url?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
meta?: Record<string, string>;
|
|
27
|
+
socket: WebSocket;
|
|
28
|
+
}
|
|
29
|
+
/** The WebSocket side of the bridge: pairs pages that present the token, then forwards calls to them. */
|
|
30
|
+
export declare class Hub {
|
|
31
|
+
private opts;
|
|
32
|
+
token: string;
|
|
33
|
+
readonly clients: Client[];
|
|
34
|
+
/** When the bridge started; a session report covers the audit log from here on. */
|
|
35
|
+
readonly startedAt: number;
|
|
36
|
+
private server;
|
|
37
|
+
private pending;
|
|
38
|
+
private nextCall;
|
|
39
|
+
private nextClient;
|
|
40
|
+
private constructor();
|
|
41
|
+
static start(opts: HubOptions): Promise<Hub>;
|
|
42
|
+
get port(): number;
|
|
43
|
+
private log;
|
|
44
|
+
private audit;
|
|
45
|
+
private issueToken;
|
|
46
|
+
private accept;
|
|
47
|
+
/** Whether a client is a `@mindmakr/reins/node` server rather than a page or an app. */
|
|
48
|
+
static isNode(c: Client): boolean;
|
|
49
|
+
/** The most recently paired page or app (not a Node server). Browser methods go here. */
|
|
50
|
+
current(): Client | undefined;
|
|
51
|
+
/** Paired `@mindmakr/reins/node` servers, oldest first. */
|
|
52
|
+
nodeClients(): Client[];
|
|
53
|
+
/** Sends a call to a client (default: the newest page or app) and waits for its result. */
|
|
54
|
+
request(method: string, params?: Record<string, unknown>, target?: Client): Promise<any>;
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { isOriginAllowed } from './origin.js';
|
|
3
|
+
import { newToken, removeToken, tokensMatch, writeToken } from './token.js';
|
|
4
|
+
export const DEFAULT_PORT = 7357;
|
|
5
|
+
/** The bridge listens on loopback only. There is no option to change this. */
|
|
6
|
+
export const HOST = '127.0.0.1';
|
|
7
|
+
/** The WebSocket side of the bridge: pairs pages that present the token, then forwards calls to them. */
|
|
8
|
+
export class Hub {
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
this.opts = opts;
|
|
11
|
+
this.token = '';
|
|
12
|
+
this.clients = [];
|
|
13
|
+
/** When the bridge started; a session report covers the audit log from here on. */
|
|
14
|
+
this.startedAt = Date.now();
|
|
15
|
+
this.pending = new Map();
|
|
16
|
+
this.nextCall = 1;
|
|
17
|
+
this.nextClient = 1;
|
|
18
|
+
}
|
|
19
|
+
static start(opts) {
|
|
20
|
+
const hub = new Hub(opts);
|
|
21
|
+
hub.issueToken();
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
hub.server = new WebSocketServer({
|
|
24
|
+
host: HOST,
|
|
25
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
26
|
+
maxPayload: 4 * 1024 * 1024,
|
|
27
|
+
verifyClient: (info, cb) => {
|
|
28
|
+
const origin = info.origin || info.req.headers.origin;
|
|
29
|
+
if (isOriginAllowed(origin, opts.origins, opts.allowNoOrigin ?? true))
|
|
30
|
+
return cb(true);
|
|
31
|
+
hub.audit({ source: 'bridge', event: 'refused', ok: false, error: `origin not allowed: ${origin}` });
|
|
32
|
+
cb(false, 403, 'origin not allowed');
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
hub.server.once('listening', () => resolve(hub));
|
|
36
|
+
hub.server.once('error', reject);
|
|
37
|
+
hub.server.on('connection', (socket) => hub.accept(socket));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
get port() {
|
|
41
|
+
return this.server.address().port;
|
|
42
|
+
}
|
|
43
|
+
log(msg) {
|
|
44
|
+
(this.opts.log ?? ((m) => process.stderr.write(m + '\n')))(msg);
|
|
45
|
+
}
|
|
46
|
+
audit(entry) {
|
|
47
|
+
if (this.opts.audit)
|
|
48
|
+
this.opts.audit(entry);
|
|
49
|
+
}
|
|
50
|
+
issueToken() {
|
|
51
|
+
this.token = newToken();
|
|
52
|
+
writeToken(this.opts.dir, this.token);
|
|
53
|
+
}
|
|
54
|
+
accept(socket) {
|
|
55
|
+
let client = null;
|
|
56
|
+
const timer = setTimeout(() => socket.close(4401, 'no handshake'), this.opts.handshakeMs ?? 5000);
|
|
57
|
+
socket.on('message', (data) => {
|
|
58
|
+
let m;
|
|
59
|
+
try {
|
|
60
|
+
m = JSON.parse(String(data));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// JSON such as `null` or `1` parses fine; reading `.type` off it would throw inside this listener and stop the bridge.
|
|
66
|
+
if (!m || typeof m !== 'object') {
|
|
67
|
+
if (!client) {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
socket.close(4401, 'bad handshake');
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!client) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
if (m.type === 'hello' && tokensMatch(m.token, this.token)) {
|
|
76
|
+
client = { id: this.nextClient++, url: m.url, title: m.title, meta: m.meta, socket };
|
|
77
|
+
this.clients.push(client);
|
|
78
|
+
socket.send(JSON.stringify({ type: 'welcome', client: client.id }));
|
|
79
|
+
this.audit({ source: 'bridge', event: 'paired', name: client.url, ok: true });
|
|
80
|
+
this.log(`[reins] paired with ${client.url ?? 'an app'} (client ${client.id})`);
|
|
81
|
+
if (this.opts.rotate !== false)
|
|
82
|
+
this.issueToken();
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
this.audit({ source: 'bridge', event: 'refused', ok: false, error: 'bad or missing token' });
|
|
86
|
+
socket.send(JSON.stringify({ type: 'refused', reason: 'bad or missing token' }));
|
|
87
|
+
socket.close(4401, 'bad token');
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (m.type === 'result') {
|
|
92
|
+
const p = this.pending.get(m.id);
|
|
93
|
+
if (!p || p.client !== client)
|
|
94
|
+
return;
|
|
95
|
+
this.pending.delete(m.id);
|
|
96
|
+
clearTimeout(p.timer);
|
|
97
|
+
if (m.ok)
|
|
98
|
+
p.resolve(m.result);
|
|
99
|
+
else
|
|
100
|
+
p.reject(new Error(String(m.error)));
|
|
101
|
+
}
|
|
102
|
+
else if (m.type === 'audit' && m.entry) {
|
|
103
|
+
const e = m.entry;
|
|
104
|
+
this.audit({ source: 'page', event: String(e.method), name: e.name, via: e.via, ok: e.ok, error: e.error });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
socket.on('close', () => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
const i = client ? this.clients.indexOf(client) : -1;
|
|
110
|
+
if (i >= 0) {
|
|
111
|
+
this.clients.splice(i, 1);
|
|
112
|
+
this.log(`[reins] client ${client.id} disconnected`);
|
|
113
|
+
for (const [id, p] of this.pending)
|
|
114
|
+
if (p.client === client) {
|
|
115
|
+
this.pending.delete(id);
|
|
116
|
+
clearTimeout(p.timer);
|
|
117
|
+
p.reject(new Error('the app disconnected'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
socket.on('error', () => { });
|
|
122
|
+
}
|
|
123
|
+
/** Whether a client is a `@mindmakr/reins/node` server rather than a page or an app. */
|
|
124
|
+
static isNode(c) {
|
|
125
|
+
return !!c.meta && c.meta.platform === 'node';
|
|
126
|
+
}
|
|
127
|
+
/** The most recently paired page or app (not a Node server). Browser methods go here. */
|
|
128
|
+
current() {
|
|
129
|
+
return this.clients.filter((c) => !Hub.isNode(c)).pop();
|
|
130
|
+
}
|
|
131
|
+
/** Paired `@mindmakr/reins/node` servers, oldest first. */
|
|
132
|
+
nodeClients() {
|
|
133
|
+
return this.clients.filter(Hub.isNode);
|
|
134
|
+
}
|
|
135
|
+
/** Sends a call to a client (default: the newest page or app) and waits for its result. */
|
|
136
|
+
request(method, params = {}, target) {
|
|
137
|
+
const client = target ?? this.current();
|
|
138
|
+
if (!client) {
|
|
139
|
+
return Promise.reject(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.'));
|
|
140
|
+
}
|
|
141
|
+
const id = this.nextCall++;
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.pending.delete(id);
|
|
145
|
+
reject(new Error(`the app did not answer "${method}" in time`));
|
|
146
|
+
}, this.opts.callMs ?? 15000);
|
|
147
|
+
this.pending.set(id, { resolve, reject, timer, client });
|
|
148
|
+
client.socket.send(JSON.stringify({ type: 'call', id, method, params }));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
close() {
|
|
152
|
+
for (const p of this.pending.values()) {
|
|
153
|
+
clearTimeout(p.timer);
|
|
154
|
+
p.reject(new Error('bridge closed'));
|
|
155
|
+
}
|
|
156
|
+
this.pending.clear();
|
|
157
|
+
for (const c of this.clients)
|
|
158
|
+
c.socket.terminate();
|
|
159
|
+
removeToken(this.opts.dir);
|
|
160
|
+
return new Promise((resolve) => this.server.close(() => resolve()));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { parseArgs, USAGE, type BridgeArgs } from './args.js';
|
|
2
|
+
export { fileAudit, type Audit, type AuditEntry } from './audit.js';
|
|
3
|
+
export { copyImage } from './clipboard.js';
|
|
4
|
+
export { DEFAULT_PORT, HOST, Hub, type Client, type HubOptions } from './hub.js';
|
|
5
|
+
export { startMcp, VERSION } from './mcp.js';
|
|
6
|
+
export { isOriginAllowed } from './origin.js';
|
|
7
|
+
export { parseReportArgs, REPORT_USAGE, reportMain, type ReportArgs } from './report-cli.js';
|
|
8
|
+
export { gatherSession, latestSession, readAudit, readSession, serverReports, writeSession } from './session.js';
|
|
9
|
+
export { snap, type SnapOptions, type SnapResult } from './snap.js';
|
|
10
|
+
export { newToken, removeToken, tokenPath, tokensMatch, writeToken } from './token.js';
|
|
11
|
+
export { callTool, TOOLS, type Tool, type ToolContext } from './tools.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { parseArgs, USAGE } from './args.js';
|
|
2
|
+
export { fileAudit } from './audit.js';
|
|
3
|
+
export { copyImage } from './clipboard.js';
|
|
4
|
+
export { DEFAULT_PORT, HOST, Hub } from './hub.js';
|
|
5
|
+
export { startMcp, VERSION } from './mcp.js';
|
|
6
|
+
export { isOriginAllowed } from './origin.js';
|
|
7
|
+
export { parseReportArgs, REPORT_USAGE, reportMain } from './report-cli.js';
|
|
8
|
+
export { gatherSession, latestSession, readAudit, readSession, serverReports, writeSession } from './session.js';
|
|
9
|
+
export { snap } from './snap.js';
|
|
10
|
+
export { newToken, removeToken, tokenPath, tokensMatch, writeToken } from './token.js';
|
|
11
|
+
export { callTool, TOOLS } from './tools.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { type ToolContext } from './tools.js';
|
|
3
|
+
export declare const VERSION = "0.1.0";
|
|
4
|
+
/** Serves the reins tools over MCP on stdio. stdout belongs to MCP; logs go to stderr. */
|
|
5
|
+
export declare function startMcp(ctx: ToolContext): Promise<Server>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { callTool, TOOLS } from './tools.js';
|
|
5
|
+
export const VERSION = '0.1.0';
|
|
6
|
+
/** Serves the reins tools over MCP on stdio. stdout belongs to MCP; logs go to stderr. */
|
|
7
|
+
export async function startMcp(ctx) {
|
|
8
|
+
ctx.snaps ?? (ctx.snaps = []);
|
|
9
|
+
const server = new Server({ name: 'reins', version: VERSION }, { capabilities: { tools: {} } });
|
|
10
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
11
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
12
|
+
try {
|
|
13
|
+
const result = await callTool(ctx, req.params.name, (req.params.arguments ?? {}));
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }] };
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
return { content: [{ type: 'text', text: String(err.message ?? err) }], isError: true };
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
await server.connect(new StdioServerTransport());
|
|
21
|
+
return server;
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a WebSocket Origin may connect.
|
|
3
|
+
* Default: any http(s) origin on localhost, 127.0.0.1 or ::1, any port.
|
|
4
|
+
* Extra origins are matched exactly, for example `http://my-app.test:3000`.
|
|
5
|
+
* A missing Origin (React Native, Node) is allowed only when `allowNoOrigin` is true; the token is still required.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isOriginAllowed(origin: string | undefined, extra?: string[], allowNoOrigin?: boolean): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
2
|
+
/**
|
|
3
|
+
* Whether a WebSocket Origin may connect.
|
|
4
|
+
* Default: any http(s) origin on localhost, 127.0.0.1 or ::1, any port.
|
|
5
|
+
* Extra origins are matched exactly, for example `http://my-app.test:3000`.
|
|
6
|
+
* A missing Origin (React Native, Node) is allowed only when `allowNoOrigin` is true; the token is still required.
|
|
7
|
+
*/
|
|
8
|
+
export function isOriginAllowed(origin, extra = [], allowNoOrigin = true) {
|
|
9
|
+
if (!origin)
|
|
10
|
+
return allowNoOrigin;
|
|
11
|
+
if (extra.includes(origin))
|
|
12
|
+
return true;
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(origin);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
21
|
+
return false;
|
|
22
|
+
return LOCAL_HOSTS.has(url.hostname) || url.hostname.endsWith('.localhost');
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const REPORT_USAGE = "usage:\n reins report [--from <session.json>] [--out <dir|file.md>]\n reins report --compare <before.json> <after.json> [--out <dir|file.md>]\n\nRe-renders a saved session (default: the newest in .reins/sessions) as Markdown, or prints a\nbefore/after table of two sessions. Prints to stdout; --out also writes the file.";
|
|
2
|
+
export interface ReportArgs {
|
|
3
|
+
from?: string;
|
|
4
|
+
compare?: [string, string];
|
|
5
|
+
out?: string;
|
|
6
|
+
help?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Parses `reins report` arguments. Throws on unknown flags. */
|
|
9
|
+
export declare function parseReportArgs(argv: string[]): ReportArgs;
|
|
10
|
+
/** Where `--out` writes: a `.md` path as is, otherwise `<dir>/<name>`. */
|
|
11
|
+
export declare const outFile: (out: string, cwd: string, name: string) => string;
|
|
12
|
+
/** Runs `reins report`. Returns the exit code: 0 done, 2 bad usage or unreadable input. */
|
|
13
|
+
export declare function reportMain(argv: string[], cwd?: string, print?: (s: string) => boolean, err?: (s: string) => boolean): number;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { compareSessions, renderMarkdown } from '@mindmakr/reins/report';
|
|
4
|
+
import { latestSession, linkFrom, readSession } from './session.js';
|
|
5
|
+
export const REPORT_USAGE = `usage:
|
|
6
|
+
reins report [--from <session.json>] [--out <dir|file.md>]
|
|
7
|
+
reins report --compare <before.json> <after.json> [--out <dir|file.md>]
|
|
8
|
+
|
|
9
|
+
Re-renders a saved session (default: the newest in .reins/sessions) as Markdown, or prints a
|
|
10
|
+
before/after table of two sessions. Prints to stdout; --out also writes the file.`;
|
|
11
|
+
/** Parses `reins report` arguments. Throws on unknown flags. */
|
|
12
|
+
export function parseReportArgs(argv) {
|
|
13
|
+
const out = {};
|
|
14
|
+
const args = [...argv];
|
|
15
|
+
const value = (flag) => {
|
|
16
|
+
const v = args.shift();
|
|
17
|
+
if (!v || v.startsWith('--'))
|
|
18
|
+
throw new Error(`${flag} needs a value`);
|
|
19
|
+
return v;
|
|
20
|
+
};
|
|
21
|
+
while (args.length) {
|
|
22
|
+
const a = args.shift();
|
|
23
|
+
if (a === '--from')
|
|
24
|
+
out.from = value(a);
|
|
25
|
+
else if (a === '--out')
|
|
26
|
+
out.out = value(a);
|
|
27
|
+
else if (a === '--compare')
|
|
28
|
+
out.compare = [value(a), value(a)];
|
|
29
|
+
else if (a === '--help' || a === '-h')
|
|
30
|
+
out.help = true;
|
|
31
|
+
else
|
|
32
|
+
throw new Error(`unknown option ${a}`);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Where `--out` writes: a `.md` path as is, otherwise `<dir>/<name>`. */
|
|
37
|
+
export const outFile = (out, cwd, name) => (out.endsWith('.md') ? resolve(cwd, out) : join(resolve(cwd, out), name));
|
|
38
|
+
/** Runs `reins report`. Returns the exit code: 0 done, 2 bad usage or unreadable input. */
|
|
39
|
+
export function reportMain(argv, cwd = process.cwd(), print = (s) => process.stdout.write(s + '\n'), err = (s) => process.stderr.write(s + '\n')) {
|
|
40
|
+
let args;
|
|
41
|
+
try {
|
|
42
|
+
args = parseReportArgs(argv);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
err(`reins report: ${e.message}\n\n${REPORT_USAGE}`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
if (args.help) {
|
|
49
|
+
err(REPORT_USAGE);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
let markdown;
|
|
54
|
+
let file;
|
|
55
|
+
if (args.compare) {
|
|
56
|
+
const [a, b] = args.compare.map((f) => readSession(resolve(cwd, f)));
|
|
57
|
+
markdown = compareSessions(a, b).markdown;
|
|
58
|
+
if (args.out)
|
|
59
|
+
file = outFile(args.out, cwd, 'compare.md');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const from = args.from ? resolve(cwd, args.from) : latestSession(cwd);
|
|
63
|
+
if (!from)
|
|
64
|
+
throw new Error('no session found: run reins_report first, or pass --from <session.json>');
|
|
65
|
+
const session = readSession(from);
|
|
66
|
+
if (args.out)
|
|
67
|
+
file = outFile(args.out, cwd, basename(from).replace(/\.json$/, '') + '.md');
|
|
68
|
+
markdown = renderMarkdown(session, file ? { link: linkFrom(cwd, file) } : {});
|
|
69
|
+
}
|
|
70
|
+
if (file) {
|
|
71
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
72
|
+
writeFileSync(file, markdown);
|
|
73
|
+
err(`reins report: wrote ${file}`);
|
|
74
|
+
}
|
|
75
|
+
print(markdown);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
err(`reins report: ${e.message}`);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type AuditLine, type SessionData } from '@mindmakr/reins/report';
|
|
2
|
+
import type { ServerReport } from '@mindmakr/reins';
|
|
3
|
+
import type { Hub } from './hub.js';
|
|
4
|
+
import type { ToolContext } from './tools.js';
|
|
5
|
+
/** Asks every paired `@mindmakr/reins/node` server for its report. Servers that do not answer are skipped. */
|
|
6
|
+
export declare function serverReports(hub: Hub): Promise<ServerReport[]>;
|
|
7
|
+
/** How much of the end of the audit log a report reads. The log is never trimmed, so it can be far larger. */
|
|
8
|
+
export declare const AUDIT_TAIL_BYTES: number;
|
|
9
|
+
/** Audit log lines written since `since` (ms), oldest first. Missing log: none. Reads only the end of the file. */
|
|
10
|
+
export declare function readAudit(projectDir: string, since: number, max?: number, tailBytes?: number): AuditLine[];
|
|
11
|
+
/** Gathers a session from the paired app and servers: everything `reins_report` puts in the files. */
|
|
12
|
+
export declare function gatherSession(ctx: ToolContext, title?: string): Promise<SessionData>;
|
|
13
|
+
/** Snap links relative to where the Markdown file lives. */
|
|
14
|
+
export declare const linkFrom: (projectDir: string, mdFile: string) => (p: string) => string;
|
|
15
|
+
/** Writes `.reins/sessions/<time>-<slug>.json` and `.md`. Returns the paths and the Markdown. */
|
|
16
|
+
export declare function writeSession(projectDir: string, session: SessionData): {
|
|
17
|
+
md: string;
|
|
18
|
+
json: string;
|
|
19
|
+
markdown: string;
|
|
20
|
+
};
|
|
21
|
+
/** Reads a session JSON and checks its schema version. */
|
|
22
|
+
export declare function readSession(file: string): SessionData;
|
|
23
|
+
/** The newest session JSON under `<projectDir>/.reins/sessions`, if any. */
|
|
24
|
+
export declare function latestSession(projectDir: string): string | undefined;
|