@reticlehq/init 3.5.0 → 3.7.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/dev-script.d.ts +17 -0
- package/dist/detect/dev-script.js +128 -0
- 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 +16 -0
- package/dist/detect/workspace-apps.js +70 -0
- package/dist/detect/workspace-redirect.d.ts +12 -0
- package/dist/detect/workspace-redirect.js +84 -9
- 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 +2 -0
- package/dist/diagnose/init-failure.js +2 -0
- package/dist/gather-plan-input.d.ts +11 -0
- package/dist/gather-plan-input.js +416 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -2
- package/dist/no-package-json.js +17 -8
- package/dist/node-io.d.ts +1 -1
- package/dist/node-io.js +11 -2
- package/dist/patch/next-patch.d.ts +28 -0
- package/dist/patch/next-patch.js +66 -0
- package/dist/patch/nuxt-snippets.js +1 -1
- package/dist/patch/snippets.d.ts +1 -1
- package/dist/patch/snippets.js +2 -2
- package/dist/patch/static-page.d.ts +1 -0
- package/dist/patch/static-page.js +3 -3
- package/dist/plan/connect-steps.d.ts +1 -0
- package/dist/plan/connect-steps.js +5 -0
- package/dist/plan/framework-adapter.js +2 -2
- package/dist/plan/plan-angular.js +1 -1
- package/dist/plan/plan-framework.d.ts +1 -1
- package/dist/plan/plan-framework.js +47 -13
- package/dist/plan/plan-types.d.ts +23 -2
- package/dist/plan/plan-vite.js +15 -0
- package/dist/plan/plan.js +68 -10
- package/dist/plan/stop-hook-step.d.ts +19 -0
- package/dist/plan/stop-hook-step.js +74 -1
- package/dist/project/agent-rules.d.ts +1 -1
- package/dist/project/agent-rules.js +16 -11
- package/dist/register/codex-toml.d.ts +12 -0
- package/dist/register/codex-toml.js +44 -0
- package/dist/register/mcp.d.ts +15 -2
- package/dist/register/mcp.js +16 -0
- package/dist/run-types.d.ts +6 -0
- package/dist/run.d.ts +1 -1
- package/dist/run.js +27 -414
- package/dist/sdk-packages.d.ts +19 -0
- package/dist/sdk-packages.js +34 -0
- package/package.json +2 -2
package/dist/run.js
CHANGED
|
@@ -4,177 +4,25 @@
|
|
|
4
4
|
* access goes through `InitIo` so the orchestration is unit-testable with an in-memory IO.
|
|
5
5
|
*/
|
|
6
6
|
import { OnboardingPhase, OnboardingStepStatus } from '@reticlehq/core/telemetry';
|
|
7
|
-
import { detectMcpClients } from './register/detect-clients.js';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
import { CONTAINER_MARKERS, runsDevServer } from './diagnose/containerised-dev-server.js';
|
|
10
7
|
import { explainInstallFailure } from './diagnose/install-retries.js';
|
|
11
|
-
import { CSP_FILES } from './diagnose/csp-doctor.js';
|
|
12
8
|
import { preflight } from './plan/preflight.js';
|
|
13
|
-
import { devCommandFrom
|
|
9
|
+
import { devCommandFrom } from './detect/dev-script.js';
|
|
14
10
|
import { restartHint, FEEDBACK_HINT } from './diagnose/closing-hint.js';
|
|
15
11
|
import { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
|
|
16
|
-
import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, UiLibrary, } from './detect/detect.js';
|
|
17
|
-
import { ANGULAR_PROXY_PATH, angularEntry } from './patch/angular.js';
|
|
18
|
-
import { HTML_INDEX_PATH } from './patch/static-page.js';
|
|
19
12
|
import { initWithoutPackageJson } from './no-package-json.js';
|
|
13
|
+
import { sdkPackagesDeclared, sdkPackagesPresent } from './sdk-packages.js';
|
|
20
14
|
import { wasMcpRegistered } from './register/mcp-registered.js';
|
|
21
|
-
import {
|
|
22
|
-
import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
|
|
15
|
+
import { PACKAGE_JSON } from './detect/workspace-apps.js';
|
|
23
16
|
import { redirectToWorkspaceApp } from './detect/workspace-redirect.js';
|
|
24
|
-
import { enclosingWorkspaceRoot } from './detect/workspace-apps.js';
|
|
17
|
+
import { appsAt, enclosingWorkspaceRoot } from './detect/workspace-apps.js';
|
|
25
18
|
import { isConnectStep } from './plan/connect-steps.js';
|
|
26
|
-
import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
|
|
27
|
-
import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
|
|
28
|
-
import { CRA_ENV_PATH } from './patch/cra.js';
|
|
29
|
-
import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './patch/next-patch.js';
|
|
30
19
|
import { formatGeneratedSource } from './patch/format-generated.js';
|
|
31
|
-
import {
|
|
32
|
-
import { installedViteMajor } from './patch/vite-owning-config.js';
|
|
33
|
-
/** CRA's bundled entry, in the order create-react-app itself generates them. */
|
|
34
|
-
const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
|
|
35
|
-
/** Preview snippets must not mint a real machine credential or imply this token is usable. */
|
|
36
|
-
const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
|
|
37
|
-
function craEntryOf(io) {
|
|
38
|
-
for (const path of CRA_ENTRY_CANDIDATES) {
|
|
39
|
-
const source = io.readFile(path);
|
|
40
|
-
if (source !== null)
|
|
41
|
-
return { path, source };
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
|
|
46
|
-
import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
|
|
47
|
-
import { reticleDevLocation } from './patch/next-patch.js';
|
|
48
|
-
import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
|
|
49
|
-
import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
|
|
50
|
-
import { deriveProjectId, packageName } from './project/project-id.js';
|
|
51
|
-
import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, nuxtPluginPath, } from './patch/snippets.js';
|
|
52
|
-
import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
|
|
53
|
-
import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './register/slash-command.js';
|
|
54
|
-
import { RETICLE_VERSION } from './version.js';
|
|
20
|
+
import { DEPS_TARGET, RETICLE_CONFIG_FILE, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
|
|
55
21
|
import { InitFailure } from './diagnose/init-failure.js';
|
|
56
22
|
import { resolveLockfiles } from './detect/lockfiles.js';
|
|
23
|
+
import { agentRootOf, gatherPlanInput } from './gather-plan-input.js';
|
|
57
24
|
// Re-exported: it moved to its own module, and every existing importer says `run.js`.
|
|
58
25
|
export { resolveLockfiles };
|
|
59
|
-
const NODE_MODULES_DIR = 'node_modules';
|
|
60
|
-
const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
|
|
61
|
-
const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
|
|
62
|
-
const TANSTACK_START_ROOT_CANDIDATES = [
|
|
63
|
-
'src/routes/__root.tsx',
|
|
64
|
-
'src/routes/__root.jsx',
|
|
65
|
-
'app/routes/__root.tsx',
|
|
66
|
-
'app/routes/__root.jsx',
|
|
67
|
-
];
|
|
68
|
-
const SOURCE_FILE = /\.(tsx|jsx|ts|js|svelte|vue|astro)$/;
|
|
69
|
-
/** Files read for the testid scan. A capabilities block is a hint; reading a whole repo for it is not. */
|
|
70
|
-
const MAX_SCANNED_FILES = 200;
|
|
71
|
-
/** Directories that never hold the app's own source, and are expensive or misleading to read. */
|
|
72
|
-
const NOT_SOURCE_DIRS = new Set([
|
|
73
|
-
'node_modules',
|
|
74
|
-
'dist',
|
|
75
|
-
'build',
|
|
76
|
-
'out',
|
|
77
|
-
'coverage',
|
|
78
|
-
'target',
|
|
79
|
-
'public',
|
|
80
|
-
'static',
|
|
81
|
-
'.next',
|
|
82
|
-
'.svelte-kit',
|
|
83
|
-
'.nuxt',
|
|
84
|
-
]);
|
|
85
|
-
/** How far below the app root to look. Deep enough for `modules/users/UserList.tsx`, not a repo crawl. */
|
|
86
|
-
const MAX_SCAN_DEPTH = 5;
|
|
87
|
-
/**
|
|
88
|
-
* Read a bounded set of the app's source files, for the `data-testid` scan.
|
|
89
|
-
*
|
|
90
|
-
* This used to walk the fixed list `src, src/components, src/pages, app, components`. Reported from
|
|
91
|
-
* the field (#318) by a repo whose app is at `src/admin`: the scan read directories that were not
|
|
92
|
-
* the app's and correctly reported finding nothing in them, so `init` said "no data-testid values
|
|
93
|
-
* yet" about an app with several — which makes an agent go and write the ones already there. That
|
|
94
|
-
* list had already grown once for a `frontend/` app, and a third report of the same shape is what
|
|
95
|
-
* says the answer is not another name in a list. So: walk the app root, bounded by depth and by a
|
|
96
|
-
* file count.
|
|
97
|
-
*/
|
|
98
|
-
function readSourceFiles(io) {
|
|
99
|
-
const out = [];
|
|
100
|
-
const read = (path) => {
|
|
101
|
-
const content = io.readFile(path);
|
|
102
|
-
if (content !== null)
|
|
103
|
-
out.push({ path, source: content });
|
|
104
|
-
};
|
|
105
|
-
for (const name of io.rootFiles()) {
|
|
106
|
-
if (out.length >= MAX_SCANNED_FILES)
|
|
107
|
-
return out;
|
|
108
|
-
if (SOURCE_FILE.test(name))
|
|
109
|
-
read(name);
|
|
110
|
-
}
|
|
111
|
-
const walk = (dir, depth) => {
|
|
112
|
-
for (const name of io.listFiles(dir)) {
|
|
113
|
-
if (out.length >= MAX_SCANNED_FILES)
|
|
114
|
-
return;
|
|
115
|
-
if (SOURCE_FILE.test(name))
|
|
116
|
-
read(`${dir}/${name}`);
|
|
117
|
-
}
|
|
118
|
-
if (depth >= MAX_SCAN_DEPTH)
|
|
119
|
-
return;
|
|
120
|
-
for (const sub of io.listDirs(dir)) {
|
|
121
|
-
if (out.length >= MAX_SCANNED_FILES)
|
|
122
|
-
return;
|
|
123
|
-
if (sub.startsWith('.') || NOT_SOURCE_DIRS.has(sub))
|
|
124
|
-
continue;
|
|
125
|
-
walk(`${dir}/${sub}`, depth + 1);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
for (const dir of io.listDirs('.')) {
|
|
129
|
-
if (out.length >= MAX_SCANNED_FILES)
|
|
130
|
-
return out;
|
|
131
|
-
if (dir.startsWith('.') || NOT_SOURCE_DIRS.has(dir))
|
|
132
|
-
continue;
|
|
133
|
-
walk(dir, 1);
|
|
134
|
-
}
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
/** Direct dependency names, for naming the state libraries an app actually has. */
|
|
138
|
-
function dependencyNames(pkg) {
|
|
139
|
-
const p = (pkg ?? {});
|
|
140
|
-
return new Set([
|
|
141
|
-
...Object.keys(p['dependencies'] ?? {}),
|
|
142
|
-
...Object.keys(p['devDependencies'] ?? {}),
|
|
143
|
-
]);
|
|
144
|
-
}
|
|
145
|
-
/** electron-vite and the unbundled Electron layouts this repo already ships. */
|
|
146
|
-
const ELECTRON_MAIN_SOURCES = [
|
|
147
|
-
'src/main/index.ts',
|
|
148
|
-
'src/main/index.js',
|
|
149
|
-
'src/main/index.mts',
|
|
150
|
-
'src/main/index.mjs',
|
|
151
|
-
'electron/main.cjs',
|
|
152
|
-
'electron/main.js',
|
|
153
|
-
'electron/main.ts',
|
|
154
|
-
'src/main.ts',
|
|
155
|
-
'src/main.js',
|
|
156
|
-
];
|
|
157
|
-
const ELECTRON_PRELOAD_SOURCES = [
|
|
158
|
-
'src/preload/index.ts',
|
|
159
|
-
'src/preload/index.js',
|
|
160
|
-
'src/preload/index.mts',
|
|
161
|
-
'src/preload/index.mjs',
|
|
162
|
-
'electron/preload.cjs',
|
|
163
|
-
'electron/preload.js',
|
|
164
|
-
'electron/preload.ts',
|
|
165
|
-
'src/preload.ts',
|
|
166
|
-
'src/preload.js',
|
|
167
|
-
];
|
|
168
|
-
const ASTRO_CONFIG_CANDIDATES = [
|
|
169
|
-
'astro.config.mjs',
|
|
170
|
-
'astro.config.js',
|
|
171
|
-
'astro.config.ts',
|
|
172
|
-
'astro.config.cjs',
|
|
173
|
-
];
|
|
174
|
-
/** Where a conventional Astro app keeps the layout every page wraps itself in. */
|
|
175
|
-
const ASTRO_LAYOUTS_DIR = 'src/layouts';
|
|
176
|
-
/** Also searched: an app with no layouts directory renders the document straight from a page. */
|
|
177
|
-
const ASTRO_PAGES_DIR = 'src/pages';
|
|
178
26
|
/**
|
|
179
27
|
* `⚠` means WORK LEFT TO DO and nothing else — it is what an agent (and the release gate) counts to
|
|
180
28
|
* decide whether the install finished. A notice gets its own mark so "steps remaining" can reach zero
|
|
@@ -195,6 +43,8 @@ function resolvedStatus(plan, target, failed, skipped) {
|
|
|
195
43
|
return StepStatus.MANUAL;
|
|
196
44
|
return step.status;
|
|
197
45
|
}
|
|
46
|
+
/** Every client registration row is titled `MCP server (<client>)`; the closing hint names the unfinished ones. */
|
|
47
|
+
const MCP_CLIENT_TITLE_PREFIX = 'MCP server (';
|
|
198
48
|
const STATUS_SYMBOL = {
|
|
199
49
|
[StepStatus.APPLY]: '✓',
|
|
200
50
|
[StepStatus.MANUAL]: '⚠',
|
|
@@ -202,234 +52,6 @@ const STATUS_SYMBOL = {
|
|
|
202
52
|
[StepStatus.SKIP]: '–',
|
|
203
53
|
[StepStatus.NOTICE]: 'ℹ',
|
|
204
54
|
};
|
|
205
|
-
function firstPresent(files, candidates) {
|
|
206
|
-
for (const c of candidates)
|
|
207
|
-
if (files.has(c))
|
|
208
|
-
return c;
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
function firstReadable(io, candidates) {
|
|
212
|
-
for (const path of candidates) {
|
|
213
|
-
const source = io.readFile(path);
|
|
214
|
-
if (source !== null)
|
|
215
|
-
return { path, source };
|
|
216
|
-
}
|
|
217
|
-
return null;
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* The directory the human's agent runs in, when it is not the app's directory.
|
|
221
|
-
*
|
|
222
|
-
* `undefined` for a single-package repo, which keeps every agent-file path project-relative exactly
|
|
223
|
-
* as it was — the two roots are the same there, which is why writing them beside the app went
|
|
224
|
-
* unnoticed until a repo with its app at `src/admin` reported it.
|
|
225
|
-
*/
|
|
226
|
-
function agentRootOf(options) {
|
|
227
|
-
const root = options.agentRoot;
|
|
228
|
-
return root === undefined || root === options.cwd ? undefined : root;
|
|
229
|
-
}
|
|
230
|
-
/** What the Angular steps read: the workspace file, the entry it names, and our proxy module. */
|
|
231
|
-
function angularInputs(io) {
|
|
232
|
-
const workspace = io.readFile(ANGULAR_WORKSPACE_FILE);
|
|
233
|
-
const entryPath = angularEntry(workspace);
|
|
234
|
-
const entry = io.readFile(entryPath);
|
|
235
|
-
return {
|
|
236
|
-
angularWorkspace: null === workspace ? null : { path: ANGULAR_WORKSPACE_FILE, source: workspace },
|
|
237
|
-
angularEntry: null === entry ? null : { path: entryPath, source: entry },
|
|
238
|
-
angularProxySource: io.readFile(ANGULAR_PROXY_PATH),
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
function gatherPlanInput(options, io, pkg) {
|
|
242
|
-
// Stable identity derived from the app's package.json name + root, so it survives port changes.
|
|
243
|
-
const projectId = deriveProjectId(packageName(pkg), options.cwd);
|
|
244
|
-
const rootFiles = new Set(io.rootFiles());
|
|
245
|
-
const nodeModulesMarkers = new Set(io.listFiles('node_modules'));
|
|
246
|
-
const detectInput = {
|
|
247
|
-
pkg: 'object' === typeof pkg && pkg !== null ? pkg : {},
|
|
248
|
-
configFiles: rootFiles,
|
|
249
|
-
// Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager —
|
|
250
|
-
// unless this package's own installed tree already answers it, which outranks an inherited one.
|
|
251
|
-
lockfiles: resolveLockfiles(rootFiles, options.cwd, io, nodeModulesMarkers),
|
|
252
|
-
// An already-installed tree names its own manager, which matters when no lockfile is committed.
|
|
253
|
-
nodeModulesMarkers,
|
|
254
|
-
};
|
|
255
|
-
const detection = detect(detectInput);
|
|
256
|
-
// Forge has no `vite.config.*`; its renderer config IS the Vite config the plugin belongs in, and
|
|
257
|
-
// handing it over under that name is what lets the ordinary Vite patcher do the work.
|
|
258
|
-
const vitePath = firstPresent(rootFiles, Framework.ELECTRON_FORGE === detection.framework
|
|
259
|
-
? FORGE_RENDERER_CONFIGS
|
|
260
|
-
: VITE_CONFIG_CANDIDATES);
|
|
261
|
-
const viteSource = null === vitePath ? null : io.readFile(vitePath);
|
|
262
|
-
const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
|
|
263
|
-
const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
|
|
264
|
-
const electronViteSource = null === electronVitePath ? null : io.readFile(electronVitePath);
|
|
265
|
-
const electronViteConfig = electronVitePath !== null && electronViteSource !== null
|
|
266
|
-
? { path: electronVitePath, source: electronViteSource }
|
|
267
|
-
: null;
|
|
268
|
-
const electronMain = firstReadable(io, ELECTRON_MAIN_SOURCES);
|
|
269
|
-
const electronPreload = firstReadable(io, ELECTRON_PRELOAD_SOURCES);
|
|
270
|
-
// Global MCP registration targets each agent that's present: Claude via its CLI, Cursor via its
|
|
271
|
-
// global config file. Only probe when the MCP step is in play.
|
|
272
|
-
const availableProbe = claudeAvailableProbe();
|
|
273
|
-
const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
|
|
274
|
-
// Read from Claude's config, never `claude mcp get`: that launches the server — see claudeHasReticle.
|
|
275
|
-
const mcpExists = claudeCli ? claudeHasReticle(io, io.cwd()) : false;
|
|
276
|
-
// Every MCP client this machine shows evidence of. Conservative and one-directional: we write
|
|
277
|
-
// into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
|
|
278
|
-
// does not use them.
|
|
279
|
-
const detectedClients = options.mcp ? detectMcpClients(io) : [];
|
|
280
|
-
const astroPath = firstPresent(rootFiles, ASTRO_CONFIG_CANDIDATES);
|
|
281
|
-
const astroSource = null === astroPath ? null : io.readFile(astroPath);
|
|
282
|
-
// Which file owns the DOCUMENT, not how many files sit in a directory. The old rule ("exactly one
|
|
283
|
-
// .astro in src/layouts") fired on neither real Astro app: one has no layouts directory and
|
|
284
|
-
// renders from src/pages/index.astro, the other has three files there of which two are partials.
|
|
285
|
-
// `</body>` is the discriminator — see astro-host.
|
|
286
|
-
const astroCandidates = [ASTRO_LAYOUTS_DIR, ASTRO_PAGES_DIR].flatMap((dir) => io
|
|
287
|
-
.listFiles(dir)
|
|
288
|
-
.filter((f) => f.endsWith('.astro'))
|
|
289
|
-
.map((f) => ({ path: `${dir}/${f}`, source: io.readFile(`${dir}/${f}`) }))
|
|
290
|
-
.filter((c) => c.source !== null));
|
|
291
|
-
const astroHost = pickAstroHost(astroCandidates);
|
|
292
|
-
const layoutRelPath = astroHost?.path ?? null;
|
|
293
|
-
const astroLayoutSource = astroHost?.source ?? null;
|
|
294
|
-
const nuxtConfigPath = firstPresent(rootFiles, NUXT_CONFIG_CANDIDATES);
|
|
295
|
-
const nuxtConfigSource = null === nuxtConfigPath ? null : io.readFile(nuxtConfigPath);
|
|
296
|
-
// Nuxt 4's default srcDir is `app/`, Nuxt 3's is the root — and a plugin written into the
|
|
297
|
-
// directory Nuxt does not scan is never registered, silently.
|
|
298
|
-
const nuxtHasAppDir = io.listDirs('.').includes('app');
|
|
299
|
-
const nextConfigFile = firstPresent(rootFiles, NEXT_CONFIG_CANDIDATES);
|
|
300
|
-
// App Router first; a Pages Router app has no layout, and its mount point is pages/_app.
|
|
301
|
-
const layoutPath = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p)) ??
|
|
302
|
-
NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ??
|
|
303
|
-
null;
|
|
304
|
-
const layoutSource = null === layoutPath ? null : io.readFile(layoutPath);
|
|
305
|
-
// Where the component goes depends on WHICH router mounts it: `pages/` routes on presence, so a
|
|
306
|
-
// component there becomes a broken route; `app/` routes on filename, so a sibling is inert.
|
|
307
|
-
const devLocation = reticleDevLocation(layoutPath ?? 'app/layout.tsx', detection.typescript);
|
|
308
|
-
// Read once: both the testid scan and the store scan want the same bounded set of files.
|
|
309
|
-
const sourceFiles = readSourceFiles(io);
|
|
310
|
-
const agentRoot = agentRootOf(options);
|
|
311
|
-
const agentFile = (relPath) => agentRoot === undefined ? relPath : join(agentRoot, relPath);
|
|
312
|
-
const cspSources = {};
|
|
313
|
-
for (const file of CSP_FILES) {
|
|
314
|
-
const source = io.readFile(file);
|
|
315
|
-
if (null !== source)
|
|
316
|
-
cspSources[file] = source;
|
|
317
|
-
}
|
|
318
|
-
return {
|
|
319
|
-
detection,
|
|
320
|
-
captureBodies: options.captureBodies,
|
|
321
|
-
hooks: options.hooks,
|
|
322
|
-
cspSources,
|
|
323
|
-
claudeCli,
|
|
324
|
-
mcpExists,
|
|
325
|
-
insideClaudeCode: io.host.insideClaudeCode(),
|
|
326
|
-
claudeProjectConfig: options.mcp ? io.readFile(agentFile(CLAUDE_PROJECT_CONFIG)) : undefined,
|
|
327
|
-
platform: process.platform,
|
|
328
|
-
detectedClients,
|
|
329
|
-
cursorProjectPresent: io.exists(CURSOR_PROJECT_MARKER),
|
|
330
|
-
// Looked for beside the app AND one level up, because the app is routinely a subdirectory of the
|
|
331
|
-
// repo that containerises it — `frontend/` under a root `docker-compose.yml` is the shape this
|
|
332
|
-
// came from. Only the first match is reported; the note is the same whichever file found it.
|
|
333
|
-
...(() => {
|
|
334
|
-
// Only a file that RUNS the dev server: a production-only image is the common case, and
|
|
335
|
-
// React Router's template ships one. See runsDevServer.
|
|
336
|
-
const devScript = devScriptBody(pkg);
|
|
337
|
-
const marker = CONTAINER_MARKERS.find((name) => [name, join('..', name)].some((path) => {
|
|
338
|
-
const content = io.readFile(path);
|
|
339
|
-
return null !== content && runsDevServer(name, content, devScript);
|
|
340
|
-
}));
|
|
341
|
-
return marker === undefined ? {} : { containerMarker: marker };
|
|
342
|
-
})(),
|
|
343
|
-
viteConfig,
|
|
344
|
-
electronViteConfig,
|
|
345
|
-
electronPreload,
|
|
346
|
-
electronMain,
|
|
347
|
-
astroConfig: astroPath !== null && astroSource !== null ? { path: astroPath, source: astroSource } : null,
|
|
348
|
-
astroLayout: layoutRelPath !== null && astroLayoutSource !== null
|
|
349
|
-
? { path: layoutRelPath, source: astroLayoutSource }
|
|
350
|
-
: null,
|
|
351
|
-
astroEnvDts: io.readFile('src/env.d.ts'),
|
|
352
|
-
astroReticleDev: io.readFile('src/components/ReticleDev.ts'),
|
|
353
|
-
nextConfigFile,
|
|
354
|
-
nextConfigSource: null === nextConfigFile ? null : io.readFile(nextConfigFile),
|
|
355
|
-
nextLayout: layoutPath !== null && layoutSource !== null
|
|
356
|
-
? { path: layoutPath, source: layoutSource }
|
|
357
|
-
: null,
|
|
358
|
-
// Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
|
|
359
|
-
testids: scanTestids(sourceFiles.map((f) => f.source)),
|
|
360
|
-
storeHints: storeHints(dependencyNames(pkg), sourceFiles),
|
|
361
|
-
foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
|
|
362
|
-
nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
|
|
363
|
-
viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
|
|
364
|
-
? ELECTRON_VITE_DEV_MODULE_PATH
|
|
365
|
-
: VITE_DEV_MODULE_PATH),
|
|
366
|
-
nextReticleDevPath: devLocation.path,
|
|
367
|
-
nextReticleDevImport: devLocation.importSpecifier,
|
|
368
|
-
nextReticleDevExists: io.exists(devLocation.path),
|
|
369
|
-
nextReticleDevSource: io.readFile(devLocation.path),
|
|
370
|
-
svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
|
|
371
|
-
svelteKitHooksSource: io.readFile(SVELTEKIT_HOOKS),
|
|
372
|
-
reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
|
|
373
|
-
// The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
|
|
374
|
-
// override of React Router's default, and everything in it is load-bearing.
|
|
375
|
-
reactRouterEntrySource: io.readFile(REACT_ROUTER_ENTRY),
|
|
376
|
-
nuxtConfig: nuxtConfigPath !== null && nuxtConfigSource !== null
|
|
377
|
-
? { path: nuxtConfigPath, source: nuxtConfigSource }
|
|
378
|
-
: null,
|
|
379
|
-
nuxtHasAppDir,
|
|
380
|
-
viteMajor: installedViteMajor(io, detectInput.pkg),
|
|
381
|
-
nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
|
|
382
|
-
nuxtPluginSource: io.readFile(nuxtPluginPath(nuxtHasAppDir)),
|
|
383
|
-
...(() => {
|
|
384
|
-
const root = TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file));
|
|
385
|
-
return root === undefined
|
|
386
|
-
? {}
|
|
387
|
-
: {
|
|
388
|
-
tanstackStartRoot: root,
|
|
389
|
-
tanstackStartRootSource: io.readFile(root),
|
|
390
|
-
tanstackStartConnectExists: io.exists(tanstackStartConnectPath(root)),
|
|
391
|
-
tanstackStartConnectSource: io.readFile(tanstackStartConnectPath(root)),
|
|
392
|
-
};
|
|
393
|
-
})(),
|
|
394
|
-
...(Framework.ANGULAR === detection.framework ? angularInputs(io) : {}),
|
|
395
|
-
...(Framework.HTML === detection.framework
|
|
396
|
-
? { htmlIndexSource: io.readFile(HTML_INDEX_PATH) }
|
|
397
|
-
: {}),
|
|
398
|
-
craEntry: craEntryOf(io),
|
|
399
|
-
craEnv: io.readFile(CRA_ENV_PATH),
|
|
400
|
-
pairingToken: options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken(),
|
|
401
|
-
installSource: io.host.installSource(),
|
|
402
|
-
reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
|
|
403
|
-
// The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
|
|
404
|
-
// the app's own dev-server port used to survive every re-run of `init`.
|
|
405
|
-
reticleConfigSource: io.readFile(RETICLE_CONFIG_FILE),
|
|
406
|
-
// Read the agent instruction files so the rule merge stays idempotent across re-runs — from the
|
|
407
|
-
// agent's own root, or the merge would idempotently check a file it is not going to write.
|
|
408
|
-
claudeMdContent: io.readFile(agentFile('CLAUDE.md')),
|
|
409
|
-
agentsMdContent: io.readFile(agentFile('AGENTS.md')),
|
|
410
|
-
reticleMdContent: io.readFile(agentFile(RETICLE_MD_PATH)),
|
|
411
|
-
cursorRuleContent: io.readFile(agentFile(CURSOR_RULE_PATH)),
|
|
412
|
-
claudeCommandContent: io.readFile(agentFile(CLAUDE_COMMAND_PATH)),
|
|
413
|
-
cursorCommandContent: io.readFile(agentFile(CURSOR_COMMAND_PATH)),
|
|
414
|
-
// Read only to MERGE into: the settings file is the user's, and other tools write in it too.
|
|
415
|
-
claudeSettingsContent: true === options.hooks ? io.readFile(agentFile(CLAUDE_SETTINGS_PATH)) : undefined,
|
|
416
|
-
...(agentRoot === undefined
|
|
417
|
-
? {}
|
|
418
|
-
: {
|
|
419
|
-
agentFileRoot: agentRoot,
|
|
420
|
-
// The config the AGENT's cwd already has, if any — see agentRootConfigStep.
|
|
421
|
-
agentRootConfigSource: io.readFile(agentFile(RETICLE_CONFIG_FILE)),
|
|
422
|
-
}),
|
|
423
|
-
options: {
|
|
424
|
-
port: options.port,
|
|
425
|
-
mcp: options.mcp,
|
|
426
|
-
install: options.install,
|
|
427
|
-
projectId,
|
|
428
|
-
// The SDK must match the CLI asking for it — see pinnedPackages.
|
|
429
|
-
sdkVersion: RETICLE_VERSION,
|
|
430
|
-
},
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
55
|
/** Where workspace tooling conventionally puts packages. */
|
|
434
56
|
/** pnpm's workspace declaration, read when present — it is authoritative about where packages live. */
|
|
435
57
|
const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
|
|
@@ -477,6 +99,8 @@ continuesToRuntime = false) {
|
|
|
477
99
|
// the app never dials the daemon and every tool answers "no browser session connected". `ok` was
|
|
478
100
|
// hardcoded true, so a run that could not possibly work reported success.
|
|
479
101
|
let connectPending = false;
|
|
102
|
+
// Other clients' registrations the run left for the reader, named for the closing hint.
|
|
103
|
+
const manualClients = [];
|
|
480
104
|
for (const s of plan.steps) {
|
|
481
105
|
// A side effect that failed to apply is reported as a manual step with its fallback command.
|
|
482
106
|
const note = degraded.get(s.target);
|
|
@@ -504,6 +128,9 @@ continuesToRuntime = false) {
|
|
|
504
128
|
manual++;
|
|
505
129
|
if (isConnectStep(s.title))
|
|
506
130
|
connectPending = true;
|
|
131
|
+
if (s.target !== MCP_TARGET && s.title.startsWith(MCP_CLIENT_TITLE_PREFIX)) {
|
|
132
|
+
manualClients.push(s.title);
|
|
133
|
+
}
|
|
507
134
|
}
|
|
508
135
|
for (const line of detail.split('\n'))
|
|
509
136
|
io.print(` ${line}`);
|
|
@@ -525,7 +152,7 @@ continuesToRuntime = false) {
|
|
|
525
152
|
}
|
|
526
153
|
const mcpStatus = resolvedStatus(plan, MCP_TARGET, failed, skipped);
|
|
527
154
|
if (!continuesToRuntime) {
|
|
528
|
-
io.print(restartHint(mcpStatus, devCommand));
|
|
155
|
+
io.print(restartHint(mcpStatus, devCommand, manualClients));
|
|
529
156
|
}
|
|
530
157
|
// Carried out even when the hint above was printed, because the RUNTIME path needs the same fact
|
|
531
158
|
// and could not reach it: `restartHint` is only printed when this run stops at the files, and the
|
|
@@ -547,25 +174,6 @@ continuesToRuntime = false) {
|
|
|
547
174
|
* MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
|
|
548
175
|
* message; a half-wired app is a broken project.
|
|
549
176
|
*/
|
|
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
177
|
function applyEffects(plan, io, packageManager) {
|
|
570
178
|
const why = new Map();
|
|
571
179
|
const failed = new Set();
|
|
@@ -635,14 +243,17 @@ function applyEffects(plan, io, packageManager) {
|
|
|
635
243
|
continue;
|
|
636
244
|
}
|
|
637
245
|
// 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
|
-
|
|
246
|
+
// already given to the wiring it gates below (#683) — but only when package.json DECLARES
|
|
247
|
+
// them. Reported from a Tauri + Next app: `pnpm add` died on ERR_PNPM_UNEXPECTED_STORE, the
|
|
248
|
+
// packages happened to resolve, init printed success, and the manifest never gained them.
|
|
249
|
+
const present = s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io);
|
|
250
|
+
if (present && sdkPackagesDeclared(plan.framework, plan.uiLibrary, io))
|
|
640
251
|
continue;
|
|
641
252
|
failed.add(s.target);
|
|
642
253
|
// A failed install only blocks the wiring when the packages are genuinely ABSENT. See
|
|
643
254
|
// sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
|
|
644
255
|
if (s.target === DEPS_TARGET) {
|
|
645
|
-
installFailed =
|
|
256
|
+
installFailed = !present;
|
|
646
257
|
const explained = explainInstallFailure(io, packageManager, exec, s.detail);
|
|
647
258
|
if (explained !== undefined)
|
|
648
259
|
why.set(s.target, explained);
|
|
@@ -687,7 +298,9 @@ function withWorkspaceRoot(options, io) {
|
|
|
687
298
|
if (options.agentRoot !== undefined || true === options.redirected)
|
|
688
299
|
return options;
|
|
689
300
|
const root = enclosingWorkspaceRoot(options.cwd, io);
|
|
690
|
-
|
|
301
|
+
if (root === undefined)
|
|
302
|
+
return options;
|
|
303
|
+
return { ...options, agentRoot: root, severalApps: 1 < appsAt(root, io).length };
|
|
691
304
|
}
|
|
692
305
|
export function runInit(options, io) {
|
|
693
306
|
const result = runInitSteps(withWorkspaceRoot(options, io), io);
|
|
@@ -752,11 +365,11 @@ function runInitSteps(options, io) {
|
|
|
752
365
|
// `--app frontend`, the flag that exists for exactly this, was never read because the bail came
|
|
753
366
|
// first. Reported twice by the same user, who tried the documented workaround and hit the same
|
|
754
367
|
// wall. Discovery already handles the case (it scans top-level directories, not just declared
|
|
755
|
-
// workspaces); it was simply unreachable.
|
|
756
|
-
//
|
|
757
|
-
// `'{}'` because the redirect only needs the manifest to ask "is THIS directory the app", and a
|
|
758
|
-
// directory with no package.json is definitively not.
|
|
368
|
+
// workspaces); it was simply unreachable. `'{}'`: a directory with no manifest is not the app.
|
|
759
369
|
const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? {}, runInit);
|
|
370
|
+
// Listing the apps below still counts as "no app here", in the same series as before.
|
|
371
|
+
if (true === redirectedEarly?.listedAppsBelow && !options.dryRun)
|
|
372
|
+
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
760
373
|
if (redirectedEarly !== null)
|
|
761
374
|
return redirectedEarly;
|
|
762
375
|
if (null === pkgRaw)
|
|
@@ -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.7.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.7.0"
|
|
17
17
|
},
|
|
18
18
|
"license": "FSL-1.1-ALv2",
|
|
19
19
|
"author": "Reticle contributors",
|