@reticlehq/init 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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/init-failure.d.ts +7 -0
  22. package/dist/diagnose/init-failure.js +7 -0
  23. package/dist/diagnose/install-hint.d.ts +9 -1
  24. package/dist/diagnose/install-hint.js +17 -7
  25. package/dist/diagnose/install-retries.d.ts +16 -1
  26. package/dist/diagnose/install-retries.js +50 -3
  27. package/dist/diagnose/terminal-wrap.d.ts +18 -0
  28. package/dist/diagnose/terminal-wrap.js +63 -0
  29. package/dist/index.d.ts +6 -2
  30. package/dist/index.js +6 -2
  31. package/dist/no-package-json.d.ts +10 -0
  32. package/dist/no-package-json.js +131 -0
  33. package/dist/node-io.d.ts +12 -6
  34. package/dist/node-io.js +33 -11
  35. package/dist/patch/angular.d.ts +68 -0
  36. package/dist/patch/angular.js +216 -0
  37. package/dist/patch/astro-patch.d.ts +1 -1
  38. package/dist/patch/astro-patch.js +2 -2
  39. package/dist/patch/cra.d.ts +0 -2
  40. package/dist/patch/cra.js +0 -2
  41. package/dist/patch/electron-patch.d.ts +10 -1
  42. package/dist/patch/electron-patch.js +79 -27
  43. package/dist/patch/electron-vite-patch.js +1 -1
  44. package/dist/patch/nuxt-patch.d.ts +1 -1
  45. package/dist/patch/nuxt-patch.js +2 -2
  46. package/dist/patch/nuxt-snippets.js +6 -6
  47. package/dist/patch/remix.d.ts +24 -0
  48. package/dist/patch/remix.js +59 -0
  49. package/dist/patch/snippets.d.ts +57 -9
  50. package/dist/patch/snippets.js +119 -28
  51. package/dist/patch/static-page.d.ts +39 -0
  52. package/dist/patch/static-page.js +102 -0
  53. package/dist/patch/tanstack-start.d.ts +36 -8
  54. package/dist/patch/tanstack-start.js +118 -29
  55. package/dist/patch/vite-config.d.ts +5 -0
  56. package/dist/patch/vite-config.js +8 -0
  57. package/dist/patch/vite-owning-config.d.ts +34 -0
  58. package/dist/patch/vite-owning-config.js +63 -5
  59. package/dist/plan/connect-steps.d.ts +7 -1
  60. package/dist/plan/connect-steps.js +18 -1
  61. package/dist/plan/framework-adapter.d.ts +3 -1
  62. package/dist/plan/framework-adapter.js +99 -6
  63. package/dist/plan/notices.js +4 -3
  64. package/dist/plan/plan-angular.d.ts +6 -0
  65. package/dist/plan/plan-angular.js +89 -0
  66. package/dist/plan/plan-electron-vite.d.ts +6 -3
  67. package/dist/plan/plan-electron-vite.js +39 -8
  68. package/dist/plan/plan-framework.d.ts +8 -46
  69. package/dist/plan/plan-framework.js +81 -312
  70. package/dist/plan/plan-types.d.ts +29 -0
  71. package/dist/plan/plan-vite.d.ts +67 -0
  72. package/dist/plan/plan-vite.js +256 -0
  73. package/dist/plan/plan.js +38 -34
  74. package/dist/plan/port-steps.d.ts +45 -0
  75. package/dist/plan/port-steps.js +119 -0
  76. package/dist/plan/preflight.d.ts +17 -3
  77. package/dist/plan/preflight.js +53 -23
  78. package/dist/register/mcp-clients.js +5 -3
  79. package/dist/register/mcp.d.ts +18 -5
  80. package/dist/register/mcp.js +48 -8
  81. package/dist/run-types.d.ts +8 -0
  82. package/dist/run.js +118 -80
  83. package/package.json +3 -4
  84. package/NOTICE +0 -5
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The Vite half of the plan: the `reticle()` plugin in a Vite config, and the dev module that
3
+ * carries `registerCapabilities` / `registerStore`.
4
+ *
5
+ * Split out of `plan-framework.ts`, which had grown to the edge of the line cap. It is its own
6
+ * module because every Vite-based stack (plain Vite, SvelteKit, React Router, Remix, TanStack
7
+ * Start, the Electron renderers) composes these steps, while the rest of `plan-framework.ts` is one
8
+ * builder per framework that has its own files to write.
9
+ */
10
+ import { newViteConfig, patchViteConfig, VitePatchKind, VITE_IMPORT } from '../patch/vite-config.js';
11
+ import { viteManual, viteDevModuleFile, VITE_DEV_MODULE_PATH } from '../patch/snippets.js';
12
+ import { hasOptOut, OPT_OUT_MARKER } from '../detect/declared/init-opt-out.js';
13
+ import { Framework } from '../detect/detect.js';
14
+ import { StepStatus } from './plan-types.js';
15
+ import { StepTitle } from './connect-steps.js';
16
+ import { alreadyOrMovedPort } from './port-steps.js';
17
+ /** What adding `reticle()` to a Vite config buys, which differs by framework. */
18
+ export const VITE_PLUGIN_DETAIL = {
19
+ /** A plain Vite app gets both halves from the plugin. */
20
+ VITE: 'add reticle() to plugins (also injects connect())',
21
+ /**
22
+ * SvelteKit renders through app.html, so the plugin's HTML injection never fires and connect()
23
+ * comes from the client hook instead. The plugin is still required: it is what stamps
24
+ * data-reticle-source into .svelte components, and without it every verdict on a SvelteKit app
25
+ * comes back with no file:line at all.
26
+ */
27
+ SVELTEKIT: 'add reticle({ inject: false }) to plugins (stamps data-reticle-source in .svelte components; the client hook connects)',
28
+ /**
29
+ * React Router framework mode renders through its own request handler, so the plugin's HTML
30
+ * injection never fires and connect() comes from the client entry instead. The plugin is still
31
+ * required for the same reason it is under SvelteKit: without it every verdict on the app comes
32
+ * back with no file:line at all.
33
+ */
34
+ REACT_ROUTER: 'add reticle() to plugins (stamps data-reticle-source in .tsx components)',
35
+ /**
36
+ * TanStack Start SSRs its own HTML, so the plugin's HTML injection never fires and connect()
37
+ * comes from a client effect instead. `inject: false` is load-bearing honesty: leaving the
38
+ * default would keep reporting "also injects connect()" for a transform that never runs.
39
+ * The plugin is still required for the same reason it is under SvelteKit: without it every
40
+ * verdict on the app comes back with no file:line at all.
41
+ */
42
+ TANSTACK_START: 'add reticle({ inject: false }) to plugins (stamps data-reticle-source; Start SSRs its own HTML so connect() cannot come from the plugin)',
43
+ };
44
+ const CAPABILITIES_TITLE = StepTitle.CAPABILITIES;
45
+ /**
46
+ * The one step `init` genuinely cannot finish, addressed to the AGENT reading this report.
47
+ *
48
+ * It used to read "Capabilities are empty until you edit that file" and describe the consequence to
49
+ * a human — homework, in a report a human mostly does not read, naming no file to open and no thing
50
+ * to look for. The agent that ran `init` is right there, has the codebase, and can finish this in
51
+ * the same minute; what it needed was the specific instruction, not the rationale.
52
+ *
53
+ * What is left is exactly what could not be PROVEN from source: which atoms/actor/queryClient an
54
+ * adapter-wrapped store should be built from, and which elements matter enough to name. `init`
55
+ * emits nothing it cannot prove, because a wrong import here throws on every dev page load — worse
56
+ * than an empty file.
57
+ */
58
+ const CAPABILITIES_TODO_TITLE = StepTitle.CAPABILITIES_TODO;
59
+ export function capabilitiesTodo(path, stores) {
60
+ return (`This app's state lives in a library nothing in the running app points at, so it is the one ` +
61
+ `thing Reticle cannot find for itself — reticle_state will not see it until you say where it ` +
62
+ `is.\n` +
63
+ `Do this now, before driving anything:\n` +
64
+ ` 1. In ${path}, uncomment the suggested line and complete it: ${stores.join('; ')}.\n` +
65
+ ` 2. Prove it: drive one flow and check reticle_state returns your key. An empty state read ` +
66
+ `means the line is still not doing anything, whatever else went green.`);
67
+ }
68
+ /**
69
+ * The dev module carrying `registerCapabilities` / `registerStore`.
70
+ *
71
+ * Without it every app came up `hasCapabilities: false` with a `reticle_state` holding nothing but
72
+ * `__reticle_renders` — the state-truth read was unavailable on every app out of the box. Written
73
+ * only when absent, because it is the one generated file a user is expected to EDIT.
74
+ */
75
+ /**
76
+ * Is there state left that ONLY the app can hand over?
77
+ *
78
+ * This used to ask a much bigger question — "does the generated file register anything at all?" —
79
+ * and fired on almost every install, because almost every install generated a file whose testids
80
+ * and stores were both empty. The answer was homework: go read the source, uncomment a line, add
81
+ * data-testid attributes, then drive to prove it. Several turns, on every onboarding.
82
+ *
83
+ * Two of those three are no longer anyone's homework. Testids are read from the live DOM, and a
84
+ * store passed through a React context provider (Redux, TanStack Query) is discovered and
85
+ * registered on the first commit. What is left is the genuinely unreachable case: a module-scope
86
+ * store, or one needing an argument only the source supplies — Zustand, Jotai atoms, an XState
87
+ * actor. Nothing in the running app points at those, so the notice still has to fire, and it now
88
+ * fires ONLY there.
89
+ *
90
+ * `wired` are stores init resolved and wrote a live `registerStore` call for. Hints are not
91
+ * registrations: they land in the file as a commented line, and counting one as a registration
92
+ * silences the notice whose whole purpose is to say "act on the hint".
93
+ */
94
+ export function needsManualStore(hints, wired) {
95
+ return hints.length > 0 && 0 === wired.length;
96
+ }
97
+ export function capabilitiesStep(input, path = VITE_DEV_MODULE_PATH) {
98
+ if (true === input.viteDevModuleExists) {
99
+ return [
100
+ {
101
+ title: CAPABILITIES_TITLE,
102
+ target: path,
103
+ status: StepStatus.ALREADY,
104
+ detail: 'file exists, left alone, it is yours to edit',
105
+ },
106
+ ];
107
+ }
108
+ const testids = input.testids ?? [];
109
+ const stores = input.storeHints ?? [];
110
+ const wired = input.foundStores ?? [];
111
+ // Testids no longer need counting here. They are read from the live DOM at announce time, so a
112
+ // number printed at install time would be a stale claim about a codebase that is about to change —
113
+ // which is exactly what "no data-testid values yet" used to be on an app whose testids arrive with
114
+ // a lazy route.
115
+ const found = 'testids read from the live DOM';
116
+ // A store we found and WIRED is a registration, so the notice must not fire. A store HINT is not:
117
+ // `stores` holds suggestions, written into the file as a commented line of the form
118
+ // `// import your store, then: registerStore(...)`. Counting a suggestion as a registration let
119
+ // the hint silence the notice whose entire job is to say "act on the hint".
120
+ //
121
+ // Measured against a real product UI (rowy — 70+ deps, jotai, a whole src/atoms tree): init
122
+ // detected jotai, offered one commented line, emitted NO notice, and wrote
123
+ // `registerCapabilities({ testids: [], signals: [], stores: [] })`. So `hasCapabilities` stayed
124
+ // false, `reticle_state` was empty forever, and the install gate reported "connected: 1,
125
+ // manual ⚠: none". Every check green, state observability zero.
126
+ //
127
+ // jotai is still exactly that case. A Redux or TanStack app is not, any more: its store rides a
128
+ // context provider and the React adapter registers it on the first commit, so `storeHints` no
129
+ // longer names either one and this notice no longer fires for them.
130
+ const nothingToRegister = needsManualStore(stores, wired);
131
+ return [
132
+ {
133
+ title: CAPABILITIES_TITLE,
134
+ target: path,
135
+ status: StepStatus.APPLY,
136
+ detail: `${found}; ${wired.length > 0
137
+ ? `registered ${wired.map((s) => `'${s.key}'`).join(', ')} from your source`
138
+ : stores.length > 0
139
+ ? `store: uncomment the ${String(stores.length)} suggested line(s)`
140
+ : 'no state library detected'}`,
141
+ write: {
142
+ path,
143
+ content: viteDevModuleFile(testids, stores, wired, input.detection.uiLibrary, input.detection.framework),
144
+ },
145
+ dependsOnInstall: true,
146
+ },
147
+ // The write is real; what it registers is not. `registerCapabilities({ testids: [], signals: [],
148
+ // stores: [] })` registers nothing, so `hasCapabilities` stays false — correctly — and the ✓
149
+ // above reads as if the feature is on. Reported as exactly that confusion: "hasCapabilities false
150
+ // on every session while init said ✓ Capabilities + store".
151
+ //
152
+ // Beside the write rather than replacing it, for two reasons: only APPLY steps are written
153
+ // (run.ts), and SKILL.md tells the reader to skip ✓ lines — so a caveat carried on the ✓ is a
154
+ // caveat nobody reads.
155
+ ...(nothingToRegister
156
+ ? [
157
+ {
158
+ title: CAPABILITIES_TODO_TITLE,
159
+ target: path,
160
+ status: StepStatus.NOTICE,
161
+ detail: capabilitiesTodo(path, stores),
162
+ },
163
+ ]
164
+ : []),
165
+ ];
166
+ }
167
+ export function viteSteps(input, detail = VITE_PLUGIN_DETAIL.VITE, inject = true) {
168
+ // Capabilities are independent of whether the config needed patching. Attaching them to the APPLY
169
+ // branch meant a re-run on an already-wired app silently never created the module.
170
+ return [...viteConfigSteps(input, detail, inject), ...capabilitiesStep(input)];
171
+ }
172
+ function viteConfigSteps(input, detail, inject = true) {
173
+ const cfg = input.viteConfig;
174
+ const port = input.options.port;
175
+ // Stamp `data-reticle-source` unless this app renders through a non-DOM React reconciler, where a
176
+ // lowercase JSX tag is not an element and the stamp crashes the app at commit time. See
177
+ // `Detection.customReconciler`.
178
+ const stampSource = true !== input.detection?.customReconciler;
179
+ // An explicit "not here" is not an invitation. `init` runs unattended in a repo it has just met,
180
+ // and it was reported adding the plugin to an app whose config said Reticle was deliberately
181
+ // excluded. A NOTICE rather than a ⚠: opting out is a decision, not something to go and fix.
182
+ if (cfg !== null && hasOptOut(cfg.source)) {
183
+ return [
184
+ {
185
+ title: StepTitle.VITE_PLUGIN,
186
+ target: cfg.path,
187
+ status: StepStatus.NOTICE,
188
+ detail: `left alone: this config carries ${OPT_OUT_MARKER}. Remove that marker to instrument this app.`,
189
+ },
190
+ ];
191
+ }
192
+ // Plain Vite runs happily with no config file (`npm create vite`'s vanilla template has none), so
193
+ // make one. Only there: a Vite-based framework's missing config also lost the framework's own
194
+ // plugin, and a file carrying ours alone would not boot the app.
195
+ const plainVite = Framework.VITE === input.detection.framework;
196
+ if (null === cfg) {
197
+ if (!plainVite) {
198
+ return [
199
+ {
200
+ title: StepTitle.VITE_PLUGIN,
201
+ target: 'vite.config',
202
+ status: StepStatus.MANUAL,
203
+ detail: viteManual(port, input.detection.uiLibrary, inject, stampSource),
204
+ },
205
+ ];
206
+ }
207
+ const path = input.detection.typescript ? 'vite.config.ts' : 'vite.config.mjs';
208
+ return [
209
+ {
210
+ title: StepTitle.VITE_PLUGIN,
211
+ target: path,
212
+ status: StepStatus.APPLY,
213
+ detail,
214
+ write: {
215
+ path,
216
+ content: newViteConfig(port, true === input.captureBodies, inject, stampSource),
217
+ expect: [VITE_IMPORT, 'reticle('],
218
+ },
219
+ dependsOnInstall: true,
220
+ },
221
+ ];
222
+ }
223
+ const patch = patchViteConfig(cfg.source, port, true === input.captureBodies, inject, stampSource);
224
+ if (patch.kind === VitePatchKind.ALREADY) {
225
+ return [
226
+ alreadyOrMovedPort({
227
+ title: StepTitle.VITE_PLUGIN,
228
+ target: cfg.path,
229
+ status: StepStatus.ALREADY,
230
+ detail: 'reticle() already in plugins',
231
+ }, cfg.source, port),
232
+ ];
233
+ }
234
+ if (patch.kind === VitePatchKind.MANUAL) {
235
+ return [
236
+ {
237
+ title: StepTitle.VITE_PLUGIN,
238
+ target: cfg.path,
239
+ status: StepStatus.MANUAL,
240
+ detail: `${patch.reason}\n\n${viteManual(port, input.detection.uiLibrary, inject, stampSource)}`,
241
+ },
242
+ ];
243
+ }
244
+ return [
245
+ {
246
+ title: StepTitle.VITE_PLUGIN,
247
+ target: cfg.path,
248
+ status: StepStatus.APPLY,
249
+ detail,
250
+ // Both halves, because either alone is a config that does not wire: the import without the
251
+ // call leaves `plugins` untouched, and the call without the import does not build.
252
+ write: { path: cfg.path, content: patch.code, expect: [VITE_IMPORT, 'reticle('] },
253
+ dependsOnInstall: true,
254
+ },
255
+ ];
256
+ }
package/dist/plan/plan.js CHANGED
@@ -3,23 +3,24 @@
3
3
  * contents, produce an ordered list of steps — each marked apply / manual / already / skip. The
4
4
  * runner performs the `write` side-effects; this module decides *what* should happen.
5
5
  */
6
- import { Framework, UiLibrary, installCommand, installCommandParts, } from '../detect/detect.js';
6
+ import { Framework, UiLibrary, installCommand, installCommandParts } from '../detect/detect.js';
7
7
  import { installFailureHint } from '../diagnose/install-hint.js';
8
8
  import { installRetries } from '../diagnose/install-retries.js';
9
9
  import { claudeAddCommand, claudeProjectMcpJson, mcpManual } from '../register/mcp.js';
10
10
  import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, CLAUDE_PROJECT_CONFIG, CLAUDE_PROJECT_SPEC, McpClient, } from '../register/mcp-clients.js';
11
11
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from '../register/slash-command.js';
12
12
  import { mergeMarkedInstruction, reticleMdFile, cursorRuleFile, AgentRuleStatus, CLAUDE_MD_PATH, markedBlock, AGENTS_MD_PATH, RETICLE_MD_PATH, CURSOR_RULE_PATH, } from '../project/agent-rules.js';
13
- import { cspStep } from './plan-framework.js';
13
+ import { cspStep } from '../diagnose/csp-step.js';
14
14
  import { frameworkSteps } from './framework-adapter.js';
15
15
  import { FRAMEWORK_ADAPTERS, RETICLE_BROWSER_SDK, RETICLE_REACT_KIT } from './framework-adapter.js';
16
- import { MCP_TARGET, StepStatus, } from './plan-types.js';
16
+ import { MCP_TARGET, StepStatus } from './plan-types.js';
17
17
  export { DEPS_TARGET, MCP_TARGET, StepStatus, } from './plan-types.js';
18
18
  import { join } from 'node:path';
19
- import { reticleConfigContent } from '../patch/snippets.js';
19
+ import { reticleConfigContent, usesReactKit } from '../patch/snippets.js';
20
20
  import { configWithInstallSource } from '../project/install-source-config.js';
21
- import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep } from './notices.js';
22
- import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE } from '../detect/existing-config.js';
21
+ import { portMoveDetail, retargetConfigPort } from './port-steps.js';
22
+ import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep, } from './notices.js';
23
+ import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE, } from '../detect/existing-config.js';
23
24
  import { CLAUDE_SETTINGS_PATH, stopHookStep } from './stop-hook-step.js';
24
25
  // Re-exported: it moved to the module that reads it, and every existing importer says `plan.js`.
25
26
  export { RETICLE_CONFIG_FILE };
@@ -37,20 +38,6 @@ function pinnedPackages(packages, version) {
37
38
  return packages;
38
39
  return packages.map((p) => `${p}@${version}`);
39
40
  }
40
- /**
41
- * Does this codebase want the React kit, or the framework-neutral sensor?
42
- *
43
- * The kit is what adds component identity — component names and stacks — and it is worth having
44
- * wherever React or Preact is rendering (the adapter reaches Preact through `preact/compat`).
45
- * Everywhere else it is a package named `@reticlehq/react`, carrying `react` in its peer
46
- * dependencies, being installed into a codebase that has no React in it.
47
- *
48
- * UNKNOWN keeps the kit deliberately. Absence of evidence is not evidence of Vue, and guessing
49
- * "sensor" on no information silently drops component identity from apps that should have it.
50
- */
51
- function wantsReactKit(ui) {
52
- return ui !== UiLibrary.VUE && ui !== UiLibrary.SVELTE;
53
- }
54
41
  /**
55
42
  * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
56
43
  *
@@ -60,7 +47,7 @@ function wantsReactKit(ui) {
60
47
  * right after `init` had detected the real UI library and said so on screen.
61
48
  */
62
49
  export function frameworkPackages(framework, uiLibrary = UiLibrary.UNKNOWN) {
63
- const kit = wantsReactKit(uiLibrary) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
50
+ const kit = usesReactKit(uiLibrary, framework) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
64
51
  return FRAMEWORK_ADAPTERS[framework].packages(kit);
65
52
  }
66
53
  /**
@@ -476,8 +463,11 @@ function alreadyDeclared(declared, pinned) {
476
463
  }
477
464
  function installStep(input) {
478
465
  const pm = input.detection.packageManager;
466
+ // `Detection.packageManagerCommand` (see `preflight.ts`, #1149). Falling back to `pm` covers every
467
+ // caller that never went through preflight (tests, and any future one).
468
+ const pmCommand = input.detection.packageManagerCommand ?? pm;
479
469
  const packages = pinnedPackages(frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion);
480
- const command = installCommand(pm, packages);
470
+ const command = installCommand(pm, packages, pmCommand);
481
471
  /*
482
472
  * A re-run over an already-wired project does NO dependency work.
483
473
  *
@@ -506,7 +496,7 @@ function installStep(input) {
506
496
  detail: command,
507
497
  };
508
498
  }
509
- const parts = installCommandParts(pm, packages);
499
+ const parts = installCommandParts(pm, packages, [], pmCommand);
510
500
  return {
511
501
  title: 'Install dependencies',
512
502
  target: 'package.json',
@@ -515,9 +505,9 @@ function installStep(input) {
515
505
  exec: {
516
506
  command: parts.command,
517
507
  args: parts.args,
518
- fallback: `${command}\n\n${installFailureHint(pm)}`,
508
+ fallback: `${command}\n\n${installFailureHint(pm, pmCommand)}`,
519
509
  },
520
- retries: installRetries(pm, packages, frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion),
510
+ retries: installRetries(pm, packages, frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion, pmCommand),
521
511
  };
522
512
  }
523
513
  const RETICLE_CONFIG_TITLE = 'Reticle config';
@@ -592,9 +582,9 @@ function agentRootConfigStep(input, content) {
592
582
  },
593
583
  ];
594
584
  }
595
- function reticleConfigStep(input, content) {
585
+ function reticleConfigStep(input, content, onDisk, movedFrom) {
596
586
  if (true === input.reticleConfigExists) {
597
- const problem = existingConfigProblem(input.reticleConfigSource);
587
+ const problem = existingConfigProblem(onDisk);
598
588
  if (problem !== undefined) {
599
589
  // `ℹ`, not `·`: the step is done and something about the result still stops things working,
600
590
  // which is the one mark that says "there is something here to read".
@@ -607,14 +597,17 @@ function reticleConfigStep(input, content) {
607
597
  }
608
598
  // The one thing a re-run can still learn: which channel the user actually arrived through.
609
599
  // See configWithInstallSource — it only ever ADDS a field that is absent.
610
- const backfilled = configWithInstallSource(input.reticleConfigSource, input.installSource);
611
- if (backfilled !== undefined) {
600
+ const backfilled = configWithInstallSource(onDisk, input.installSource);
601
+ if (backfilled !== undefined || movedFrom !== undefined) {
602
+ const port = input.options.port;
612
603
  return {
613
604
  title: RETICLE_CONFIG_TITLE,
614
605
  target: RETICLE_CONFIG_FILE,
615
606
  status: StepStatus.APPLY,
616
- detail: 'record which install route this project came through',
617
- write: { path: RETICLE_CONFIG_FILE, content: backfilled },
607
+ detail: movedFrom !== undefined && port !== undefined
608
+ ? portMoveDetail(movedFrom, port)
609
+ : 'record which install route this project came through',
610
+ write: { path: RETICLE_CONFIG_FILE, content: backfilled ?? onDisk ?? content },
618
611
  };
619
612
  }
620
613
  return {
@@ -636,16 +629,27 @@ function reticleConfigStep(input, content) {
636
629
  * The project config, in every directory that has to read it: the app's, and — after a redirect —
637
630
  * the one the agent runs from. One content string for both, so they cannot disagree; an existing
638
631
  * app-side config wins over a freshly derived one, or a re-run would copy a DIFFERENT identity up.
632
+ *
633
+ * Except its port, when a re-run asked for a different one: the daemon is about to start on the
634
+ * requested port, and a config still naming the old one is half of a split brain (see
635
+ * port-steps.ts). Only the port moves; the identity stays the one already on disk.
639
636
  */
640
637
  function reticleConfigSteps(input) {
641
638
  const existing = input.reticleConfigSource;
642
- const content = true === input.reticleConfigExists && null !== existing && existing !== undefined
639
+ const onDisk = true === input.reticleConfigExists && null !== existing && existing !== undefined
643
640
  ? existing
644
- : reticleConfigContent(input.detection.framework, input.options.port, input.options.projectId,
641
+ : undefined;
642
+ const moved = onDisk === undefined ? null : retargetConfigPort(onDisk, input.options.port);
643
+ const content = moved?.code ??
644
+ onDisk ??
645
+ reticleConfigContent(input.detection.framework, input.options.port, input.options.projectId,
645
646
  // Only when it is actually known. Writing `unknown` would be indistinguishable from a
646
647
  // config written before this field existed, and the two mean different things.
647
648
  input.installSource);
648
- return [reticleConfigStep(input, content), ...agentRootConfigStep(input, content)];
649
+ return [
650
+ reticleConfigStep(input, content, moved?.code ?? existing, moved?.from),
651
+ ...agentRootConfigStep(input, content),
652
+ ];
649
653
  }
650
654
  export function buildPlan(input) {
651
655
  const steps = [
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Moving a project that `init` already wired onto a different bridge port.
3
+ *
4
+ * Re-running `init --port 4689` over a project wired at 4688 used to be a silent no-op that
5
+ * reported success: every step read "already", nothing was rewritten, setup started the daemon on
6
+ * 4689, and the page kept dialling 4688 — "never dialled the bridge", with every file green. Each
7
+ * of those files had an "exists → done" check that never looked at the port inside it.
8
+ *
9
+ * So the port is compared wherever `init` wrote one, and rewritten IN PLACE: the rest of each file
10
+ * is the user's (registered stores, their own plugins), so only the number moves. With no `--port`
11
+ * on the re-run there is nothing requested to converge on, and every file is left exactly as it is —
12
+ * a re-run without the flag must not quietly reset a project to the default port.
13
+ */
14
+ import { type Step } from './plan-types.js';
15
+ /** A rewritten source, and the port it was moved off (for the "port A → B" line). */
16
+ export interface Retargeted {
17
+ code: string;
18
+ from: number;
19
+ }
20
+ /** The one line both rewrites print, so every file reports a move the same way. */
21
+ export declare function portMoveDetail(from: number, to: number): string;
22
+ /**
23
+ * Rewrite every port `init` baked into a generated source file, or null when none differs.
24
+ *
25
+ * Covers the two shapes `init` writes: the bridge URL literal (Next's component, the SvelteKit hook,
26
+ * the Nuxt plugin) and the plugin option (`reticle({ port })` in a Vite config). A Vite config with
27
+ * a bare `reticle()` is null: the plugin reads the port from `.reticle.json`, which moves with it.
28
+ */
29
+ export declare function retargetPort(source: string, to: number | undefined): Retargeted | null;
30
+ /**
31
+ * The same, for `.reticle.json`: the `port` field set to the requested one, every other field kept.
32
+ *
33
+ * An absent `port` means the default, so moving onto the default REMOVES the field rather than
34
+ * writing `4400` — the same shape a fresh install writes (see `reticleConfigContent`). Unreadable
35
+ * JSON is null: `existingConfigProblem` already reports it, and rewriting a file we cannot parse
36
+ * would drop whatever the user meant by it.
37
+ */
38
+ export declare function retargetConfigPort(source: string, to: number | undefined): Retargeted | null;
39
+ /**
40
+ * The step for a file `init` already wrote, once a re-run's `--port` has been compared with it.
41
+ *
42
+ * Every "file exists" branch used to return ALREADY without reading the port inside the file; this is
43
+ * the one place that turns the rewrite above into a step.
44
+ */
45
+ export declare function alreadyOrMovedPort(already: Step, source: string | null | undefined, port: number | undefined): Step;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Moving a project that `init` already wired onto a different bridge port.
3
+ *
4
+ * Re-running `init --port 4689` over a project wired at 4688 used to be a silent no-op that
5
+ * reported success: every step read "already", nothing was rewritten, setup started the daemon on
6
+ * 4689, and the page kept dialling 4688 — "never dialled the bridge", with every file green. Each
7
+ * of those files had an "exists → done" check that never looked at the port inside it.
8
+ *
9
+ * So the port is compared wherever `init` wrote one, and rewritten IN PLACE: the rest of each file
10
+ * is the user's (registered stores, their own plugins), so only the number moves. With no `--port`
11
+ * on the re-run there is nothing requested to converge on, and every file is left exactly as it is —
12
+ * a re-run without the flag must not quietly reset a project to the default port.
13
+ */
14
+ import { RETICLE_CLIENT_HOST, RETICLE_DEFAULT_PORT, RETICLE_WS_PATH, bridgeWsUrl, } from '@reticlehq/core';
15
+ import { StepStatus } from './plan-types.js';
16
+ /** The bridge URL every generated connect bakes (`bridgeWsUrl`), with its port captured. */
17
+ const BRIDGE_URL = new RegExp(`(ws://${RETICLE_CLIENT_HOST}:)(\\d+)(${RETICLE_WS_PATH})\\b`, 'g');
18
+ /** The opening of the connect call every generated connect file makes. */
19
+ const CONNECT_CALL = 'reticle.connect({';
20
+ /** `port: <n>` inside the options object of a `reticle({ … })` plugin call. */
21
+ const PLUGIN_PORT = /(\breticle\(\s*\{[^}]*?\bport\s*:\s*)(\d+)/g;
22
+ /** The one line both rewrites print, so every file reports a move the same way. */
23
+ export function portMoveDetail(from, to) {
24
+ return `port ${String(from)} → ${String(to)}`;
25
+ }
26
+ /**
27
+ * Rewrite every port `init` baked into a generated source file, or null when none differs.
28
+ *
29
+ * Covers the two shapes `init` writes: the bridge URL literal (Next's component, the SvelteKit hook,
30
+ * the Nuxt plugin) and the plugin option (`reticle({ port })` in a Vite config). A Vite config with
31
+ * a bare `reticle()` is null: the plugin reads the port from `.reticle.json`, which moves with it.
32
+ */
33
+ export function retargetPort(source, to) {
34
+ if (to === undefined)
35
+ return null;
36
+ let from;
37
+ let baked = false;
38
+ /** True when this literal is the one to move; remembers the first port moved off. */
39
+ const moves = (port) => {
40
+ baked = true;
41
+ const current = Number(port);
42
+ if (current === to)
43
+ return false;
44
+ from ??= current;
45
+ return true;
46
+ };
47
+ const target = String(to);
48
+ const code = source
49
+ .replace(BRIDGE_URL, (match, head, port, tail) => moves(port) ? `${head}${target}${tail}` : match)
50
+ .replace(PLUGIN_PORT, (match, head, port) => moves(port) ? `${head}${target}` : match);
51
+ if (from !== undefined)
52
+ return { code, from };
53
+ // A port was baked and it is already the requested one: nothing to do. Falling through here used
54
+ // to insert a SECOND `url:` beside the one already there on every same-port re-run —
55
+ // `TS1117: An object literal cannot have multiple properties with the same name`, and the app's
56
+ // build failed.
57
+ if (baked)
58
+ return null;
59
+ // A default-port install bakes no URL at all — the SDK's default IS 4400 — so there is nothing to
60
+ // rewrite, and the page would go on dialling 4400. Give the connect call the URL it now needs.
61
+ if (RETICLE_DEFAULT_PORT === to || !source.includes(CONNECT_CALL))
62
+ return null;
63
+ return {
64
+ code: source.replace(CONNECT_CALL, `${CONNECT_CALL} url: '${bridgeWsUrl(to)}',`),
65
+ from: RETICLE_DEFAULT_PORT,
66
+ };
67
+ }
68
+ /**
69
+ * The same, for `.reticle.json`: the `port` field set to the requested one, every other field kept.
70
+ *
71
+ * An absent `port` means the default, so moving onto the default REMOVES the field rather than
72
+ * writing `4400` — the same shape a fresh install writes (see `reticleConfigContent`). Unreadable
73
+ * JSON is null: `existingConfigProblem` already reports it, and rewriting a file we cannot parse
74
+ * would drop whatever the user meant by it.
75
+ */
76
+ export function retargetConfigPort(source, to) {
77
+ if (to === undefined)
78
+ return null;
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(source);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
87
+ return null;
88
+ const fields = { ...parsed };
89
+ const declared = fields['port'];
90
+ const from = 'number' === typeof declared ? declared : RETICLE_DEFAULT_PORT;
91
+ if (from === to)
92
+ return null;
93
+ if (RETICLE_DEFAULT_PORT === to)
94
+ delete fields['port'];
95
+ else
96
+ fields['port'] = to;
97
+ return { code: `${JSON.stringify(fields, null, 2)}\n`, from };
98
+ }
99
+ /**
100
+ * The step for a file `init` already wrote, once a re-run's `--port` has been compared with it.
101
+ *
102
+ * Every "file exists" branch used to return ALREADY without reading the port inside the file; this is
103
+ * the one place that turns the rewrite above into a step.
104
+ */
105
+ export function alreadyOrMovedPort(already, source, port) {
106
+ if (StepStatus.ALREADY !== already.status)
107
+ return already;
108
+ if ('string' !== typeof source || port === undefined)
109
+ return already;
110
+ const moved = retargetPort(source, port);
111
+ if (null === moved)
112
+ return already;
113
+ return {
114
+ ...already,
115
+ status: StepStatus.APPLY,
116
+ detail: portMoveDetail(moved.from, port),
117
+ write: { path: already.target, content: moved.code },
118
+ };
119
+ }
@@ -16,8 +16,22 @@ export interface PreflightIo {
16
16
  /** Runs a command quietly for a yes/no check; true on exit code 0. */
17
17
  probe(command: string, args: readonly string[]): boolean;
18
18
  }
19
+ /** What `preflight` decided: run through `command`, or refuse with this message. */
20
+ export type PreflightResult = {
21
+ refusal: string;
22
+ command?: undefined;
23
+ } | {
24
+ refusal?: undefined;
25
+ command: string;
26
+ };
19
27
  /**
20
- * The refusal to print, or undefined when this machine can run the install.
28
+ * The one place this run decides whether — and how — it can invoke the package manager.
29
+ *
30
+ * Resolves the invocation ONCE: earlier, `preflightRefusal` probed the machine to decide whether to
31
+ * refuse, and callers that got past it probed AGAIN, separately, to learn what the resolved command
32
+ * actually was — two subprocesses (the bare binary, then corepack) doing the same test twice on every
33
+ * ordinary run. This is the single call site: it returns either the command every later step should
34
+ * invoke, or the refusal to print, never both and never neither.
21
35
  *
22
36
  * `packageManager` is the one init RESOLVED, never a raw lockfile check. An inherited
23
37
  * `pnpm-lock.yaml` at a monorepo root does not mean the app in `frontend/` uses pnpm — that app's own
@@ -25,6 +39,6 @@ export interface PreflightIo {
25
39
  * here from `exists('pnpm-lock.yaml')` refused an npm app sitting under a pnpm monorepo on a machine
26
40
  * with no pnpm, which the install gate proves must succeed.
27
41
  */
28
- export declare function preflightRefusal(io: PreflightIo, packageManager: string, options?: {
42
+ export declare function preflight(io: PreflightIo, packageManager: string, options?: {
29
43
  alreadyServed?: boolean;
30
- }): string | undefined;
44
+ }): PreflightResult;