@reticlehq/init 3.5.0 → 3.6.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/detect.d.ts +8 -0
- package/dist/detect/detect.js +6 -3
- package/dist/detect/non-js-project.d.ts +23 -0
- package/dist/detect/non-js-project.js +55 -23
- package/dist/detect/workspace-apps.d.ts +5 -0
- package/dist/detect/workspace-apps.js +7 -0
- package/dist/detect/workspace-redirect.d.ts +12 -0
- package/dist/detect/workspace-redirect.js +36 -5
- package/dist/diagnose/closing-hint.d.ts +7 -1
- package/dist/diagnose/closing-hint.js +39 -12
- package/dist/diagnose/csp-check.js +65 -20
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/no-package-json.js +17 -8
- package/dist/run.js +17 -26
- package/dist/sdk-packages.d.ts +19 -0
- package/dist/sdk-packages.js +34 -0
- package/package.json +2 -2
package/dist/detect/detect.d.ts
CHANGED
|
@@ -194,6 +194,14 @@ export declare const ANGULAR_WORKSPACE_FILE = "angular.json";
|
|
|
194
194
|
interface FrameworkSignals {
|
|
195
195
|
/** `package.json` dependency names that name this framework outright. */
|
|
196
196
|
readonly deps: readonly string[];
|
|
197
|
+
/**
|
|
198
|
+
* Dependencies that disprove this framework's dependency signal, without affecting an explicit
|
|
199
|
+
* config-file signal.
|
|
200
|
+
*
|
|
201
|
+
* Vinext carries `next` for API compatibility but builds through Vite, so treating `next` as
|
|
202
|
+
* conclusive there would install wiring that Vinext never evaluates.
|
|
203
|
+
*/
|
|
204
|
+
readonly depsUnless?: readonly string[];
|
|
197
205
|
/** Root config-file basenames that name it when the dependency is absent. */
|
|
198
206
|
readonly configs: readonly string[];
|
|
199
207
|
/**
|
package/dist/detect/detect.js
CHANGED
|
@@ -164,7 +164,7 @@ const VITE_INDEX_HTML = 'index.html';
|
|
|
164
164
|
* asserted in `framework-adapter.test.ts`.
|
|
165
165
|
*/
|
|
166
166
|
export const FRAMEWORK_SIGNALS = {
|
|
167
|
-
[Framework.NEXT]: { deps: ['next'], configs: NEXT_CONFIGS },
|
|
167
|
+
[Framework.NEXT]: { deps: ['next'], depsUnless: ['vinext'], configs: NEXT_CONFIGS },
|
|
168
168
|
[Framework.NUXT]: { deps: ['nuxt'], configs: NUXT_CONFIGS },
|
|
169
169
|
[Framework.SVELTEKIT]: {
|
|
170
170
|
deps: ['@sveltejs/kit'],
|
|
@@ -201,7 +201,7 @@ export const FRAMEWORK_SIGNALS = {
|
|
|
201
201
|
deps: ['@tanstack/react-start', '@tanstack/start'],
|
|
202
202
|
configs: [],
|
|
203
203
|
},
|
|
204
|
-
[Framework.VITE]: { deps: ['vite'], configs: VITE_CONFIGS },
|
|
204
|
+
[Framework.VITE]: { deps: ['vite', 'vinext'], configs: VITE_CONFIGS },
|
|
205
205
|
/** CRA has no config file at all, so the dependency is the only signal. */
|
|
206
206
|
[Framework.CRA]: { deps: ['react-scripts'], configs: [] },
|
|
207
207
|
/** Nothing identifies plain HTML; it is where the chain below ends. */
|
|
@@ -325,8 +325,11 @@ function declaredDependencies(pkg) {
|
|
|
325
325
|
function detectFramework(input) {
|
|
326
326
|
for (const framework of DETECTION_ORDER) {
|
|
327
327
|
const signals = FRAMEWORK_SIGNALS[framework];
|
|
328
|
-
|
|
328
|
+
const hasDisqualifyingDependency = true === signals.depsUnless?.some((d) => depVersion(input.pkg, d) !== undefined);
|
|
329
|
+
if (signals.deps.some((d) => depVersion(input.pkg, d) !== undefined) &&
|
|
330
|
+
!hasDisqualifyingDependency) {
|
|
329
331
|
return framework;
|
|
332
|
+
}
|
|
330
333
|
if (hasAnyConfig(input.configFiles, signals.configs) &&
|
|
331
334
|
!hasAnyConfig(input.configFiles, signals.configsUnless ?? [])) {
|
|
332
335
|
return framework;
|
|
@@ -34,6 +34,22 @@ export declare function detectDjangoProject(exists: (file: string) => boolean, r
|
|
|
34
34
|
export declare function djangoSetupMessage(): string;
|
|
35
35
|
/** The ecosystem this directory looks like, or undefined when nothing says. */
|
|
36
36
|
export declare function detectNonJsEcosystem(exists: (file: string) => boolean): string | undefined;
|
|
37
|
+
/** Which answer `init` gives when there is no package.json. */
|
|
38
|
+
export declare const NoPackageJsonCase: {
|
|
39
|
+
/** No ecosystem marker and no page: the only honest answer is "run `init` somewhere else". */
|
|
40
|
+
readonly WRONG_DIRECTORY: "wrong_directory";
|
|
41
|
+
/** A page with no build step, which takes the script-tag snippet. */
|
|
42
|
+
readonly STATIC_PAGE: "static_page";
|
|
43
|
+
/** Flutter web paints into a canvas, so there is nothing to instrument. */
|
|
44
|
+
readonly FLUTTER: "flutter";
|
|
45
|
+
/** A recognised non-JS ecosystem, which can load the SDK from a URL. */
|
|
46
|
+
readonly NON_JS: "non_js";
|
|
47
|
+
};
|
|
48
|
+
export type NoPackageJsonCase = (typeof NoPackageJsonCase)[keyof typeof NoPackageJsonCase];
|
|
49
|
+
export interface NoPackageJsonAnswer {
|
|
50
|
+
kind: NoPackageJsonCase;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
37
53
|
/**
|
|
38
54
|
* What `init` should say when there is no package.json.
|
|
39
55
|
*
|
|
@@ -42,3 +58,10 @@ export declare function detectNonJsEcosystem(exists: (file: string) => boolean):
|
|
|
42
58
|
* second needs to know that no path exists and why.
|
|
43
59
|
*/
|
|
44
60
|
export declare function noPackageJsonMessage(exists: (file: string) => boolean, rootFiles?: readonly string[]): string;
|
|
61
|
+
/**
|
|
62
|
+
* {@link noPackageJsonMessage}, plus which case it picked.
|
|
63
|
+
*
|
|
64
|
+
* `init` acts on the case (it writes nothing for {@link NoPackageJsonCase.WRONG_DIRECTORY}), so the
|
|
65
|
+
* choice is made here once rather than re-derived beside the message and kept in sync by hand.
|
|
66
|
+
*/
|
|
67
|
+
export declare function noPackageJsonAnswer(exists: (file: string) => boolean, rootFiles?: readonly string[]): NoPackageJsonAnswer;
|
|
@@ -97,6 +97,17 @@ export function detectNonJsEcosystem(exists) {
|
|
|
97
97
|
}
|
|
98
98
|
return undefined;
|
|
99
99
|
}
|
|
100
|
+
/** Which answer `init` gives when there is no package.json. */
|
|
101
|
+
export const NoPackageJsonCase = {
|
|
102
|
+
/** No ecosystem marker and no page: the only honest answer is "run `init` somewhere else". */
|
|
103
|
+
WRONG_DIRECTORY: 'wrong_directory',
|
|
104
|
+
/** A page with no build step, which takes the script-tag snippet. */
|
|
105
|
+
STATIC_PAGE: 'static_page',
|
|
106
|
+
/** Flutter web paints into a canvas, so there is nothing to instrument. */
|
|
107
|
+
FLUTTER: 'flutter',
|
|
108
|
+
/** A recognised non-JS ecosystem, which can load the SDK from a URL. */
|
|
109
|
+
NON_JS: 'non_js',
|
|
110
|
+
};
|
|
100
111
|
/**
|
|
101
112
|
* What `init` should say when there is no package.json.
|
|
102
113
|
*
|
|
@@ -105,38 +116,59 @@ export function detectNonJsEcosystem(exists) {
|
|
|
105
116
|
* second needs to know that no path exists and why.
|
|
106
117
|
*/
|
|
107
118
|
export function noPackageJsonMessage(exists, rootFiles = []) {
|
|
119
|
+
return noPackageJsonAnswer(exists, rootFiles).message;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* {@link noPackageJsonMessage}, plus which case it picked.
|
|
123
|
+
*
|
|
124
|
+
* `init` acts on the case (it writes nothing for {@link NoPackageJsonCase.WRONG_DIRECTORY}), so the
|
|
125
|
+
* choice is made here once rather than re-derived beside the message and kept in sync by hand.
|
|
126
|
+
*/
|
|
127
|
+
export function noPackageJsonAnswer(exists, rootFiles = []) {
|
|
108
128
|
const ecosystem = detectNonJsEcosystem(exists);
|
|
109
129
|
if (ecosystem === undefined) {
|
|
110
130
|
if (exists('index.html') || rootFiles.some((name) => name.endsWith('.html'))) {
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
'
|
|
131
|
+
return {
|
|
132
|
+
kind: NoPackageJsonCase.STATIC_PAGE,
|
|
133
|
+
message: 'This is a static page with no build step, so there is nothing for `reticle init` to wire. ' +
|
|
134
|
+
'Add the script-tag snippet below to the page while you develop, then reload it. Remove the ' +
|
|
135
|
+
'snippet before publishing: a static file has no development build to keep it out of production.',
|
|
136
|
+
};
|
|
114
137
|
}
|
|
115
|
-
return
|
|
116
|
-
|
|
138
|
+
return {
|
|
139
|
+
kind: NoPackageJsonCase.WRONG_DIRECTORY,
|
|
140
|
+
message: 'No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
|
|
141
|
+
"your app's directory, or from a repo root that contains it.",
|
|
142
|
+
};
|
|
117
143
|
}
|
|
118
144
|
if (ecosystem === FLUTTER) {
|
|
119
145
|
// An agent on a Flutter workspace asked us to "support Flutter, or clearly identify it as
|
|
120
146
|
// unsupported during init". The second is the truthful one, and saying it by name in one line
|
|
121
147
|
// is the difference between an agent that stops and an agent that spends an hour looking for
|
|
122
148
|
// the package.json we told it was missing.
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
'
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
return {
|
|
150
|
+
kind: NoPackageJsonCase.FLUTTER,
|
|
151
|
+
message: 'This is a Flutter project. Reticle cannot instrument it, and no directory or flag will ' +
|
|
152
|
+
'change that: Reticle reads the DOM of a running page, and Flutter web paints its entire UI ' +
|
|
153
|
+
'into a single canvas element with no DOM behind it. Nothing here is missing or misconfigured. ' +
|
|
154
|
+
'If this repo also contains an ordinary web app with its own package.json, run `reticle init` ' +
|
|
155
|
+
'inside that directory instead.',
|
|
156
|
+
};
|
|
128
157
|
}
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
return {
|
|
159
|
+
kind: NoPackageJsonCase.NON_JS,
|
|
160
|
+
message: `This looks like a ${ecosystem} project, and there is no package.json here or in any app ` +
|
|
161
|
+
'directory beneath it. That is not a blocker: Reticle needs its SDK loaded by the page, not ' +
|
|
162
|
+
'built by npm, and a page with no build step can load it from a URL. ' +
|
|
163
|
+
// The old message ended at "there is no directory you could run this from that would change
|
|
164
|
+
// that", which read as a refusal and was acted on as one — agents went looking for a bundler,
|
|
165
|
+
// or for a standalone build to inject by hand, or gave up and used a different tool. The
|
|
166
|
+
// capability was there the whole time; only the sentence was missing. Proven end to end on a
|
|
167
|
+
// page served by `python3 -m http.server` before this line was written.
|
|
168
|
+
'Add the script-tag snippet below to a template you only serve in development, then reload the ' +
|
|
169
|
+
'page. ' +
|
|
170
|
+
'If this project ALSO has a JS front end (a `frontend/`, `client/` or `assets/` directory with ' +
|
|
171
|
+
'its own package.json), running `reticle init` there instead gives you source mapping and ' +
|
|
172
|
+
'framework state as well: point at it with `--app <dir>`.',
|
|
173
|
+
};
|
|
142
174
|
}
|
|
@@ -26,6 +26,11 @@ export declare function workspaceParents(sources: WorkspaceSources): string[];
|
|
|
26
26
|
*/
|
|
27
27
|
/** Deps that mark a directory as a runnable web app even when it has no bundler config file. */
|
|
28
28
|
export declare const APP_DEPS: readonly ["next", "vite", "electron-vite"];
|
|
29
|
+
/**
|
|
30
|
+
* Whether `dir` is itself an app with a page: what the daemon asks before wiring a folder unasked,
|
|
31
|
+
* so a library or API package it happens to run in is left alone.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isWebApp(dir: string, io: Pick<InitIo, 'exists' | 'readFile'>): boolean;
|
|
29
34
|
/**
|
|
30
35
|
* App directories under a workspace root.
|
|
31
36
|
*
|
|
@@ -179,6 +179,13 @@ function appSignal(dir, io) {
|
|
|
179
179
|
// backend. A package with a dev script and no page is only a candidate when nothing else is.
|
|
180
180
|
return hasPage(dir, pkgRaw, io) ? AppSignal.UI : AppSignal.SERVED;
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether `dir` is itself an app with a page: what the daemon asks before wiring a folder unasked,
|
|
184
|
+
* so a library or API package it happens to run in is left alone.
|
|
185
|
+
*/
|
|
186
|
+
export function isWebApp(dir, io) {
|
|
187
|
+
return AppSignal.UI === appSignal(dir, io);
|
|
188
|
+
}
|
|
182
189
|
/**
|
|
183
190
|
* App directories under a workspace root.
|
|
184
191
|
*
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
import type { InitIo, InitOptions, InitResult } from '../run-types.js';
|
|
12
12
|
/** Re-enter `init`, scoped to one directory of the workspace. */
|
|
13
13
|
type RunInit = (options: InitOptions, io: InitIo) => InitResult;
|
|
14
|
+
declare const POWERSHELL_ONLY_LABEL: "PowerShell only:";
|
|
15
|
+
interface DisplayedWorkspaceAppCommand {
|
|
16
|
+
command: string;
|
|
17
|
+
label?: typeof POWERSHELL_ONLY_LABEL;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Render one discovered workspace app as a command the user can paste back into a shell.
|
|
21
|
+
*
|
|
22
|
+
* Plain paths need no quoting. Complex Windows paths are explicitly PowerShell-only because
|
|
23
|
+
* cmd.exe and PowerShell use different quoting grammars.
|
|
24
|
+
*/
|
|
25
|
+
export declare function displayedWorkspaceAppCommand(app: string, platform: NodeJS.Platform): DisplayedWorkspaceAppCommand;
|
|
14
26
|
/**
|
|
15
27
|
* When the current directory is a workspace root with no app of its own, wire the app instead of the
|
|
16
28
|
* root. One candidate is wired silently (there is nothing to ask about); several are listed, because
|
|
@@ -29,7 +29,36 @@ function appNamedByRootConfig(io) {
|
|
|
29
29
|
const named = workspacePackageDirs(io).filter((dir) => projectIdOf(io.readFile(`${dir}/${RETICLE_CONFIG_FILE}`)) === wanted);
|
|
30
30
|
return 1 === named.length ? named[0] : undefined;
|
|
31
31
|
}
|
|
32
|
-
const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init`
|
|
32
|
+
const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init` for the one you want:';
|
|
33
|
+
const WORKSPACE_APP_COMMAND = 'reticle init --app';
|
|
34
|
+
const POWERSHELL_ONLY_LABEL = 'PowerShell only:';
|
|
35
|
+
const PLAIN_SHELL_ARG = /^[A-Za-z0-9_./-]+$/;
|
|
36
|
+
function posixShellArg(arg) {
|
|
37
|
+
return `'${arg.replaceAll("'", "'\"'\"'")}'`;
|
|
38
|
+
}
|
|
39
|
+
function powerShellArg(arg) {
|
|
40
|
+
return `'${arg.replaceAll("'", "''")}'`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Render one discovered workspace app as a command the user can paste back into a shell.
|
|
44
|
+
*
|
|
45
|
+
* Plain paths need no quoting. Complex Windows paths are explicitly PowerShell-only because
|
|
46
|
+
* cmd.exe and PowerShell use different quoting grammars.
|
|
47
|
+
*/
|
|
48
|
+
export function displayedWorkspaceAppCommand(app, platform) {
|
|
49
|
+
if (PLAIN_SHELL_ARG.test(app)) {
|
|
50
|
+
return { command: `${WORKSPACE_APP_COMMAND} ${app}` };
|
|
51
|
+
}
|
|
52
|
+
if ('win32' === platform) {
|
|
53
|
+
return {
|
|
54
|
+
label: POWERSHELL_ONLY_LABEL,
|
|
55
|
+
command: `${WORKSPACE_APP_COMMAND} ${powerShellArg(app)}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
command: `${WORKSPACE_APP_COMMAND} ${posixShellArg(app)}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
33
62
|
/**
|
|
34
63
|
* When the current directory is a workspace root with no app of its own, wire the app instead of the
|
|
35
64
|
* root. One candidate is wired silently (there is nothing to ask about); several are listed, because
|
|
@@ -89,10 +118,12 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
89
118
|
if (0 === apps.length)
|
|
90
119
|
return null; // not a workspace — fall through to the normal HTML plan
|
|
91
120
|
io.print(AMBIGUOUS_HEADER);
|
|
92
|
-
for (const a of apps)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
for (const a of apps) {
|
|
122
|
+
const displayed = displayedWorkspaceAppCommand(a, process.platform);
|
|
123
|
+
if (displayed.label !== undefined)
|
|
124
|
+
io.print(` ${displayed.label}`);
|
|
125
|
+
io.print(` ${displayed.command}`);
|
|
126
|
+
}
|
|
96
127
|
return { ok: false, applied: 0, manual: apps.length };
|
|
97
128
|
}
|
|
98
129
|
return enterApp(options, io, target, 'No app in this directory — wiring', runInit);
|
|
@@ -31,4 +31,10 @@ export declare const FEEDBACK_HINT: string;
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function restartHint(mcpStatus: StepStatus | undefined,
|
|
33
33
|
/** The project's own dev command, when package.json named one. */
|
|
34
|
-
devCommand?: string
|
|
34
|
+
devCommand?: string,
|
|
35
|
+
/**
|
|
36
|
+
* Titles of other clients' registration steps this run left for the reader (the `⚠` rows). The
|
|
37
|
+
* Claude Code row says nothing about them, so without these the closing line contradicts the
|
|
38
|
+
* report above it.
|
|
39
|
+
*/
|
|
40
|
+
manualClients?: readonly string[]): string;
|
|
@@ -21,6 +21,24 @@ export const FEEDBACK_HINT = 'Anything wrong, missing, or awkward — in this se
|
|
|
21
21
|
'one thing that decides what gets fixed:\n' +
|
|
22
22
|
` ${CLI} feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened" (agents)\n` +
|
|
23
23
|
` ${CLI} feedback "what worked, what didn't" (humans)`;
|
|
24
|
+
/** User-facing closing copy for `restartHint` — one named home per sentence block. */
|
|
25
|
+
const RestartHintCopy = {
|
|
26
|
+
DEMO: 'Then drive one real flow of your app end to end and report the verdict — that is the install ' +
|
|
27
|
+
"proving itself, and it is the agent's job, not a separate command.",
|
|
28
|
+
PROVE: 'Then run `npx @reticlehq/server status` — it confirms the app connected, or says exactly why ' +
|
|
29
|
+
'it has not.',
|
|
30
|
+
ALREADY_REGISTERED_ALL: 'The Reticle MCP server was already registered on this machine, so there is no restart to do ' +
|
|
31
|
+
'and the tools are available now.',
|
|
32
|
+
ALREADY_REGISTERED_CLAUDE_HEAD: 'The Reticle MCP server was already registered with Claude Code, so there is no restart to ' +
|
|
33
|
+
'do and the tools are available there now. They are NOT available in the clients whose ' +
|
|
34
|
+
'step above still needs a hand edit (',
|
|
35
|
+
ALREADY_REGISTERED_CLAUDE_TAIL: ') until that is done.',
|
|
36
|
+
CLOSING_ALREADY_NO_MANUAL: 'Once it shows a session, drive a flow — that is the install finished. Carry straight on; ' +
|
|
37
|
+
'nothing here is waiting on a human.',
|
|
38
|
+
CLOSING_ALREADY_WITH_MANUAL: 'Once it shows a session, drive a flow where the tools are already available — that is the ' +
|
|
39
|
+
'install finished on that client. The hand-edit step above still needs a human for the other ' +
|
|
40
|
+
'clients named there.',
|
|
41
|
+
};
|
|
24
42
|
/**
|
|
25
43
|
* What to say about the dev server.
|
|
26
44
|
*
|
|
@@ -53,7 +71,13 @@ function devServerRestart(devCommand) {
|
|
|
53
71
|
*/
|
|
54
72
|
export function restartHint(mcpStatus,
|
|
55
73
|
/** The project's own dev command, when package.json named one. */
|
|
56
|
-
devCommand
|
|
74
|
+
devCommand,
|
|
75
|
+
/**
|
|
76
|
+
* Titles of other clients' registration steps this run left for the reader (the `⚠` rows). The
|
|
77
|
+
* Claude Code row says nothing about them, so without these the closing line contradicts the
|
|
78
|
+
* report above it.
|
|
79
|
+
*/
|
|
80
|
+
manualClients = []) {
|
|
57
81
|
const dev = `${devServerRestart(devCommand)}.`;
|
|
58
82
|
// NAME THE COMMAND THAT PROVES IT, not one that merely asks.
|
|
59
83
|
//
|
|
@@ -77,10 +101,8 @@ devCommand) {
|
|
|
77
101
|
// So the close hands the agent the sequence instead. It is deliberately one drive of one real
|
|
78
102
|
// flow ending in a verdict, because an install that ends in "the tools are available" is an
|
|
79
103
|
// install that ends in nothing.
|
|
80
|
-
const demo =
|
|
81
|
-
|
|
82
|
-
const prove = 'Then run `npx @reticlehq/server status` — it confirms the app connected, or says exactly why ' +
|
|
83
|
-
'it has not.';
|
|
104
|
+
const demo = RestartHintCopy.DEMO;
|
|
105
|
+
const prove = RestartHintCopy.PROVE;
|
|
84
106
|
// Nothing registered this run (`--no-mcp`, or a client that needs a manual edit): a restart picks
|
|
85
107
|
// up nothing, so advice about one would be advice about something we did not do.
|
|
86
108
|
if (!wasMcpRegistered(mcpStatus))
|
|
@@ -92,13 +114,18 @@ devCommand) {
|
|
|
92
114
|
// agent, the tools only appear after that" — and an agent believes the output in front of it over
|
|
93
115
|
// whatever its skill file said, so it stops with the project wired and nothing driven.
|
|
94
116
|
if (mcpStatus === StepStatus.ALREADY) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
117
|
+
// Only Claude Code's row was read, so "available now" is true for Claude Code and nothing else.
|
|
118
|
+
// Name the registrations still left, or a Codex user reads two contradicting lines and believes
|
|
119
|
+
// the last one.
|
|
120
|
+
const availability = 0 === manualClients.length
|
|
121
|
+
? RestartHintCopy.ALREADY_REGISTERED_ALL
|
|
122
|
+
: RestartHintCopy.ALREADY_REGISTERED_CLAUDE_HEAD +
|
|
123
|
+
manualClients.join(', ') +
|
|
124
|
+
RestartHintCopy.ALREADY_REGISTERED_CLAUDE_TAIL;
|
|
125
|
+
const closing = 0 === manualClients.length
|
|
126
|
+
? RestartHintCopy.CLOSING_ALREADY_NO_MANUAL
|
|
127
|
+
: RestartHintCopy.CLOSING_ALREADY_WITH_MANUAL;
|
|
128
|
+
return `${dev}\n${demo}\n${availability}\n${prove}\n${closing}`;
|
|
102
129
|
}
|
|
103
130
|
return (`${dev}\n` +
|
|
104
131
|
`${demo}\n` +
|
|
@@ -24,23 +24,26 @@ const WILDCARDS = ['*', 'ws:', 'wss:', 'ws://*', 'https:'];
|
|
|
24
24
|
function bridgeOrigins(port) {
|
|
25
25
|
return [`ws://localhost:${String(port)}`, `ws://127.0.0.1:${String(port)}`];
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
* Matches to the end of the directive (`;`) or the end of the enclosing quoted string, which is how
|
|
31
|
-
* these are written in both a Next `headers()` value and a `<meta content="...">`.
|
|
32
|
-
*/
|
|
27
|
+
const TERNARY_GAP = /^"\s*:\s*"$/;
|
|
28
|
+
const EXPRESSION_EDGE = /^["'`})\]]+|["'`})\]]+$/g;
|
|
33
29
|
function directiveSources(text, directive) {
|
|
34
30
|
// Stops at the directive separator or the closing double quote of the enclosing string. Single
|
|
35
31
|
// quotes are NOT terminators: `'self'` is a source, not the end of the list.
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (list === undefined)
|
|
32
|
+
const matches = [...text.matchAll(new RegExp(`${directive}([^;"]*)`, 'gi'))];
|
|
33
|
+
if (0 === matches.length)
|
|
39
34
|
return undefined;
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
35
|
+
return matches.map((match, index) => {
|
|
36
|
+
const previous = matches[index - 1];
|
|
37
|
+
const previousEnd = previous === undefined ? undefined : (previous.index ?? 0) + previous[0].length;
|
|
38
|
+
return {
|
|
39
|
+
sources: (match[1] ?? '')
|
|
40
|
+
.split(/\s+/)
|
|
41
|
+
.map((source) => source.trim())
|
|
42
|
+
.filter((source) => source.length > 0),
|
|
43
|
+
alternativeWithPrevious: previousEnd !== undefined &&
|
|
44
|
+
TERNARY_GAP.test(text.slice(previousEnd, match.index ?? previousEnd)),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
49
|
* What this policy allows a WebSocket to reach — `connect-src`, or `default-src` when it is absent.
|
|
@@ -55,7 +58,11 @@ function directiveSources(text, directive) {
|
|
|
55
58
|
* Still undefined when NEITHER is present — a policy that constrains neither is not blocking us.
|
|
56
59
|
*/
|
|
57
60
|
function connectSrcSources(text) {
|
|
58
|
-
|
|
61
|
+
const sources = directiveSources(text, 'connect-src') ?? directiveSources(text, 'default-src');
|
|
62
|
+
return sources?.map(({ sources: sourceList, alternativeWithPrevious }) => ({
|
|
63
|
+
sources: sourceList.map((source) => source.replace(EXPRESSION_EDGE, '')),
|
|
64
|
+
alternativeWithPrevious,
|
|
65
|
+
}));
|
|
59
66
|
}
|
|
60
67
|
/**
|
|
61
68
|
* The problem with this app's `connect-src`, or undefined if there is not one.
|
|
@@ -67,15 +74,41 @@ export function cspConnectSrcProblem(text, port) {
|
|
|
67
74
|
const sources = connectSrcSources(text);
|
|
68
75
|
if (sources === undefined || 0 === sources.length)
|
|
69
76
|
return undefined;
|
|
70
|
-
if (sources.some((source) => WILDCARDS.includes(source)))
|
|
71
|
-
return undefined;
|
|
72
77
|
const wanted = bridgeOrigins(port);
|
|
78
|
+
// Separate CSP policies are all enforced, so every group must admit the bridge. Two occurrences
|
|
79
|
+
// that are alternate branches of one source-level ternary are not enforced together: either
|
|
80
|
+
// branch admitting the bridge is enough for that group.
|
|
81
|
+
const missingByPolicyGroup = [];
|
|
82
|
+
for (const { sources: sourceList, alternativeWithPrevious } of sources) {
|
|
83
|
+
const missingFromSourceList = sourceList.some((source) => WILDCARDS.includes(source))
|
|
84
|
+
? []
|
|
85
|
+
: wanted.filter((origin) => !sourceList.includes(origin));
|
|
86
|
+
if (alternativeWithPrevious) {
|
|
87
|
+
const previousIndex = missingByPolicyGroup.length - 1;
|
|
88
|
+
const previousMissing = missingByPolicyGroup[previousIndex];
|
|
89
|
+
if (previousMissing === undefined) {
|
|
90
|
+
missingByPolicyGroup.push(missingFromSourceList);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Either complete branch makes this conditional group safe. If both branches block, retain
|
|
94
|
+
// every origin needed to make either possible policy admit the bridge.
|
|
95
|
+
missingByPolicyGroup[previousIndex] =
|
|
96
|
+
0 === previousMissing.length || 0 === missingFromSourceList.length
|
|
97
|
+
? []
|
|
98
|
+
: wanted.filter((origin) => previousMissing.includes(origin) || missingFromSourceList.includes(origin));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
missingByPolicyGroup.push(missingFromSourceList);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
73
105
|
// BOTH, not either: the SDK picks its host from how the page was served, and a policy that admits
|
|
74
106
|
// one is a coin flip. A coin flip that fails is indistinguishable from every other silent
|
|
75
107
|
// non-connect, which is the whole cost being avoided here.
|
|
76
|
-
|
|
108
|
+
const blockingGroups = missingByPolicyGroup.filter((missing) => 0 < missing.length);
|
|
109
|
+
if (0 === blockingGroups.length)
|
|
77
110
|
return undefined;
|
|
78
|
-
const missing = wanted.filter((origin) =>
|
|
111
|
+
const missing = wanted.filter((origin) => blockingGroups.some((group) => group.includes(origin)));
|
|
79
112
|
return (`this app declares a Content-Security-Policy whose \`connect-src\` does not admit the Reticle ` +
|
|
80
113
|
`bridge: ${missing.join(' and ')} ${1 === missing.length ? 'is' : 'are'} missing. The browser ` +
|
|
81
114
|
`will block the WebSocket and report it in ITS console only — every check on the Reticle side ` +
|
|
@@ -125,7 +158,19 @@ export function cspInlineScriptProblem(text, port) {
|
|
|
125
158
|
const sources = scriptSrcSources(text);
|
|
126
159
|
if (sources === undefined || 0 === sources.length)
|
|
127
160
|
return undefined;
|
|
128
|
-
|
|
161
|
+
// The same CSP composition rule applies here: any independent blocking policy wins, while a
|
|
162
|
+
// source-level conditional is safe when either of its mutually exclusive branches permits inline.
|
|
163
|
+
const policiesThatBlockInlineScript = [];
|
|
164
|
+
for (const { sources: sourceList, alternativeWithPrevious } of sources) {
|
|
165
|
+
const blocksInline = blocksInlineScript(sourceList);
|
|
166
|
+
if (alternativeWithPrevious) {
|
|
167
|
+
policiesThatBlockInlineScript[policiesThatBlockInlineScript.length - 1] &&= blocksInline;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
policiesThatBlockInlineScript.push(blocksInline);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!policiesThatBlockInlineScript.some(Boolean))
|
|
129
174
|
return undefined;
|
|
130
175
|
return (`this app declares a Content-Security-Policy whose \`script-src\` does not admit an inline ` +
|
|
131
176
|
`script, so the connect snippet never executes. Nothing on the Reticle side can see this: with ` +
|
|
@@ -182,7 +227,7 @@ function withBridgeOrigins(policy, port) {
|
|
|
182
227
|
}
|
|
183
228
|
// No connect-src: the socket falls back to default-src, so the new directive starts from what
|
|
184
229
|
// default-src already allowed — adding it bare would take 'self' away from every fetch the app does.
|
|
185
|
-
const fallback = directiveSources(policy, 'default-src') ?? [];
|
|
230
|
+
const fallback = directiveSources(policy, 'default-src')?.[0]?.sources ?? [];
|
|
186
231
|
const added = ` connect-src ${[...fallback, ...origins].join(' ')}`;
|
|
187
232
|
const trimmed = policy.trimEnd();
|
|
188
233
|
return trimmed.endsWith(';') ? `${trimmed}${added};` : `${trimmed};${added}`;
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,9 @@ export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning, } from '
|
|
|
29
29
|
export { configWithInstallSource } from './project/install-source-config.js';
|
|
30
30
|
export { projectIdOf, rememberProjectOnDisk, type RegistryIo } from './project/remember-project.js';
|
|
31
31
|
export { detectPackageManager, Framework, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
|
|
32
|
-
export { findWorkspaceApps } from './detect/workspace-apps.js';
|
|
32
|
+
export { findWorkspaceApps, isWebApp, workspacePackageDirs } from './detect/workspace-apps.js';
|
|
33
33
|
export { desktopLaunch, type DesktopLaunch } from './detect/dev-script.js';
|
|
34
|
+
export { detectNonJsEcosystem, noPackageJsonMessage } from './detect/non-js-project.js';
|
|
34
35
|
export { deriveProjectId, packageName } from './project/project-id.js';
|
|
35
36
|
export { refreshAgentRules } from './project/refresh-rules.js';
|
|
36
37
|
export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
|
package/dist/index.js
CHANGED
|
@@ -29,8 +29,9 @@ export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning, } from '
|
|
|
29
29
|
export { configWithInstallSource } from './project/install-source-config.js';
|
|
30
30
|
export { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
|
|
31
31
|
export { detectPackageManager, Framework, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
|
|
32
|
-
export { findWorkspaceApps } from './detect/workspace-apps.js';
|
|
32
|
+
export { findWorkspaceApps, isWebApp, workspacePackageDirs } from './detect/workspace-apps.js';
|
|
33
33
|
export { desktopLaunch } from './detect/dev-script.js';
|
|
34
|
+
export { detectNonJsEcosystem, noPackageJsonMessage } from './detect/non-js-project.js';
|
|
34
35
|
export { deriveProjectId, packageName } from './project/project-id.js';
|
|
35
36
|
export { refreshAgentRules } from './project/refresh-rules.js';
|
|
36
37
|
export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
|
package/dist/no-package-json.js
CHANGED
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
* static page it can see whole, or prints the snippet for the stack it recognised (Streamlit,
|
|
7
7
|
* Django, several pages) and stops.
|
|
8
8
|
*/
|
|
9
|
-
import { join } from 'node:path';
|
|
10
9
|
import { PackageManager, Framework } from './detect/detect.js';
|
|
11
|
-
import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage,
|
|
10
|
+
import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, NoPackageJsonCase, noPackageJsonAnswer, streamlitSetupMessage, } from './detect/non-js-project.js';
|
|
12
11
|
import { InitFailure } from './diagnose/init-failure.js';
|
|
13
12
|
import { HTML_INDEX_PATH, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
|
|
14
13
|
import { connectArg, connectArgWithToken, djangoMiddlewareSnippet, reticleConfigContent, staticPageSnippet, streamlitPageSnippet, } from './patch/snippets.js';
|
|
@@ -60,9 +59,23 @@ function wireStaticPage(options, io, connect) {
|
|
|
60
59
|
};
|
|
61
60
|
}
|
|
62
61
|
export function initWithoutPackageJson(options, io) {
|
|
62
|
+
// Relative to the IO's own root, like every other read here (`readFile` below, `HTML_INDEX_PATH`).
|
|
63
|
+
// The real IO resolves it to the same file; joining `options.cwd` onto it only made these checks
|
|
64
|
+
// miss in the in-memory IO the tests use, so no test could tell the branches below apart.
|
|
65
|
+
const existsHere = (file) => io.exists(file);
|
|
63
66
|
const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
|
|
64
67
|
// Asked only when Streamlit already said no, so the two can never both claim the page.
|
|
65
|
-
const django = !streamlit && detectDjangoProject(
|
|
68
|
+
const django = !streamlit && detectDjangoProject(existsHere, io.rootFiles());
|
|
69
|
+
// "Run init from your app's directory" is the whole answer, so stop at it. This used to fall
|
|
70
|
+
// through to the shared tail below: it wrote `.reticle.json` into the directory it had just called
|
|
71
|
+
// wrong, then printed a script-tag snippet under a message that asks for none (#1366).
|
|
72
|
+
const answer = noPackageJsonAnswer(existsHere, io.rootFiles());
|
|
73
|
+
if (!streamlit && !django && answer.kind === NoPackageJsonCase.WRONG_DIRECTORY) {
|
|
74
|
+
io.print(answer.message);
|
|
75
|
+
if (!options.dryRun)
|
|
76
|
+
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
77
|
+
return { ok: false, applied: 0, manual: 0 };
|
|
78
|
+
}
|
|
66
79
|
// Scope the project on disk before anything else, so the daemon can serve this page.
|
|
67
80
|
//
|
|
68
81
|
// This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
|
|
@@ -110,11 +123,7 @@ export function initWithoutPackageJson(options, io) {
|
|
|
110
123
|
// a path problem and goes looking for a directory that cannot exist — reported from a
|
|
111
124
|
// Streamlit app, where the search continued into hunting for a browser bundle to inject by
|
|
112
125
|
// hand before the real answer surfaced.
|
|
113
|
-
streamlit
|
|
114
|
-
? streamlitSetupMessage()
|
|
115
|
-
: django
|
|
116
|
-
? djangoSetupMessage()
|
|
117
|
-
: noPackageJsonMessage((file) => io.exists(join(options.cwd, file)), io.rootFiles()));
|
|
126
|
+
streamlit ? streamlitSetupMessage() : django ? djangoSetupMessage() : answer.message);
|
|
118
127
|
writeConfig();
|
|
119
128
|
// The message says "add the snippet below". Print the snippet, or the message is the same broken
|
|
120
129
|
// promise in the other direction.
|
package/dist/run.js
CHANGED
|
@@ -13,10 +13,11 @@ import { preflight } from './plan/preflight.js';
|
|
|
13
13
|
import { devCommandFrom, devScriptBody } from './detect/dev-script.js';
|
|
14
14
|
import { restartHint, FEEDBACK_HINT } from './diagnose/closing-hint.js';
|
|
15
15
|
import { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
|
|
16
|
-
import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework,
|
|
16
|
+
import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, } from './detect/detect.js';
|
|
17
17
|
import { ANGULAR_PROXY_PATH, angularEntry } from './patch/angular.js';
|
|
18
18
|
import { HTML_INDEX_PATH } from './patch/static-page.js';
|
|
19
19
|
import { initWithoutPackageJson } from './no-package-json.js';
|
|
20
|
+
import { sdkPackagesDeclared, sdkPackagesPresent } from './sdk-packages.js';
|
|
20
21
|
import { wasMcpRegistered } from './register/mcp-registered.js';
|
|
21
22
|
import { pickAstroHost } from './patch/astro-host.js';
|
|
22
23
|
import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
|
|
@@ -42,7 +43,7 @@ function craEntryOf(io) {
|
|
|
42
43
|
}
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
45
|
-
import { DEPS_TARGET, RETICLE_CONFIG_FILE,
|
|
46
|
+
import { DEPS_TARGET, RETICLE_CONFIG_FILE, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
|
|
46
47
|
import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
|
|
47
48
|
import { reticleDevLocation } from './patch/next-patch.js';
|
|
48
49
|
import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
|
|
@@ -56,7 +57,6 @@ import { InitFailure } from './diagnose/init-failure.js';
|
|
|
56
57
|
import { resolveLockfiles } from './detect/lockfiles.js';
|
|
57
58
|
// Re-exported: it moved to its own module, and every existing importer says `run.js`.
|
|
58
59
|
export { resolveLockfiles };
|
|
59
|
-
const NODE_MODULES_DIR = 'node_modules';
|
|
60
60
|
const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
|
|
61
61
|
const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
|
|
62
62
|
const TANSTACK_START_ROOT_CANDIDATES = [
|
|
@@ -195,6 +195,8 @@ function resolvedStatus(plan, target, failed, skipped) {
|
|
|
195
195
|
return StepStatus.MANUAL;
|
|
196
196
|
return step.status;
|
|
197
197
|
}
|
|
198
|
+
/** Every client registration row is titled `MCP server (<client>)`; the closing hint names the unfinished ones. */
|
|
199
|
+
const MCP_CLIENT_TITLE_PREFIX = 'MCP server (';
|
|
198
200
|
const STATUS_SYMBOL = {
|
|
199
201
|
[StepStatus.APPLY]: '✓',
|
|
200
202
|
[StepStatus.MANUAL]: '⚠',
|
|
@@ -477,6 +479,8 @@ continuesToRuntime = false) {
|
|
|
477
479
|
// the app never dials the daemon and every tool answers "no browser session connected". `ok` was
|
|
478
480
|
// hardcoded true, so a run that could not possibly work reported success.
|
|
479
481
|
let connectPending = false;
|
|
482
|
+
// Other clients' registrations the run left for the reader, named for the closing hint.
|
|
483
|
+
const manualClients = [];
|
|
480
484
|
for (const s of plan.steps) {
|
|
481
485
|
// A side effect that failed to apply is reported as a manual step with its fallback command.
|
|
482
486
|
const note = degraded.get(s.target);
|
|
@@ -504,6 +508,9 @@ continuesToRuntime = false) {
|
|
|
504
508
|
manual++;
|
|
505
509
|
if (isConnectStep(s.title))
|
|
506
510
|
connectPending = true;
|
|
511
|
+
if (s.target !== MCP_TARGET && s.title.startsWith(MCP_CLIENT_TITLE_PREFIX)) {
|
|
512
|
+
manualClients.push(s.title);
|
|
513
|
+
}
|
|
507
514
|
}
|
|
508
515
|
for (const line of detail.split('\n'))
|
|
509
516
|
io.print(` ${line}`);
|
|
@@ -525,7 +532,7 @@ continuesToRuntime = false) {
|
|
|
525
532
|
}
|
|
526
533
|
const mcpStatus = resolvedStatus(plan, MCP_TARGET, failed, skipped);
|
|
527
534
|
if (!continuesToRuntime) {
|
|
528
|
-
io.print(restartHint(mcpStatus, devCommand));
|
|
535
|
+
io.print(restartHint(mcpStatus, devCommand, manualClients));
|
|
529
536
|
}
|
|
530
537
|
// Carried out even when the hint above was printed, because the RUNTIME path needs the same fact
|
|
531
538
|
// and could not reach it: `restartHint` is only printed when this run stops at the files, and the
|
|
@@ -547,25 +554,6 @@ continuesToRuntime = false) {
|
|
|
547
554
|
* MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
|
|
548
555
|
* message; a half-wired app is a broken project.
|
|
549
556
|
*/
|
|
550
|
-
/**
|
|
551
|
-
* Are the SDK packages actually on disk, whatever the install step reported?
|
|
552
|
-
*
|
|
553
|
-
* `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
|
|
554
|
-
* not there — that took a dev server down once, and installing Reticle must never be why an app
|
|
555
|
-
* stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
|
|
556
|
-
* install subprocess exited 0", and those come apart in exactly the situation the failure creates:
|
|
557
|
-
* init tells the user to install by hand, they do, they re-run init, the install step fails again
|
|
558
|
-
* (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
|
|
559
|
-
* a Next 16 app where npm had already installed both packages successfully — leaving an init that
|
|
560
|
-
* could not be retried into working, which is the shape this guard was written to prevent.
|
|
561
|
-
*
|
|
562
|
-
* Reading node_modules answers the real question and costs one `exists` call per package.
|
|
563
|
-
*/
|
|
564
|
-
function sdkPackagesPresent(framework, uiLibrary, io) {
|
|
565
|
-
const packages = frameworkPackages(framework, uiLibrary);
|
|
566
|
-
return (packages.length > 0 &&
|
|
567
|
-
packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
|
|
568
|
-
}
|
|
569
557
|
function applyEffects(plan, io, packageManager) {
|
|
570
558
|
const why = new Map();
|
|
571
559
|
const failed = new Set();
|
|
@@ -635,14 +623,17 @@ function applyEffects(plan, io, packageManager) {
|
|
|
635
623
|
continue;
|
|
636
624
|
}
|
|
637
625
|
// Verify, don't re-run: give the install step itself the same sdkPackagesPresent benefit
|
|
638
|
-
// already given to the wiring it gates below (#683).
|
|
639
|
-
|
|
626
|
+
// already given to the wiring it gates below (#683) — but only when package.json DECLARES
|
|
627
|
+
// them. Reported from a Tauri + Next app: `pnpm add` died on ERR_PNPM_UNEXPECTED_STORE, the
|
|
628
|
+
// packages happened to resolve, init printed success, and the manifest never gained them.
|
|
629
|
+
const present = s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io);
|
|
630
|
+
if (present && sdkPackagesDeclared(plan.framework, plan.uiLibrary, io))
|
|
640
631
|
continue;
|
|
641
632
|
failed.add(s.target);
|
|
642
633
|
// A failed install only blocks the wiring when the packages are genuinely ABSENT. See
|
|
643
634
|
// sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
|
|
644
635
|
if (s.target === DEPS_TARGET) {
|
|
645
|
-
installFailed =
|
|
636
|
+
installFailed = !present;
|
|
646
637
|
const explained = explainInstallFailure(io, packageManager, exec, s.detail);
|
|
647
638
|
if (explained !== undefined)
|
|
648
639
|
why.set(s.target, explained);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Framework, UiLibrary } from './detect/detect.js';
|
|
2
|
+
import type { InitIo } from './run-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Are the SDK packages actually on disk, whatever the install step reported?
|
|
5
|
+
*
|
|
6
|
+
* `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
|
|
7
|
+
* not there — that took a dev server down once, and installing Reticle must never be why an app
|
|
8
|
+
* stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
|
|
9
|
+
* install subprocess exited 0", and those come apart in exactly the situation the failure creates:
|
|
10
|
+
* init tells the user to install by hand, they do, they re-run init, the install step fails again
|
|
11
|
+
* (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
|
|
12
|
+
* a Next 16 app where npm had already installed both packages successfully — leaving an init that
|
|
13
|
+
* could not be retried into working, which is the shape this guard was written to prevent.
|
|
14
|
+
*
|
|
15
|
+
* Reading node_modules answers the real question and costs one `exists` call per package.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sdkPackagesPresent(framework: Framework, uiLibrary: UiLibrary, io: Pick<InitIo, 'exists'>): boolean;
|
|
18
|
+
/** Does package.json list every SDK package? On disk without this, the next clean install drops them. */
|
|
19
|
+
export declare function sdkPackagesDeclared(framework: Framework, uiLibrary: UiLibrary, io: Pick<InitIo, 'readFile'>): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PACKAGE_JSON } from './detect/workspace-apps.js';
|
|
2
|
+
import { frameworkPackages } from './plan/plan.js';
|
|
3
|
+
const NODE_MODULES_DIR = 'node_modules';
|
|
4
|
+
/**
|
|
5
|
+
* Are the SDK packages actually on disk, whatever the install step reported?
|
|
6
|
+
*
|
|
7
|
+
* `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
|
|
8
|
+
* not there — that took a dev server down once, and installing Reticle must never be why an app
|
|
9
|
+
* stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
|
|
10
|
+
* install subprocess exited 0", and those come apart in exactly the situation the failure creates:
|
|
11
|
+
* init tells the user to install by hand, they do, they re-run init, the install step fails again
|
|
12
|
+
* (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
|
|
13
|
+
* a Next 16 app where npm had already installed both packages successfully — leaving an init that
|
|
14
|
+
* could not be retried into working, which is the shape this guard was written to prevent.
|
|
15
|
+
*
|
|
16
|
+
* Reading node_modules answers the real question and costs one `exists` call per package.
|
|
17
|
+
*/
|
|
18
|
+
export function sdkPackagesPresent(framework, uiLibrary, io) {
|
|
19
|
+
const packages = frameworkPackages(framework, uiLibrary);
|
|
20
|
+
return (packages.length > 0 &&
|
|
21
|
+
packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
|
|
22
|
+
}
|
|
23
|
+
/** Does package.json list every SDK package? On disk without this, the next clean install drops them. */
|
|
24
|
+
export function sdkPackagesDeclared(framework, uiLibrary, io) {
|
|
25
|
+
let manifest;
|
|
26
|
+
try {
|
|
27
|
+
manifest = JSON.parse(io.readFile(PACKAGE_JSON) ?? '{}');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const m = (manifest ?? {});
|
|
33
|
+
return frameworkPackages(framework, uiLibrary).every((p) => m['dependencies']?.[p] !== undefined || m['devDependencies']?.[p] !== undefined);
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/init",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Reticle project scaffolder — detects the framework, writes the config, patches the build and registers the MCP server. The build-time half of `reticle init`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"./package.json": "./package.json"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@reticlehq/core": "3.
|
|
16
|
+
"@reticlehq/core": "3.6.0"
|
|
17
17
|
},
|
|
18
18
|
"license": "FSL-1.1-ALv2",
|
|
19
19
|
"author": "Reticle contributors",
|