@reticlehq/init 2.14.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 +105 -0
- package/NOTICE +5 -0
- package/README.md +29 -0
- package/dist/agent-rules.d.ts +116 -0
- package/dist/agent-rules.js +279 -0
- package/dist/app-choice.d.ts +29 -0
- package/dist/app-choice.js +39 -0
- package/dist/astro-host.d.ts +21 -0
- package/dist/astro-host.js +27 -0
- package/dist/astro-patch.d.ts +33 -0
- package/dist/astro-patch.js +103 -0
- package/dist/brace-scan.d.ts +24 -0
- package/dist/brace-scan.js +46 -0
- package/dist/capabilities.d.ts +40 -0
- package/dist/capabilities.js +167 -0
- package/dist/closing-hint.d.ts +35 -0
- package/dist/closing-hint.js +122 -0
- package/dist/codex-toml.d.ts +10 -0
- package/dist/codex-toml.js +144 -0
- package/dist/connect-steps.d.ts +55 -0
- package/dist/connect-steps.js +92 -0
- package/dist/cra.d.ts +82 -0
- package/dist/cra.js +183 -0
- package/dist/csp-check.d.ts +49 -0
- package/dist/csp-check.js +168 -0
- package/dist/csp-doctor.d.ts +32 -0
- package/dist/csp-doctor.js +62 -0
- package/dist/desktop-doctor.d.ts +46 -0
- package/dist/desktop-doctor.js +201 -0
- package/dist/detect.d.ts +225 -0
- package/dist/detect.js +334 -0
- package/dist/dev-script.d.ts +51 -0
- package/dist/dev-script.js +66 -0
- package/dist/dev-server-ports.d.ts +20 -0
- package/dist/dev-server-ports.js +45 -0
- package/dist/electron-patch.d.ts +11 -0
- package/dist/electron-patch.js +70 -0
- package/dist/electron-vite-patch.d.ts +12 -0
- package/dist/electron-vite-patch.js +100 -0
- package/dist/existing-config.d.ts +31 -0
- package/dist/existing-config.js +63 -0
- package/dist/format-generated.d.ts +18 -0
- package/dist/format-generated.js +48 -0
- package/dist/framework-adapter.d.ts +60 -0
- package/dist/framework-adapter.js +138 -0
- package/dist/host.d.ts +41 -0
- package/dist/host.js +21 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/init-failure.d.ts +19 -0
- package/dist/init-failure.js +18 -0
- package/dist/init-opt-out.d.ts +17 -0
- package/dist/init-opt-out.js +30 -0
- package/dist/install-hint.d.ts +9 -0
- package/dist/install-hint.js +41 -0
- package/dist/install-retries.d.ts +22 -0
- package/dist/install-retries.js +65 -0
- package/dist/install-source-config.d.ts +18 -0
- package/dist/install-source-config.js +34 -0
- package/dist/lockfiles.d.ts +14 -0
- package/dist/lockfiles.js +49 -0
- package/dist/mcp-clients.d.ts +94 -0
- package/dist/mcp-clients.js +327 -0
- package/dist/mcp-registered.d.ts +12 -0
- package/dist/mcp-registered.js +14 -0
- package/dist/mcp.d.ts +89 -0
- package/dist/mcp.js +129 -0
- package/dist/next-patch.d.ts +49 -0
- package/dist/next-patch.js +237 -0
- package/dist/node-io.d.ts +22 -0
- package/dist/node-io.js +150 -0
- package/dist/non-js-project.d.ts +44 -0
- package/dist/non-js-project.js +137 -0
- package/dist/nuxt-patch.d.ts +18 -0
- package/dist/nuxt-patch.js +33 -0
- package/dist/patch-kind.d.ts +20 -0
- package/dist/patch-kind.js +10 -0
- package/dist/plan-electron-vite.d.ts +16 -0
- package/dist/plan-electron-vite.js +72 -0
- package/dist/plan-framework.d.ts +122 -0
- package/dist/plan-framework.js +773 -0
- package/dist/plan.d.ts +312 -0
- package/dist/plan.js +650 -0
- package/dist/platform.d.ts +16 -0
- package/dist/platform.js +16 -0
- package/dist/preflight.d.ts +30 -0
- package/dist/preflight.js +52 -0
- package/dist/project-id.d.ts +12 -0
- package/dist/project-id.js +25 -0
- package/dist/refresh-rules.d.ts +40 -0
- package/dist/refresh-rules.js +51 -0
- package/dist/remember-project.d.ts +24 -0
- package/dist/remember-project.js +40 -0
- package/dist/run.d.ts +122 -0
- package/dist/run.js +762 -0
- package/dist/slash-command.d.ts +27 -0
- package/dist/slash-command.js +132 -0
- package/dist/snippets.d.ts +267 -0
- package/dist/snippets.js +898 -0
- package/dist/tanstack-start.d.ts +18 -0
- package/dist/tanstack-start.js +57 -0
- package/dist/version.d.ts +18 -0
- package/dist/version.js +23 -0
- package/dist/vite-config.d.ts +18 -0
- package/dist/vite-config.js +111 -0
- package/dist/vite-owning-config.d.ts +25 -0
- package/dist/vite-owning-config.js +219 -0
- package/dist/windows-quote.d.ts +38 -0
- package/dist/windows-quote.js +70 -0
- package/dist/workspace-apps.d.ts +40 -0
- package/dist/workspace-apps.js +188 -0
- package/dist/workspace-redirect.d.ts +22 -0
- package/dist/workspace-redirect.js +97 -0
- package/package.json +53 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this app's Content-Security-Policy let the browser reach the Reticle bridge?
|
|
3
|
+
*
|
|
4
|
+
* Two independent field reports, both Next: `init` reported every step successful, the SDK mounted,
|
|
5
|
+
* the dial URL was correct, and the app never connected because a strict `connect-src` excluded
|
|
6
|
+
* `ws://localhost:<port>`. The browser blocks the WebSocket and says so in its own console, which
|
|
7
|
+
* nothing on the Reticle side reads — so `status` and `doctor` reported perfect health at an app
|
|
8
|
+
* that could not connect at all, forever. A setup step that exits 0 while leaving the user broken is
|
|
9
|
+
* the worst failure mode this repo has.
|
|
10
|
+
*
|
|
11
|
+
* A TEXT SCAN, deliberately. `headers()` is a function; `init` cannot execute a user's Next config
|
|
12
|
+
* without importing their whole app, and the thing worth finding is the policy string a developer
|
|
13
|
+
* typed — in `next.config.*`, in middleware, or in a `<meta http-equiv>` tag. The scan finds the
|
|
14
|
+
* string wherever it is written.
|
|
15
|
+
*
|
|
16
|
+
* NARROW on purpose: it fires only when a `connect-src` exists AND does not admit the bridge. No
|
|
17
|
+
* CSP, or one that already covers us, produces nothing. A warning that fires on a working setup
|
|
18
|
+
* costs precisely what a green check on a broken one costs, and this file exists because of the
|
|
19
|
+
* second kind.
|
|
20
|
+
*/
|
|
21
|
+
/** Sources that admit any ws origin, so the bridge is already reachable. */
|
|
22
|
+
const WILDCARDS = ['*', 'ws:', 'wss:', 'ws://*', 'https:'];
|
|
23
|
+
/** The two hosts the SDK may dial. `localhost` and the IPv4 loopback are different CSP sources. */
|
|
24
|
+
function bridgeOrigins(port) {
|
|
25
|
+
return [`ws://localhost:${String(port)}`, `ws://127.0.0.1:${String(port)}`];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The `connect-src` directive's source list, or undefined when the text declares none.
|
|
29
|
+
*
|
|
30
|
+
* Matches to the end of the directive (`;`) or the end of the enclosing quoted string, which is how
|
|
31
|
+
* these are written in both a Next `headers()` value and a `<meta content="...">`.
|
|
32
|
+
*/
|
|
33
|
+
function directiveSources(text, directive) {
|
|
34
|
+
// Stops at the directive separator or the closing double quote of the enclosing string. Single
|
|
35
|
+
// quotes are NOT terminators: `'self'` is a source, not the end of the list.
|
|
36
|
+
const match = new RegExp(`${directive}([^;"]*)`, 'i').exec(text);
|
|
37
|
+
const list = match?.[1];
|
|
38
|
+
if (list === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
return list
|
|
41
|
+
.split(/\s+/)
|
|
42
|
+
.map((source) => source.trim())
|
|
43
|
+
.filter((source) => source.length > 0);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* What this policy allows a WebSocket to reach — `connect-src`, or `default-src` when it is absent.
|
|
47
|
+
*
|
|
48
|
+
* The fallback is the CSP spec's, and leaving it out made the check blind to the commonest strict
|
|
49
|
+
* policy there is. MarkText, a production Electron editor, declares
|
|
50
|
+
* `default-src 'self'; script-src 'self'; …` with NO `connect-src`: its WebSockets are restricted to
|
|
51
|
+
* 'self', the bridge is blocked, and this returned "no problem" because it was looking for a
|
|
52
|
+
* directive that was not written. Every fetch-directive falls back to `default-src`; that is what
|
|
53
|
+
* `default` means, and it is why an author does not repeat themselves.
|
|
54
|
+
*
|
|
55
|
+
* Still undefined when NEITHER is present — a policy that constrains neither is not blocking us.
|
|
56
|
+
*/
|
|
57
|
+
function connectSrcSources(text) {
|
|
58
|
+
return directiveSources(text, 'connect-src') ?? directiveSources(text, 'default-src');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The problem with this app's `connect-src`, or undefined if there is not one.
|
|
62
|
+
*
|
|
63
|
+
* `https:` counts as a wildcard here only in the sense that a policy written that loosely will not
|
|
64
|
+
* be the thing blocking a dev WebSocket; the check stays quiet rather than arguing about it.
|
|
65
|
+
*/
|
|
66
|
+
export function cspConnectSrcProblem(text, port) {
|
|
67
|
+
const sources = connectSrcSources(text);
|
|
68
|
+
if (sources === undefined || 0 === sources.length)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (sources.some((source) => WILDCARDS.includes(source)))
|
|
71
|
+
return undefined;
|
|
72
|
+
const wanted = bridgeOrigins(port);
|
|
73
|
+
// BOTH, not either: the SDK picks its host from how the page was served, and a policy that admits
|
|
74
|
+
// one is a coin flip. A coin flip that fails is indistinguishable from every other silent
|
|
75
|
+
// non-connect, which is the whole cost being avoided here.
|
|
76
|
+
if (wanted.every((origin) => sources.includes(origin)))
|
|
77
|
+
return undefined;
|
|
78
|
+
const missing = wanted.filter((origin) => !sources.includes(origin));
|
|
79
|
+
return (`this app declares a Content-Security-Policy whose \`connect-src\` does not admit the Reticle ` +
|
|
80
|
+
`bridge: ${missing.join(' and ')} ${1 === missing.length ? 'is' : 'are'} missing. The browser ` +
|
|
81
|
+
`will block the WebSocket and report it in ITS console only — every check on the Reticle side ` +
|
|
82
|
+
`will pass while the app never connects. ${devCspAddition(port)}`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What this policy allows a SCRIPT to be — `script-src`, or `default-src` when it is absent.
|
|
86
|
+
*
|
|
87
|
+
* Same fallback and same reason as {@link connectSrcSources}: every fetch-directive falls back to
|
|
88
|
+
* `default-src`, which is why an author writing `default-src 'self'` does not repeat themselves.
|
|
89
|
+
*/
|
|
90
|
+
function scriptSrcSources(text) {
|
|
91
|
+
return directiveSources(text, 'script-src') ?? directiveSources(text, 'default-src');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Would this policy stop the pasted connect snippet from RUNNING?
|
|
95
|
+
*
|
|
96
|
+
* The `connect-src` check above assumes the SDK got as far as opening a socket. Under a policy
|
|
97
|
+
* without `'unsafe-inline'` it never does: the snippet `init` prints is an inline
|
|
98
|
+
* `<script type="module">`, the browser refuses to execute it, and there is no SDK, no socket and
|
|
99
|
+
* nothing for `connect-src` to block. `reticle open` then reports "no session / app carries no SDK",
|
|
100
|
+
* which sends diagnosis at the wrong cause entirely (#679).
|
|
101
|
+
*
|
|
102
|
+
* The rule is the spec's, not a guess:
|
|
103
|
+
*
|
|
104
|
+
* - `'unsafe-inline'` is the ONLY source that admits an inline script. `*` does not — a host
|
|
105
|
+
* wildcard says nothing about inline code, and reading it as permission is the commonest CSP
|
|
106
|
+
* misconception there is.
|
|
107
|
+
* - a nonce, a hash, or `'strict-dynamic'` makes browsers IGNORE `'unsafe-inline'` entirely. A
|
|
108
|
+
* policy carrying both is a policy that blocks inline scripts, and one carrying a nonce blocks
|
|
109
|
+
* ours specifically: the nonce is minted for the app's own tags, not for a snippet pasted in by
|
|
110
|
+
* hand.
|
|
111
|
+
*/
|
|
112
|
+
function blocksInlineScript(sources) {
|
|
113
|
+
const overridesUnsafeInline = sources.some((source) => source.startsWith("'nonce-") || source.startsWith("'sha") || "'strict-dynamic'" === source);
|
|
114
|
+
if (overridesUnsafeInline)
|
|
115
|
+
return true;
|
|
116
|
+
return !sources.includes("'unsafe-inline'");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The problem with this app's `script-src`, or undefined if there is not one.
|
|
120
|
+
*
|
|
121
|
+
* Reports the remedy that works under `'self'` — serve the connect code as an external module file —
|
|
122
|
+
* rather than telling anyone to weaken their policy with `'unsafe-inline'`.
|
|
123
|
+
*/
|
|
124
|
+
export function cspInlineScriptProblem(text, port) {
|
|
125
|
+
const sources = scriptSrcSources(text);
|
|
126
|
+
if (sources === undefined || 0 === sources.length)
|
|
127
|
+
return undefined;
|
|
128
|
+
if (!blocksInlineScript(sources))
|
|
129
|
+
return undefined;
|
|
130
|
+
return (`this app declares a Content-Security-Policy whose \`script-src\` does not admit an inline ` +
|
|
131
|
+
`script, so the connect snippet never executes. Nothing on the Reticle side can see this: with ` +
|
|
132
|
+
`no SDK there is no socket, and \`reticle open\` reports "no session / app carries no SDK" — ` +
|
|
133
|
+
`the wrong cause. ${externalScriptRemedy()} ${devCspAddition(port)}`);
|
|
134
|
+
}
|
|
135
|
+
/** Where the external connect module goes, for an app served from a static directory. */
|
|
136
|
+
export const EXTERNAL_CONNECT_PATH = 'public/reticle-connect.js';
|
|
137
|
+
/** The remedy that works under `script-src 'self'`, as text to copy. */
|
|
138
|
+
export function externalScriptRemedy() {
|
|
139
|
+
return (`Serve the connect code as an EXTERNAL module instead: put it in ` +
|
|
140
|
+
`\`${EXTERNAL_CONNECT_PATH}\` and reference it with ` +
|
|
141
|
+
`\`<script type="module" src="/reticle-connect.js"></script>\`, which \`script-src 'self'\` ` +
|
|
142
|
+
`already allows. Note that an external module is DEFERRED, so the app's own classic scripts run ` +
|
|
143
|
+
`first and requests they fire before the SDK attaches are not observed.`);
|
|
144
|
+
}
|
|
145
|
+
/** The exact addition to paste. Text to copy, not advice to interpret. */
|
|
146
|
+
export function devCspAddition(port) {
|
|
147
|
+
return (`Add to \`connect-src\` in development only (e.g. behind ` +
|
|
148
|
+
`\`process.env.NODE_ENV === 'development'\`): ` +
|
|
149
|
+
`${bridgeOrigins(port).join(' ')}`);
|
|
150
|
+
}
|
|
151
|
+
/** The step title, named here so `doctor` and the plan cannot drift apart on what this check is called. */
|
|
152
|
+
export const CSP_STEP_TITLE = 'Content-Security-Policy';
|
|
153
|
+
/**
|
|
154
|
+
* The first blocking policy among the sources `init` already has in hand, or undefined.
|
|
155
|
+
*
|
|
156
|
+
* At most one finding: a second copy of the same policy in a second file is the same problem, and
|
|
157
|
+
* two identical notices read as two problems.
|
|
158
|
+
*/
|
|
159
|
+
export function cspPlanProblem(sources, port) {
|
|
160
|
+
for (const source of sources) {
|
|
161
|
+
if ('string' !== typeof source)
|
|
162
|
+
continue;
|
|
163
|
+
const problem = cspConnectSrcProblem(source, port);
|
|
164
|
+
if (problem !== undefined)
|
|
165
|
+
return problem;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doctor`'s web CSP check — the sibling of the desktop one next door.
|
|
3
|
+
*
|
|
4
|
+
* The desktop findings exist because a Tauri app with the default CSP runs perfectly and never
|
|
5
|
+
* connects. The WEB version of that failure had no check at all, and it arrived twice in one batch
|
|
6
|
+
* of field reports: two Next apps where `init` printed success for every step, the SDK mounted, the
|
|
7
|
+
* dial URL was right, and the browser silently refused the WebSocket because `connect-src` excluded
|
|
8
|
+
* the bridge. The violation is reported in the browser's console and nowhere else, so every check on
|
|
9
|
+
* this side stayed green while the app was permanently unreachable.
|
|
10
|
+
*
|
|
11
|
+
* Reads the files a CSP is actually written in, by name — a full source scan would be slower and
|
|
12
|
+
* would find policy strings in test fixtures and documentation.
|
|
13
|
+
*/
|
|
14
|
+
/** Read a project-relative file, or undefined when it is absent/unreadable. */
|
|
15
|
+
type ReadFile = (relative: string) => string | undefined;
|
|
16
|
+
interface CspDiagnosis {
|
|
17
|
+
/** Which file to look at. */
|
|
18
|
+
file: string;
|
|
19
|
+
/** What is wrong, in one line. */
|
|
20
|
+
problem: string;
|
|
21
|
+
/** Exactly what to add — copy-pasteable, with the port actually in use. */
|
|
22
|
+
fix: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The places a Content-Security-Policy gets written in a JS app.
|
|
26
|
+
*
|
|
27
|
+
* Next config and middleware cover `headers()` and the edge-middleware style; the layouts and the
|
|
28
|
+
* plain `index.html` cover the `<meta http-equiv>` style. Both reported cases were in this list.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CSP_FILES: readonly string[];
|
|
31
|
+
export declare function diagnoseWebCsp(read: ReadFile, port: number, alsoCheck?: readonly string[]): CspDiagnosis[];
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doctor`'s web CSP check — the sibling of the desktop one next door.
|
|
3
|
+
*
|
|
4
|
+
* The desktop findings exist because a Tauri app with the default CSP runs perfectly and never
|
|
5
|
+
* connects. The WEB version of that failure had no check at all, and it arrived twice in one batch
|
|
6
|
+
* of field reports: two Next apps where `init` printed success for every step, the SDK mounted, the
|
|
7
|
+
* dial URL was right, and the browser silently refused the WebSocket because `connect-src` excluded
|
|
8
|
+
* the bridge. The violation is reported in the browser's console and nowhere else, so every check on
|
|
9
|
+
* this side stayed green while the app was permanently unreachable.
|
|
10
|
+
*
|
|
11
|
+
* Reads the files a CSP is actually written in, by name — a full source scan would be slower and
|
|
12
|
+
* would find policy strings in test fixtures and documentation.
|
|
13
|
+
*/
|
|
14
|
+
import { cspConnectSrcProblem, cspInlineScriptProblem, devCspAddition, externalScriptRemedy, } from './csp-check.js';
|
|
15
|
+
/**
|
|
16
|
+
* The places a Content-Security-Policy gets written in a JS app.
|
|
17
|
+
*
|
|
18
|
+
* Next config and middleware cover `headers()` and the edge-middleware style; the layouts and the
|
|
19
|
+
* plain `index.html` cover the `<meta http-equiv>` style. Both reported cases were in this list.
|
|
20
|
+
*/
|
|
21
|
+
export const CSP_FILES = [
|
|
22
|
+
'next.config.js',
|
|
23
|
+
'next.config.mjs',
|
|
24
|
+
'next.config.ts',
|
|
25
|
+
'middleware.ts',
|
|
26
|
+
'middleware.js',
|
|
27
|
+
'src/middleware.ts',
|
|
28
|
+
'app/layout.tsx',
|
|
29
|
+
'app/layout.js',
|
|
30
|
+
'pages/_document.tsx',
|
|
31
|
+
'pages/_document.js',
|
|
32
|
+
'index.html',
|
|
33
|
+
'public/index.html',
|
|
34
|
+
// Vite puts the entry HTML at the project root; electron-vite and Tauri put the RENDERER's one
|
|
35
|
+
// under src/. MarkText declares its policy in `src/renderer/index.html`, and a desktop app is the
|
|
36
|
+
// likeliest place to meet a strict CSP at all — Electron's own security guidance asks for one.
|
|
37
|
+
'src/index.html',
|
|
38
|
+
'src/renderer/index.html',
|
|
39
|
+
'vercel.json',
|
|
40
|
+
'netlify.toml',
|
|
41
|
+
];
|
|
42
|
+
export function diagnoseWebCsp(read, port, alsoCheck = []) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
for (const file of [...alsoCheck, ...CSP_FILES]) {
|
|
45
|
+
const source = read(file);
|
|
46
|
+
if (source === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
// `script-src` first, because it is the earlier failure. A policy that blocks the inline
|
|
49
|
+
// snippet means there is no SDK at all, so a `connect-src` finding on the same file would be
|
|
50
|
+
// describing a socket that is never opened -- true, and the wrong thing to fix first (#679).
|
|
51
|
+
const inline = cspInlineScriptProblem(source, port);
|
|
52
|
+
if (inline !== undefined) {
|
|
53
|
+
findings.push({ file, problem: inline, fix: externalScriptRemedy() });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const problem = cspConnectSrcProblem(source, port);
|
|
57
|
+
if (problem === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
findings.push({ file, problem, fix: devCspAddition(port) });
|
|
60
|
+
}
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup RCA for desktop apps.
|
|
3
|
+
*
|
|
4
|
+
* Every failure this catches is SILENT. A Tauri app with the default CSP runs perfectly and simply
|
|
5
|
+
* never appears in `reticle status` — the WebSocket is blocked before it opens, and nothing in the
|
|
6
|
+
* app's own console says so. An Electron app missing the preload line works fine and reports zero
|
|
7
|
+
* network activity forever, which reads as "the app makes no backend calls" rather than "you are
|
|
8
|
+
* blind to all of them".
|
|
9
|
+
*
|
|
10
|
+
* Silent misconfiguration is the same disease as a false green: the tool answers confidently and the
|
|
11
|
+
* answer is wrong. So the diagnosis names the file, the reason, and the exact line to add.
|
|
12
|
+
*
|
|
13
|
+
* Pure: a `read` function in, findings out. No fs, no cwd, no process.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DesktopFinding: {
|
|
16
|
+
/** Tauri's CSP will block the bridge WebSocket — the app will never connect, with no error. */
|
|
17
|
+
readonly TAURI_CSP_BLOCKS_BRIDGE: "tauri-csp-blocks-bridge";
|
|
18
|
+
/** A custom CSP dropped the `ipc:` source Tauri needs for `invoke` itself. */
|
|
19
|
+
readonly TAURI_CSP_BLOCKS_IPC: "tauri-csp-blocks-ipc";
|
|
20
|
+
/** The preload never requires the shim, so every IPC call is invisible. */
|
|
21
|
+
readonly ELECTRON_PRELOAD_MISSING: "electron-preload-missing";
|
|
22
|
+
/** The main process never installs the capture helper, so screenshots are unavailable. */
|
|
23
|
+
readonly ELECTRON_CAPTURE_MISSING: "electron-capture-missing";
|
|
24
|
+
};
|
|
25
|
+
export type DesktopFinding = (typeof DesktopFinding)[keyof typeof DesktopFinding];
|
|
26
|
+
export interface DesktopDiagnosis {
|
|
27
|
+
code: DesktopFinding;
|
|
28
|
+
/** Which file to look at. */
|
|
29
|
+
file: string;
|
|
30
|
+
/** What is wrong, in one line. */
|
|
31
|
+
problem: string;
|
|
32
|
+
/** Exactly what to add — copy-pasteable, with the port actually in use. */
|
|
33
|
+
fix: string;
|
|
34
|
+
}
|
|
35
|
+
/** Reads a project-relative path. Undefined means the file does not exist. */
|
|
36
|
+
type ReadFile = (path: string) => string | undefined;
|
|
37
|
+
export declare const PRELOAD_REQUIRE = "@reticlehq/electron/preload";
|
|
38
|
+
export declare const CAPTURE_REQUIRE = "@reticlehq/electron/main";
|
|
39
|
+
/** The copy-pasteable line `diagnoseDesktop` already prints; the main patcher reuses it on MANUAL. */
|
|
40
|
+
export declare const ELECTRON_CAPTURE_FIX = "add: const { installReticleCapture } = require('@reticlehq/electron/main') then installReticleCapture(win)";
|
|
41
|
+
export declare const ELECTRON_PRELOAD_FIX = "add as the FIRST line: require('@reticlehq/electron/preload')";
|
|
42
|
+
/** Is this project a desktop shell at all? Used to print a positive result instead of silence. */
|
|
43
|
+
export declare function isDesktopProject(read: ReadFile): boolean;
|
|
44
|
+
/** Which desktop shell (if any) this project is, and what is missing from its Reticle wiring. */
|
|
45
|
+
export declare function diagnoseDesktop(read: ReadFile, port: number): DesktopDiagnosis[];
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { RETICLE_IPC_GLOBAL } from '@reticlehq/core';
|
|
2
|
+
/**
|
|
3
|
+
* Setup RCA for desktop apps.
|
|
4
|
+
*
|
|
5
|
+
* Every failure this catches is SILENT. A Tauri app with the default CSP runs perfectly and simply
|
|
6
|
+
* never appears in `reticle status` — the WebSocket is blocked before it opens, and nothing in the
|
|
7
|
+
* app's own console says so. An Electron app missing the preload line works fine and reports zero
|
|
8
|
+
* network activity forever, which reads as "the app makes no backend calls" rather than "you are
|
|
9
|
+
* blind to all of them".
|
|
10
|
+
*
|
|
11
|
+
* Silent misconfiguration is the same disease as a false green: the tool answers confidently and the
|
|
12
|
+
* answer is wrong. So the diagnosis names the file, the reason, and the exact line to add.
|
|
13
|
+
*
|
|
14
|
+
* Pure: a `read` function in, findings out. No fs, no cwd, no process.
|
|
15
|
+
*/
|
|
16
|
+
export const DesktopFinding = {
|
|
17
|
+
/** Tauri's CSP will block the bridge WebSocket — the app will never connect, with no error. */
|
|
18
|
+
TAURI_CSP_BLOCKS_BRIDGE: 'tauri-csp-blocks-bridge',
|
|
19
|
+
/** A custom CSP dropped the `ipc:` source Tauri needs for `invoke` itself. */
|
|
20
|
+
TAURI_CSP_BLOCKS_IPC: 'tauri-csp-blocks-ipc',
|
|
21
|
+
/** The preload never requires the shim, so every IPC call is invisible. */
|
|
22
|
+
ELECTRON_PRELOAD_MISSING: 'electron-preload-missing',
|
|
23
|
+
/** The main process never installs the capture helper, so screenshots are unavailable. */
|
|
24
|
+
ELECTRON_CAPTURE_MISSING: 'electron-capture-missing',
|
|
25
|
+
};
|
|
26
|
+
const TAURI_CONF = 'src-tauri/tauri.conf.json';
|
|
27
|
+
export const PRELOAD_REQUIRE = '@reticlehq/electron/preload';
|
|
28
|
+
export const CAPTURE_REQUIRE = '@reticlehq/electron/main';
|
|
29
|
+
/** The copy-pasteable line `diagnoseDesktop` already prints; the main patcher reuses it on MANUAL. */
|
|
30
|
+
export const ELECTRON_CAPTURE_FIX = `add: const { installReticleCapture } = require('${CAPTURE_REQUIRE}') then installReticleCapture(win)`;
|
|
31
|
+
export const ELECTRON_PRELOAD_FIX = `add as the FIRST line: require('${PRELOAD_REQUIRE}')`;
|
|
32
|
+
function parseJson(text) {
|
|
33
|
+
if (text === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(text);
|
|
37
|
+
return 'object' === typeof parsed && parsed !== null
|
|
38
|
+
? parsed
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined; // a malformed config is the app's problem, not something to crash the doctor on
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function record(value) {
|
|
46
|
+
return 'object' === typeof value && value !== null ? value : {};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Does this CSP permit the bridge WebSocket on `port`?
|
|
50
|
+
*
|
|
51
|
+
* A MISSING csp key is treated as blocking, not permissive: Tauri applies a restrictive default, so
|
|
52
|
+
* reporting "no CSP, therefore fine" would be precisely the false green this tool exists to prevent.
|
|
53
|
+
*/
|
|
54
|
+
function cspAllowsBridge(csp, port) {
|
|
55
|
+
if (csp === undefined)
|
|
56
|
+
return false;
|
|
57
|
+
const exact = `ws://localhost:${String(port)}`;
|
|
58
|
+
const loopback = `ws://127.0.0.1:${String(port)}`;
|
|
59
|
+
// A BARE `ws:` scheme source allows any ws origin. It must be matched as a whole source, not as a
|
|
60
|
+
// substring: `includes('ws:')` also matches `ws://localhost:4400`, which would make every port
|
|
61
|
+
// look allowed and silently defeat the port check this function exists to perform.
|
|
62
|
+
const bareScheme = /(^|[\s;])ws:(?!\/\/)/.test(csp);
|
|
63
|
+
return (csp.includes(exact) ||
|
|
64
|
+
csp.includes(loopback) ||
|
|
65
|
+
csp.includes('ws://localhost:*') ||
|
|
66
|
+
csp.includes('ws://127.0.0.1:*') ||
|
|
67
|
+
bareScheme);
|
|
68
|
+
}
|
|
69
|
+
/** Tauri v2 needs `ipc:`/`http://ipc.localhost` in connect-src for `invoke` to work at all. */
|
|
70
|
+
function cspAllowsIpc(csp) {
|
|
71
|
+
return csp.includes('ipc:') || csp.includes('ipc.localhost');
|
|
72
|
+
}
|
|
73
|
+
function diagnoseTauri(read, port) {
|
|
74
|
+
const conf = parseJson(read(TAURI_CONF));
|
|
75
|
+
if (conf === undefined)
|
|
76
|
+
return [];
|
|
77
|
+
const security = record(record(conf['app'])['security']);
|
|
78
|
+
const csp = 'string' === typeof security['csp'] ? security['csp'] : undefined;
|
|
79
|
+
const findings = [];
|
|
80
|
+
if (!cspAllowsBridge(csp, port)) {
|
|
81
|
+
findings.push({
|
|
82
|
+
code: DesktopFinding.TAURI_CSP_BLOCKS_BRIDGE,
|
|
83
|
+
file: TAURI_CONF,
|
|
84
|
+
problem: csp === undefined
|
|
85
|
+
? "no `app.security.csp` set, so Tauri's restrictive default applies and blocks the Reticle bridge — the app will run normally and never connect"
|
|
86
|
+
: 'the CSP `connect-src` does not allow the Reticle bridge WebSocket — the app will run normally and never connect',
|
|
87
|
+
fix: `add to app.security.csp connect-src: ws://localhost:${String(port)}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (csp !== undefined && !cspAllowsIpc(csp)) {
|
|
91
|
+
findings.push({
|
|
92
|
+
code: DesktopFinding.TAURI_CSP_BLOCKS_IPC,
|
|
93
|
+
file: TAURI_CONF,
|
|
94
|
+
problem: 'the CSP dropped the `ipc:` source Tauri needs — `invoke` itself will fail',
|
|
95
|
+
fix: 'add to app.security.csp connect-src: ipc: http://ipc.localhost',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
/** The directory portion of a path, without pulling in node:path (this module stays pure). */
|
|
101
|
+
function dirOf(path) {
|
|
102
|
+
const cut = path.lastIndexOf('/');
|
|
103
|
+
return cut < 0 ? '' : path.slice(0, cut + 1);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Where a preload SOURCE might live, in the order worth trying.
|
|
107
|
+
*
|
|
108
|
+
* Beside `main` first (the unbundled layout), then the usual source directories — a bundled setup
|
|
109
|
+
* points `main` at `out/` or `dist/` while the real preload stays in `src/` or `electron/`.
|
|
110
|
+
*/
|
|
111
|
+
function preloadCandidates(mainDir) {
|
|
112
|
+
const names = ['preload.cjs', 'preload.js', 'preload.mjs', 'preload.ts', 'preload.mts'];
|
|
113
|
+
const dirs = [mainDir, 'electron/', 'src/', 'src/preload/', 'src/main/', ''];
|
|
114
|
+
return dirs.flatMap((dir) => names.map((name) => `${dir}${name}`));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Where a BUNDLED preload's output lands. electron-vite's default layout mirrors `out/main` with
|
|
118
|
+
* `out/preload`; Forge and hand-rolled setups favour `dist`/`build`.
|
|
119
|
+
*/
|
|
120
|
+
function bundledPreloadCandidates(mainDir) {
|
|
121
|
+
const roots = [mainDir.replace(/main\/?$/, ''), 'out/', 'dist/', 'build/', '.vite/build/'];
|
|
122
|
+
const names = [
|
|
123
|
+
'preload/index.js',
|
|
124
|
+
'preload/index.mjs',
|
|
125
|
+
'preload/index.cjs',
|
|
126
|
+
'preload.js',
|
|
127
|
+
'preload.mjs',
|
|
128
|
+
'preload.cjs',
|
|
129
|
+
];
|
|
130
|
+
return roots.flatMap((root) => names.map((name) => `${root}${name}`));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Is the preload wired, judged from whatever evidence exists?
|
|
134
|
+
*
|
|
135
|
+
* Source first (the require is right there). Failing that, the BUILD OUTPUT: bundling inlines the
|
|
136
|
+
* shim, and the shim carries the contract's own window global — finding that string in the artifact
|
|
137
|
+
* proves the preload is wired even though the require has been compiled away. Undefined means no
|
|
138
|
+
* evidence either way, and an unknown must never be reported as a fault.
|
|
139
|
+
*/
|
|
140
|
+
function preloadWired(read, mainDir) {
|
|
141
|
+
for (const candidate of preloadCandidates(mainDir)) {
|
|
142
|
+
const source = read(candidate);
|
|
143
|
+
if (source !== undefined)
|
|
144
|
+
return { wired: source.includes(PRELOAD_REQUIRE), file: candidate };
|
|
145
|
+
}
|
|
146
|
+
for (const candidate of bundledPreloadCandidates(mainDir)) {
|
|
147
|
+
const built = read(candidate);
|
|
148
|
+
if (built !== undefined) {
|
|
149
|
+
return { wired: built.includes(RETICLE_IPC_GLOBAL), file: candidate };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
function diagnoseElectron(read, main) {
|
|
155
|
+
const findings = [];
|
|
156
|
+
const dir = dirOf(main);
|
|
157
|
+
// Undefined = no source AND no build output to judge from. An unknown is not a fault.
|
|
158
|
+
const preload = preloadWired(read, dir);
|
|
159
|
+
if (preload !== undefined && !preload.wired) {
|
|
160
|
+
findings.push({
|
|
161
|
+
code: DesktopFinding.ELECTRON_PRELOAD_MISSING,
|
|
162
|
+
file: preload.file,
|
|
163
|
+
problem: 'the preload never installs the Reticle IPC shim, so every ipcRenderer.invoke is invisible — reticle_network will report nothing and read as "this app makes no backend calls"',
|
|
164
|
+
fix: ELECTRON_PRELOAD_FIX,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const mainSource = read(main);
|
|
168
|
+
if (mainSource !== undefined && !mainSource.includes(CAPTURE_REQUIRE)) {
|
|
169
|
+
findings.push({
|
|
170
|
+
code: DesktopFinding.ELECTRON_CAPTURE_MISSING,
|
|
171
|
+
file: main,
|
|
172
|
+
problem: 'the main process never installs the capture helper, so screenshots are unavailable',
|
|
173
|
+
fix: ELECTRON_CAPTURE_FIX,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return findings;
|
|
177
|
+
}
|
|
178
|
+
/** Is this project a desktop shell at all? Used to print a positive result instead of silence. */
|
|
179
|
+
export function isDesktopProject(read) {
|
|
180
|
+
if (read(TAURI_CONF) !== undefined)
|
|
181
|
+
return true;
|
|
182
|
+
const pkg = parseJson(read('package.json'));
|
|
183
|
+
if (pkg === undefined)
|
|
184
|
+
return false;
|
|
185
|
+
const deps = { ...record(pkg['dependencies']), ...record(pkg['devDependencies']) };
|
|
186
|
+
return deps['electron'] !== undefined;
|
|
187
|
+
}
|
|
188
|
+
/** Which desktop shell (if any) this project is, and what is missing from its Reticle wiring. */
|
|
189
|
+
export function diagnoseDesktop(read, port) {
|
|
190
|
+
const tauri = diagnoseTauri(read, port);
|
|
191
|
+
if (tauri.length > 0 || read(TAURI_CONF) !== undefined)
|
|
192
|
+
return tauri;
|
|
193
|
+
const pkg = parseJson(read('package.json'));
|
|
194
|
+
if (pkg === undefined)
|
|
195
|
+
return [];
|
|
196
|
+
const deps = { ...record(pkg['dependencies']), ...record(pkg['devDependencies']) };
|
|
197
|
+
if (deps['electron'] === undefined)
|
|
198
|
+
return [];
|
|
199
|
+
const main = 'string' === typeof pkg['main'] ? pkg['main'] : undefined;
|
|
200
|
+
return main === undefined ? [] : diagnoseElectron(read, main);
|
|
201
|
+
}
|