@reticlehq/init 3.3.0 → 3.4.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.
Files changed (81) hide show
  1. package/dist/detect/capabilities.d.ts +4 -1
  2. package/dist/detect/capabilities.js +45 -9
  3. package/dist/detect/detect.d.ts +45 -2
  4. package/dist/detect/detect.js +76 -5
  5. package/dist/detect/dev-script.d.ts +26 -2
  6. package/dist/detect/dev-script.js +80 -7
  7. package/dist/detect/non-js-project.d.ts +1 -1
  8. package/dist/detect/non-js-project.js +6 -1
  9. package/dist/detect/workspace-apps.d.ts +21 -0
  10. package/dist/detect/workspace-apps.js +111 -12
  11. package/dist/detect/workspace-redirect.js +28 -2
  12. package/dist/diagnose/containerised-dev-server.d.ts +12 -4
  13. package/dist/diagnose/containerised-dev-server.js +36 -4
  14. package/dist/diagnose/csp-check.d.ts +12 -6
  15. package/dist/diagnose/csp-check.js +61 -13
  16. package/dist/diagnose/csp-doctor.d.ts +13 -1
  17. package/dist/diagnose/csp-doctor.js +2 -2
  18. package/dist/diagnose/csp-step.d.ts +27 -0
  19. package/dist/diagnose/csp-step.js +86 -0
  20. package/dist/diagnose/desktop-doctor.js +8 -2
  21. package/dist/diagnose/install-hint.d.ts +9 -1
  22. package/dist/diagnose/install-hint.js +17 -7
  23. package/dist/diagnose/install-retries.d.ts +16 -1
  24. package/dist/diagnose/install-retries.js +50 -3
  25. package/dist/diagnose/terminal-wrap.d.ts +18 -0
  26. package/dist/diagnose/terminal-wrap.js +63 -0
  27. package/dist/index.d.ts +6 -2
  28. package/dist/index.js +6 -2
  29. package/dist/no-package-json.d.ts +10 -0
  30. package/dist/no-package-json.js +121 -0
  31. package/dist/node-io.d.ts +9 -5
  32. package/dist/node-io.js +25 -7
  33. package/dist/patch/angular.d.ts +68 -0
  34. package/dist/patch/angular.js +216 -0
  35. package/dist/patch/astro-patch.d.ts +1 -1
  36. package/dist/patch/astro-patch.js +2 -2
  37. package/dist/patch/cra.d.ts +0 -2
  38. package/dist/patch/cra.js +0 -2
  39. package/dist/patch/electron-patch.d.ts +10 -1
  40. package/dist/patch/electron-patch.js +79 -27
  41. package/dist/patch/electron-vite-patch.js +1 -1
  42. package/dist/patch/nuxt-patch.d.ts +1 -1
  43. package/dist/patch/nuxt-patch.js +2 -2
  44. package/dist/patch/nuxt-snippets.js +6 -6
  45. package/dist/patch/remix.d.ts +24 -0
  46. package/dist/patch/remix.js +59 -0
  47. package/dist/patch/snippets.d.ts +57 -9
  48. package/dist/patch/snippets.js +119 -28
  49. package/dist/patch/static-page.d.ts +39 -0
  50. package/dist/patch/static-page.js +102 -0
  51. package/dist/patch/tanstack-start.d.ts +36 -8
  52. package/dist/patch/tanstack-start.js +118 -29
  53. package/dist/patch/vite-config.d.ts +5 -0
  54. package/dist/patch/vite-config.js +8 -0
  55. package/dist/patch/vite-owning-config.d.ts +34 -0
  56. package/dist/patch/vite-owning-config.js +63 -5
  57. package/dist/plan/connect-steps.d.ts +7 -1
  58. package/dist/plan/connect-steps.js +18 -1
  59. package/dist/plan/framework-adapter.d.ts +3 -1
  60. package/dist/plan/framework-adapter.js +99 -6
  61. package/dist/plan/notices.js +4 -3
  62. package/dist/plan/plan-angular.d.ts +6 -0
  63. package/dist/plan/plan-angular.js +89 -0
  64. package/dist/plan/plan-electron-vite.d.ts +6 -3
  65. package/dist/plan/plan-electron-vite.js +39 -8
  66. package/dist/plan/plan-framework.d.ts +8 -46
  67. package/dist/plan/plan-framework.js +81 -312
  68. package/dist/plan/plan-types.d.ts +29 -0
  69. package/dist/plan/plan-vite.d.ts +67 -0
  70. package/dist/plan/plan-vite.js +256 -0
  71. package/dist/plan/plan.js +38 -34
  72. package/dist/plan/port-steps.d.ts +45 -0
  73. package/dist/plan/port-steps.js +119 -0
  74. package/dist/plan/preflight.d.ts +17 -3
  75. package/dist/plan/preflight.js +53 -23
  76. package/dist/register/mcp.d.ts +18 -5
  77. package/dist/register/mcp.js +48 -8
  78. package/dist/run-types.d.ts +8 -0
  79. package/dist/run.js +111 -78
  80. package/package.json +3 -4
  81. 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
- function looksLikeApp(dir, io) {
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 false;
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 true;
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 true;
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
- return hasDevScript(pkgRaw);
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
- if (looksLikeApp(parent, io))
178
- found.push(parent);
179
- for (const name of io.listDirs(parent)) {
180
- const dir = `${parent}/${name}`;
181
- if (looksLikeApp(dir, io))
182
- found.push(dir);
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 [...new Set(found)];
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 { findWorkspaceApps, PACKAGE_JSON } from './workspace-apps.js';
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
- io.print(`--app ${named} does not name a directory with a package.json in it.`);
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 DELIBERATELY CHEAP AND OVER-EAGER. A Dockerfile or a compose file near the app is not
21
- * proof the dev server runs in one — plenty of repos containerise only production. But this step
22
- * asks for nothing and changes nothing, so a false positive costs one paragraph the reader skips,
23
- * while a false negative costs what it cost in the field.
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 DELIBERATELY CHEAP AND OVER-EAGER. A Dockerfile or a compose file near the app is not
21
- * proof the dev server runs in one — plenty of repos containerise only production. But this step
22
- * asks for nothing and changes nothing, so a false positive costs one paragraph the reader skips,
23
- * while a false negative costs what it cost in the field.
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 first blocking policy among the sources `init` already has in hand, or undefined.
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
- * At most one finding: a second copy of the same policy in a second file is the same problem, and
47
- * two identical notices read as two problems.
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 cspPlanProblem(sources: readonly (string | null | undefined)[], port: number): string | undefined;
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
- /** 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';
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 first blocking policy among the sources `init` already has in hand, or undefined.
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
- * 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.
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
- 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;
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
- return undefined;
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 declare function diagnoseWebCsp(read: ReadFile, port: number, alsoCheck?: readonly string[]): CspDiagnosis[];
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
- if (mainSource !== undefined && !mainSource.includes(CAPTURE_REQUIRE)) {
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,
@@ -5,4 +5,12 @@
5
5
  * which package manager was used.
6
6
  */
7
7
  import { PackageManager } from '../detect/detect.js';
8
- export declare function installFailureHint(pm: PackageManager): string;
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;
@@ -24,17 +24,27 @@ const REGISTRY_HINT = 'If it could not reach the registry at all (offline, a pro
24
24
  * sent the reader through their dependency versions looking for a problem that was actually
25
25
  * about where the store lives (#683).
26
26
  */
27
- const VIRTUAL_STORE_HINT = "If pnpm reported ERR_PNPM_UNEXPECTED_VIRTUAL_STORE, this checkout's node_modules is " +
28
- "symlinked into another checkout's pnpm store (a git worktree, or an A/B harness). Either run " +
29
- '`pnpm install` once in that other checkout first, or point this one at its own store:\n' +
30
- ' pnpm add -D --config.virtual-store-dir=node_modules/.pnpm <packages>';
31
- export function installFailureHint(pm) {
27
+ function virtualStoreHint(pmCommand) {
28
+ return ("If pnpm reported ERR_PNPM_UNEXPECTED_VIRTUAL_STORE, this checkout's node_modules is " +
29
+ "symlinked into another checkout's pnpm store (a git worktree, or an A/B harness). Either run " +
30
+ `\`${pmCommand} install\` once in that other checkout first, or point this one at its own store:\n` +
31
+ ` ${pmCommand} add -D --config.virtual-store-dir=node_modules/.pnpm <packages>`);
32
+ }
33
+ /**
34
+ * `pm` picks which hint applies; `pmCommand` is what the reader actually types.
35
+ *
36
+ * A corepack-only machine (see `preflight.ts`, #1149) has no bare `pnpm` on PATH — that is the whole
37
+ * shape of the bug this hint is used alongside. Printing `pnpm config set …` to that reader hands them
38
+ * a command that fails the same way the original install did; `pmCommand` is the resolved invocation
39
+ * (`corepack pnpm`, there) that is actually run on this machine.
40
+ */
41
+ export function installFailureHint(pm, pmCommand = pm) {
32
42
  if (pm !== PackageManager.PNPM) {
33
43
  return `If the version was refused, install the SDK yourself. ${REGISTRY_HINT}`;
34
44
  }
35
45
  return ('If pnpm reported ERR_PNPM_NO_MATURE_MATCHING_VERSION, its minimumReleaseAge setting is holding ' +
36
46
  'this release back. Either wait out the window, or allow these packages explicitly:\n' +
37
- ' pnpm config set minimumReleaseAgeExclude "@reticlehq/*"\n' +
47
+ ` ${pmCommand} config set minimumReleaseAgeExclude "@reticlehq/*"\n` +
38
48
  'Do NOT drop the version pin — unpinned, pnpm installs an older SDK against a newer daemon, and ' +
39
- `that mismatch surfaces as a -32000 with nothing naming a version.\n${VIRTUAL_STORE_HINT}\n${REGISTRY_HINT}`);
49
+ `that mismatch surfaces as a -32000 with nothing naming a version.\n${virtualStoreHint(pmCommand)}\n${REGISTRY_HINT}`);
40
50
  }