@reticlehq/init 3.3.0 → 3.5.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/dist/detect/capabilities.d.ts +4 -1
- package/dist/detect/capabilities.js +45 -9
- package/dist/detect/detect.d.ts +45 -2
- package/dist/detect/detect.js +76 -5
- package/dist/detect/dev-script.d.ts +26 -2
- package/dist/detect/dev-script.js +80 -7
- package/dist/detect/non-js-project.d.ts +1 -1
- package/dist/detect/non-js-project.js +6 -1
- package/dist/detect/workspace-apps.d.ts +21 -0
- package/dist/detect/workspace-apps.js +111 -12
- package/dist/detect/workspace-redirect.js +28 -2
- package/dist/diagnose/containerised-dev-server.d.ts +12 -4
- package/dist/diagnose/containerised-dev-server.js +36 -4
- package/dist/diagnose/csp-check.d.ts +12 -6
- package/dist/diagnose/csp-check.js +61 -13
- package/dist/diagnose/csp-doctor.d.ts +13 -1
- package/dist/diagnose/csp-doctor.js +2 -2
- package/dist/diagnose/csp-step.d.ts +27 -0
- package/dist/diagnose/csp-step.js +86 -0
- package/dist/diagnose/desktop-doctor.js +8 -2
- package/dist/diagnose/init-failure.d.ts +7 -0
- package/dist/diagnose/init-failure.js +7 -0
- package/dist/diagnose/install-hint.d.ts +9 -1
- package/dist/diagnose/install-hint.js +17 -7
- package/dist/diagnose/install-retries.d.ts +16 -1
- package/dist/diagnose/install-retries.js +50 -3
- package/dist/diagnose/terminal-wrap.d.ts +18 -0
- package/dist/diagnose/terminal-wrap.js +63 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/no-package-json.d.ts +10 -0
- package/dist/no-package-json.js +131 -0
- package/dist/node-io.d.ts +12 -6
- package/dist/node-io.js +33 -11
- package/dist/patch/angular.d.ts +68 -0
- package/dist/patch/angular.js +216 -0
- package/dist/patch/astro-patch.d.ts +1 -1
- package/dist/patch/astro-patch.js +2 -2
- package/dist/patch/cra.d.ts +0 -2
- package/dist/patch/cra.js +0 -2
- package/dist/patch/electron-patch.d.ts +10 -1
- package/dist/patch/electron-patch.js +79 -27
- package/dist/patch/electron-vite-patch.js +1 -1
- package/dist/patch/nuxt-patch.d.ts +1 -1
- package/dist/patch/nuxt-patch.js +2 -2
- package/dist/patch/nuxt-snippets.js +6 -6
- package/dist/patch/remix.d.ts +24 -0
- package/dist/patch/remix.js +59 -0
- package/dist/patch/snippets.d.ts +57 -9
- package/dist/patch/snippets.js +119 -28
- package/dist/patch/static-page.d.ts +39 -0
- package/dist/patch/static-page.js +102 -0
- package/dist/patch/tanstack-start.d.ts +36 -8
- package/dist/patch/tanstack-start.js +118 -29
- package/dist/patch/vite-config.d.ts +5 -0
- package/dist/patch/vite-config.js +8 -0
- package/dist/patch/vite-owning-config.d.ts +34 -0
- package/dist/patch/vite-owning-config.js +63 -5
- package/dist/plan/connect-steps.d.ts +7 -1
- package/dist/plan/connect-steps.js +18 -1
- package/dist/plan/framework-adapter.d.ts +3 -1
- package/dist/plan/framework-adapter.js +99 -6
- package/dist/plan/notices.js +4 -3
- package/dist/plan/plan-angular.d.ts +6 -0
- package/dist/plan/plan-angular.js +89 -0
- package/dist/plan/plan-electron-vite.d.ts +6 -3
- package/dist/plan/plan-electron-vite.js +39 -8
- package/dist/plan/plan-framework.d.ts +8 -46
- package/dist/plan/plan-framework.js +81 -312
- package/dist/plan/plan-types.d.ts +29 -0
- package/dist/plan/plan-vite.d.ts +67 -0
- package/dist/plan/plan-vite.js +256 -0
- package/dist/plan/plan.js +38 -34
- package/dist/plan/port-steps.d.ts +45 -0
- package/dist/plan/port-steps.js +119 -0
- package/dist/plan/preflight.d.ts +17 -3
- package/dist/plan/preflight.js +53 -23
- package/dist/register/mcp-clients.js +5 -3
- package/dist/register/mcp.d.ts +18 -5
- package/dist/register/mcp.js +48 -8
- package/dist/run-types.d.ts +8 -0
- package/dist/run.js +118 -80
- package/package.json +3 -4
- package/NOTICE +0 -5
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
1
2
|
import { DEV_SCRIPT_NAMES } from './dev-script.js';
|
|
3
|
+
import { detect, Framework, UiLibrary } from './detect.js';
|
|
2
4
|
/**
|
|
3
5
|
* The file names that say what a directory IS, shared by everything that has to ask.
|
|
4
6
|
*
|
|
@@ -117,27 +119,65 @@ function hasDevScript(pkgRaw) {
|
|
|
117
119
|
return false;
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
|
-
|
|
122
|
+
/** How strongly a directory claims to be the app somebody is working in. */
|
|
123
|
+
const AppSignal = {
|
|
124
|
+
/** A page to connect: a bundler, a UI framework, or an index.html of its own. */
|
|
125
|
+
UI: 'ui',
|
|
126
|
+
/** Something that can be served and shows no page — an API, a worker, a server-rendered backend. */
|
|
127
|
+
SERVED: 'served',
|
|
128
|
+
NONE: 'none',
|
|
129
|
+
};
|
|
130
|
+
/** The pages a package can serve without any framework to say so. */
|
|
131
|
+
const OWN_PAGES = ['index.html', 'public/index.html', 'src/index.html'];
|
|
132
|
+
function parsedManifest(pkgRaw) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(pkgRaw);
|
|
135
|
+
return 'object' === typeof parsed && parsed !== null ? parsed : {};
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Whether a package with a dev script has a page. Asked of the same detector the plan uses, so
|
|
143
|
+
* "is this the app" and "which framework is it" cannot disagree about what a UI dependency is.
|
|
144
|
+
*/
|
|
145
|
+
function hasPage(dir, pkgRaw, io) {
|
|
146
|
+
const found = detect({
|
|
147
|
+
pkg: parsedManifest(pkgRaw),
|
|
148
|
+
configFiles: new Set(),
|
|
149
|
+
lockfiles: new Set(),
|
|
150
|
+
});
|
|
151
|
+
if (Framework.HTML !== found.framework || UiLibrary.UNKNOWN !== found.uiLibrary)
|
|
152
|
+
return true;
|
|
153
|
+
return OWN_PAGES.some((page) => io.exists(`${dir}/${page}`));
|
|
154
|
+
}
|
|
155
|
+
function appSignal(dir, io) {
|
|
121
156
|
const pkgRaw = io.readFile(`${dir}/${PACKAGE_JSON}`);
|
|
122
157
|
if (null === pkgRaw)
|
|
123
|
-
return
|
|
158
|
+
return AppSignal.NONE;
|
|
124
159
|
const configs = [
|
|
125
160
|
...VITE_CONFIG_CANDIDATES,
|
|
126
161
|
...ELECTRON_VITE_CONFIG_CANDIDATES,
|
|
127
162
|
...NEXT_CONFIG_CANDIDATES,
|
|
128
163
|
];
|
|
129
164
|
if (configs.some((c) => io.exists(`${dir}/${c}`)))
|
|
130
|
-
return
|
|
165
|
+
return AppSignal.UI;
|
|
131
166
|
// `next.config` is optional in Next, so the dependency list is the other half of the signal.
|
|
132
167
|
if (APP_DEPS.some((d) => pkgRaw.includes(`"${d}"`)))
|
|
133
|
-
return
|
|
168
|
+
return AppSignal.UI;
|
|
134
169
|
// And the honest third: an app somebody can SERVE. Asking only for a bundler missed every app
|
|
135
170
|
// built on anything else — Remix, Astro, a plain node server — and in a monorepo missing it means
|
|
136
171
|
// init never redirects, wires the ROOT, and reports ✓ for files nothing compiles.
|
|
137
172
|
//
|
|
138
173
|
// A monorepo root has no dev script by design, and a package that can only be BUILT is not the app
|
|
139
174
|
// somebody is working in, so this stays a filter rather than matching every directory.
|
|
140
|
-
|
|
175
|
+
if (!hasDevScript(pkgRaw))
|
|
176
|
+
return AppSignal.NONE;
|
|
177
|
+
// But a dev script alone is also what an API package has, and counting it made every workspace
|
|
178
|
+
// with a backend beside its web app "ambiguous" (#1148): the root refused, and its hint named the
|
|
179
|
+
// backend. A package with a dev script and no page is only a candidate when nothing else is.
|
|
180
|
+
return hasPage(dir, pkgRaw, io) ? AppSignal.UI : AppSignal.SERVED;
|
|
141
181
|
}
|
|
142
182
|
/**
|
|
143
183
|
* App directories under a workspace root.
|
|
@@ -173,14 +213,73 @@ export function findWorkspaceApps(io) {
|
|
|
173
213
|
});
|
|
174
214
|
// A declared parent can itself BE the app (`workspaces: ["web"]`), so check both the directory and
|
|
175
215
|
// its children rather than assuming one level of nesting.
|
|
216
|
+
const served = [];
|
|
217
|
+
const consider = (dir) => {
|
|
218
|
+
const signal = appSignal(dir, io);
|
|
219
|
+
if (AppSignal.UI === signal)
|
|
220
|
+
found.push(dir);
|
|
221
|
+
if (AppSignal.SERVED === signal)
|
|
222
|
+
served.push(dir);
|
|
223
|
+
};
|
|
176
224
|
for (const parent of parents) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
225
|
+
consider(parent);
|
|
226
|
+
for (const name of io.listDirs(parent))
|
|
227
|
+
consider(`${parent}/${name}`);
|
|
228
|
+
}
|
|
229
|
+
// A backend is still the app when it is the only thing that can be served — a server-rendered
|
|
230
|
+
// app is exactly that — so it is demoted, never dropped.
|
|
231
|
+
return [...new Set(found.length > 0 ? found : served)];
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Every package directory the workspace declares or holds, app-shaped or not.
|
|
235
|
+
*
|
|
236
|
+
* For the questions discovery's filter must not answer — "which package does the root config name"
|
|
237
|
+
* is about identity, and a package `--app` wired is the app whether or not it looks like one.
|
|
238
|
+
*/
|
|
239
|
+
export function workspacePackageDirs(io) {
|
|
240
|
+
const pkgRaw = io.readFile(PACKAGE_JSON);
|
|
241
|
+
const pnpmWorkspace = io.readFile(PNPM_WORKSPACE);
|
|
242
|
+
const parents = workspaceParents({
|
|
243
|
+
...(null === pnpmWorkspace ? {} : { pnpmWorkspace }),
|
|
244
|
+
...(null === pkgRaw ? {} : { pkgWorkspaces: parsedManifest(pkgRaw)['workspaces'] }),
|
|
245
|
+
topLevelDirs: io.listDirs('.'),
|
|
246
|
+
});
|
|
247
|
+
return [...new Set(parents.flatMap((p) => [p, ...io.listDirs(p).map((n) => `${p}/${n}`)]))];
|
|
248
|
+
}
|
|
249
|
+
/** How far up `init` looks for the workspace a package belongs to. Deeper than any real layout. */
|
|
250
|
+
const MAX_WORKSPACE_DEPTH = 8;
|
|
251
|
+
/**
|
|
252
|
+
* The workspace root that DECLARES `cwd` as one of its packages, or undefined.
|
|
253
|
+
*
|
|
254
|
+
* `init` run inside `apps/web` wrote `.reticle.json` there only, and `reticle mcp` finds that file by
|
|
255
|
+
* walking UP from where the agent stands — so an agent opened at the repo root never found it, fell
|
|
256
|
+
* back to the default port, and drove whichever daemon was there. `init --app apps/web` run from the
|
|
257
|
+
* root writes the root pointer and the agent files at the root; this is what lets a run from inside
|
|
258
|
+
* the app arrive at the same state.
|
|
259
|
+
*
|
|
260
|
+
* Only the NEAREST workspace root is asked, and only its declaration counts: a directory that merely
|
|
261
|
+
* sits under a repo with a package.json is not that repo's app, and guessing it is would write
|
|
262
|
+
* config into a stranger's root.
|
|
263
|
+
*/
|
|
264
|
+
export function enclosingWorkspaceRoot(cwd, io) {
|
|
265
|
+
let dir = dirname(cwd);
|
|
266
|
+
for (let depth = 0; depth < MAX_WORKSPACE_DEPTH; depth++) {
|
|
267
|
+
const pnpmWorkspace = io.readFile(join(dir, PNPM_WORKSPACE));
|
|
268
|
+
const pkgRaw = io.readFile(join(dir, PACKAGE_JSON));
|
|
269
|
+
const pkgWorkspaces = null === pkgRaw ? undefined : parsedManifest(pkgRaw)['workspaces'];
|
|
270
|
+
if (null !== pnpmWorkspace || pkgWorkspaces !== undefined) {
|
|
271
|
+
const declared = workspaceParents({
|
|
272
|
+
...(null === pnpmWorkspace ? {} : { pnpmWorkspace }),
|
|
273
|
+
...(pkgWorkspaces === undefined ? {} : { pkgWorkspaces }),
|
|
274
|
+
});
|
|
275
|
+
const rel = relative(dir, cwd).split(sep).join('/');
|
|
276
|
+
const member = declared.some((base) => rel === base || dirname(rel) === base);
|
|
277
|
+
return member ? dir : undefined;
|
|
183
278
|
}
|
|
279
|
+
const parent = dirname(dir);
|
|
280
|
+
if (parent === dir)
|
|
281
|
+
return undefined;
|
|
282
|
+
dir = parent;
|
|
184
283
|
}
|
|
185
|
-
return
|
|
284
|
+
return undefined;
|
|
186
285
|
}
|
|
@@ -10,8 +10,25 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { detect, Framework } from './detect.js';
|
|
13
|
-
import {
|
|
13
|
+
import { detectNonJsEcosystem, noPackageJsonMessage } from './non-js-project.js';
|
|
14
|
+
import { findWorkspaceApps, PACKAGE_JSON, workspacePackageDirs } from './workspace-apps.js';
|
|
15
|
+
import { projectIdOf, RETICLE_CONFIG_FILE } from './existing-config.js';
|
|
14
16
|
import { chooseWorkspaceApp, withoutTrailingSlashes } from './declared/app-choice.js';
|
|
17
|
+
const ROOT_CONFIG_LEAD = `This root's ${RETICLE_CONFIG_FILE} names`;
|
|
18
|
+
/**
|
|
19
|
+
* The one workspace package whose `.reticle.json` carries the project the root's does.
|
|
20
|
+
*
|
|
21
|
+
* The root config is a copy of the app's (see `agentRootConfigStep`), so the projectId is what ties
|
|
22
|
+
* the two together; no path is stored. Searched across every package directory rather than the
|
|
23
|
+
* discovered apps, because the app `--app` named is the app whether or not discovery would list it.
|
|
24
|
+
*/
|
|
25
|
+
function appNamedByRootConfig(io) {
|
|
26
|
+
const wanted = projectIdOf(io.readFile(RETICLE_CONFIG_FILE));
|
|
27
|
+
if (wanted === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const named = workspacePackageDirs(io).filter((dir) => projectIdOf(io.readFile(`${dir}/${RETICLE_CONFIG_FILE}`)) === wanted);
|
|
30
|
+
return 1 === named.length ? named[0] : undefined;
|
|
31
|
+
}
|
|
15
32
|
const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init` inside the one you want:';
|
|
16
33
|
/**
|
|
17
34
|
* When the current directory is a workspace root with no app of its own, wire the app instead of the
|
|
@@ -44,11 +61,20 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
44
61
|
const wanted = withoutTrailingSlashes(named);
|
|
45
62
|
if (io.exists(`${wanted}/${PACKAGE_JSON}`))
|
|
46
63
|
return enterApp(options, io, wanted, 'Wiring', runInit);
|
|
47
|
-
|
|
64
|
+
const inWanted = (file) => io.exists(`${wanted}/${file}`);
|
|
65
|
+
io.print(detectNonJsEcosystem(inWanted) === undefined
|
|
66
|
+
? `--app ${named} does not name a directory with a package.json in it.`
|
|
67
|
+
: noPackageJsonMessage(inWanted));
|
|
48
68
|
return { ok: false, applied: 0, manual: 1 };
|
|
49
69
|
}
|
|
50
70
|
if (here.framework !== Framework.HTML)
|
|
51
71
|
return null; // this directory IS the app
|
|
72
|
+
// A root that an earlier `--app` run (or a run inside the app) already pointed at one app has
|
|
73
|
+
// answered the question discovery would ask. Ignoring it made every re-run at the root refuse
|
|
74
|
+
// again with the same list, and exit 1 over a setup that had worked.
|
|
75
|
+
const pointed = appNamedByRootConfig(io);
|
|
76
|
+
if (pointed !== undefined)
|
|
77
|
+
return enterApp(options, io, pointed, ROOT_CONFIG_LEAD, runInit);
|
|
52
78
|
const apps = findWorkspaceApps(io);
|
|
53
79
|
// An explicitly named app answers the ambiguity. Refusing to guess is right, but "re-run inside the
|
|
54
80
|
// one you want" is not something a script, a CI step, or an agent that cannot change directory can
|
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
* container — it needs an image rebuild and a fresh volume. The same field install lost two and a
|
|
18
18
|
* half minutes discovering that through a failed build.
|
|
19
19
|
*
|
|
20
|
-
* DETECTION IS
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* DETECTION IS CHEAP, AND ERRS TOWARDS SPEAKING — but a container file counts only when it RUNS a
|
|
21
|
+
* dev server (see runsDevServer). It used to count on presence alone, and React Router's own
|
|
22
|
+
* template ships a production Dockerfile, so every React Router install was told to rebuild an image
|
|
23
|
+
* and mount a token for a dev server that runs on the host. A devcontainer still counts on presence:
|
|
24
|
+
* it is the development environment by definition.
|
|
24
25
|
*/
|
|
25
26
|
/** Files whose presence means this repo builds a container image somewhere near the app. */
|
|
26
27
|
export declare const CONTAINER_MARKERS: readonly ["Dockerfile", "dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", ".devcontainer/devcontainer.json"];
|
|
@@ -30,3 +31,10 @@ export declare const CONTAINERISED_TITLE = "Containerised dev server";
|
|
|
30
31
|
* the page — the SDK loads, the socket opens, no session appears — so they are told together.
|
|
31
32
|
*/
|
|
32
33
|
export declare function containerisedDevServerNote(marker: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Does this container file run the app's dev server?
|
|
36
|
+
*
|
|
37
|
+
* @param devScript the app's own `scripts.dev`, so a container that runs it by its command rather
|
|
38
|
+
* than through the package manager (`CMD react-router dev`) still counts.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runsDevServer(marker: string, content: string, devScript: string | undefined): boolean;
|
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
* container — it needs an image rebuild and a fresh volume. The same field install lost two and a
|
|
18
18
|
* half minutes discovering that through a failed build.
|
|
19
19
|
*
|
|
20
|
-
* DETECTION IS
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* DETECTION IS CHEAP, AND ERRS TOWARDS SPEAKING — but a container file counts only when it RUNS a
|
|
21
|
+
* dev server (see runsDevServer). It used to count on presence alone, and React Router's own
|
|
22
|
+
* template ships a production Dockerfile, so every React Router install was told to rebuild an image
|
|
23
|
+
* and mount a token for a dev server that runs on the host. A devcontainer still counts on presence:
|
|
24
|
+
* it is the development environment by definition.
|
|
24
25
|
*/
|
|
25
26
|
import { ReticleEnv } from '@reticlehq/core';
|
|
26
27
|
/** Files whose presence means this repo builds a container image somewhere near the app. */
|
|
@@ -54,3 +55,34 @@ export function containerisedDevServerNote(marker) {
|
|
|
54
55
|
' The token is inlined when the dev server resolves its config, so restart it afterwards. ' +
|
|
55
56
|
'Run the Reticle daemon at least once first, or Docker creates a DIRECTORY at that host path.');
|
|
56
57
|
}
|
|
58
|
+
const DEVCONTAINER = '.devcontainer/devcontainer.json';
|
|
59
|
+
/**
|
|
60
|
+
* Commands that start a dev server, in shell form or Dockerfile exec form (`["npm", "run", "dev"]`).
|
|
61
|
+
* The separator class takes quotes and commas so both spellings match. `vite` counts bare, or
|
|
62
|
+
* followed by a flag, but never as `vite build` / `vite preview`, which serve nothing live.
|
|
63
|
+
*/
|
|
64
|
+
const SEP = `["',\\s]+`;
|
|
65
|
+
const DEV_SERVER_COMMANDS = [
|
|
66
|
+
new RegExp(`\\b(?:npm|pnpm|yarn|bun)${SEP}(?:run${SEP})?dev\\b`),
|
|
67
|
+
new RegExp(`\\b(?:next|nuxt|nuxi|astro|react-router|remix|vinxi)${SEP}dev\\b`),
|
|
68
|
+
new RegExp(`\\bng${SEP}serve\\b`),
|
|
69
|
+
new RegExp(`\\breact-scripts${SEP}start\\b`),
|
|
70
|
+
new RegExp(`\\bwebpack${SEP}serve\\b|\\bwebpack-dev-server\\b`),
|
|
71
|
+
/\bvite["']?(?=\s*(?:$|--|["']?\s*[\],]))/m,
|
|
72
|
+
];
|
|
73
|
+
/**
|
|
74
|
+
* Does this container file run the app's dev server?
|
|
75
|
+
*
|
|
76
|
+
* @param devScript the app's own `scripts.dev`, so a container that runs it by its command rather
|
|
77
|
+
* than through the package manager (`CMD react-router dev`) still counts.
|
|
78
|
+
*/
|
|
79
|
+
export function runsDevServer(marker, content, devScript) {
|
|
80
|
+
if (DEVCONTAINER === marker)
|
|
81
|
+
return true;
|
|
82
|
+
if (devScript !== undefined &&
|
|
83
|
+
0 < devScript.trim().length &&
|
|
84
|
+
content.includes(devScript.trim())) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return DEV_SERVER_COMMANDS.some((command) => command.test(content));
|
|
88
|
+
}
|
|
@@ -38,12 +38,18 @@ export declare const EXTERNAL_CONNECT_PATH = "public/reticle-connect.js";
|
|
|
38
38
|
export declare function externalScriptRemedy(): string;
|
|
39
39
|
/** The exact addition to paste. Text to copy, not advice to interpret. */
|
|
40
40
|
export declare function devCspAddition(port: number): string;
|
|
41
|
-
/** The step title, named here so `doctor` and the plan cannot drift apart on what this check is called. */
|
|
42
|
-
export declare const CSP_STEP_TITLE = "Content-Security-Policy";
|
|
43
41
|
/**
|
|
44
|
-
* The
|
|
42
|
+
* The same HTML with every CSP `<meta>` that blocks the bridge made to admit it, or undefined when
|
|
43
|
+
* none needed it — the one policy location `init` can edit safely, because it is a file of the app's
|
|
44
|
+
* own and the edit is a pair of sources appended to one directive.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* Reproduced on electron-vite's own template: `default-src 'self'` and no `connect-src`, so the
|
|
47
|
+
* bridge WebSocket is refused and init waited out its whole budget. The origins are loopback-only;
|
|
48
|
+
* a static `<meta>` has no development branch, so they are present in a packaged build too, where
|
|
49
|
+
* nothing listens on them. On a port move the pair is REPLACED (see stalePairs).
|
|
48
50
|
*/
|
|
49
|
-
export declare function
|
|
51
|
+
export declare function patchCspMetaConnectSrc(html: string, port: number): string | undefined;
|
|
52
|
+
/** What the patched step says it did. */
|
|
53
|
+
export declare function cspPatchedDetail(port: number): string;
|
|
54
|
+
/** The step title, named here so `doctor` and the plan cannot drift apart on what this check is called. */
|
|
55
|
+
export declare const CSP_STEP_TITLE = "Content-Security-Policy";
|
|
@@ -148,21 +148,69 @@ export function devCspAddition(port) {
|
|
|
148
148
|
`\`process.env.NODE_ENV === 'development'\`): ` +
|
|
149
149
|
`${bridgeOrigins(port).join(' ')}`);
|
|
150
150
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
151
|
+
/** A `<meta http-equiv="Content-Security-Policy" content=…>` tag's content attribute, by quote. */
|
|
152
|
+
const CSP_META = /<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi;
|
|
153
|
+
const CONTENT_ATTR = /(\bcontent\s*=\s*)(["'])((?:(?!\2).)*)\2/i;
|
|
154
|
+
/** A loopback WebSocket source, with its port captured. */
|
|
155
|
+
const LOOPBACK_WS = /^ws:\/\/(?:localhost|127\.0\.0\.1):(\d+)$/;
|
|
153
156
|
/**
|
|
154
|
-
* The
|
|
157
|
+
* The ports for which BOTH bridge origins are present — the pair this file writes, and the only
|
|
158
|
+
* loopback sources it may take back out.
|
|
155
159
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
160
|
+
* A port move used to APPEND the new pair beside the old one, so the policy kept admitting a port
|
|
161
|
+
* nothing listened on and grew a pair per move. One `ws://localhost:3000` the app wrote for its own
|
|
162
|
+
* dev server is not a pair, and is left alone.
|
|
158
163
|
*/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
function stalePairs(sources, port) {
|
|
165
|
+
const ports = sources.flatMap((source) => LOOPBACK_WS.exec(source)?.[1] ?? []);
|
|
166
|
+
return new Set(ports.filter((p) => String(port) !== p && bridgeOrigins(Number(p)).every((origin) => sources.includes(origin))));
|
|
167
|
+
}
|
|
168
|
+
/** The policy with both bridge origins admitted to `connect-src`, every other directive untouched. */
|
|
169
|
+
function withBridgeOrigins(policy, port) {
|
|
170
|
+
const origins = bridgeOrigins(port);
|
|
171
|
+
const directives = policy.split(';');
|
|
172
|
+
const connect = directives.findIndex((d) => /^\s*connect-src\b/i.test(d));
|
|
173
|
+
if (-1 !== connect) {
|
|
174
|
+
const current = directives[connect] ?? '';
|
|
175
|
+
const sources = current.trim().split(/\s+/);
|
|
176
|
+
const stale = stalePairs(sources, port);
|
|
177
|
+
const kept = sources.filter((source) => !stale.has(LOOPBACK_WS.exec(source)?.[1] ?? ''));
|
|
178
|
+
const missing = origins.filter((origin) => !kept.includes(origin));
|
|
179
|
+
const lead = /^\s*/.exec(current)?.[0] ?? '';
|
|
180
|
+
directives[connect] = `${lead}${[...kept, ...missing].join(' ')}`;
|
|
181
|
+
return directives.join(';');
|
|
166
182
|
}
|
|
167
|
-
|
|
183
|
+
// No connect-src: the socket falls back to default-src, so the new directive starts from what
|
|
184
|
+
// default-src already allowed — adding it bare would take 'self' away from every fetch the app does.
|
|
185
|
+
const fallback = directiveSources(policy, 'default-src') ?? [];
|
|
186
|
+
const added = ` connect-src ${[...fallback, ...origins].join(' ')}`;
|
|
187
|
+
const trimmed = policy.trimEnd();
|
|
188
|
+
return trimmed.endsWith(';') ? `${trimmed}${added};` : `${trimmed};${added}`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The same HTML with every CSP `<meta>` that blocks the bridge made to admit it, or undefined when
|
|
192
|
+
* none needed it — the one policy location `init` can edit safely, because it is a file of the app's
|
|
193
|
+
* own and the edit is a pair of sources appended to one directive.
|
|
194
|
+
*
|
|
195
|
+
* Reproduced on electron-vite's own template: `default-src 'self'` and no `connect-src`, so the
|
|
196
|
+
* bridge WebSocket is refused and init waited out its whole budget. The origins are loopback-only;
|
|
197
|
+
* a static `<meta>` has no development branch, so they are present in a packaged build too, where
|
|
198
|
+
* nothing listens on them. On a port move the pair is REPLACED (see stalePairs).
|
|
199
|
+
*/
|
|
200
|
+
export function patchCspMetaConnectSrc(html, port) {
|
|
201
|
+
let changed = false;
|
|
202
|
+
const out = html.replace(CSP_META, (tag) => tag.replace(CONTENT_ATTR, (whole, head, quote, policy) => {
|
|
203
|
+
if (cspConnectSrcProblem(policy, port) === undefined)
|
|
204
|
+
return whole;
|
|
205
|
+
changed = true;
|
|
206
|
+
return `${head}${quote}${withBridgeOrigins(policy, port)}${quote}`;
|
|
207
|
+
}));
|
|
208
|
+
return changed ? out : undefined;
|
|
209
|
+
}
|
|
210
|
+
/** What the patched step says it did. */
|
|
211
|
+
export function cspPatchedDetail(port) {
|
|
212
|
+
return (`admit the Reticle bridge (${bridgeOrigins(port).join(' ')}) to connect-src — the policy ` +
|
|
213
|
+
'blocked the WebSocket, so the app could never connect');
|
|
168
214
|
}
|
|
215
|
+
/** The step title, named here so `doctor` and the plan cannot drift apart on what this check is called. */
|
|
216
|
+
export const CSP_STEP_TITLE = 'Content-Security-Policy';
|
|
@@ -39,7 +39,19 @@ export interface ObservedWebDocument {
|
|
|
39
39
|
* plain `index.html` cover the `<meta http-equiv>` style. Both reported cases were in this list.
|
|
40
40
|
*/
|
|
41
41
|
export declare const CSP_FILES: readonly string[];
|
|
42
|
-
export
|
|
42
|
+
export interface WebCspOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Whether this app connects through the hand-pasted INLINE snippet — the plain-HTML path. Only
|
|
45
|
+
* then can `script-src` stop the connect from running. Every plugin-wired app connects from an
|
|
46
|
+
* external module (web) or from its entry module (desktop), and for those the inline rule was
|
|
47
|
+
* reported INSTEAD of the connect-src block that actually stopped them — with a fix (move the
|
|
48
|
+
* snippet to public/reticle-connect.js) that does not apply to an app with no snippet. Defaults to
|
|
49
|
+
* true because `doctor` does not know how the app is wired, and the inline finding carries the
|
|
50
|
+
* connect-src addition too.
|
|
51
|
+
*/
|
|
52
|
+
readonly inlineSnippet?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare function diagnoseWebCsp(read: ReadFile, port: number, alsoCheck?: readonly string[], options?: WebCspOptions): CspDiagnosis[];
|
|
43
55
|
/** Diagnose only policies observed on the served development document. */
|
|
44
56
|
export declare function diagnoseObservedWebCsp(document: ObservedWebDocument, port: number): CspDiagnosis[];
|
|
45
57
|
/** Runtime evidence outranks source prediction; a connected SDK disproves a blocking policy. */
|
|
@@ -52,7 +52,7 @@ function pagePolicySource(file, source) {
|
|
|
52
52
|
return source;
|
|
53
53
|
return source.replace(/contentSecurityPolicy\s*[:=]\s*(['\x22`])[\s\S]*?\1/g, '');
|
|
54
54
|
}
|
|
55
|
-
export function diagnoseWebCsp(read, port, alsoCheck = []) {
|
|
55
|
+
export function diagnoseWebCsp(read, port, alsoCheck = [], options = {}) {
|
|
56
56
|
const findings = [];
|
|
57
57
|
for (const file of [...alsoCheck, ...CSP_FILES]) {
|
|
58
58
|
const raw = read(file);
|
|
@@ -62,7 +62,7 @@ export function diagnoseWebCsp(read, port, alsoCheck = []) {
|
|
|
62
62
|
// `script-src` first, because it is the earlier failure. A policy that blocks the inline
|
|
63
63
|
// snippet means there is no SDK at all, so a `connect-src` finding on the same file would be
|
|
64
64
|
// describing a socket that is never opened -- true, and the wrong thing to fix first (#679).
|
|
65
|
-
const inline = cspInlineScriptProblem(source, port);
|
|
65
|
+
const inline = false === options.inlineSnippet ? undefined : cspInlineScriptProblem(source, port);
|
|
66
66
|
if (inline !== undefined) {
|
|
67
67
|
findings.push({
|
|
68
68
|
file,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan's Content-Security-Policy step: the one check that runs before any framework's wiring,
|
|
3
|
+
* because a policy that refuses the bridge makes every other step moot.
|
|
4
|
+
*/
|
|
5
|
+
import { type PlanInput, type Step } from '../plan/plan-types.js';
|
|
6
|
+
import { type WebCspOptions } from './csp-doctor.js';
|
|
7
|
+
/**
|
|
8
|
+
* How the app connects, as the CSP diagnosis needs to know it. Only the plain-HTML path pastes an
|
|
9
|
+
* inline snippet, so only there can `script-src` stop the connect; see WebCspOptions.
|
|
10
|
+
*
|
|
11
|
+
* Shared with `reticle doctor`, which reads the framework `init` stamped into `.reticle.json`. It
|
|
12
|
+
* used to assume the inline snippet for every app, so on electron-vite it reported the inline rule
|
|
13
|
+
* while `init` reported the connect-src block that actually stops the bridge. Unknown wiring keeps
|
|
14
|
+
* the conservative default.
|
|
15
|
+
*/
|
|
16
|
+
export declare function webCspOptionsFor(framework: string | undefined): WebCspOptions;
|
|
17
|
+
/**
|
|
18
|
+
* A Content-Security-Policy whose `connect-src` excludes the bridge, said out loud at install time.
|
|
19
|
+
*
|
|
20
|
+
* Both reported cases were Next apps where `init` printed success for every step and the app then
|
|
21
|
+
* never connected: the browser blocked the WebSocket and reported it in its own console, which
|
|
22
|
+
* nothing on this side reads. It was a NOTICE, and on electron-vite's own template that meant init
|
|
23
|
+
* waited out its whole connect budget without saying the app could not connect. So a `<meta>` policy
|
|
24
|
+
* in the app's HTML is now edited to admit the bridge, and a policy anywhere else is a blocking ⚠
|
|
25
|
+
* carrying the exact text to paste.
|
|
26
|
+
*/
|
|
27
|
+
export declare function cspStep(input: PlanInput): Step[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan's Content-Security-Policy step: the one check that runs before any framework's wiring,
|
|
3
|
+
* because a policy that refuses the bridge makes every other step moot.
|
|
4
|
+
*/
|
|
5
|
+
import { RETICLE_DEFAULT_PORT } from '@reticlehq/core';
|
|
6
|
+
import { Framework } from '../detect/detect.js';
|
|
7
|
+
import { StepStatus } from '../plan/plan-types.js';
|
|
8
|
+
import { CSP_STEP_TITLE, cspPatchedDetail, externalScriptRemedy, patchCspMetaConnectSrc, } from './csp-check.js';
|
|
9
|
+
import { diagnoseWebCsp } from './csp-doctor.js';
|
|
10
|
+
/**
|
|
11
|
+
* How the app connects, as the CSP diagnosis needs to know it. Only the plain-HTML path pastes an
|
|
12
|
+
* inline snippet, so only there can `script-src` stop the connect; see WebCspOptions.
|
|
13
|
+
*
|
|
14
|
+
* Shared with `reticle doctor`, which reads the framework `init` stamped into `.reticle.json`. It
|
|
15
|
+
* used to assume the inline snippet for every app, so on electron-vite it reported the inline rule
|
|
16
|
+
* while `init` reported the connect-src block that actually stops the bridge. Unknown wiring keeps
|
|
17
|
+
* the conservative default.
|
|
18
|
+
*/
|
|
19
|
+
export function webCspOptionsFor(framework) {
|
|
20
|
+
return framework === undefined ? {} : { inlineSnippet: Framework.HTML === framework };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A Content-Security-Policy whose `connect-src` excludes the bridge, said out loud at install time.
|
|
24
|
+
*
|
|
25
|
+
* Both reported cases were Next apps where `init` printed success for every step and the app then
|
|
26
|
+
* never connected: the browser blocked the WebSocket and reported it in its own console, which
|
|
27
|
+
* nothing on this side reads. It was a NOTICE, and on electron-vite's own template that meant init
|
|
28
|
+
* waited out its whole connect budget without saying the app could not connect. So a `<meta>` policy
|
|
29
|
+
* in the app's HTML is now edited to admit the bridge, and a policy anywhere else is a blocking ⚠
|
|
30
|
+
* carrying the exact text to paste.
|
|
31
|
+
*/
|
|
32
|
+
export function cspStep(input) {
|
|
33
|
+
// Reads the SAME list `reticle doctor` reads. It used to read a hand-written pair —
|
|
34
|
+
// `[nextConfigSource, nextLayout?.source]` — while csp-doctor.ts already carried the full set
|
|
35
|
+
// including `index.html`, which is where every Vite and Electron app declares its policy. So the
|
|
36
|
+
// command you run BEFORE anything works checked less than the one you run after it has failed.
|
|
37
|
+
//
|
|
38
|
+
// Measured on MarkText, a production Electron editor: its renderer sets `default-src 'self'` with
|
|
39
|
+
// no `connect-src`, the browser blocked the bridge WebSocket, and `init` printed a clean plan. The
|
|
40
|
+
// daemon cannot see a dial that never left the page, so nothing anywhere said why — and the check
|
|
41
|
+
// that would have said it was sitting one import away.
|
|
42
|
+
// The pre-read Next sources are folded in ON TOP of the shared list, not replaced by it: init
|
|
43
|
+
// resolves `nextConfigFile` across more spellings than CSP_FILES names (`.mts`, for one), and a
|
|
44
|
+
// layout is found by search rather than by a fixed path. Two sources of the same truth is the
|
|
45
|
+
// problem being fixed here — one of them being a SUPERSET is not.
|
|
46
|
+
const extra = {
|
|
47
|
+
...(input.nextConfigFile !== null && input.nextConfigFile !== undefined
|
|
48
|
+
? { [input.nextConfigFile]: input.nextConfigSource ?? undefined }
|
|
49
|
+
: {}),
|
|
50
|
+
...(input.nextLayout ? { [input.nextLayout.path]: input.nextLayout.source } : {}),
|
|
51
|
+
};
|
|
52
|
+
const read = (file) => extra[file] ?? input.cspSources?.[file];
|
|
53
|
+
const port = input.options.port ?? RETICLE_DEFAULT_PORT;
|
|
54
|
+
const findings = diagnoseWebCsp(read, port, [...Object.keys(extra)], webCspOptionsFor(input.detection.framework));
|
|
55
|
+
const first = findings[0];
|
|
56
|
+
if (first === undefined)
|
|
57
|
+
return [];
|
|
58
|
+
// A `<meta>` policy in the app's own HTML is edited in place. Anything else — a header built in
|
|
59
|
+
// next.config, middleware, a host's config — is a ⚠ that fails init: the browser is guaranteed to
|
|
60
|
+
// refuse the socket, and a NOTICE let init wait out its whole budget without saying so.
|
|
61
|
+
const source = read(first.file);
|
|
62
|
+
const patched = first.file.endsWith('.html') && source !== undefined && first.fix !== externalScriptRemedy()
|
|
63
|
+
? patchCspMetaConnectSrc(source, port)
|
|
64
|
+
: undefined;
|
|
65
|
+
if (patched !== undefined) {
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
title: CSP_STEP_TITLE,
|
|
69
|
+
target: first.file,
|
|
70
|
+
status: StepStatus.APPLY,
|
|
71
|
+
detail: cspPatchedDetail(port),
|
|
72
|
+
write: { path: first.file, content: patched },
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
title: CSP_STEP_TITLE,
|
|
79
|
+
target: first.file,
|
|
80
|
+
status: StepStatus.MANUAL,
|
|
81
|
+
// `problem` already ends with the text to paste; `fix` is the same sentence for callers that
|
|
82
|
+
// want it on its own (doctor renders them separately). Printing both said it twice.
|
|
83
|
+
detail: first.problem,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RETICLE_IPC_GLOBAL, realmOf, realmOfProject } from '@reticlehq/core';
|
|
1
|
+
import { RETICLE_CAPTURE_CHANNEL, RETICLE_IPC_GLOBAL, realmOf, realmOfProject, } from '@reticlehq/core';
|
|
2
2
|
/**
|
|
3
3
|
* Setup RCA for desktop apps.
|
|
4
4
|
*
|
|
@@ -165,7 +165,13 @@ function diagnoseElectron(read, main) {
|
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
167
|
const mainSource = read(main);
|
|
168
|
-
|
|
168
|
+
// The require OR the contract's capture channel. `main` is usually the BUILD output, and bundling
|
|
169
|
+
// erases the require: an electron-vite app that did install the helper was told it never had. The
|
|
170
|
+
// helper registers the channel by its contract string, which survives bundling — the same
|
|
171
|
+
// artifact-level evidence the preload check above reads from the IPC global.
|
|
172
|
+
const captureWired = true === mainSource?.includes(CAPTURE_REQUIRE) ||
|
|
173
|
+
true === mainSource?.includes(RETICLE_CAPTURE_CHANNEL);
|
|
174
|
+
if (mainSource !== undefined && !captureWired) {
|
|
169
175
|
findings.push({
|
|
170
176
|
code: DesktopFinding.ELECTRON_CAPTURE_MISSING,
|
|
171
177
|
file: main,
|
|
@@ -13,6 +13,13 @@ export declare const InitFailure: {
|
|
|
13
13
|
readonly DEPENDENCY_INSTALL: "dependency_install";
|
|
14
14
|
/** The `claude mcp add` step failed — the CLI is missing or refused. Reticle installs but is unreachable. */
|
|
15
15
|
readonly MCP_REGISTRATION: "mcp_registration";
|
|
16
|
+
/** The checkout or required tooling failed preflight. */
|
|
17
|
+
readonly PREFLIGHT: "preflight";
|
|
18
|
+
/** Runtime continuation could not start the app or connect its SDK. */
|
|
19
|
+
readonly DEV_SERVER: "dev_server";
|
|
20
|
+
readonly APP_CONNECTION: "app_connection";
|
|
21
|
+
readonly BRIDGE_OCCUPIED: "bridge_occupied";
|
|
22
|
+
readonly RUNTIME_ERROR: "runtime_error";
|
|
16
23
|
readonly OTHER: "other";
|
|
17
24
|
};
|
|
18
25
|
export type InitFailure = (typeof InitFailure)[keyof typeof InitFailure];
|
|
@@ -13,5 +13,12 @@ export const InitFailure = {
|
|
|
13
13
|
DEPENDENCY_INSTALL: 'dependency_install',
|
|
14
14
|
/** The `claude mcp add` step failed — the CLI is missing or refused. Reticle installs but is unreachable. */
|
|
15
15
|
MCP_REGISTRATION: 'mcp_registration',
|
|
16
|
+
/** The checkout or required tooling failed preflight. */
|
|
17
|
+
PREFLIGHT: 'preflight',
|
|
18
|
+
/** Runtime continuation could not start the app or connect its SDK. */
|
|
19
|
+
DEV_SERVER: 'dev_server',
|
|
20
|
+
APP_CONNECTION: 'app_connection',
|
|
21
|
+
BRIDGE_OCCUPIED: 'bridge_occupied',
|
|
22
|
+
RUNTIME_ERROR: 'runtime_error',
|
|
16
23
|
OTHER: 'other',
|
|
17
24
|
};
|
|
@@ -5,4 +5,12 @@
|
|
|
5
5
|
* which package manager was used.
|
|
6
6
|
*/
|
|
7
7
|
import { PackageManager } from '../detect/detect.js';
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `pm` picks which hint applies; `pmCommand` is what the reader actually types.
|
|
10
|
+
*
|
|
11
|
+
* A corepack-only machine (see `preflight.ts`, #1149) has no bare `pnpm` on PATH — that is the whole
|
|
12
|
+
* shape of the bug this hint is used alongside. Printing `pnpm config set …` to that reader hands them
|
|
13
|
+
* a command that fails the same way the original install did; `pmCommand` is the resolved invocation
|
|
14
|
+
* (`corepack pnpm`, there) that is actually run on this machine.
|
|
15
|
+
*/
|
|
16
|
+
export declare function installFailureHint(pm: PackageManager, pmCommand?: string): string;
|