@reticlehq/init 3.6.0 → 3.8.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 (64) hide show
  1. package/dist/detect/detect.d.ts +9 -0
  2. package/dist/detect/detect.js +5 -1
  3. package/dist/detect/dev-script.d.ts +17 -0
  4. package/dist/detect/dev-script.js +128 -0
  5. package/dist/detect/dev-server-ports.d.ts +2 -0
  6. package/dist/detect/dev-server-ports.js +20 -0
  7. package/dist/detect/prettier-config.d.ts +21 -0
  8. package/dist/detect/prettier-config.js +38 -0
  9. package/dist/detect/workspace-apps.d.ts +11 -0
  10. package/dist/detect/workspace-apps.js +63 -0
  11. package/dist/detect/workspace-redirect.js +69 -10
  12. package/dist/diagnose/closing-hint.d.ts +5 -0
  13. package/dist/diagnose/closing-hint.js +18 -11
  14. package/dist/diagnose/init-failure.d.ts +2 -0
  15. package/dist/diagnose/init-failure.js +2 -0
  16. package/dist/gather-plan-input.d.ts +11 -0
  17. package/dist/gather-plan-input.js +468 -0
  18. package/dist/host.d.ts +6 -0
  19. package/dist/index.d.ts +11 -4
  20. package/dist/index.js +14 -4
  21. package/dist/no-package-json.js +62 -14
  22. package/dist/node-io.d.ts +1 -1
  23. package/dist/node-io.js +11 -2
  24. package/dist/patch/next-patch.d.ts +45 -9
  25. package/dist/patch/next-patch.js +93 -0
  26. package/dist/patch/nuxt-snippets.js +1 -1
  27. package/dist/patch/prettier-ignore.d.ts +34 -0
  28. package/dist/patch/prettier-ignore.js +49 -0
  29. package/dist/patch/python-snippets.d.ts +22 -0
  30. package/dist/patch/python-snippets.js +113 -0
  31. package/dist/patch/snippets.d.ts +10 -31
  32. package/dist/patch/snippets.js +42 -105
  33. package/dist/patch/static-page.d.ts +1 -0
  34. package/dist/patch/static-page.js +6 -8
  35. package/dist/patch/vite-config.js +11 -0
  36. package/dist/plan/connect-steps.d.ts +2 -0
  37. package/dist/plan/connect-steps.js +6 -0
  38. package/dist/plan/framework-adapter.d.ts +10 -0
  39. package/dist/plan/framework-adapter.js +122 -3
  40. package/dist/plan/notices.js +1 -1
  41. package/dist/plan/plan-angular.js +1 -1
  42. package/dist/plan/plan-framework.d.ts +1 -1
  43. package/dist/plan/plan-framework.js +140 -37
  44. package/dist/plan/plan-types.d.ts +50 -2
  45. package/dist/plan/plan-vite.js +15 -0
  46. package/dist/plan/plan.d.ts +1 -10
  47. package/dist/plan/plan.js +181 -126
  48. package/dist/plan/stop-hook-step.d.ts +30 -0
  49. package/dist/plan/stop-hook-step.js +154 -5
  50. package/dist/project/agent-rules.d.ts +1 -1
  51. package/dist/project/agent-rules.js +22 -17
  52. package/dist/project/refresh-rules.js +2 -1
  53. package/dist/register/codex-toml.d.ts +12 -0
  54. package/dist/register/codex-toml.js +44 -0
  55. package/dist/register/mcp.d.ts +15 -2
  56. package/dist/register/mcp.js +16 -0
  57. package/dist/register/prompt-hooks.d.ts +123 -0
  58. package/dist/register/prompt-hooks.js +449 -0
  59. package/dist/register/slash-command.d.ts +1 -1
  60. package/dist/register/slash-command.js +5 -4
  61. package/dist/run-types.d.ts +10 -0
  62. package/dist/run.d.ts +1 -1
  63. package/dist/run.js +15 -390
  64. package/package.json +2 -2
@@ -35,19 +35,16 @@ export declare const CDN_SDK_URL: string;
35
35
  * (so the app is identifiable across port changes). Empty string only when neither applies.
36
36
  */
37
37
  export declare function connectArg(port: number | undefined, projectId?: string): string;
38
+ /** The gitignored module beside the page that holds this machine's pairing token. */
39
+ export declare const LOCAL_TOKEN_MODULE = "reticle.local.js";
38
40
  /**
39
- * The same literal, with the pairing token folded in.
41
+ * A connect literal with `token` set to a JS expression, never to the token itself.
40
42
  *
41
- * The token belongs INSIDE the call the user pastes. Every other stack has a build step to inline
42
- * it (the Vite plugin's `define`, Next's NEXT_PUBLIC_*, Astro's config, CRA's .env); the hand-wired
43
- * paths have none, so `init` inlines the literal it already read. Without it the bridge closes the
44
- * socket with AUTH_FAILED and no session ever appears: see Bridge's hello handler.
45
- *
46
- * An empty token is omitted rather than emitted as `token: ''`. A daemon that could not write to
47
- * $HOME runs without auth and trusts loopback, and an empty string would fail the comparison against
48
- * one that does hold a token.
43
+ * The token used to be a literal in every printed snippet. It then stayed in terminal history,
44
+ * in agent transcripts, and in every file the snippet was pasted into. The snippets now read it at
45
+ * run time: from the gitignored module, or, in Python, from the token file.
49
46
  */
50
- export declare function connectArgWithToken(port: number | undefined, projectId: string | undefined, pairingToken: string | undefined): string;
47
+ export declare function withTokenExpression(connectArgLiteral: string, tokenExpression: string): string;
51
48
  export declare function usesReactKit(uiLibrary: UiLibrary, framework?: Framework): boolean;
52
49
  /**
53
50
  * Which SDK package the GENERATED code should import, and whether `install()` applies.
@@ -152,26 +149,8 @@ export declare const NEXT_LAYOUT_MANUAL = "Mount <ReticleDev /> in your root lay
152
149
  * opposite sign, so the two share a builder rather than a copy.
153
150
  */
154
151
  export declare function staticPageSnippet(connectArgLiteral: string): string;
155
- /**
156
- * Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
157
- * it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
158
- * so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
159
- * idempotent and is removed after an import failure so the next rerun can retry.
160
- */
161
- export declare function streamlitPageSnippet(connectArgLiteral: string): string;
162
- /**
163
- * DEBUG-only middleware that injects Reticle into every Django-rendered page.
164
- *
165
- * Django serves templates, so the generic script tag has no single home: an app has many templates
166
- * and a base template it may not have. Middleware has exactly one, runs for every page, and is the
167
- * idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
168
- * a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
169
- *
170
- * Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
171
- * does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
172
- * `<head>`, so the SDK sees a parsed document.
173
- */
174
- export declare function djangoMiddlewareSnippet(connectArgLiteral: string): string;
152
+ /** The same snippet, with the token read from the gitignored module beside the page. */
153
+ export declare function staticPageSnippetWithLocalToken(connectArgLiteral: string): string;
175
154
  export declare function htmlManual(port: number | undefined, projectId?: string, pairingToken?: string, uiLibrary?: UiLibrary): string;
176
155
  export declare const NEXT_RETICLE_DEV_PATH = "app/reticle-dev.tsx";
177
156
  export declare const SVELTEKIT_HOOKS_PATH = "src/hooks.client.ts";
@@ -182,7 +161,7 @@ export declare const SVELTEKIT_HOOKS_PATH = "src/hooks.client.ts";
182
161
  * reporting all-green.
183
162
  */
184
163
  export declare function unverifiedUiLibraryNote(library: string, installGated?: boolean): string;
185
- export declare const SVELTEKIT_SETUP_GATED_NOTE = "The install gate scaffolds a SvelteKit app on every release and requires this hook to connect a session, so the wiring is proven; driving flows on SvelteKit to a verdict is not. If the hook does not register a session, please open an issue.";
164
+ export declare const SVELTEKIT_SETUP_GATED_NOTE = "The install gate scaffolds a SvelteKit app whenever the install path changes and requires this hook to connect a session, so the wiring is proven; driving flows on SvelteKit to a verdict is not. If the hook does not register a session, please open an issue.";
186
165
  export declare const UNVERIFIED_TANSTACK_START_NOTE = "Reticle has no TanStack Start app and no CI gate for one, so this wiring is untested \u2014 it may work, but nothing proves it and nothing will tell us if it breaks. Gated today: Vite (React, Vue), Next.js, Nuxt, React Router, Remix, Astro, SvelteKit, Create React App and Angular. If the client effect does not register a session, please open an issue.";
187
166
  /**
188
167
  * Printed when init detects react-three-fiber. Reticle's model is DOM + store + network; a WebGL
@@ -46,24 +46,21 @@ export function connectArg(port, projectId) {
46
46
  parts.push(`projectId: '${projectId}'`);
47
47
  return parts.length > 0 ? `{ ${parts.join(', ')} }` : '';
48
48
  }
49
+ /** The gitignored module beside the page that holds this machine's pairing token. */
50
+ export const LOCAL_TOKEN_MODULE = 'reticle.local.js';
51
+ /** The token as the printed and written snippets read it, from the module they import. */
52
+ const LOCAL_TOKEN_EXPR = 'local.token';
49
53
  /**
50
- * The same literal, with the pairing token folded in.
54
+ * A connect literal with `token` set to a JS expression, never to the token itself.
51
55
  *
52
- * The token belongs INSIDE the call the user pastes. Every other stack has a build step to inline
53
- * it (the Vite plugin's `define`, Next's NEXT_PUBLIC_*, Astro's config, CRA's .env); the hand-wired
54
- * paths have none, so `init` inlines the literal it already read. Without it the bridge closes the
55
- * socket with AUTH_FAILED and no session ever appears: see Bridge's hello handler.
56
- *
57
- * An empty token is omitted rather than emitted as `token: ''`. A daemon that could not write to
58
- * $HOME runs without auth and trusts loopback, and an empty string would fail the comparison against
59
- * one that does hold a token.
56
+ * The token used to be a literal in every printed snippet. It then stayed in terminal history,
57
+ * in agent transcripts, and in every file the snippet was pasted into. The snippets now read it at
58
+ * run time: from the gitignored module, or, in Python, from the token file.
60
59
  */
61
- export function connectArgWithToken(port, projectId, pairingToken) {
62
- const base = connectArg(port, projectId);
63
- if (pairingToken === undefined || 0 === pairingToken.length)
64
- return base;
65
- const inner = base.length > 0 ? base.slice(1, -1).trim() : '';
66
- return `{ ${[inner, `token: '${pairingToken}'`].filter((p) => p.length > 0).join(', ')} }`;
60
+ export function withTokenExpression(connectArgLiteral, tokenExpression) {
61
+ return '' === connectArgLiteral
62
+ ? `{ token: ${tokenExpression} }`
63
+ : `{ ...${connectArgLiteral}, token: ${tokenExpression} }`;
67
64
  }
68
65
  /**
69
66
  * Does this codebase want the React kit, or the framework-neutral sensor? The ONE answer, read by
@@ -537,104 +534,44 @@ export function staticPageSnippet(connectArgLiteral) {
537
534
  reticle.connect(${connectArgLiteral});
538
535
  </script>`;
539
536
  }
540
- /**
541
- * Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
542
- * it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
543
- * so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
544
- * idempotent and is removed after an import failure so the next rerun can retry.
545
- */
546
- export function streamlitPageSnippet(connectArgLiteral) {
547
- return `import streamlit as st
548
-
549
- st.html(
550
- """
551
- <script>
552
- (() => {
553
- if (document.getElementById('reticle-streamlit-connect')) return;
554
- const marker = document.createElement('meta');
555
- marker.id = 'reticle-streamlit-connect';
556
- document.head.appendChild(marker);
557
- void import('${CDN_SDK_URL}')
558
- .then(({ reticle }) => reticle.connect(${connectArgLiteral}))
559
- .catch((error) => {
560
- marker.remove();
561
- throw error;
562
- });
563
- })();
564
- </script>
565
- """,
566
- unsafe_allow_javascript=True,
567
- )`;
568
- }
569
- /**
570
- * DEBUG-only middleware that injects Reticle into every Django-rendered page.
571
- *
572
- * Django serves templates, so the generic script tag has no single home: an app has many templates
573
- * and a base template it may not have. Middleware has exactly one, runs for every page, and is the
574
- * idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
575
- * a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
576
- *
577
- * Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
578
- * does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
579
- * `<head>`, so the SDK sees a parsed document.
580
- */
581
- export function djangoMiddlewareSnippet(connectArgLiteral) {
582
- return `# reticle_dev.py: add to MIDDLEWARE while DEBUG is on:
583
- # MIDDLEWARE = [..., "reticle_dev.ReticleDevMiddleware"]
584
- from django.conf import settings
585
-
586
- _SNIPPET = """<script type="module">
587
- import { reticle } from '${CDN_SDK_URL}';
588
- reticle.connect(${connectArgLiteral});
589
- </script>"""
590
-
591
-
592
- class ReticleDevMiddleware:
593
- """Inject the Reticle SDK into HTML responses during development only."""
594
-
595
- def __init__(self, get_response):
596
- self.get_response = get_response
597
-
598
- def __call__(self, request):
599
- response = self.get_response(request)
600
- if not settings.DEBUG:
601
- return response
602
- if "text/html" not in response.get("Content-Type", ""):
603
- return response
604
- # Streaming responses have no .content to rewrite; leave them alone.
605
- if getattr(response, "streaming", False):
606
- return response
607
- body = response.content.decode(response.charset)
608
- if "</body>" not in body:
609
- return response
610
- response.content = body.replace("</body>", _SNIPPET + "</body>", 1).encode(
611
- response.charset
612
- )
613
- if response.has_header("Content-Length"):
614
- response["Content-Length"] = str(len(response.content))
615
- return response
616
- `;
537
+ /** The same snippet, with the token read from the gitignored module beside the page. */
538
+ export function staticPageSnippetWithLocalToken(connectArgLiteral) {
539
+ return ` <script type="module">
540
+ Promise.all([
541
+ import('${CDN_SDK_URL}'),
542
+ import('./${LOCAL_TOKEN_MODULE}').catch(() => ({})),
543
+ ]).then(([{ reticle }, local]) => reticle.connect(${withTokenExpression(connectArgLiteral, LOCAL_TOKEN_EXPR)}));
544
+ </script>`;
617
545
  }
618
546
  export function htmlManual(port, projectId, pairingToken,
619
547
  // Which SDK the bundled form imports must be the one init INSTALLED. A page with no UI library
620
548
  // now gets the sensor, and a snippet still naming `@reticlehq/react` would import a package that
621
549
  // is not there.
622
550
  uiLibrary = UiLibrary.UNKNOWN) {
623
- const withToken = connectArgWithToken(port, projectId, pairingToken);
551
+ const base = connectArg(port, projectId);
552
+ // The token is read from the gitignored module that `init` writes, never printed here.
553
+ const hasToken = pairingToken !== undefined && 0 < pairingToken.length;
554
+ const withToken = hasToken ? withTokenExpression(base, LOCAL_TOKEN_EXPR) : base;
624
555
  const sdk = sdkImport(uiLibrary, Framework.HTML);
625
- const bundledImport = sdk.usesInstall
626
- ? `void import('${sdk.specifier}').then(({ reticle, install }) => {
627
- install();
556
+ const install = sdk.usesInstall ? '\n install();' : '';
557
+ const names = sdk.usesInstall ? '{ reticle, install }' : '{ reticle }';
558
+ const bundledImport = hasToken
559
+ ? `void Promise.all([
560
+ import('${sdk.specifier}'),
561
+ import('./${LOCAL_TOKEN_MODULE}').catch(() => ({})),
562
+ ]).then(([${names}, local]) => {${install}
628
563
  reticle.connect(${withToken});
629
564
  });`
630
- : `void import('${sdk.specifier}').then(({ reticle }) => {
565
+ : `void import('${sdk.specifier}').then((${names}) => {${install}
631
566
  reticle.connect(${withToken});
632
567
  });`;
633
- const tokenNote = pairingToken === undefined || 0 === pairingToken.length
634
- ? ''
635
- : `\n\n The \`token\` is this machine's pairing token, read from ~/.reticle/pairing-token. Keep it: the
636
- bridge REJECTS a connect without it ("authentication failed") and no session appears. It is
637
- per-machine and local-only, so do not commit it — a teammate's daemon mints their own.`;
568
+ const tokenNote = hasToken
569
+ ? `\n\n \`${LOCAL_TOKEN_MODULE}\` holds this machine's pairing token. Init wrote it at the project root and
570
+ added it to .gitignore. Change the import path so it points there from your entry module. Keep it:
571
+ the bridge REJECTS a connect without the token ("authentication failed") and no session appears.
572
+ It is per-machine, so do not commit it. A teammate runs \`reticle init\` for their own.`
573
+ : '';
574
+ const pageSnippet = hasToken ? staticPageSnippetWithLocalToken(base) : staticPageSnippet(base);
638
575
  return `No Vite/Next plugin detected — wire the dev-only connect by hand. Pick the form for your setup:
639
576
 
640
577
  • Bundled app (Create React App, webpack, Parcel, Vue/Svelte CLI, etc.) — add to your ENTRY module
@@ -648,7 +585,7 @@ uiLibrary = UiLibrary.UNKNOWN) {
648
585
  paste this into the page, in a template you only serve in development. There is nothing to
649
586
  install: no npm, no bundler, no package.json.
650
587
 
651
- ${staticPageSnippet(withToken)}
588
+ ${pageSnippet}
652
589
 
653
590
  Serving the app on something other than localhost (a hosts-file alias, a LAN IP, a container, a
654
591
  tunnel)? You need TWO things, not one: \`allowNonLocalhost: true\` AND a pairing token, passed as
@@ -675,9 +612,9 @@ export function unverifiedUiLibraryNote(library, installGated = false) {
675
612
  const identity = 'preact' === library
676
613
  ? 'React component identity — component names and stacks — comes from `@reticlehq/react`, which reaches Preact through `preact/compat`. That path is not covered by a CI gate here, so treat it as expected-to-work rather than proven.'
677
614
  : 'What `@reticlehq/react` adds and you will NOT get is React component identity: component names and component stacks.';
678
- return `Detected a ${library} app. Reticle's DOM, network, console and state tools work here. ${'vue' === library ? 'Source `file:line` does NOT come through: the build plugin stamps JSX and, separately, Svelte components, and a Vue single-file component is neither — measured, a Svelte counter reports `src/lib/Counter.svelte:5` and the same drive on Vue reports no source at all.' : 'Source `file:line` does too — the build plugin stamps it for this library (measured on preact and svelte).'} ${identity} ${installGated ? `The install gate scaffolds this ${library} stack from scratch on every change, so this SETUP is proven; no gate drives it to a verdict, so the drive is not.` : `No CI gate covers ${library}.`} Driven on every change: Vite + React, Next.js, Astro. If something doesn't work, please open an issue.`;
615
+ return `Detected a ${library} app. Reticle's DOM, network, console and state tools work here. ${'vue' === library ? 'Source `file:line` does NOT come through: the build plugin stamps JSX and, separately, Svelte components, and a Vue single-file component is neither — measured, a Svelte counter reports `src/lib/Counter.svelte:5` and the same drive on Vue reports no source at all.' : 'Source `file:line` does too — the build plugin stamps it for this library (measured on preact and svelte).'} ${identity} ${installGated ? `The install gate scaffolds this ${library} stack from scratch whenever the install path changes, so this SETUP is proven; no gate drives it to a verdict, so the drive is not.` : `No CI gate covers ${library}.`} Driven to a verdict in CI: Vite + React and Next.js; Astro is checked for a connected session. If something doesn't work, please open an issue.`;
679
616
  }
680
- export const SVELTEKIT_SETUP_GATED_NOTE = 'The install gate scaffolds a SvelteKit app on every release and requires this hook to connect a session, so the wiring is proven; driving flows on SvelteKit to a verdict is not. If the hook does not register a session, please open an issue.';
617
+ export const SVELTEKIT_SETUP_GATED_NOTE = 'The install gate scaffolds a SvelteKit app whenever the install path changes and requires this hook to connect a session, so the wiring is proven; driving flows on SvelteKit to a verdict is not. If the hook does not register a session, please open an issue.';
681
618
  export const UNVERIFIED_TANSTACK_START_NOTE = 'Reticle has no TanStack Start app and no CI gate for one, so this wiring is untested — it may work, but nothing proves it and nothing will tell us if it breaks. Gated today: Vite (React, Vue), Next.js, Nuxt, React Router, Remix, Astro, SvelteKit, Create React App and Angular. If the client effect does not register a session, please open an issue.';
682
619
  /**
683
620
  * Printed when init detects react-three-fiber. Reticle's model is DOM + store + network; a WebGL
@@ -22,6 +22,7 @@
22
22
  export declare const HTML_INDEX_PATH = "index.html";
23
23
  /** The gitignored module beside the page that holds this machine's pairing token. */
24
24
  export declare const STATIC_TOKEN_MODULE = "reticle.local.js";
25
+ export declare const STATIC_GITIGNORE_PATH = ".gitignore";
25
26
  /** Opens the block `init` writes; a re-run looks for it. */
26
27
  export declare const STATIC_SNIPPET_MARKER = "<!-- reticle:connect (dev only, written by reticle init) -->";
27
28
  /** Whether this page already carries a Reticle connect. */
@@ -18,12 +18,12 @@
18
18
  * gitignores, which the block imports and any static server serves. Absent — a teammate's
19
19
  * checkout, a published copy — the connect goes without one, and the bridge says why it refused.
20
20
  */
21
- import { CDN_SDK_URL } from './snippets.js';
21
+ import { CDN_SDK_URL, LOCAL_TOKEN_MODULE, withTokenExpression } from './snippets.js';
22
22
  /** The page a static site serves at its root, and the one file `init` will write the snippet into. */
23
23
  export const HTML_INDEX_PATH = 'index.html';
24
24
  /** The gitignored module beside the page that holds this machine's pairing token. */
25
- export const STATIC_TOKEN_MODULE = 'reticle.local.js';
26
- const GITIGNORE_PATH = '.gitignore';
25
+ export const STATIC_TOKEN_MODULE = LOCAL_TOKEN_MODULE;
26
+ export const STATIC_GITIGNORE_PATH = '.gitignore';
27
27
  /** Opens the block `init` writes; a re-run looks for it. */
28
28
  export const STATIC_SNIPPET_MARKER = '<!-- reticle:connect (dev only, written by reticle init) -->';
29
29
  const STATIC_SNIPPET_END = '<!-- /reticle:connect -->';
@@ -42,9 +42,7 @@ export function hasStaticSnippet(html) {
42
42
  }
43
43
  function block(connectArgLiteral) {
44
44
  const hosts = LOOPBACK_HOSTS.map((h) => `'${h}'`).join(', ');
45
- const arg = '' === connectArgLiteral
46
- ? '{ token: local.token }'
47
- : `{ ...${connectArgLiteral}, token: local.token }`;
45
+ const arg = withTokenExpression(connectArgLiteral, 'local.token');
48
46
  return ` ${STATIC_SNIPPET_MARKER}
49
47
  <script type="module">
50
48
  if ([${hosts}].includes(location.hostname)) {
@@ -71,12 +69,12 @@ export function staticTokenFiles(pairingToken, readFile) {
71
69
  const module = staticTokenModule(pairingToken);
72
70
  if (readFile(STATIC_TOKEN_MODULE) !== module)
73
71
  files[STATIC_TOKEN_MODULE] = module;
74
- const ignore = readFile(GITIGNORE_PATH) ?? '';
72
+ const ignore = readFile(STATIC_GITIGNORE_PATH) ?? '';
75
73
  const covered = ignore
76
74
  .split('\n')
77
75
  .some((l) => [STATIC_TOKEN_MODULE, `/${STATIC_TOKEN_MODULE}`].includes(l.trim()));
78
76
  if (!covered) {
79
- files[GITIGNORE_PATH] =
77
+ files[STATIC_GITIGNORE_PATH] =
80
78
  `${'' === ignore ? '' : ignore.replace(/\n*$/, '\n')}${STATIC_TOKEN_MODULE}\n`;
81
79
  }
82
80
  return files;
@@ -44,6 +44,12 @@ function reticlePluginCall(opts) {
44
44
  }
45
45
  /** Matches the start of a `plugins: [` array literal. */
46
46
  const PLUGINS_ARRAY = /plugins\s*:\s*\[/;
47
+ /**
48
+ * A `plugins` key in any shape: `plugins: <expr>`, or the shorthand `{ plugins }`. When this holds
49
+ * and PLUGINS_ARRAY does not, the key is there but not as an array literal, so adding our own key
50
+ * would make a second one. The later key wins, and one of the two lists is lost.
51
+ */
52
+ const PLUGINS_KEY = /\bplugins\s*[:,}]/;
47
53
  /** Matches an ES import statement (used to place our import after the last one). */
48
54
  const IMPORT_LINE = /^import\s.+from\s+['"][^'"]+['"];?\s*$/gm;
49
55
  /**
@@ -59,6 +65,8 @@ const CONFIG_OBJECT = /(export\s+default\s+(?:defineConfig\s*\(\s*)?)\{/;
59
65
  /** Alias kept so existing call sites read in Vite terms; the vocabulary is shared (see patch-kind). */
60
66
  export const VitePatchKind = PatchKind;
61
67
  const NO_PLUGINS_REASON = "couldn't find a `plugins: [...]` array to extend";
68
+ const PLUGINS_NOT_ARRAY_REASON = 'the `plugins` key is not an array literal, so init did not change it. Add `reticle()` to the ' +
69
+ 'list that the key builds. Do not add a second `plugins` key: the later key replaces the first.';
62
70
  function insertImport(source) {
63
71
  const matches = [...source.matchAll(IMPORT_LINE)];
64
72
  const last = matches[matches.length - 1];
@@ -114,6 +122,9 @@ export function patchViteConfig(source, port, captureBodies = false, inject = tr
114
122
  if (PLUGINS_ARRAY.test(source)) {
115
123
  return { kind: VitePatchKind.APPLY, code: insertImport(insertPlugin(source, call)) };
116
124
  }
125
+ if (PLUGINS_KEY.test(source)) {
126
+ return { kind: VitePatchKind.MANUAL, reason: PLUGINS_NOT_ARRAY_REASON };
127
+ }
117
128
  if (CONFIG_OBJECT.test(source)) {
118
129
  return { kind: VitePatchKind.APPLY, code: insertImport(insertPluginsKey(source, call)) };
119
130
  }
@@ -18,9 +18,11 @@ export declare const StepTitle: {
18
18
  readonly RETICLE_DEV_COMPONENT: "ReticleDev component";
19
19
  readonly NEXT_CONFIG: "Next config (withReticle)";
20
20
  readonly MOUNT_RETICLE_DEV: "Mount ReticleDev";
21
+ readonly ROUTE_GROUPS_WITHOUT_RETICLE_DEV: "Route groups without ReticleDev";
21
22
  readonly CONNECT_MODULE: "Reticle connect module";
22
23
  readonly PAIRING_TOKEN: "Pairing token";
23
24
  readonly PAIRING_TOKEN_PER_MACHINE: "Pairing token is per-machine";
25
+ readonly STATIC_PAIRING_TOKEN: "Pairing token (static page)";
24
26
  readonly CONNECT_SNIPPET_CRA: "Connect snippet (CRA)";
25
27
  readonly CONNECT_SNIPPET_NUXT: "Connect snippet (Nuxt)";
26
28
  readonly NUXT_CONFIG: "Nuxt config (token + watcher)";
@@ -19,9 +19,11 @@ export const StepTitle = {
19
19
  RETICLE_DEV_COMPONENT: 'ReticleDev component',
20
20
  NEXT_CONFIG: 'Next config (withReticle)',
21
21
  MOUNT_RETICLE_DEV: 'Mount ReticleDev',
22
+ ROUTE_GROUPS_WITHOUT_RETICLE_DEV: 'Route groups without ReticleDev',
22
23
  CONNECT_MODULE: 'Reticle connect module',
23
24
  PAIRING_TOKEN: 'Pairing token',
24
25
  PAIRING_TOKEN_PER_MACHINE: 'Pairing token is per-machine',
26
+ STATIC_PAIRING_TOKEN: 'Pairing token (static page)',
25
27
  CONNECT_SNIPPET_CRA: 'Connect snippet (CRA)',
26
28
  CONNECT_SNIPPET_NUXT: 'Connect snippet (Nuxt)',
27
29
  NUXT_CONFIG: 'Nuxt config (token + watcher)',
@@ -98,6 +100,10 @@ export const CONNECT_STEP_TITLES = new Set([
98
100
  // not, and `gate:install` scaffolds no CRA app, so that change would ship with no coverage of the
99
101
  // path it changes. It is worth doing, and worth doing with a scaffold behind it.
100
102
  StepTitle.VITE_PLUGIN,
103
+ // A plain page imports its token from a gitignored sibling module. Unlike CRA's, this step never
104
+ // goes MANUAL by plan: it is ⚠ only when the write itself failed — no token module means the page
105
+ // is refused, and no ignore entry means the token is on disk without the protection we promised.
106
+ StepTitle.STATIC_PAIRING_TOKEN,
101
107
  // electron-vite's connect IS the renderer plugin. Without it the SDK never injects, and the
102
108
  // preload/capture steps are not enough to produce a session.
103
109
  StepTitle.ELECTRON_VITE_PLUGIN,
@@ -65,4 +65,14 @@ export declare const FRAMEWORK_ADAPTERS: Record<Framework, FrameworkAdapter>;
65
65
  * for the packages, the connect-step titles and the unverified-note flag in the same edit.
66
66
  */
67
67
  export declare function frameworkSteps(input: PlanInput): Step[];
68
+ /**
69
+ * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
70
+ *
71
+ * `uiLibrary` matters because the framework does not always name the renderer. The rule below was
72
+ * already written for Nuxt, correctly, and applied only there: a Vue app on plain Vite is
73
+ * `Framework.VITE` and a SvelteKit app renders Svelte, and both were being handed the React kit
74
+ * right after `init` had detected the real UI library and said so on screen.
75
+ */
76
+ export declare function frameworkPackages(framework: Framework, uiLibrary?: UiLibrary): readonly string[];
77
+ export declare function installStep(input: PlanInput): Step;
68
78
  export {};
@@ -18,7 +18,10 @@
18
18
  *
19
19
  * `framework-adapter.test.ts` asserts the seam between the two halves.
20
20
  */
21
- import { Framework, UiLibrary } from '../detect/detect.js';
21
+ import { Framework, UiLibrary, installCommand, installCommandParts } from '../detect/detect.js';
22
+ import { installFailureHint } from '../diagnose/install-hint.js';
23
+ import { installRetries } from '../diagnose/install-retries.js';
24
+ import { usesReactKit } from '../patch/snippets.js';
22
25
  import { StepTitle } from './connect-steps.js';
23
26
  import { astroSteps, craSteps, htmlSteps, nextSteps, nuxtSteps, reactRouterSteps, svelteKitSteps, tanstackStartSteps, } from './plan-framework.js';
24
27
  import { VITE_PLUGIN_DETAIL, viteSteps } from './plan-vite.js';
@@ -223,10 +226,10 @@ export const FRAMEWORK_ADAPTERS = {
223
226
  carriesOwnUnverifiedNote: false,
224
227
  },
225
228
  [Framework.HTML]: {
226
- // No bundler plugin to install — just the kit; connect is wired by hand (see htmlManual).
229
+ // No bundler plugin to install — write the static connect into index.html when it is present.
227
230
  packages: (kit) => [kit],
228
231
  steps: htmlSteps,
229
- connectStepTitles: [StepTitle.CONNECT_SNIPPET],
232
+ connectStepTitles: [StepTitle.CONNECT_SNIPPET, StepTitle.STATIC_PAIRING_TOKEN],
230
233
  carriesOwnUnverifiedNote: false,
231
234
  },
232
235
  };
@@ -241,3 +244,119 @@ export const FRAMEWORK_ADAPTERS = {
241
244
  export function frameworkSteps(input) {
242
245
  return FRAMEWORK_ADAPTERS[input.detection.framework].steps(input);
243
246
  }
247
+ /**
248
+ * Pin the SDK to the CLI's own version.
249
+ *
250
+ * `pnpm add -D @reticlehq/react` once installed an older release in one project while npm and yarn
251
+ * took the current one in the next — a stale registry metadata cache, invisible to everyone. A
252
+ * version-skewed SDK talking to a newer daemon is the `-32000` failure path: the app connects, the
253
+ * protocol disagrees, and nothing on either side names a version. Asking for the CLI's exact version
254
+ * makes the cache irrelevant, and a skewed pair impossible to install by accident.
255
+ */
256
+ function pinnedPackages(packages, version) {
257
+ if (version === undefined || 0 === version.length) {
258
+ return packages;
259
+ }
260
+ return packages.map((p) => `${p}@${version}`);
261
+ }
262
+ /**
263
+ * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
264
+ *
265
+ * `uiLibrary` matters because the framework does not always name the renderer. The rule below was
266
+ * already written for Nuxt, correctly, and applied only there: a Vue app on plain Vite is
267
+ * `Framework.VITE` and a SvelteKit app renders Svelte, and both were being handed the React kit
268
+ * right after `init` had detected the real UI library and said so on screen.
269
+ */
270
+ export function frameworkPackages(framework, uiLibrary = UiLibrary.UNKNOWN) {
271
+ const kit = usesReactKit(uiLibrary, framework) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
272
+ return FRAMEWORK_ADAPTERS[framework].packages(kit);
273
+ }
274
+ /**
275
+ * What to say when the install command fails.
276
+ *
277
+ * pnpm's `minimumReleaseAge` refuses any release younger than the configured window — a deliberate
278
+ * supply-chain policy, not a bug — with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. Unpinned it silently
279
+ * resolves to an OLDER version instead, which is how an app ends up running a stale SDK against a
280
+ * newer daemon: the connection succeeds, the protocol disagrees, and the failure surfaces as -32000
281
+ * with nothing naming a version. Pinning turns that into this loud failure, which is the better
282
+ * trade — but only if the message says what to do about it.
283
+ */
284
+ /**
285
+ * Is this package already declared at the version we would pin?
286
+ *
287
+ * The caret is the point. `npm i -D pkg@3.2.0` WRITES `^3.2.0`, so on every later run the pinned
288
+ * `3.2.0` failed a string comparison against the range npm had just created, and init re-installed
289
+ * for ever. Only the two forms a package manager writes by itself are read - an exact version, and
290
+ * `^`/`~` over that same version.
291
+ *
292
+ * Everything else installs. A false "already" leaves somebody with no SDK and an install that
293
+ * claims to have run; a redundant install only costs time, so the unreadable cases go the safe way.
294
+ */
295
+ function alreadyDeclared(declared, pinned) {
296
+ if (declared === undefined) {
297
+ return false;
298
+ }
299
+ if (pinned === undefined || 0 === pinned.length) {
300
+ return true;
301
+ }
302
+ return declared === pinned || `^${pinned}` === declared || `~${pinned}` === declared;
303
+ }
304
+ export function installStep(input) {
305
+ const pm = input.detection.packageManager;
306
+ // `Detection.packageManagerCommand` (see `preflight.ts`, #1149). Falling back to `pm` covers every
307
+ // caller that never went through preflight (tests, and any future one).
308
+ const pmCommand = input.detection.packageManagerCommand ?? pm;
309
+ const packages = pinnedPackages(frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion);
310
+ const command = installCommand(pm, packages, pmCommand);
311
+ /*
312
+ * A re-run over an already-wired project does NO dependency work.
313
+ *
314
+ * Reported from the field: `init` on an instrumented npm-workspaces project ran a pnpm dependency
315
+ * migration, which moved the existing `node_modules` aside and broke the dev server. Whatever
316
+ * manager gets chosen, the redundant install is the step that touches the tree — so the fix that
317
+ * matters is not running one that nothing needs. `init` is documented as idempotent; this is the
318
+ * step that was not.
319
+ */
320
+ const declared = input.detection.dependencies ?? {};
321
+ const version = input.options.sdkVersion;
322
+ const names = frameworkPackages(input.detection.framework, input.detection.uiLibrary);
323
+ if (names.length > 0 && names.every((n) => alreadyDeclared(declared[n], version))) {
324
+ return {
325
+ title: 'Install dependencies',
326
+ target: 'package.json',
327
+ status: StepStatus.ALREADY,
328
+ detail: `${names.join(', ')} already declared${version === undefined || 0 === version.length ? '' : ` at ${version}`}`,
329
+ };
330
+ }
331
+ // Never install into a linked tree: the package manager replaces the link (a workspace's tree).
332
+ if (true === input.nodeModulesLinked) {
333
+ return {
334
+ title: 'Install dependencies',
335
+ target: 'package.json',
336
+ status: StepStatus.SKIP,
337
+ detail: `skipped: node_modules is a link to another tree, which an install here would replace; install them where it points: ${command}`,
338
+ };
339
+ }
340
+ // The person said `--no-install`: a choice, not a step left undone, so not a ⚠.
341
+ if (!input.options.install) {
342
+ return {
343
+ title: 'Install dependencies',
344
+ target: 'package.json',
345
+ status: StepStatus.SKIP,
346
+ detail: `skipped (--no-install); install them yourself: ${command}`,
347
+ };
348
+ }
349
+ const parts = installCommandParts(pm, packages, [], pmCommand);
350
+ return {
351
+ title: 'Install dependencies',
352
+ target: 'package.json',
353
+ status: StepStatus.APPLY,
354
+ detail: command,
355
+ exec: {
356
+ command: parts.command,
357
+ args: parts.args,
358
+ fallback: `${command}\n\n${installFailureHint(pm, pmCommand)}`,
359
+ },
360
+ retries: installRetries(pm, packages, frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion, pmCommand),
361
+ };
362
+ }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * They live together because that shared test is a real seam.
10
10
  */
11
- import { CONTAINERISED_TITLE, containerisedDevServerNote } from '../diagnose/containerised-dev-server.js';
11
+ import { CONTAINERISED_TITLE, containerisedDevServerNote, } from '../diagnose/containerised-dev-server.js';
12
12
  import { mcpWindowsNote } from '../register/mcp.js';
13
13
  import { unverifiedUiLibraryNote, WEBGL_CANVAS_LIMIT_NOTE } from '../patch/snippets.js';
14
14
  import { FRAMEWORK_ADAPTERS, installGated } from './framework-adapter.js';
@@ -9,7 +9,7 @@ import { StepTitle } from './connect-steps.js';
9
9
  import { patchStep } from './plan-framework.js';
10
10
  import { alreadyOrMovedPort, retargetPort } from './port-steps.js';
11
11
  import { StepStatus } from './plan-types.js';
12
- const ANGULAR_SETUP_GATED_NOTE = 'The install gate scaffolds an Angular app (Angular 21, no SSR) on every release and requires a ' +
12
+ const ANGULAR_SETUP_GATED_NOTE = 'The install gate scaffolds an Angular app (Angular 21, no SSR) whenever the install path changes and requires a ' +
13
13
  'session, and this wiring was proven by hand on Angular 22 with and without SSR; nothing yet ' +
14
14
  'drives an Angular app to a verdict on each change. DOM, network, console and state ' +
15
15
  'tools work here; component identity does not (that is the React adapter). If no session ' +
@@ -66,7 +66,7 @@ export declare function svelteKitSteps(input: PlanInput): Step[];
66
66
  */
67
67
  export declare function astroSteps(input: PlanInput): Step[];
68
68
  /**
69
- * The plain-HTML path: no bundler to hook, so the connect snippet is printed for a hand edit.
69
+ * The plain-HTML path: write the static connect when the page exists, otherwise print the recipe.
70
70
  *
71
71
  * A function of its own, and NOT a fallthrough. Every framework is sent here on purpose or not at
72
72
  * all — a member of `Framework` with no adapter entry is a compile error in `FRAMEWORK_ADAPTERS`,