@reticlehq/init 3.4.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/diagnose/init-failure.d.ts +7 -0
- package/dist/diagnose/init-failure.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/no-package-json.js +29 -10
- package/dist/node-io.d.ts +3 -1
- package/dist/node-io.js +9 -5
- package/dist/register/mcp-clients.js +5 -3
- package/dist/run.js +24 -28
- 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}`;
|
|
@@ -13,6 +13,13 @@ export declare const InitFailure: {
|
|
|
13
13
|
readonly DEPENDENCY_INSTALL: "dependency_install";
|
|
14
14
|
/** The `claude mcp add` step failed — the CLI is missing or refused. Reticle installs but is unreachable. */
|
|
15
15
|
readonly MCP_REGISTRATION: "mcp_registration";
|
|
16
|
+
/** The checkout or required tooling failed preflight. */
|
|
17
|
+
readonly PREFLIGHT: "preflight";
|
|
18
|
+
/** Runtime continuation could not start the app or connect its SDK. */
|
|
19
|
+
readonly DEV_SERVER: "dev_server";
|
|
20
|
+
readonly APP_CONNECTION: "app_connection";
|
|
21
|
+
readonly BRIDGE_OCCUPIED: "bridge_occupied";
|
|
22
|
+
readonly RUNTIME_ERROR: "runtime_error";
|
|
16
23
|
readonly OTHER: "other";
|
|
17
24
|
};
|
|
18
25
|
export type InitFailure = (typeof InitFailure)[keyof typeof InitFailure];
|
|
@@ -13,5 +13,12 @@ export const InitFailure = {
|
|
|
13
13
|
DEPENDENCY_INSTALL: 'dependency_install',
|
|
14
14
|
/** The `claude mcp add` step failed — the CLI is missing or refused. Reticle installs but is unreachable. */
|
|
15
15
|
MCP_REGISTRATION: 'mcp_registration',
|
|
16
|
+
/** The checkout or required tooling failed preflight. */
|
|
17
|
+
PREFLIGHT: 'preflight',
|
|
18
|
+
/** Runtime continuation could not start the app or connect its SDK. */
|
|
19
|
+
DEV_SERVER: 'dev_server',
|
|
20
|
+
APP_CONNECTION: 'app_connection',
|
|
21
|
+
BRIDGE_OCCUPIED: 'bridge_occupied',
|
|
22
|
+
RUNTIME_ERROR: 'runtime_error',
|
|
16
23
|
OTHER: 'other',
|
|
17
24
|
};
|
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';
|
|
@@ -21,6 +20,7 @@ const SNIPPET_WRITTEN = `Wrote the dev-only connect snippet into ${HTML_INDEX_PA
|
|
|
21
20
|
'gitignored — keep it out of anything you publish, and let each teammate run `reticle init` ' +
|
|
22
21
|
'for their own.';
|
|
23
22
|
const SNIPPET_ALREADY = `${HTML_INDEX_PATH} already loads the Reticle SDK — nothing to change.`;
|
|
23
|
+
const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
|
|
24
24
|
/**
|
|
25
25
|
* Write the snippet into `index.html`, and hand over to setup as a success.
|
|
26
26
|
*
|
|
@@ -59,9 +59,23 @@ function wireStaticPage(options, io, connect) {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
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);
|
|
62
66
|
const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
|
|
63
67
|
// Asked only when Streamlit already said no, so the two can never both claim the page.
|
|
64
|
-
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
|
+
}
|
|
65
79
|
// Scope the project on disk before anything else, so the daemon can serve this page.
|
|
66
80
|
//
|
|
67
81
|
// This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
|
|
@@ -74,6 +88,10 @@ export function initWithoutPackageJson(options, io) {
|
|
|
74
88
|
// Written only when absent. A config a user or an earlier run already placed here is theirs.
|
|
75
89
|
const nonJsProjectId = deriveProjectId(undefined, io.cwd());
|
|
76
90
|
const writeConfig = () => {
|
|
91
|
+
if (options.dryRun) {
|
|
92
|
+
io.print('Dry run: no files written; the snippet uses a placeholder pairing token.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
77
95
|
// Except its port. A re-run with a new `--port` starts the daemon there, and a config still
|
|
78
96
|
// naming the old one sends the agent to a daemon that is no longer running.
|
|
79
97
|
const existingConfig = io.readFile(RETICLE_CONFIG_FILE);
|
|
@@ -88,10 +106,14 @@ export function initWithoutPackageJson(options, io) {
|
|
|
88
106
|
rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
|
|
89
107
|
io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
|
|
90
108
|
};
|
|
91
|
-
const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
|
|
109
|
+
const connect = connectArgWithToken(options.port, nonJsProjectId, options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken());
|
|
92
110
|
// One page at the root is a page we can see whole. Several, or none, is a choice that is not ours.
|
|
93
111
|
if (!streamlit && !django && io.exists(HTML_INDEX_PATH)) {
|
|
94
112
|
writeConfig();
|
|
113
|
+
if (options.dryRun) {
|
|
114
|
+
io.print(staticPageSnippet(connect));
|
|
115
|
+
return { ok: true, applied: 0, manual: 0 };
|
|
116
|
+
}
|
|
95
117
|
// Without the token: the page is the deployable artifact, so the token goes beside it instead.
|
|
96
118
|
return wireStaticPage(options, io, connectArg(options.port, nonJsProjectId));
|
|
97
119
|
}
|
|
@@ -101,11 +123,7 @@ export function initWithoutPackageJson(options, io) {
|
|
|
101
123
|
// a path problem and goes looking for a directory that cannot exist — reported from a
|
|
102
124
|
// Streamlit app, where the search continued into hunting for a browser bundle to inject by
|
|
103
125
|
// hand before the real answer surfaced.
|
|
104
|
-
streamlit
|
|
105
|
-
? streamlitSetupMessage()
|
|
106
|
-
: django
|
|
107
|
-
? djangoSetupMessage()
|
|
108
|
-
: noPackageJsonMessage((file) => io.exists(join(options.cwd, file)), io.rootFiles()));
|
|
126
|
+
streamlit ? streamlitSetupMessage() : django ? djangoSetupMessage() : answer.message);
|
|
109
127
|
writeConfig();
|
|
110
128
|
// The message says "add the snippet below". Print the snippet, or the message is the same broken
|
|
111
129
|
// promise in the other direction.
|
|
@@ -116,6 +134,7 @@ export function initWithoutPackageJson(options, io) {
|
|
|
116
134
|
: staticPageSnippet(connect));
|
|
117
135
|
// The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
|
|
118
136
|
// from someone who never ran the command — the two failure modes with the most different fixes.
|
|
119
|
-
|
|
137
|
+
if (!options.dryRun)
|
|
138
|
+
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
120
139
|
return { ok: false, applied: 0, manual: 0 };
|
|
121
140
|
}
|
package/dist/node-io.d.ts
CHANGED
|
@@ -34,4 +34,6 @@ export declare const RUNNABLE_COMMANDS: readonly string[];
|
|
|
34
34
|
* reason a second, broken copy existed.
|
|
35
35
|
*/
|
|
36
36
|
export declare function probeCli(command: string, args: readonly string[]): boolean;
|
|
37
|
-
export declare function buildNodeIo(cwd: string, host: InitHost
|
|
37
|
+
export declare function buildNodeIo(cwd: string, host: InitHost, output?: {
|
|
38
|
+
stderr?: boolean;
|
|
39
|
+
}): InitIo;
|
package/dist/node-io.js
CHANGED
|
@@ -102,7 +102,7 @@ export function probeCli(command, args) {
|
|
|
102
102
|
return false;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
export function buildNodeIo(cwd, host) {
|
|
105
|
+
export function buildNodeIo(cwd, host, output = {}) {
|
|
106
106
|
// Project-relative by default; absolute paths (e.g. ~/.cursor/mcp.json) pass through unchanged.
|
|
107
107
|
const abs = (rel) => (isAbsolute(rel) ? rel : join(cwd, rel));
|
|
108
108
|
return {
|
|
@@ -161,11 +161,14 @@ export function buildNodeIo(cwd, host) {
|
|
|
161
161
|
scoped(rel) {
|
|
162
162
|
// The host travels with the re-rooted IO: a monorepo redirect re-enters `runInit` through
|
|
163
163
|
// this, and an untraced, unreported inner run is the half of init that actually did the work.
|
|
164
|
-
return buildNodeIo(abs(rel), host);
|
|
164
|
+
return buildNodeIo(abs(rel), host, output);
|
|
165
165
|
},
|
|
166
166
|
exec(command, args) {
|
|
167
|
-
//
|
|
168
|
-
const result = spawnAllowed(command, args, {
|
|
167
|
+
// JSON callers reserve stdout for the result, including across package-manager subprocesses.
|
|
168
|
+
const result = spawnAllowed(command, args, {
|
|
169
|
+
cwd,
|
|
170
|
+
stdio: true === output.stderr ? ['inherit', 2, 2] : 'inherit',
|
|
171
|
+
});
|
|
169
172
|
return 0 === result.status;
|
|
170
173
|
},
|
|
171
174
|
capture(command, args) {
|
|
@@ -189,7 +192,8 @@ export function buildNodeIo(cwd, host) {
|
|
|
189
192
|
return 0 === result.status;
|
|
190
193
|
},
|
|
191
194
|
print(line) {
|
|
192
|
-
process.stdout
|
|
195
|
+
const stream = true === output.stderr ? process.stderr : process.stdout;
|
|
196
|
+
stream.write(`${wrapForTerminal(line, terminalWidth())}\n`);
|
|
193
197
|
},
|
|
194
198
|
host,
|
|
195
199
|
};
|
|
@@ -162,9 +162,8 @@ export const MCP_CLIENTS = [
|
|
|
162
162
|
* `~/.gemini/config/mcp_config.json`, and one file serves the 2.0 IDE, the CLI and the SDK. Same
|
|
163
163
|
* `command`/`args` object Cursor, Windsurf and Gemini CLI already use.
|
|
164
164
|
*
|
|
165
|
-
* It shares the `~/.gemini` tree with Gemini CLI
|
|
166
|
-
*
|
|
167
|
-
* so having one installed cannot make `init` write a config for the other.
|
|
165
|
+
* It shares the `~/.gemini` tree with Gemini CLI. Gemini detection requires its settings file:
|
|
166
|
+
* checking only the parent directory also matches an Antigravity-only installation.
|
|
168
167
|
*
|
|
169
168
|
* NOT verified against a running install. The path is documented, the merge is the shared JSON
|
|
170
169
|
* path that every other client here uses, and an unparseable config still degrades to a printed
|
|
@@ -329,6 +328,9 @@ export function clientSnippet(spec) {
|
|
|
329
328
|
export function clientMarkerRelPath(spec) {
|
|
330
329
|
if (spec.scope === ConfigScope.CLI)
|
|
331
330
|
return '';
|
|
331
|
+
// Antigravity also creates ~/.gemini. The shared parent is not evidence of Gemini CLI.
|
|
332
|
+
if (spec.id === McpClient.GEMINI)
|
|
333
|
+
return spec.relPath;
|
|
332
334
|
const parts = spec.relPath.split('/');
|
|
333
335
|
return parts.length > 1 ? parts.slice(0, -1).join('/') : spec.relPath;
|
|
334
336
|
}
|
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';
|
|
@@ -32,6 +33,8 @@ import { tanstackStartConnectPath } from './patch/tanstack-start.js';
|
|
|
32
33
|
import { installedViteMajor } from './patch/vite-owning-config.js';
|
|
33
34
|
/** CRA's bundled entry, in the order create-react-app itself generates them. */
|
|
34
35
|
const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
|
|
36
|
+
/** Preview snippets must not mint a real machine credential or imply this token is usable. */
|
|
37
|
+
const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
|
|
35
38
|
function craEntryOf(io) {
|
|
36
39
|
for (const path of CRA_ENTRY_CANDIDATES) {
|
|
37
40
|
const source = io.readFile(path);
|
|
@@ -40,7 +43,7 @@ function craEntryOf(io) {
|
|
|
40
43
|
}
|
|
41
44
|
return null;
|
|
42
45
|
}
|
|
43
|
-
import { DEPS_TARGET, RETICLE_CONFIG_FILE,
|
|
46
|
+
import { DEPS_TARGET, RETICLE_CONFIG_FILE, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
|
|
44
47
|
import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
|
|
45
48
|
import { reticleDevLocation } from './patch/next-patch.js';
|
|
46
49
|
import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
|
|
@@ -54,7 +57,6 @@ import { InitFailure } from './diagnose/init-failure.js';
|
|
|
54
57
|
import { resolveLockfiles } from './detect/lockfiles.js';
|
|
55
58
|
// Re-exported: it moved to its own module, and every existing importer says `run.js`.
|
|
56
59
|
export { resolveLockfiles };
|
|
57
|
-
const NODE_MODULES_DIR = 'node_modules';
|
|
58
60
|
const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
|
|
59
61
|
const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
|
|
60
62
|
const TANSTACK_START_ROOT_CANDIDATES = [
|
|
@@ -193,6 +195,8 @@ function resolvedStatus(plan, target, failed, skipped) {
|
|
|
193
195
|
return StepStatus.MANUAL;
|
|
194
196
|
return step.status;
|
|
195
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 (';
|
|
196
200
|
const STATUS_SYMBOL = {
|
|
197
201
|
[StepStatus.APPLY]: '✓',
|
|
198
202
|
[StepStatus.MANUAL]: '⚠',
|
|
@@ -395,7 +399,7 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
395
399
|
: {}),
|
|
396
400
|
craEntry: craEntryOf(io),
|
|
397
401
|
craEnv: io.readFile(CRA_ENV_PATH),
|
|
398
|
-
pairingToken: io.host.pairingToken(),
|
|
402
|
+
pairingToken: options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken(),
|
|
399
403
|
installSource: io.host.installSource(),
|
|
400
404
|
reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
|
|
401
405
|
// The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
|
|
@@ -475,6 +479,8 @@ continuesToRuntime = false) {
|
|
|
475
479
|
// the app never dials the daemon and every tool answers "no browser session connected". `ok` was
|
|
476
480
|
// hardcoded true, so a run that could not possibly work reported success.
|
|
477
481
|
let connectPending = false;
|
|
482
|
+
// Other clients' registrations the run left for the reader, named for the closing hint.
|
|
483
|
+
const manualClients = [];
|
|
478
484
|
for (const s of plan.steps) {
|
|
479
485
|
// A side effect that failed to apply is reported as a manual step with its fallback command.
|
|
480
486
|
const note = degraded.get(s.target);
|
|
@@ -502,6 +508,9 @@ continuesToRuntime = false) {
|
|
|
502
508
|
manual++;
|
|
503
509
|
if (isConnectStep(s.title))
|
|
504
510
|
connectPending = true;
|
|
511
|
+
if (s.target !== MCP_TARGET && s.title.startsWith(MCP_CLIENT_TITLE_PREFIX)) {
|
|
512
|
+
manualClients.push(s.title);
|
|
513
|
+
}
|
|
505
514
|
}
|
|
506
515
|
for (const line of detail.split('\n'))
|
|
507
516
|
io.print(` ${line}`);
|
|
@@ -523,7 +532,7 @@ continuesToRuntime = false) {
|
|
|
523
532
|
}
|
|
524
533
|
const mcpStatus = resolvedStatus(plan, MCP_TARGET, failed, skipped);
|
|
525
534
|
if (!continuesToRuntime) {
|
|
526
|
-
io.print(restartHint(mcpStatus, devCommand));
|
|
535
|
+
io.print(restartHint(mcpStatus, devCommand, manualClients));
|
|
527
536
|
}
|
|
528
537
|
// Carried out even when the hint above was printed, because the RUNTIME path needs the same fact
|
|
529
538
|
// and could not reach it: `restartHint` is only printed when this run stops at the files, and the
|
|
@@ -545,25 +554,6 @@ continuesToRuntime = false) {
|
|
|
545
554
|
* MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
|
|
546
555
|
* message; a half-wired app is a broken project.
|
|
547
556
|
*/
|
|
548
|
-
/**
|
|
549
|
-
* Are the SDK packages actually on disk, whatever the install step reported?
|
|
550
|
-
*
|
|
551
|
-
* `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
|
|
552
|
-
* not there — that took a dev server down once, and installing Reticle must never be why an app
|
|
553
|
-
* stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
|
|
554
|
-
* install subprocess exited 0", and those come apart in exactly the situation the failure creates:
|
|
555
|
-
* init tells the user to install by hand, they do, they re-run init, the install step fails again
|
|
556
|
-
* (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
|
|
557
|
-
* a Next 16 app where npm had already installed both packages successfully — leaving an init that
|
|
558
|
-
* could not be retried into working, which is the shape this guard was written to prevent.
|
|
559
|
-
*
|
|
560
|
-
* Reading node_modules answers the real question and costs one `exists` call per package.
|
|
561
|
-
*/
|
|
562
|
-
function sdkPackagesPresent(framework, uiLibrary, io) {
|
|
563
|
-
const packages = frameworkPackages(framework, uiLibrary);
|
|
564
|
-
return (packages.length > 0 &&
|
|
565
|
-
packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
|
|
566
|
-
}
|
|
567
557
|
function applyEffects(plan, io, packageManager) {
|
|
568
558
|
const why = new Map();
|
|
569
559
|
const failed = new Set();
|
|
@@ -633,14 +623,17 @@ function applyEffects(plan, io, packageManager) {
|
|
|
633
623
|
continue;
|
|
634
624
|
}
|
|
635
625
|
// Verify, don't re-run: give the install step itself the same sdkPackagesPresent benefit
|
|
636
|
-
// already given to the wiring it gates below (#683).
|
|
637
|
-
|
|
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))
|
|
638
631
|
continue;
|
|
639
632
|
failed.add(s.target);
|
|
640
633
|
// A failed install only blocks the wiring when the packages are genuinely ABSENT. See
|
|
641
634
|
// sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
|
|
642
635
|
if (s.target === DEPS_TARGET) {
|
|
643
|
-
installFailed =
|
|
636
|
+
installFailed = !present;
|
|
644
637
|
const explained = explainInstallFailure(io, packageManager, exec, s.detail);
|
|
645
638
|
if (explained !== undefined)
|
|
646
639
|
why.set(s.target, explained);
|
|
@@ -737,7 +730,8 @@ function runInitSteps(options, io) {
|
|
|
737
730
|
io.print(`${PACKAGE_JSON} is not valid JSON (${manifest.error}). Fix it and re-run — init reads the ` +
|
|
738
731
|
'framework, the dev script and the package manager from it, and will not guess at any of ' +
|
|
739
732
|
'them from a file it cannot read.');
|
|
740
|
-
|
|
733
|
+
if (!options.dryRun)
|
|
734
|
+
io.host.reportOutcome({ ok: false, reason: InitFailure.MALFORMED_PACKAGE_JSON });
|
|
741
735
|
return { ok: false, applied: 0, manual: 0 };
|
|
742
736
|
}
|
|
743
737
|
const pkgRaw = manifest.pkg;
|
|
@@ -777,6 +771,8 @@ function runInitSteps(options, io) {
|
|
|
777
771
|
});
|
|
778
772
|
if (resolved.refusal !== undefined) {
|
|
779
773
|
io.print(resolved.refusal);
|
|
774
|
+
if (!options.dryRun)
|
|
775
|
+
io.host.reportOutcome({ ok: false, reason: InitFailure.PREFLIGHT });
|
|
780
776
|
return { ok: false, applied: 0, manual: 1 };
|
|
781
777
|
}
|
|
782
778
|
// The prefix that can actually run it on THIS machine — `pnpm`, or `corepack pnpm` where preflight
|
|
@@ -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",
|