@reticlehq/init 3.7.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 (45) hide show
  1. package/dist/detect/detect.d.ts +9 -0
  2. package/dist/detect/detect.js +5 -1
  3. package/dist/detect/dev-server-ports.d.ts +2 -0
  4. package/dist/detect/dev-server-ports.js +20 -0
  5. package/dist/detect/prettier-config.d.ts +21 -0
  6. package/dist/detect/prettier-config.js +38 -0
  7. package/dist/detect/workspace-redirect.js +23 -8
  8. package/dist/diagnose/closing-hint.d.ts +5 -0
  9. package/dist/diagnose/closing-hint.js +18 -11
  10. package/dist/gather-plan-input.js +70 -18
  11. package/dist/host.d.ts +6 -0
  12. package/dist/index.d.ts +8 -2
  13. package/dist/index.js +8 -2
  14. package/dist/no-package-json.js +62 -14
  15. package/dist/patch/next-patch.d.ts +19 -11
  16. package/dist/patch/next-patch.js +33 -6
  17. package/dist/patch/prettier-ignore.d.ts +34 -0
  18. package/dist/patch/prettier-ignore.js +49 -0
  19. package/dist/patch/python-snippets.d.ts +22 -0
  20. package/dist/patch/python-snippets.js +113 -0
  21. package/dist/patch/snippets.d.ts +9 -30
  22. package/dist/patch/snippets.js +40 -103
  23. package/dist/patch/static-page.js +3 -5
  24. package/dist/patch/vite-config.js +11 -0
  25. package/dist/plan/connect-steps.d.ts +1 -0
  26. package/dist/plan/connect-steps.js +1 -0
  27. package/dist/plan/framework-adapter.d.ts +10 -0
  28. package/dist/plan/framework-adapter.js +120 -1
  29. package/dist/plan/notices.js +1 -1
  30. package/dist/plan/plan-framework.js +101 -32
  31. package/dist/plan/plan-types.d.ts +27 -0
  32. package/dist/plan/plan.d.ts +1 -10
  33. package/dist/plan/plan.js +128 -131
  34. package/dist/plan/stop-hook-step.d.ts +11 -0
  35. package/dist/plan/stop-hook-step.js +82 -6
  36. package/dist/project/agent-rules.d.ts +1 -1
  37. package/dist/project/agent-rules.js +8 -8
  38. package/dist/project/refresh-rules.js +2 -1
  39. package/dist/register/prompt-hooks.d.ts +123 -0
  40. package/dist/register/prompt-hooks.js +449 -0
  41. package/dist/register/slash-command.d.ts +1 -1
  42. package/dist/register/slash-command.js +5 -4
  43. package/dist/run-types.d.ts +4 -0
  44. package/dist/run.js +4 -1
  45. package/package.json +2 -2
@@ -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
@@ -18,11 +18,11 @@
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';
25
+ export const STATIC_TOKEN_MODULE = LOCAL_TOKEN_MODULE;
26
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) -->';
@@ -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)) {
@@ -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,6 +18,7 @@ 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";
@@ -19,6 +19,7 @@ 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',
@@ -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';
@@ -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';
@@ -28,6 +28,20 @@ const STATIC_TOKEN_DETAIL = `write this machine’s pairing token to ${STATIC_TO
28
28
  const STATIC_IGNORE_DETAIL = `add ${STATIC_TOKEN_MODULE} to ${STATIC_GITIGNORE_PATH} — it holds this machine’s pairing token`;
29
29
  const STATIC_CONNECT_DETAIL = 'write the dev-only connect snippet (loopback hosts only)';
30
30
  const STATIC_CONNECT_ALREADY_DETAIL = `${HTML_INDEX_PATH} already loads the Reticle SDK`;
31
+ /** The package whose presence makes a static page an Electron renderer. */
32
+ const ELECTRON_PACKAGE = 'electron';
33
+ /**
34
+ * An Electron app often serves its renderer from a local HTTP server, so the packaged app runs on
35
+ * `localhost` on every user's machine. The localhost-only guard then lets the snippet run in
36
+ * production, and a packager glob such as `**\/*.js` can ship the token module.
37
+ */
38
+ const ELECTRON_STATIC_DETAIL = `This is an Electron app. Its packaged renderer can also run on localhost, so init did not ` +
39
+ `write the localhost-only snippet into ${HTML_INDEX_PATH} or the ${STATIC_TOKEN_MODULE} token ` +
40
+ 'module. Connect only in development: for example, send `!app.isPackaged` from the main ' +
41
+ 'process to the renderer, and call `reticle.connect()` only when it is true. Use ' +
42
+ `@reticlehq/electron for IPC and capture. If ${STATIC_TOKEN_MODULE} is in the project, keep ` +
43
+ `it out of the package: electron-builder \`files: ["!${STATIC_TOKEN_MODULE}"]\`, or Electron ` +
44
+ `Forge \`packagerConfig.ignore: [/${STATIC_TOKEN_MODULE.replaceAll('.', '\\.')}$/]\`.`;
31
45
  /**
32
46
  * Turn a conservative source patch into a step: applied when it patched, already when the wiring is
33
47
  * there, and the hand-edit instructions when the file shape wasn't one we recognise.
@@ -60,15 +74,21 @@ export function patchStep(title, path, patch, applyDetail, manualDetail) {
60
74
  */
61
75
  /** The env var withReticle sets from the discovered daemon, and the component must read. */
62
76
  const NEXT_DAEMON_URL_ENV = 'NEXT_PUBLIC_RETICLE_URL';
77
+ const NEXT_SDK_VERSION_ENV = 'NEXT_PUBLIC_RETICLE_SDK_VERSION';
63
78
  /**
64
79
  * The two-line edit that unfreezes an existing install's port.
65
80
  *
66
81
  * Stated as the edit rather than as a problem, because the reader is an agent that can apply it in
67
82
  * one write and is otherwise going to ask what to do.
68
83
  */
69
- const NEXT_DEV_FILE_STALE_DETAIL = 'predates daemon discovery, so it dials the port init saw when it ran. In this file add ' +
84
+ const NEXT_DEV_FILE_STALE_DETAIL_SOLO = 'predates daemon discovery, so it dials the port init saw when it ran. In this file add ' +
70
85
  '`const url = process.env.NEXT_PUBLIC_RETICLE_URL;` and spread `...(url ? { url } : {})` into ' +
71
86
  'reticle.connect(), after any url already there. Nothing else needs to change.';
87
+ const NEXT_DEV_FILE_STALE_DETAIL_DUAL = 'predates daemon discovery, so it dials the port init saw when it ran. In this file add ' +
88
+ '`const url = process.env.NEXT_PUBLIC_RETICLE_URL;` and spread `...(url ? { url } : {})` into ' +
89
+ 'reticle.connect(), after any url already there.';
90
+ const NEXT_SDK_VERSION_DETAIL = 'lacks the SDK version. In this file add `const sdkVersion = process.env.NEXT_PUBLIC_RETICLE_SDK_VERSION;` ' +
91
+ 'and spread `...(sdkVersion ? { sdkVersion } : {})` into the reticle.connect() options.';
72
92
  export function nextSteps(input) {
73
93
  const configFile = input.nextConfigFile ?? 'next.config.mjs';
74
94
  const devPath = input.nextReticleDevPath ?? NEXT_RETICLE_DEV_PATH;
@@ -82,34 +102,52 @@ export function nextSteps(input) {
82
102
  const devStale = true === input.nextReticleDevExists &&
83
103
  'string' === typeof input.nextReticleDevSource &&
84
104
  !input.nextReticleDevSource.includes(NEXT_DAEMON_URL_ENV);
85
- const devFile = input.nextReticleDevExists
86
- ? devStale
87
- ? {
105
+ const sdkVersionMissing = true === input.nextReticleDevExists &&
106
+ 'string' === typeof input.nextReticleDevSource &&
107
+ !input.nextReticleDevSource.includes(NEXT_SDK_VERSION_ENV);
108
+ const baseDevStep = devStale
109
+ ? {
110
+ title: StepTitle.RETICLE_DEV_COMPONENT,
111
+ target: devPath,
112
+ status: StepStatus.MANUAL,
113
+ detail: sdkVersionMissing
114
+ ? `${NEXT_DEV_FILE_STALE_DETAIL_DUAL} Also ${NEXT_SDK_VERSION_DETAIL}`
115
+ : NEXT_DEV_FILE_STALE_DETAIL_SOLO,
116
+ }
117
+ : alreadyOrMovedPort({
118
+ title: StepTitle.RETICLE_DEV_COMPONENT,
119
+ target: devPath,
120
+ status: StepStatus.ALREADY,
121
+ detail: 'file exists',
122
+ }, input.nextReticleDevSource, input.options.port);
123
+ const devNoticeSteps = !devStale && sdkVersionMissing
124
+ ? [
125
+ {
88
126
  title: StepTitle.RETICLE_DEV_COMPONENT,
89
127
  target: devPath,
90
- status: StepStatus.MANUAL,
91
- detail: NEXT_DEV_FILE_STALE_DETAIL,
92
- }
93
- : alreadyOrMovedPort({
128
+ status: StepStatus.NOTICE,
129
+ detail: NEXT_SDK_VERSION_DETAIL,
130
+ },
131
+ ]
132
+ : [];
133
+ const devFileSteps = input.nextReticleDevExists
134
+ ? [baseDevStep, ...devNoticeSteps]
135
+ : [
136
+ {
94
137
  title: StepTitle.RETICLE_DEV_COMPONENT,
95
138
  target: devPath,
96
- status: StepStatus.ALREADY,
97
- detail: 'file exists',
98
- }, input.nextReticleDevSource, input.options.port)
99
- : {
100
- title: StepTitle.RETICLE_DEV_COMPONENT,
101
- target: devPath,
102
- status: StepStatus.APPLY,
103
- detail: 'create dev-only connect component',
104
- write: {
105
- path: devPath,
106
- content: nextReticleDevFile(input.options.port, input.options.projectId, input.testids ?? [], input.storeHints ?? [],
107
- // Next's dev module is not a sibling of `src/` — its imports are resolved from wherever
108
- // the component actually lands (`app/`, or `src/app/` in a --src-dir app).
109
- input.nextFoundStores ?? []),
139
+ status: StepStatus.APPLY,
140
+ detail: 'create dev-only connect component',
141
+ write: {
142
+ path: devPath,
143
+ content: nextReticleDevFile(input.options.port, input.options.projectId, input.testids ?? [], input.storeHints ?? [],
144
+ // Next's dev module is not a sibling of `src/` — its imports are resolved from wherever
145
+ // the component actually lands (`app/`, or `src/app/` in a --src-dir app).
146
+ input.nextFoundStores ?? []),
147
+ },
148
+ dependsOnInstall: true,
110
149
  },
111
- dependsOnInstall: true,
112
- };
150
+ ];
113
151
  const configPatch = null === input.nextConfigSource || input.nextConfigSource === undefined
114
152
  ? { kind: PatchKind.MANUAL, reason: `no ${configFile} found` }
115
153
  : patchNextConfig(input.nextConfigSource, true !== input.detection?.customReconciler);
@@ -141,11 +179,26 @@ export function nextSteps(input) {
141
179
  },
142
180
  ]
143
181
  : [];
182
+ // Each route-group root layout renders only its own routes, so one mount covers one group: the
183
+ // routes under the other root layouts render without ReticleDev, and saying nothing about them
184
+ // would read as an app that is fully wired.
185
+ const unmounted = input.nextUnmountedLayouts ?? [];
186
+ const otherRootLayouts = null !== layout && 0 < unmounted.length
187
+ ? [
188
+ {
189
+ title: StepTitle.ROUTE_GROUPS_WITHOUT_RETICLE_DEV,
190
+ target: unmounted.join(', '),
191
+ status: StepStatus.NOTICE,
192
+ detail: `<ReticleDev /> is mounted in ${layout.path} only; ${unmounted.join(', ')} ${1 === unmounted.length ? 'is a root layout' : 'are root layouts'} of ${1 === unmounted.length ? 'its' : 'their'} own, so the routes under ${1 === unmounted.length ? 'it' : 'them'} render without it and Reticle sees nothing there. Mount it there too (dev-only) to cover them.`,
193
+ },
194
+ ]
195
+ : [];
144
196
  return [
145
- devFile,
197
+ ...devFileSteps,
146
198
  ...nextTodo,
147
199
  patchStep(StepTitle.NEXT_CONFIG, configFile, configPatch, 'wrap the export in withReticle (source mapping, dev-only)', nextConfigManual(configFile, true !== input.detection?.customReconciler)),
148
200
  patchStep(StepTitle.MOUNT_RETICLE_DEV, layout?.path ?? NEXT_LAYOUT_PATH, layoutPatch, 'mount <ReticleDev /> in the root layout (dev-only)', NEXT_LAYOUT_MANUAL),
201
+ ...otherRootLayouts,
149
202
  ];
150
203
  }
151
204
  /**
@@ -529,6 +582,16 @@ export function astroSteps(input) {
529
582
  patchStep(StepTitle.ASTRO_ENV_DTS, ASTRO_ENV_DTS_PATH, envPatch, 'declare window.__RETICLE_TOKEN__ / __RETICLE_ROOT__ for astro check', manualWithLayout),
530
583
  ];
531
584
  }
585
+ /** Write the gitignored module that holds this machine's pairing token, and its ignore line. */
586
+ function staticTokenSteps(input) {
587
+ return Object.entries(staticTokenFiles(input.pairingToken, (path) => input.htmlLocalSources?.[path] ?? null)).map(([path, content]) => ({
588
+ title: StepTitle.STATIC_PAIRING_TOKEN,
589
+ target: path,
590
+ status: StepStatus.APPLY,
591
+ detail: STATIC_GITIGNORE_PATH === path ? STATIC_IGNORE_DETAIL : STATIC_TOKEN_DETAIL,
592
+ write: { path, content },
593
+ }));
594
+ }
532
595
  /**
533
596
  * The plain-HTML path: write the static connect when the page exists, otherwise print the recipe.
534
597
  *
@@ -537,17 +600,21 @@ export function astroSteps(input) {
537
600
  * where the if/else chain this replaced used to hand it these instructions silently.
538
601
  */
539
602
  export function htmlSteps(input) {
603
+ if (input.detection.dependencies?.[ELECTRON_PACKAGE] !== undefined) {
604
+ return [
605
+ {
606
+ title: StepTitle.CONNECT_SNIPPET,
607
+ target: HTML_INDEX_PATH,
608
+ status: StepStatus.MANUAL,
609
+ detail: ELECTRON_STATIC_DETAIL,
610
+ },
611
+ ];
612
+ }
540
613
  const index = input.htmlIndexSource ?? null;
541
614
  if (null !== index) {
542
615
  // The page is publishable; the pairing token belongs in its gitignored sibling module instead.
543
616
  // Refresh that module even when the connect block itself is already in place.
544
- const steps = Object.entries(staticTokenFiles(input.pairingToken, (path) => input.htmlLocalSources?.[path] ?? null)).map(([path, content]) => ({
545
- title: StepTitle.STATIC_PAIRING_TOKEN,
546
- target: path,
547
- status: StepStatus.APPLY,
548
- detail: STATIC_GITIGNORE_PATH === path ? STATIC_IGNORE_DETAIL : STATIC_TOKEN_DETAIL,
549
- write: { path, content },
550
- }));
617
+ const steps = staticTokenSteps(input);
551
618
  const written = withStaticSnippet(index, connectArg(input.options.port, input.options.projectId));
552
619
  return [
553
620
  ...steps,
@@ -568,6 +635,8 @@ export function htmlSteps(input) {
568
635
  ];
569
636
  }
570
637
  return [
638
+ // The manual snippet imports the token from this module rather than printing it.
639
+ ...staticTokenSteps(input),
571
640
  {
572
641
  title: StepTitle.CONNECT_SNIPPET,
573
642
  target: HTML_INDEX_PATH,