@reticlehq/init 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/detect/capabilities.d.ts +4 -1
- package/dist/detect/capabilities.js +45 -9
- package/dist/detect/detect.d.ts +45 -2
- package/dist/detect/detect.js +76 -5
- package/dist/detect/dev-script.d.ts +26 -2
- package/dist/detect/dev-script.js +80 -7
- package/dist/detect/non-js-project.d.ts +1 -1
- package/dist/detect/non-js-project.js +6 -1
- package/dist/detect/workspace-apps.d.ts +21 -0
- package/dist/detect/workspace-apps.js +111 -12
- package/dist/detect/workspace-redirect.js +28 -2
- package/dist/diagnose/containerised-dev-server.d.ts +12 -4
- package/dist/diagnose/containerised-dev-server.js +36 -4
- package/dist/diagnose/csp-check.d.ts +12 -6
- package/dist/diagnose/csp-check.js +61 -13
- package/dist/diagnose/csp-doctor.d.ts +13 -1
- package/dist/diagnose/csp-doctor.js +2 -2
- package/dist/diagnose/csp-step.d.ts +27 -0
- package/dist/diagnose/csp-step.js +86 -0
- package/dist/diagnose/desktop-doctor.js +8 -2
- package/dist/diagnose/install-hint.d.ts +9 -1
- package/dist/diagnose/install-hint.js +17 -7
- package/dist/diagnose/install-retries.d.ts +16 -1
- package/dist/diagnose/install-retries.js +50 -3
- package/dist/diagnose/terminal-wrap.d.ts +18 -0
- package/dist/diagnose/terminal-wrap.js +63 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/no-package-json.d.ts +10 -0
- package/dist/no-package-json.js +121 -0
- package/dist/node-io.d.ts +9 -5
- package/dist/node-io.js +25 -7
- package/dist/patch/angular.d.ts +68 -0
- package/dist/patch/angular.js +216 -0
- package/dist/patch/astro-patch.d.ts +1 -1
- package/dist/patch/astro-patch.js +2 -2
- package/dist/patch/cra.d.ts +0 -2
- package/dist/patch/cra.js +0 -2
- package/dist/patch/electron-patch.d.ts +10 -1
- package/dist/patch/electron-patch.js +79 -27
- package/dist/patch/electron-vite-patch.js +1 -1
- package/dist/patch/nuxt-patch.d.ts +1 -1
- package/dist/patch/nuxt-patch.js +2 -2
- package/dist/patch/nuxt-snippets.js +6 -6
- package/dist/patch/remix.d.ts +24 -0
- package/dist/patch/remix.js +59 -0
- package/dist/patch/snippets.d.ts +57 -9
- package/dist/patch/snippets.js +119 -28
- package/dist/patch/static-page.d.ts +39 -0
- package/dist/patch/static-page.js +102 -0
- package/dist/patch/tanstack-start.d.ts +36 -8
- package/dist/patch/tanstack-start.js +118 -29
- package/dist/patch/vite-config.d.ts +5 -0
- package/dist/patch/vite-config.js +8 -0
- package/dist/patch/vite-owning-config.d.ts +34 -0
- package/dist/patch/vite-owning-config.js +63 -5
- package/dist/plan/connect-steps.d.ts +7 -1
- package/dist/plan/connect-steps.js +18 -1
- package/dist/plan/framework-adapter.d.ts +3 -1
- package/dist/plan/framework-adapter.js +99 -6
- package/dist/plan/notices.js +4 -3
- package/dist/plan/plan-angular.d.ts +6 -0
- package/dist/plan/plan-angular.js +89 -0
- package/dist/plan/plan-electron-vite.d.ts +6 -3
- package/dist/plan/plan-electron-vite.js +39 -8
- package/dist/plan/plan-framework.d.ts +8 -46
- package/dist/plan/plan-framework.js +81 -312
- package/dist/plan/plan-types.d.ts +29 -0
- package/dist/plan/plan-vite.d.ts +67 -0
- package/dist/plan/plan-vite.js +256 -0
- package/dist/plan/plan.js +38 -34
- package/dist/plan/port-steps.d.ts +45 -0
- package/dist/plan/port-steps.js +119 -0
- package/dist/plan/preflight.d.ts +17 -3
- package/dist/plan/preflight.js +53 -23
- package/dist/register/mcp.d.ts +18 -5
- package/dist/register/mcp.js +48 -8
- package/dist/run-types.d.ts +8 -0
- package/dist/run.js +111 -78
- package/package.json +3 -4
- package/NOTICE +0 -5
package/dist/plan/preflight.js
CHANGED
|
@@ -11,8 +11,38 @@
|
|
|
11
11
|
* npm ships with node, so refusing for its absence would refuse on a machine that is fine.
|
|
12
12
|
*/
|
|
13
13
|
const ALWAYS_PRESENT = 'npm';
|
|
14
|
+
/** The one program allowed to run a package manager it cannot find on PATH itself. */
|
|
15
|
+
const COREPACK = 'corepack';
|
|
14
16
|
/**
|
|
15
|
-
* The
|
|
17
|
+
* The prefix this run can actually invoke `packageManager` through, or undefined when neither the
|
|
18
|
+
* bare binary nor corepack can.
|
|
19
|
+
*
|
|
20
|
+
* Reported from the field (#1149): a Windows machine with a corepack-managed pnpm — no `pnpm` on
|
|
21
|
+
* PATH, only `corepack`, which resolves it from the project's `packageManager` field — failed the
|
|
22
|
+
* bare `pnpm --version` probe and refused, even though `corepack pnpm --version` succeeds and every
|
|
23
|
+
* later step could have run through it. `corepack <pm>` is not a fallback guess: corepack shipped
|
|
24
|
+
* with node and does exactly what the bare binary would have, so a machine that can only reach it
|
|
25
|
+
* that way is not missing the tool, only the shim. Every other place in this codebase that needs to
|
|
26
|
+
* talk about a corepack-only machine (`Detection.packageManagerCommand` and its readers) points back
|
|
27
|
+
* here rather than re-explaining it.
|
|
28
|
+
*/
|
|
29
|
+
function resolvedPmCommand(io, packageManager) {
|
|
30
|
+
if (ALWAYS_PRESENT === packageManager)
|
|
31
|
+
return packageManager;
|
|
32
|
+
if (io.probe(packageManager, ['--version']))
|
|
33
|
+
return packageManager;
|
|
34
|
+
if (io.probe(COREPACK, [packageManager, '--version']))
|
|
35
|
+
return `${COREPACK} ${packageManager}`;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The one place this run decides whether — and how — it can invoke the package manager.
|
|
40
|
+
*
|
|
41
|
+
* Resolves the invocation ONCE: earlier, `preflightRefusal` probed the machine to decide whether to
|
|
42
|
+
* refuse, and callers that got past it probed AGAIN, separately, to learn what the resolved command
|
|
43
|
+
* actually was — two subprocesses (the bare binary, then corepack) doing the same test twice on every
|
|
44
|
+
* ordinary run. This is the single call site: it returns either the command every later step should
|
|
45
|
+
* invoke, or the refusal to print, never both and never neither.
|
|
16
46
|
*
|
|
17
47
|
* `packageManager` is the one init RESOLVED, never a raw lockfile check. An inherited
|
|
18
48
|
* `pnpm-lock.yaml` at a monorepo root does not mean the app in `frontend/` uses pnpm — that app's own
|
|
@@ -20,33 +50,33 @@ const ALWAYS_PRESENT = 'npm';
|
|
|
20
50
|
* here from `exists('pnpm-lock.yaml')` refused an npm app sitting under a pnpm monorepo on a machine
|
|
21
51
|
* with no pnpm, which the install gate proves must succeed.
|
|
22
52
|
*/
|
|
23
|
-
export function
|
|
53
|
+
export function preflight(io, packageManager, options = {}) {
|
|
24
54
|
// First: on a read-only checkout nothing else matters, and one access check is cheaper and
|
|
25
55
|
// quieter than spawning a subprocess to discover the same thing.
|
|
26
56
|
if (!io.canWrite()) {
|
|
27
|
-
return
|
|
28
|
-
|
|
57
|
+
return {
|
|
58
|
+
refusal: `${io.cwd()} is not writable, and init has to write into it (.reticle.json, the build config, ` +
|
|
59
|
+
'a capabilities file). Fix the permissions, or run init from a checkout you own.',
|
|
60
|
+
};
|
|
29
61
|
}
|
|
62
|
+
const resolved = resolvedPmCommand(io, packageManager);
|
|
63
|
+
if (resolved !== undefined)
|
|
64
|
+
return { command: resolved };
|
|
30
65
|
// What the project resolves to says nothing about what the machine HAS, and a project committed to
|
|
31
66
|
// pnpm on an npm-only box is an ordinary Monday.
|
|
32
67
|
//
|
|
33
|
-
// `alreadyServed` is `--url
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return (`this project uses ${packageManager} (its lockfile says so) and ${packageManager} is not ` +
|
|
48
|
-
`installed on this machine. Install it (npm i -g ${packageManager}, or corepack enable), or ` +
|
|
49
|
-
'pass --url with the address the app already serves.');
|
|
50
|
-
}
|
|
51
|
-
return undefined;
|
|
68
|
+
// `alreadyServed` is `--url`. This guard exists for the DEV SERVER — to stop `spawn pnpm ENOENT`
|
|
69
|
+
// surfacing inside "the dev server exited" — and `--url` says the app is already up, so init starts
|
|
70
|
+
// nothing and the failure it protects against cannot happen. The dependency install can still fail
|
|
71
|
+
// without the package manager (one step reporting ⚠, a better outcome than writing nothing), so a
|
|
72
|
+
// command is still returned here — the bare name, since neither probe above found one — rather than
|
|
73
|
+
// refusing a run that `--url` already made safe.
|
|
74
|
+
if (true === options.alreadyServed)
|
|
75
|
+
return { command: packageManager };
|
|
76
|
+
return {
|
|
77
|
+
refusal: `this project uses ${packageManager} (its lockfile says so) and ${packageManager} is not ` +
|
|
78
|
+
`installed on this machine — and corepack ${packageManager} could not run it either. Install ` +
|
|
79
|
+
`it (npm i -g ${packageManager}), or enable corepack for it (corepack enable — on Windows this ` +
|
|
80
|
+
'needs an elevated shell), or pass --url with the address the app already serves.',
|
|
81
|
+
};
|
|
52
82
|
}
|
package/dist/register/mcp.d.ts
CHANGED
|
@@ -66,11 +66,24 @@ interface ClaudeAddCommand {
|
|
|
66
66
|
}
|
|
67
67
|
/** `claude mcp add reticle -s user -- npx @reticlehq/server mcp` — registers globally for all projects (portless). */
|
|
68
68
|
export declare function claudeAddCommand(): ClaudeAddCommand;
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Is a `reticle` server already registered with Claude Code, in any scope this project sees?
|
|
71
|
+
*
|
|
72
|
+
* READ FROM CLAUDE'S OWN CONFIG, never asked of the CLI. `claude mcp get reticle` health-checks the
|
|
73
|
+
* entry by LAUNCHING it — `npx @reticlehq/server mcp` in the project — which starts a daemon on
|
|
74
|
+
* whatever `.reticle.json` says before `init` has rewritten it. A re-run moving the port brought the
|
|
75
|
+
* old-port daemon back about two seconds after it was stopped. `claude mcp list` health-checks
|
|
76
|
+
* every server the same way.
|
|
77
|
+
*
|
|
78
|
+
* The three scopes `claude mcp add` writes: user (`mcpServers` at the top of the state file), local
|
|
79
|
+
* (`projects[<dir>].mcpServers`, keyed by absolute path) and project (`<dir>/.mcp.json`). Without a
|
|
80
|
+
* project directory only user scope is looked at. An unreadable file answers "not registered",
|
|
81
|
+
* which costs at worst a `claude mcp add` that reports the entry already exists.
|
|
82
|
+
*/
|
|
83
|
+
export declare function claudeHasReticle(io: {
|
|
84
|
+
readFile(path: string): string | null;
|
|
85
|
+
homeDir(): string;
|
|
86
|
+
}, projectDir: string | undefined, configDir?: string | undefined): boolean;
|
|
74
87
|
/** Probe args for whether the `claude` CLI is installed at all. */
|
|
75
88
|
export declare function claudeAvailableProbe(): {
|
|
76
89
|
command: string;
|
package/dist/register/mcp.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* project's `.reticle.json` in its CWD (see cli-port.ts). The port belongs to the project, not the
|
|
12
12
|
* global agent config.
|
|
13
13
|
*/
|
|
14
|
+
import { join } from 'node:path';
|
|
14
15
|
import { RETICLE_NPM_PACKAGE } from '../version.js';
|
|
15
16
|
// The name is a wire identity and lives in the contract; re-exported here so the registration writers
|
|
16
17
|
// below (and their tests) keep reading it from the module that USES it.
|
|
@@ -87,14 +88,53 @@ export function claudeAddCommand() {
|
|
|
87
88
|
const args = [MCP_SUBCOMMAND, 'add', MCP_SERVER_NAME, '-s', 'user', '--', ...tail];
|
|
88
89
|
return { command: CLAUDE_CLI, args, display: `${CLAUDE_CLI} ${args.join(' ')}` };
|
|
89
90
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return
|
|
91
|
+
/** Where Claude Code keeps its state: `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`. */
|
|
92
|
+
const CLAUDE_STATE_FILE = '.claude.json';
|
|
93
|
+
/** `mcpServers` holds a `reticle` entry. */
|
|
94
|
+
function namesReticle(scope) {
|
|
95
|
+
if ('object' !== typeof scope || null === scope)
|
|
96
|
+
return false;
|
|
97
|
+
const servers = scope.mcpServers;
|
|
98
|
+
return 'object' === typeof servers && null !== servers && MCP_SERVER_NAME in servers;
|
|
99
|
+
}
|
|
100
|
+
function parsed(source) {
|
|
101
|
+
if (null === source)
|
|
102
|
+
return undefined;
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(source);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Is a `reticle` server already registered with Claude Code, in any scope this project sees?
|
|
112
|
+
*
|
|
113
|
+
* READ FROM CLAUDE'S OWN CONFIG, never asked of the CLI. `claude mcp get reticle` health-checks the
|
|
114
|
+
* entry by LAUNCHING it — `npx @reticlehq/server mcp` in the project — which starts a daemon on
|
|
115
|
+
* whatever `.reticle.json` says before `init` has rewritten it. A re-run moving the port brought the
|
|
116
|
+
* old-port daemon back about two seconds after it was stopped. `claude mcp list` health-checks
|
|
117
|
+
* every server the same way.
|
|
118
|
+
*
|
|
119
|
+
* The three scopes `claude mcp add` writes: user (`mcpServers` at the top of the state file), local
|
|
120
|
+
* (`projects[<dir>].mcpServers`, keyed by absolute path) and project (`<dir>/.mcp.json`). Without a
|
|
121
|
+
* project directory only user scope is looked at. An unreadable file answers "not registered",
|
|
122
|
+
* which costs at worst a `claude mcp add` that reports the entry already exists.
|
|
123
|
+
*/
|
|
124
|
+
export function claudeHasReticle(io, projectDir, configDir = process.env['CLAUDE_CONFIG_DIR']) {
|
|
125
|
+
const state = parsed(io.readFile(join(configDir ?? io.homeDir(), CLAUDE_STATE_FILE)));
|
|
126
|
+
if (namesReticle(state))
|
|
127
|
+
return true;
|
|
128
|
+
if (projectDir === undefined)
|
|
129
|
+
return false;
|
|
130
|
+
const projects = 'object' === typeof state && null !== state
|
|
131
|
+
? state.projects
|
|
132
|
+
: undefined;
|
|
133
|
+
if ('object' === typeof projects && null !== projects) {
|
|
134
|
+
if (namesReticle(projects[projectDir]))
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return namesReticle(parsed(io.readFile(join(projectDir, CLAUDE_PROJECT_CONFIG))));
|
|
98
138
|
}
|
|
99
139
|
/** Probe args for whether the `claude` CLI is installed at all. */
|
|
100
140
|
export function claudeAvailableProbe() {
|
package/dist/run-types.d.ts
CHANGED
|
@@ -78,6 +78,14 @@ export interface InitIo {
|
|
|
78
78
|
scoped(relPath: string): InitIo;
|
|
79
79
|
/** Runs a subprocess to completion (inherits stdio); returns true on exit code 0. */
|
|
80
80
|
exec(command: string, args: readonly string[]): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Runs a subprocess quietly and returns its combined output — for a failure whose text names its
|
|
83
|
+
* cause (see explainInstallFailure). Optional: without it the generic failure text stands.
|
|
84
|
+
*/
|
|
85
|
+
capture?(command: string, args: readonly string[]): {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
output: string;
|
|
88
|
+
};
|
|
81
89
|
/** Runs a subprocess quietly (no stdio) for a yes/no check; returns true on exit code 0. */
|
|
82
90
|
probe(command: string, args: readonly string[]): boolean;
|
|
83
91
|
/** Can this process write into the project root — see preflight.ts. */
|
package/dist/run.js
CHANGED
|
@@ -6,24 +6,30 @@
|
|
|
6
6
|
import { OnboardingPhase, OnboardingStepStatus } from '@reticlehq/core/telemetry';
|
|
7
7
|
import { detectMcpClients } from './register/detect-clients.js';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
|
-
import { CONTAINER_MARKERS } from './diagnose/containerised-dev-server.js';
|
|
9
|
+
import { CONTAINER_MARKERS, runsDevServer } from './diagnose/containerised-dev-server.js';
|
|
10
|
+
import { explainInstallFailure } from './diagnose/install-retries.js';
|
|
10
11
|
import { CSP_FILES } from './diagnose/csp-doctor.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { devCommandFrom } from './detect/dev-script.js';
|
|
12
|
+
import { preflight } from './plan/preflight.js';
|
|
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 { detect, Framework, UiLibrary } from './detect/detect.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
|
+
import { initWithoutPackageJson } from './no-package-json.js';
|
|
17
20
|
import { wasMcpRegistered } from './register/mcp-registered.js';
|
|
18
21
|
import { pickAstroHost } from './patch/astro-host.js';
|
|
19
22
|
import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
|
|
20
23
|
import { redirectToWorkspaceApp } from './detect/workspace-redirect.js';
|
|
24
|
+
import { enclosingWorkspaceRoot } from './detect/workspace-apps.js';
|
|
21
25
|
import { isConnectStep } from './plan/connect-steps.js';
|
|
22
26
|
import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
|
|
23
27
|
import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
|
|
24
28
|
import { CRA_ENV_PATH } from './patch/cra.js';
|
|
25
29
|
import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './patch/next-patch.js';
|
|
26
30
|
import { formatGeneratedSource } from './patch/format-generated.js';
|
|
31
|
+
import { tanstackStartConnectPath } from './patch/tanstack-start.js';
|
|
32
|
+
import { installedViteMajor } from './patch/vite-owning-config.js';
|
|
27
33
|
/** CRA's bundled entry, in the order create-react-app itself generates them. */
|
|
28
34
|
const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
|
|
29
35
|
function craEntryOf(io) {
|
|
@@ -35,12 +41,12 @@ function craEntryOf(io) {
|
|
|
35
41
|
return null;
|
|
36
42
|
}
|
|
37
43
|
import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
|
|
38
|
-
import { claudeAvailableProbe,
|
|
44
|
+
import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
|
|
39
45
|
import { reticleDevLocation } from './patch/next-patch.js';
|
|
40
46
|
import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
|
|
41
47
|
import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
|
|
42
48
|
import { deriveProjectId, packageName } from './project/project-id.js';
|
|
43
|
-
import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH,
|
|
49
|
+
import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, nuxtPluginPath, } from './patch/snippets.js';
|
|
44
50
|
import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
|
|
45
51
|
import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './register/slash-command.js';
|
|
46
52
|
import { RETICLE_VERSION } from './version.js';
|
|
@@ -219,6 +225,17 @@ function agentRootOf(options) {
|
|
|
219
225
|
const root = options.agentRoot;
|
|
220
226
|
return root === undefined || root === options.cwd ? undefined : root;
|
|
221
227
|
}
|
|
228
|
+
/** What the Angular steps read: the workspace file, the entry it names, and our proxy module. */
|
|
229
|
+
function angularInputs(io) {
|
|
230
|
+
const workspace = io.readFile(ANGULAR_WORKSPACE_FILE);
|
|
231
|
+
const entryPath = angularEntry(workspace);
|
|
232
|
+
const entry = io.readFile(entryPath);
|
|
233
|
+
return {
|
|
234
|
+
angularWorkspace: null === workspace ? null : { path: ANGULAR_WORKSPACE_FILE, source: workspace },
|
|
235
|
+
angularEntry: null === entry ? null : { path: entryPath, source: entry },
|
|
236
|
+
angularProxySource: io.readFile(ANGULAR_PROXY_PATH),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
222
239
|
function gatherPlanInput(options, io, pkg) {
|
|
223
240
|
// Stable identity derived from the app's package.json name + root, so it survives port changes.
|
|
224
241
|
const projectId = deriveProjectId(packageName(pkg), options.cwd);
|
|
@@ -234,7 +251,11 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
234
251
|
nodeModulesMarkers,
|
|
235
252
|
};
|
|
236
253
|
const detection = detect(detectInput);
|
|
237
|
-
|
|
254
|
+
// Forge has no `vite.config.*`; its renderer config IS the Vite config the plugin belongs in, and
|
|
255
|
+
// handing it over under that name is what lets the ordinary Vite patcher do the work.
|
|
256
|
+
const vitePath = firstPresent(rootFiles, Framework.ELECTRON_FORGE === detection.framework
|
|
257
|
+
? FORGE_RENDERER_CONFIGS
|
|
258
|
+
: VITE_CONFIG_CANDIDATES);
|
|
238
259
|
const viteSource = null === vitePath ? null : io.readFile(vitePath);
|
|
239
260
|
const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
|
|
240
261
|
const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
|
|
@@ -248,8 +269,8 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
248
269
|
// global config file. Only probe when the MCP step is in play.
|
|
249
270
|
const availableProbe = claudeAvailableProbe();
|
|
250
271
|
const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
|
|
251
|
-
|
|
252
|
-
const mcpExists = claudeCli ? io
|
|
272
|
+
// Read from Claude's config, never `claude mcp get`: that launches the server — see claudeHasReticle.
|
|
273
|
+
const mcpExists = claudeCli ? claudeHasReticle(io, io.cwd()) : false;
|
|
253
274
|
// Every MCP client this machine shows evidence of. Conservative and one-directional: we write
|
|
254
275
|
// into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
|
|
255
276
|
// does not use them.
|
|
@@ -308,7 +329,13 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
308
329
|
// repo that containerises it — `frontend/` under a root `docker-compose.yml` is the shape this
|
|
309
330
|
// came from. Only the first match is reported; the note is the same whichever file found it.
|
|
310
331
|
...(() => {
|
|
311
|
-
|
|
332
|
+
// Only a file that RUNS the dev server: a production-only image is the common case, and
|
|
333
|
+
// React Router's template ships one. See runsDevServer.
|
|
334
|
+
const devScript = devScriptBody(pkg);
|
|
335
|
+
const marker = CONTAINER_MARKERS.find((name) => [name, join('..', name)].some((path) => {
|
|
336
|
+
const content = io.readFile(path);
|
|
337
|
+
return null !== content && runsDevServer(name, content, devScript);
|
|
338
|
+
}));
|
|
312
339
|
return marker === undefined ? {} : { containerMarker: marker };
|
|
313
340
|
})(),
|
|
314
341
|
viteConfig,
|
|
@@ -328,7 +355,7 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
328
355
|
: null,
|
|
329
356
|
// Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
|
|
330
357
|
testids: scanTestids(sourceFiles.map((f) => f.source)),
|
|
331
|
-
storeHints: storeHints(dependencyNames(pkg)),
|
|
358
|
+
storeHints: storeHints(dependencyNames(pkg), sourceFiles),
|
|
332
359
|
foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
|
|
333
360
|
nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
|
|
334
361
|
viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
|
|
@@ -339,6 +366,7 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
339
366
|
nextReticleDevExists: io.exists(devLocation.path),
|
|
340
367
|
nextReticleDevSource: io.readFile(devLocation.path),
|
|
341
368
|
svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
|
|
369
|
+
svelteKitHooksSource: io.readFile(SVELTEKIT_HOOKS),
|
|
342
370
|
reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
|
|
343
371
|
// The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
|
|
344
372
|
// override of React Router's default, and everything in it is load-bearing.
|
|
@@ -347,8 +375,24 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
347
375
|
? { path: nuxtConfigPath, source: nuxtConfigSource }
|
|
348
376
|
: null,
|
|
349
377
|
nuxtHasAppDir,
|
|
378
|
+
viteMajor: installedViteMajor(io, detectInput.pkg),
|
|
350
379
|
nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
|
|
351
|
-
|
|
380
|
+
nuxtPluginSource: io.readFile(nuxtPluginPath(nuxtHasAppDir)),
|
|
381
|
+
...(() => {
|
|
382
|
+
const root = TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file));
|
|
383
|
+
return root === undefined
|
|
384
|
+
? {}
|
|
385
|
+
: {
|
|
386
|
+
tanstackStartRoot: root,
|
|
387
|
+
tanstackStartRootSource: io.readFile(root),
|
|
388
|
+
tanstackStartConnectExists: io.exists(tanstackStartConnectPath(root)),
|
|
389
|
+
tanstackStartConnectSource: io.readFile(tanstackStartConnectPath(root)),
|
|
390
|
+
};
|
|
391
|
+
})(),
|
|
392
|
+
...(Framework.ANGULAR === detection.framework ? angularInputs(io) : {}),
|
|
393
|
+
...(Framework.HTML === detection.framework
|
|
394
|
+
? { htmlIndexSource: io.readFile(HTML_INDEX_PATH) }
|
|
395
|
+
: {}),
|
|
352
396
|
craEntry: craEntryOf(io),
|
|
353
397
|
craEnv: io.readFile(CRA_ENV_PATH),
|
|
354
398
|
pairingToken: io.host.pairingToken(),
|
|
@@ -388,7 +432,7 @@ function gatherPlanInput(options, io, pkg) {
|
|
|
388
432
|
/** pnpm's workspace declaration, read when present — it is authoritative about where packages live. */
|
|
389
433
|
const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
|
|
390
434
|
'installed stops it booting. Run that install, then re-run `reticle init`.';
|
|
391
|
-
function report(plan, dryRun, failed, skipped, degraded, io, projectDir, agentRoot,
|
|
435
|
+
function report(plan, dryRun, failed, skipped, degraded, why, io, projectDir, agentRoot,
|
|
392
436
|
/** The project's own dev command, so the closing line names what a human would type. */
|
|
393
437
|
devCommand,
|
|
394
438
|
/**
|
|
@@ -412,7 +456,7 @@ continuesToRuntime = false) {
|
|
|
412
456
|
* On the REAL run only. In a dry run the reader has already found the flag.
|
|
413
457
|
*/
|
|
414
458
|
if (!dryRun) {
|
|
415
|
-
io.print(' --dry-run
|
|
459
|
+
io.print(' --dry-run previews · --app <dir> picks the app · --no-mcp skips agent registration');
|
|
416
460
|
}
|
|
417
461
|
// Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
|
|
418
462
|
// field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
|
|
@@ -447,7 +491,7 @@ continuesToRuntime = false) {
|
|
|
447
491
|
const detail = skipped.has(s.target)
|
|
448
492
|
? SKIPPED_DETAIL
|
|
449
493
|
: downgraded && s.exec !== undefined
|
|
450
|
-
? `step failed — run manually: ${s.exec.fallback}`
|
|
494
|
+
? (why.get(s.target) ?? `step failed — run manually: ${s.exec.fallback}`)
|
|
451
495
|
: s.detail;
|
|
452
496
|
io.print(` [${STATUS_SYMBOL[status]}] ${s.title} → ${s.target}`);
|
|
453
497
|
if (status === StepStatus.APPLY)
|
|
@@ -520,7 +564,8 @@ function sdkPackagesPresent(framework, uiLibrary, io) {
|
|
|
520
564
|
return (packages.length > 0 &&
|
|
521
565
|
packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
|
|
522
566
|
}
|
|
523
|
-
function applyEffects(plan, io) {
|
|
567
|
+
function applyEffects(plan, io, packageManager) {
|
|
568
|
+
const why = new Map();
|
|
524
569
|
const failed = new Set();
|
|
525
570
|
const skipped = new Set();
|
|
526
571
|
const degraded = new Map();
|
|
@@ -594,8 +639,12 @@ function applyEffects(plan, io) {
|
|
|
594
639
|
failed.add(s.target);
|
|
595
640
|
// A failed install only blocks the wiring when the packages are genuinely ABSENT. See
|
|
596
641
|
// sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
|
|
597
|
-
if (s.target === DEPS_TARGET)
|
|
642
|
+
if (s.target === DEPS_TARGET) {
|
|
598
643
|
installFailed = true;
|
|
644
|
+
const explained = explainInstallFailure(io, packageManager, exec, s.detail);
|
|
645
|
+
if (explained !== undefined)
|
|
646
|
+
why.set(s.target, explained);
|
|
647
|
+
}
|
|
599
648
|
}
|
|
600
649
|
}
|
|
601
650
|
// Where this project lives, remembered for a daemon that will be started somewhere else.
|
|
@@ -609,7 +658,7 @@ function applyEffects(plan, io) {
|
|
|
609
658
|
if (projectId !== undefined) {
|
|
610
659
|
rememberProjectOnDisk(io, projectId, io.cwd(), Date.now());
|
|
611
660
|
}
|
|
612
|
-
return { failed, skipped, degraded };
|
|
661
|
+
return { failed, skipped, degraded, why };
|
|
613
662
|
}
|
|
614
663
|
/**
|
|
615
664
|
* Which STEP failed, from the step targets — not from an error string, which would carry paths.
|
|
@@ -626,8 +675,20 @@ function classifyInitFailure(failed) {
|
|
|
626
675
|
return InitFailure.MCP_REGISTRATION;
|
|
627
676
|
return InitFailure.OTHER;
|
|
628
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* Where the agent stands when `init` runs inside a workspace package: the workspace root.
|
|
680
|
+
*
|
|
681
|
+
* The same answer `--app` from the root already gives (see enterApp), so both routes into one app
|
|
682
|
+
* leave the root in one state — a pointer config and the agent files an agent opened there reads.
|
|
683
|
+
*/
|
|
684
|
+
function withWorkspaceRoot(options, io) {
|
|
685
|
+
if (options.agentRoot !== undefined || true === options.redirected)
|
|
686
|
+
return options;
|
|
687
|
+
const root = enclosingWorkspaceRoot(options.cwd, io);
|
|
688
|
+
return root === undefined ? options : { ...options, agentRoot: root };
|
|
689
|
+
}
|
|
629
690
|
export function runInit(options, io) {
|
|
630
|
-
const result = runInitSteps(options, io);
|
|
691
|
+
const result = runInitSteps(withWorkspaceRoot(options, io), io);
|
|
631
692
|
// The ask goes here, not in report(): report() is only the success-shaped path, and the exits that
|
|
632
693
|
// matter most are the ones that never reach it — no package.json, an ambiguous workspace — where
|
|
633
694
|
// setup died before anything ran and the person holding the report has the least to go on. This
|
|
@@ -635,7 +696,10 @@ export function runInit(options, io) {
|
|
|
635
696
|
//
|
|
636
697
|
// `redirected` is what keeps it to ONE print: wiring an app in a monorepo re-enters runInit for the
|
|
637
698
|
// chosen directory, and the inner call must not ask again.
|
|
638
|
-
|
|
699
|
+
//
|
|
700
|
+
// And not when the runtime follows: it asks once it has finished, as the last thing printed,
|
|
701
|
+
// rather than here in the middle, before the dev server has even started.
|
|
702
|
+
if (true !== options.redirected && true !== options.continuesToRuntime) {
|
|
639
703
|
io.print('');
|
|
640
704
|
io.print(FEEDBACK_HINT);
|
|
641
705
|
}
|
|
@@ -692,52 +756,8 @@ function runInitSteps(options, io) {
|
|
|
692
756
|
const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? {}, runInit);
|
|
693
757
|
if (redirectedEarly !== null)
|
|
694
758
|
return redirectedEarly;
|
|
695
|
-
if (null === pkgRaw)
|
|
696
|
-
|
|
697
|
-
// Asked only when Streamlit already said no, so the two can never both claim the page.
|
|
698
|
-
const django = !streamlit &&
|
|
699
|
-
detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
|
|
700
|
-
io.print(
|
|
701
|
-
// Two genuinely different situations used to share one sentence: a JS developer in the wrong
|
|
702
|
-
// directory, and a project that is not JavaScript at all. The second reads the old wording as
|
|
703
|
-
// a path problem and goes looking for a directory that cannot exist — reported from a
|
|
704
|
-
// Streamlit app, where the search continued into hunting for a browser bundle to inject by
|
|
705
|
-
// hand before the real answer surfaced.
|
|
706
|
-
streamlit
|
|
707
|
-
? streamlitSetupMessage()
|
|
708
|
-
: django
|
|
709
|
-
? djangoSetupMessage()
|
|
710
|
-
: noPackageJsonMessage((file) => io.exists(join(options.cwd, file))));
|
|
711
|
-
// The message says "add the snippet below". Print the snippet, or the message is the same
|
|
712
|
-
// broken promise in the other direction. `connectArg` carries the port; there is no projectId
|
|
713
|
-
// to bake, because a projectId is derived from the package.json that does not exist here.
|
|
714
|
-
// Scope the project on disk before printing anything, so the daemon can serve this page.
|
|
715
|
-
//
|
|
716
|
-
// This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
|
|
717
|
-
// asked for the Django path had to hand-write one alongside the middleware. Without it the
|
|
718
|
-
// daemon has no config in its own directory, refuses the page's dial, and every downstream
|
|
719
|
-
// symptom points somewhere other than the cause (see #685). The projectId derivation already
|
|
720
|
-
// handles the no-package case: it falls back to the root folder name, fingerprinted by the
|
|
721
|
-
// absolute path so two checkouts stay distinct.
|
|
722
|
-
//
|
|
723
|
-
// Written only when absent. A config a user or an earlier run already placed here is theirs.
|
|
724
|
-
const nonJsProjectId = deriveProjectId(undefined, io.cwd());
|
|
725
|
-
if (!io.exists(RETICLE_CONFIG_FILE)) {
|
|
726
|
-
io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
|
|
727
|
-
rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
|
|
728
|
-
io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
|
|
729
|
-
}
|
|
730
|
-
const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
|
|
731
|
-
io.print(streamlit
|
|
732
|
-
? streamlitPageSnippet(connect)
|
|
733
|
-
: django
|
|
734
|
-
? djangoMiddlewareSnippet(connect)
|
|
735
|
-
: staticPageSnippet(connect));
|
|
736
|
-
// The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
|
|
737
|
-
// from someone who never ran the command — the two failure modes with the most different fixes.
|
|
738
|
-
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
739
|
-
return { ok: false, applied: 0, manual: 0 };
|
|
740
|
-
}
|
|
759
|
+
if (null === pkgRaw)
|
|
760
|
+
return initWithoutPackageJson(options, io);
|
|
741
761
|
// Init is the flow a user experiences the wait of personally, and the fixture gate measures it at
|
|
742
762
|
// 1–6s per app with no explanation of the spread. These three spans split that number into detect
|
|
743
763
|
// (filesystem probing), plan (pure), and apply (writes + package-manager and CLI subprocesses).
|
|
@@ -747,23 +767,32 @@ function runInitSteps(options, io) {
|
|
|
747
767
|
// the app in frontend/ uses pnpm, and checking the file refused a scaffold the install gate proves
|
|
748
768
|
// must succeed. Both conditions make every later phase fail, and each arrives far from its cause
|
|
749
769
|
// when it is not checked here. See preflight.ts.
|
|
750
|
-
const
|
|
770
|
+
const preflightIo = {
|
|
751
771
|
cwd: () => io.cwd(),
|
|
752
772
|
canWrite: () => io.canWrite(),
|
|
753
773
|
probe: (command, args) => io.probe(command, args),
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
|
|
774
|
+
};
|
|
775
|
+
const resolved = preflight(preflightIo, planInput.detection.packageManager, {
|
|
776
|
+
alreadyServed: options.url !== undefined && '' !== options.url,
|
|
777
|
+
});
|
|
778
|
+
if (resolved.refusal !== undefined) {
|
|
779
|
+
io.print(resolved.refusal);
|
|
757
780
|
return { ok: false, applied: 0, manual: 1 };
|
|
758
781
|
}
|
|
782
|
+
// The prefix that can actually run it on THIS machine — `pnpm`, or `corepack pnpm` where preflight
|
|
783
|
+
// just proved only corepack can. Threaded onto detection (never recomputed by `detect`, which is
|
|
784
|
+
// pure and never probes) so the install step, its retries and the dev command all spawn and print
|
|
785
|
+
// the same thing preflight just confirmed works, instead of a bare binary it already ruled out.
|
|
786
|
+
const packageManagerCommand = resolved.command;
|
|
787
|
+
planInput.detection = { ...planInput.detection, packageManagerCommand };
|
|
759
788
|
const plan = io.host.span('init.plan', {}, () => buildPlan(planInput));
|
|
760
789
|
const effects = options.dryRun
|
|
761
|
-
? { failed: new Set(), skipped: new Set(), degraded: new Map() }
|
|
762
|
-
: io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io));
|
|
763
|
-
const { failed, skipped, degraded } = effects;
|
|
790
|
+
? { failed: new Set(), skipped: new Set(), degraded: new Map(), why: new Map() }
|
|
791
|
+
: io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io, planInput.detection.packageManager));
|
|
792
|
+
const { failed, skipped, degraded, why } = effects;
|
|
764
793
|
// The project's own dev command, so the closing line names what a human would actually type.
|
|
765
|
-
const devCommand = devCommandFrom(pkgRaw,
|
|
766
|
-
const result = report(plan, options.dryRun, failed, skipped, degraded, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
|
|
794
|
+
const devCommand = devCommandFrom(pkgRaw, packageManagerCommand);
|
|
795
|
+
const result = report(plan, options.dryRun, failed, skipped, degraded, why, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
|
|
767
796
|
// A dry run is a preview, not an outcome — reporting it would inflate both success and failure.
|
|
768
797
|
if (options.dryRun)
|
|
769
798
|
return result;
|
|
@@ -831,8 +860,12 @@ function runInitSteps(options, io) {
|
|
|
831
860
|
stack: plan.framework,
|
|
832
861
|
...(result.ok ? {} : { reason: classifyInitFailure(failed) }),
|
|
833
862
|
});
|
|
863
|
+
// A failed dependency install skipped every wiring step, so there is no instrumented app to boot:
|
|
864
|
+
// without a context the runtime stops at this report, rather than starting the dev server and
|
|
865
|
+
// diagnosing an SDK that was never installed as a dev server that needs restarting.
|
|
866
|
+
const handover = failed.has(DEPS_TARGET) ? {} : { context };
|
|
834
867
|
if (true === options.deferOutcome)
|
|
835
|
-
return { ...result,
|
|
868
|
+
return { ...result, ...handover, outcome };
|
|
836
869
|
io.host.reportOutcome(outcome);
|
|
837
|
-
return { ...result,
|
|
870
|
+
return { ...result, ...handover };
|
|
838
871
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/init",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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.4.0"
|
|
17
17
|
},
|
|
18
18
|
"license": "FSL-1.1-ALv2",
|
|
19
19
|
"author": "Reticle contributors",
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
|
29
29
|
"README.md",
|
|
30
|
-
"LICENSE"
|
|
31
|
-
"NOTICE"
|
|
30
|
+
"LICENSE"
|
|
32
31
|
],
|
|
33
32
|
"sideEffects": false,
|
|
34
33
|
"keywords": [
|