@reticlehq/init 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/detect/capabilities.d.ts +4 -1
- package/dist/detect/capabilities.js +45 -9
- package/dist/detect/detect.d.ts +45 -2
- package/dist/detect/detect.js +76 -5
- package/dist/detect/dev-script.d.ts +26 -2
- package/dist/detect/dev-script.js +80 -7
- package/dist/detect/non-js-project.d.ts +1 -1
- package/dist/detect/non-js-project.js +6 -1
- package/dist/detect/workspace-apps.d.ts +21 -0
- package/dist/detect/workspace-apps.js +111 -12
- package/dist/detect/workspace-redirect.js +28 -2
- package/dist/diagnose/containerised-dev-server.d.ts +12 -4
- package/dist/diagnose/containerised-dev-server.js +36 -4
- package/dist/diagnose/csp-check.d.ts +12 -6
- package/dist/diagnose/csp-check.js +61 -13
- package/dist/diagnose/csp-doctor.d.ts +13 -1
- package/dist/diagnose/csp-doctor.js +2 -2
- package/dist/diagnose/csp-step.d.ts +27 -0
- package/dist/diagnose/csp-step.js +86 -0
- package/dist/diagnose/desktop-doctor.js +8 -2
- package/dist/diagnose/install-hint.d.ts +9 -1
- package/dist/diagnose/install-hint.js +17 -7
- package/dist/diagnose/install-retries.d.ts +16 -1
- package/dist/diagnose/install-retries.js +50 -3
- package/dist/diagnose/terminal-wrap.d.ts +18 -0
- package/dist/diagnose/terminal-wrap.js +63 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/no-package-json.d.ts +10 -0
- package/dist/no-package-json.js +121 -0
- package/dist/node-io.d.ts +9 -5
- package/dist/node-io.js +25 -7
- package/dist/patch/angular.d.ts +68 -0
- package/dist/patch/angular.js +216 -0
- package/dist/patch/astro-patch.d.ts +1 -1
- package/dist/patch/astro-patch.js +2 -2
- package/dist/patch/cra.d.ts +0 -2
- package/dist/patch/cra.js +0 -2
- package/dist/patch/electron-patch.d.ts +10 -1
- package/dist/patch/electron-patch.js +79 -27
- package/dist/patch/electron-vite-patch.js +1 -1
- package/dist/patch/nuxt-patch.d.ts +1 -1
- package/dist/patch/nuxt-patch.js +2 -2
- package/dist/patch/nuxt-snippets.js +6 -6
- package/dist/patch/remix.d.ts +24 -0
- package/dist/patch/remix.js +59 -0
- package/dist/patch/snippets.d.ts +57 -9
- package/dist/patch/snippets.js +119 -28
- package/dist/patch/static-page.d.ts +39 -0
- package/dist/patch/static-page.js +102 -0
- package/dist/patch/tanstack-start.d.ts +36 -8
- package/dist/patch/tanstack-start.js +118 -29
- package/dist/patch/vite-config.d.ts +5 -0
- package/dist/patch/vite-config.js +8 -0
- package/dist/patch/vite-owning-config.d.ts +34 -0
- package/dist/patch/vite-owning-config.js +63 -5
- package/dist/plan/connect-steps.d.ts +7 -1
- package/dist/plan/connect-steps.js +18 -1
- package/dist/plan/framework-adapter.d.ts +3 -1
- package/dist/plan/framework-adapter.js +99 -6
- package/dist/plan/notices.js +4 -3
- package/dist/plan/plan-angular.d.ts +6 -0
- package/dist/plan/plan-angular.js +89 -0
- package/dist/plan/plan-electron-vite.d.ts +6 -3
- package/dist/plan/plan-electron-vite.js +39 -8
- package/dist/plan/plan-framework.d.ts +8 -46
- package/dist/plan/plan-framework.js +81 -312
- package/dist/plan/plan-types.d.ts +29 -0
- package/dist/plan/plan-vite.d.ts +67 -0
- package/dist/plan/plan-vite.js +256 -0
- package/dist/plan/plan.js +38 -34
- package/dist/plan/port-steps.d.ts +45 -0
- package/dist/plan/port-steps.js +119 -0
- package/dist/plan/preflight.d.ts +17 -3
- package/dist/plan/preflight.js +53 -23
- package/dist/register/mcp.d.ts +18 -5
- package/dist/register/mcp.js +48 -8
- package/dist/run-types.d.ts +8 -0
- package/dist/run.js +111 -78
- package/package.json +3 -4
- 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
|
|
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 '
|
|
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
|
|
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 {
|
|
22
|
-
import {
|
|
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 =
|
|
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(
|
|
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(
|
|
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:
|
|
617
|
-
|
|
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
|
|
639
|
+
const onDisk = true === input.reticleConfigExists && null !== existing && existing !== undefined
|
|
643
640
|
? existing
|
|
644
|
-
:
|
|
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 [
|
|
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
|
+
}
|
package/dist/plan/preflight.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
42
|
+
export declare function preflight(io: PreflightIo, packageManager: string, options?: {
|
|
29
43
|
alreadyServed?: boolean;
|
|
30
|
-
}):
|
|
44
|
+
}): PreflightResult;
|