@reticlehq/init 3.6.0 → 3.8.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 +9 -0
- package/dist/detect/detect.js +5 -1
- package/dist/detect/dev-script.d.ts +17 -0
- package/dist/detect/dev-script.js +128 -0
- package/dist/detect/dev-server-ports.d.ts +2 -0
- package/dist/detect/dev-server-ports.js +20 -0
- package/dist/detect/prettier-config.d.ts +21 -0
- package/dist/detect/prettier-config.js +38 -0
- package/dist/detect/workspace-apps.d.ts +11 -0
- package/dist/detect/workspace-apps.js +63 -0
- package/dist/detect/workspace-redirect.js +69 -10
- package/dist/diagnose/closing-hint.d.ts +5 -0
- package/dist/diagnose/closing-hint.js +18 -11
- 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 +468 -0
- package/dist/host.d.ts +6 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +14 -4
- package/dist/no-package-json.js +62 -14
- package/dist/node-io.d.ts +1 -1
- package/dist/node-io.js +11 -2
- package/dist/patch/next-patch.d.ts +45 -9
- package/dist/patch/next-patch.js +93 -0
- package/dist/patch/nuxt-snippets.js +1 -1
- package/dist/patch/prettier-ignore.d.ts +34 -0
- package/dist/patch/prettier-ignore.js +49 -0
- package/dist/patch/python-snippets.d.ts +22 -0
- package/dist/patch/python-snippets.js +113 -0
- package/dist/patch/snippets.d.ts +10 -31
- package/dist/patch/snippets.js +42 -105
- package/dist/patch/static-page.d.ts +1 -0
- package/dist/patch/static-page.js +6 -8
- package/dist/patch/vite-config.js +11 -0
- package/dist/plan/connect-steps.d.ts +2 -0
- package/dist/plan/connect-steps.js +6 -0
- package/dist/plan/framework-adapter.d.ts +10 -0
- package/dist/plan/framework-adapter.js +122 -3
- package/dist/plan/notices.js +1 -1
- package/dist/plan/plan-angular.js +1 -1
- package/dist/plan/plan-framework.d.ts +1 -1
- package/dist/plan/plan-framework.js +140 -37
- package/dist/plan/plan-types.d.ts +50 -2
- package/dist/plan/plan-vite.js +15 -0
- package/dist/plan/plan.d.ts +1 -10
- package/dist/plan/plan.js +181 -126
- package/dist/plan/stop-hook-step.d.ts +30 -0
- package/dist/plan/stop-hook-step.js +154 -5
- package/dist/project/agent-rules.d.ts +1 -1
- package/dist/project/agent-rules.js +22 -17
- package/dist/project/refresh-rules.js +2 -1
- 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/register/prompt-hooks.d.ts +123 -0
- package/dist/register/prompt-hooks.js +449 -0
- package/dist/register/slash-command.d.ts +1 -1
- package/dist/register/slash-command.js +5 -4
- package/dist/run-types.d.ts +10 -0
- package/dist/run.d.ts +1 -1
- package/dist/run.js +15 -390
- package/package.json +2 -2
package/dist/detect/detect.d.ts
CHANGED
|
@@ -122,6 +122,13 @@ export interface Detection {
|
|
|
122
122
|
* require-hook down with it and the dev server never starts.
|
|
123
123
|
*/
|
|
124
124
|
typescript: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Whether the project uses Prettier (config file or package.json "prettier" key).
|
|
127
|
+
* Used by `init` to decide whether to add `.reticle/` to `.prettierignore` (#1560).
|
|
128
|
+
*
|
|
129
|
+
* Optional so existing test fixtures keep compiling without naming it; `detect` always sets it.
|
|
130
|
+
*/
|
|
131
|
+
usesPrettier?: boolean | undefined;
|
|
125
132
|
reactMajor: number | undefined;
|
|
126
133
|
/**
|
|
127
134
|
* `react-scripts`' major, when the project has it. `undefined` on every other stack.
|
|
@@ -251,6 +258,8 @@ export declare function parseMajor(range: string | undefined): number | undefine
|
|
|
251
258
|
*/
|
|
252
259
|
export declare function namesAPackageManager(markers: ReadonlySet<string>): boolean;
|
|
253
260
|
export declare function detectPackageManager(lockfiles: ReadonlySet<string>, nodeModulesMarkers: ReadonlySet<string>, pkg?: unknown): PackageManager;
|
|
261
|
+
/** The file that makes a project TypeScript to its tooling, Next and Vite included. */
|
|
262
|
+
export declare const TSCONFIG_FILE = "tsconfig.json";
|
|
254
263
|
export declare function detect(input: DetectInput): Detection;
|
|
255
264
|
interface InstallCommand {
|
|
256
265
|
command: string;
|
package/dist/detect/detect.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Pure framework + toolchain detection for `reticle init`. No filesystem access — callers pass in
|
|
3
3
|
* the parsed package.json and the set of config/lock filenames present in the project root.
|
|
4
4
|
*/
|
|
5
|
+
import { hasPrettierConfig } from './prettier-config.js';
|
|
5
6
|
export const Framework = {
|
|
6
7
|
NEXT: 'next',
|
|
7
8
|
/**
|
|
@@ -353,7 +354,9 @@ function detectUiLibrary(pkg) {
|
|
|
353
354
|
return UiLibrary.SVELTE;
|
|
354
355
|
return UiLibrary.UNKNOWN;
|
|
355
356
|
}
|
|
356
|
-
|
|
357
|
+
/** The file that makes a project TypeScript to its tooling, Next and Vite included. */
|
|
358
|
+
export const TSCONFIG_FILE = 'tsconfig.json';
|
|
359
|
+
const TS_CONFIGS = [TSCONFIG_FILE];
|
|
357
360
|
export function detect(input) {
|
|
358
361
|
const reactMajor = parseMajor(depVersion(input.pkg, 'react'));
|
|
359
362
|
return {
|
|
@@ -362,6 +365,7 @@ export function detect(input) {
|
|
|
362
365
|
uiLibrary: detectUiLibrary(input.pkg),
|
|
363
366
|
typescript: hasAnyConfig(input.configFiles, TS_CONFIGS) ||
|
|
364
367
|
depVersion(input.pkg, 'typescript') !== undefined,
|
|
368
|
+
usesPrettier: hasPrettierConfig(input.configFiles, input.pkg),
|
|
365
369
|
reactMajor,
|
|
366
370
|
needsSourceMapping: reactMajor !== undefined && reactMajor >= 19,
|
|
367
371
|
customReconciler: CUSTOM_RECONCILER_DEPS.some((name) => depVersion(input.pkg, name) !== undefined),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InitIo } from '../run-types.js';
|
|
1
2
|
/**
|
|
2
3
|
* Which command starts this project, and should we start it ourselves?
|
|
3
4
|
*
|
|
@@ -72,4 +73,20 @@ export declare function desktopLaunch(pkg: unknown): DesktopLaunch | undefined;
|
|
|
72
73
|
export declare function devCommandFrom(pkg: unknown, packageManagerCommand: string): string | undefined;
|
|
73
74
|
/** The dev script's own command line (`react-router dev`, `vite`), or undefined when there is none. */
|
|
74
75
|
export declare function devScriptBody(pkg: unknown): string | undefined;
|
|
76
|
+
interface ViteConfigDiscovery {
|
|
77
|
+
config: {
|
|
78
|
+
path: string;
|
|
79
|
+
source: string;
|
|
80
|
+
} | null;
|
|
81
|
+
/** Paths that may own Vite but cannot safely be selected automatically. */
|
|
82
|
+
candidates: readonly string[];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Find the config that a plain-Vite dev script actually loads.
|
|
86
|
+
*
|
|
87
|
+
* An explicit `--config` is authoritative. A direct `vite` command without one keeps Vite's own
|
|
88
|
+
* root lookup. A custom server with no root config but nested ones is ambiguous, so it returns
|
|
89
|
+
* evidence for a precise MANUAL step instead of creating a root config the server may never read.
|
|
90
|
+
*/
|
|
91
|
+
export declare function discoverPlainViteConfig(io: InitIo, pkg: unknown, rootFiles: ReadonlySet<string>, configNames: readonly string[]): ViteConfigDiscovery;
|
|
75
92
|
export {};
|
|
@@ -137,3 +137,131 @@ export function devScriptBody(pkg) {
|
|
|
137
137
|
const body = name === undefined ? undefined : table[name];
|
|
138
138
|
return 'string' === typeof body ? body : undefined;
|
|
139
139
|
}
|
|
140
|
+
const SHELL_TOKEN = /"(?:\\.|[^"\\])*"|'[^']*'|&&|\|\||[;|]|[^\s;&|]+/g;
|
|
141
|
+
const SHELL_SEPARATORS = new Set(['&&', '||', ';', '|']);
|
|
142
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
143
|
+
const MAX_CONFIG_DEPTH = 4;
|
|
144
|
+
const MAX_CONFIG_CANDIDATES = 20;
|
|
145
|
+
const SKIP_CONFIG_DIRS = new Set([
|
|
146
|
+
'node_modules',
|
|
147
|
+
'dist',
|
|
148
|
+
'out',
|
|
149
|
+
'coverage',
|
|
150
|
+
'target',
|
|
151
|
+
'.git',
|
|
152
|
+
'.next',
|
|
153
|
+
'.nuxt',
|
|
154
|
+
'.svelte-kit',
|
|
155
|
+
]);
|
|
156
|
+
function unquote(token) {
|
|
157
|
+
if (token.startsWith("'") && token.endsWith("'"))
|
|
158
|
+
return token.slice(1, -1);
|
|
159
|
+
if (token.startsWith('"') && token.endsWith('"')) {
|
|
160
|
+
return token.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
161
|
+
}
|
|
162
|
+
return token;
|
|
163
|
+
}
|
|
164
|
+
function safeRelativePath(value) {
|
|
165
|
+
const normal = unquote(value).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
166
|
+
if ('' === normal ||
|
|
167
|
+
normal.startsWith('/') ||
|
|
168
|
+
normal.startsWith('~') ||
|
|
169
|
+
/^[A-Za-z]:\//.test(normal) ||
|
|
170
|
+
normal.split('/').includes('..') ||
|
|
171
|
+
/[\0$`]/.test(normal)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return normal;
|
|
175
|
+
}
|
|
176
|
+
function isViteCommand(token) {
|
|
177
|
+
const normal = token.replace(/\\/g, '/');
|
|
178
|
+
return 'vite' === normal || 'vite.cmd' === normal || normal.endsWith('/node_modules/.bin/vite');
|
|
179
|
+
}
|
|
180
|
+
/** The direct Vite invocation in the selected dev script, if that script has one. */
|
|
181
|
+
function viteInvocation(script) {
|
|
182
|
+
const tokens = script.match(SHELL_TOKEN) ?? [];
|
|
183
|
+
let start = 0;
|
|
184
|
+
while (start < tokens.length) {
|
|
185
|
+
let end = start;
|
|
186
|
+
while (end < tokens.length && !SHELL_SEPARATORS.has(tokens[end] ?? ''))
|
|
187
|
+
end += 1;
|
|
188
|
+
const segment = tokens.slice(start, end).map(unquote);
|
|
189
|
+
let command = 0;
|
|
190
|
+
while (command < segment.length && ENV_ASSIGNMENT.test(segment[command] ?? ''))
|
|
191
|
+
command += 1;
|
|
192
|
+
if (isViteCommand(segment[command] ?? '')) {
|
|
193
|
+
const args = segment.slice(command + 1);
|
|
194
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
195
|
+
const arg = args[i] ?? '';
|
|
196
|
+
const inline = /^(?:--config|-c)=(.*)$/.exec(arg);
|
|
197
|
+
const raw = inline?.[1] ?? ('--config' === arg || '-c' === arg ? args[i + 1] : undefined);
|
|
198
|
+
if (raw !== undefined) {
|
|
199
|
+
return { configArgument: raw, configPath: safeRelativePath(raw) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { configArgument: null, configPath: null };
|
|
203
|
+
}
|
|
204
|
+
start = end + 1;
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
function nestedViteConfigs(io, configNames) {
|
|
209
|
+
const found = [];
|
|
210
|
+
const walk = (dir, depth) => {
|
|
211
|
+
if (found.length >= MAX_CONFIG_CANDIDATES || depth > MAX_CONFIG_DEPTH)
|
|
212
|
+
return;
|
|
213
|
+
for (const file of io.listFiles(dir)) {
|
|
214
|
+
if (configNames.includes(file))
|
|
215
|
+
found.push(`${dir}/${file}`);
|
|
216
|
+
if (found.length >= MAX_CONFIG_CANDIDATES)
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const child of io.listDirs(dir)) {
|
|
220
|
+
if (found.length >= MAX_CONFIG_CANDIDATES)
|
|
221
|
+
return;
|
|
222
|
+
if (child.startsWith('.') || SKIP_CONFIG_DIRS.has(child))
|
|
223
|
+
continue;
|
|
224
|
+
walk(`${dir}/${child}`, depth + 1);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
for (const dir of io.listDirs('.')) {
|
|
228
|
+
if (found.length >= MAX_CONFIG_CANDIDATES)
|
|
229
|
+
break;
|
|
230
|
+
if (dir.startsWith('.') || SKIP_CONFIG_DIRS.has(dir))
|
|
231
|
+
continue;
|
|
232
|
+
walk(dir, 1);
|
|
233
|
+
}
|
|
234
|
+
return [...new Set(found)];
|
|
235
|
+
}
|
|
236
|
+
function readable(io, path) {
|
|
237
|
+
if (null === path)
|
|
238
|
+
return null;
|
|
239
|
+
const source = io.readFile(path);
|
|
240
|
+
return null === source ? null : { path, source };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Find the config that a plain-Vite dev script actually loads.
|
|
244
|
+
*
|
|
245
|
+
* An explicit `--config` is authoritative. A direct `vite` command without one keeps Vite's own
|
|
246
|
+
* root lookup. A custom server with no root config but nested ones is ambiguous, so it returns
|
|
247
|
+
* evidence for a precise MANUAL step instead of creating a root config the server may never read.
|
|
248
|
+
*/
|
|
249
|
+
export function discoverPlainViteConfig(io, pkg, rootFiles, configNames) {
|
|
250
|
+
const rootPath = configNames.find((path) => rootFiles.has(path)) ?? null;
|
|
251
|
+
const script = devScriptBody(pkg);
|
|
252
|
+
const invocation = script === undefined ? undefined : viteInvocation(script);
|
|
253
|
+
if (invocation !== undefined && invocation.configArgument !== null) {
|
|
254
|
+
const config = readable(io, invocation.configPath);
|
|
255
|
+
return {
|
|
256
|
+
config,
|
|
257
|
+
candidates: null === config ? [invocation.configArgument] : [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (invocation !== undefined)
|
|
261
|
+
return { config: readable(io, rootPath), candidates: [] };
|
|
262
|
+
const nested = script === undefined ? [] : nestedViteConfigs(io, configNames);
|
|
263
|
+
if (null === rootPath && nested.length > 0) {
|
|
264
|
+
return { config: null, candidates: nested };
|
|
265
|
+
}
|
|
266
|
+
return { config: readable(io, rootPath), candidates: [] };
|
|
267
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* on the server side changed.
|
|
16
16
|
*/
|
|
17
17
|
export declare const DEV_SERVER_PORTS: ReadonlySet<number>;
|
|
18
|
+
/** The framework's default dev-server port, or undefined for one that has none of its own. */
|
|
19
|
+
export declare function frameworkDevPort(framework: string): number | undefined;
|
|
18
20
|
export declare function isLikelyDevServerPort(port: number): boolean;
|
|
19
21
|
/** The warning printed when a project pins the bridge to something that looks like its dev server. */
|
|
20
22
|
export declare function devServerPortWarning(port: number): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Framework } from './detect.js';
|
|
1
2
|
/**
|
|
2
3
|
* Ports that belong to a DEV SERVER, not to Reticle's bridge. Next defaults to 3000, Vite to 5173,
|
|
3
4
|
* Astro to 4321, and so on.
|
|
@@ -44,6 +45,25 @@ export const DEV_SERVER_PORTS = new Set([
|
|
|
44
45
|
6006, // Storybook
|
|
45
46
|
19006, // Expo web
|
|
46
47
|
]);
|
|
48
|
+
/**
|
|
49
|
+
* The port each framework's dev server takes when nothing configures another. Every value is in
|
|
50
|
+
* {@link DEV_SERVER_PORTS} as well. `reticle setup` reads it to know which port a dev script will
|
|
51
|
+
* take before starting one, so a server already on half of that port is found first (#1377).
|
|
52
|
+
*/
|
|
53
|
+
const FRAMEWORK_DEV_PORTS = {
|
|
54
|
+
[Framework.VITE]: 5173,
|
|
55
|
+
[Framework.SVELTEKIT]: 5173,
|
|
56
|
+
[Framework.REACT_ROUTER]: 5173,
|
|
57
|
+
[Framework.NEXT]: 3000,
|
|
58
|
+
[Framework.NUXT]: 3000,
|
|
59
|
+
[Framework.CRA]: 3000,
|
|
60
|
+
[Framework.ASTRO]: 4321,
|
|
61
|
+
[Framework.ANGULAR]: 4200,
|
|
62
|
+
};
|
|
63
|
+
/** The framework's default dev-server port, or undefined for one that has none of its own. */
|
|
64
|
+
export function frameworkDevPort(framework) {
|
|
65
|
+
return FRAMEWORK_DEV_PORTS[framework];
|
|
66
|
+
}
|
|
47
67
|
export function isLikelyDevServerPort(port) {
|
|
48
68
|
return DEV_SERVER_PORTS.has(port);
|
|
49
69
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecting whether a project uses Prettier.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `detect/` (not `patch/`) because `patch/` already imports from `detect/` —
|
|
5
|
+
* the reverse import would create a `detect <-> patch` cycle that
|
|
6
|
+
* `directory-reach.test.ts` forbids. Detection belongs here anyway.
|
|
7
|
+
*/
|
|
8
|
+
interface PackageJsonLike {
|
|
9
|
+
dependencies?: Record<string, string>;
|
|
10
|
+
devDependencies?: Record<string, string>;
|
|
11
|
+
peerDependencies?: Record<string, string>;
|
|
12
|
+
prettier?: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** Filenames that indicate the project uses Prettier. */
|
|
15
|
+
export declare const PRETTIER_CONFIG_FILES: Set<string>;
|
|
16
|
+
/**
|
|
17
|
+
* True when the project uses Prettier: a Prettier config file exists at the root,
|
|
18
|
+
* package.json declares a "prettier" key, or prettier is in (dev)dependencies.
|
|
19
|
+
*/
|
|
20
|
+
export declare function hasPrettierConfig(rootFiles: ReadonlySet<string>, pkg: PackageJsonLike): boolean;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecting whether a project uses Prettier.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `detect/` (not `patch/`) because `patch/` already imports from `detect/` —
|
|
5
|
+
* the reverse import would create a `detect <-> patch` cycle that
|
|
6
|
+
* `directory-reach.test.ts` forbids. Detection belongs here anyway.
|
|
7
|
+
*/
|
|
8
|
+
/** Filenames that indicate the project uses Prettier. */
|
|
9
|
+
export const PRETTIER_CONFIG_FILES = new Set([
|
|
10
|
+
'.prettierrc',
|
|
11
|
+
'.prettierrc.json',
|
|
12
|
+
'.prettierrc.yml',
|
|
13
|
+
'.prettierrc.yaml',
|
|
14
|
+
'.prettierrc.json5',
|
|
15
|
+
'.prettierrc.js',
|
|
16
|
+
'.prettierrc.cjs',
|
|
17
|
+
'.prettierrc.mjs',
|
|
18
|
+
'.prettierrc.ts',
|
|
19
|
+
'prettier.config.js',
|
|
20
|
+
'prettier.config.cjs',
|
|
21
|
+
'prettier.config.mjs',
|
|
22
|
+
'prettier.config.ts',
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* True when the project uses Prettier: a Prettier config file exists at the root,
|
|
26
|
+
* package.json declares a "prettier" key, or prettier is in (dev)dependencies.
|
|
27
|
+
*/
|
|
28
|
+
export function hasPrettierConfig(rootFiles, pkg) {
|
|
29
|
+
for (const name of rootFiles) {
|
|
30
|
+
if (PRETTIER_CONFIG_FILES.has(name))
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (pkg.prettier !== undefined)
|
|
34
|
+
return true;
|
|
35
|
+
return (pkg.dependencies?.prettier !== undefined ||
|
|
36
|
+
pkg.devDependencies?.prettier !== undefined ||
|
|
37
|
+
pkg.peerDependencies?.prettier !== undefined);
|
|
38
|
+
}
|
|
@@ -40,6 +40,15 @@ export declare function isWebApp(dir: string, io: Pick<InitIo, 'exists' | 'readF
|
|
|
40
40
|
* it knows it is in a workspace; this is the matching walk DOWN.
|
|
41
41
|
*/
|
|
42
42
|
export declare function findWorkspaceApps(io: Pick<InitIo, 'exists' | 'readFile' | 'listDirs'>): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Apps anywhere below a folder that is not one, for `init` run above its app with no workspace to
|
|
45
|
+
* say where the app is: a backend + frontend repo, a home folder, a docs site in `site/`.
|
|
46
|
+
*
|
|
47
|
+
* Breadth first and bounded (depth and folders read), never into dependencies, build output or
|
|
48
|
+
* hidden folders, and never below an app it found. Apps with a page win; a bare `index.html` with
|
|
49
|
+
* no package.json counts only when there is none, as the static page it is.
|
|
50
|
+
*/
|
|
51
|
+
export declare function findAppsBelow(io: Pick<InitIo, 'exists' | 'readFile' | 'listDirs'>): string[];
|
|
43
52
|
/**
|
|
44
53
|
* Every package directory the workspace declares or holds, app-shaped or not.
|
|
45
54
|
*
|
|
@@ -61,4 +70,6 @@ export declare function workspacePackageDirs(io: Pick<InitIo, 'readFile' | 'list
|
|
|
61
70
|
* config into a stranger's root.
|
|
62
71
|
*/
|
|
63
72
|
export declare function enclosingWorkspaceRoot(cwd: string, io: Pick<InitIo, 'readFile'>): string | undefined;
|
|
73
|
+
/** The apps of the workspace at `root`, read through an io standing somewhere else in it. */
|
|
74
|
+
export declare function appsAt(root: string, io: Pick<InitIo, 'exists' | 'readFile' | 'listDirs'>): string[];
|
|
64
75
|
export {};
|
|
@@ -237,6 +237,61 @@ export function findWorkspaceApps(io) {
|
|
|
237
237
|
// app is exactly that — so it is demoted, never dropped.
|
|
238
238
|
return [...new Set(found.length > 0 ? found : served)];
|
|
239
239
|
}
|
|
240
|
+
/** Folders the search for an app never enters: dependencies, output, and a home folder's own. */
|
|
241
|
+
const NEVER_SEARCHED = new Set([
|
|
242
|
+
...NEVER_A_PACKAGE,
|
|
243
|
+
'vendor',
|
|
244
|
+
'venv',
|
|
245
|
+
'__pycache__',
|
|
246
|
+
'Library',
|
|
247
|
+
'Applications',
|
|
248
|
+
'AppData',
|
|
249
|
+
'Pictures',
|
|
250
|
+
'Music',
|
|
251
|
+
'Movies',
|
|
252
|
+
]);
|
|
253
|
+
/** How deep the search goes: `client/web/app` is three, and nothing real is deeper than four. */
|
|
254
|
+
const SEARCH_DEPTH = 4;
|
|
255
|
+
/** Folders read before the search gives up, so a home folder costs a fraction of a second. */
|
|
256
|
+
const SEARCH_BUDGET = 600;
|
|
257
|
+
/**
|
|
258
|
+
* Apps anywhere below a folder that is not one, for `init` run above its app with no workspace to
|
|
259
|
+
* say where the app is: a backend + frontend repo, a home folder, a docs site in `site/`.
|
|
260
|
+
*
|
|
261
|
+
* Breadth first and bounded (depth and folders read), never into dependencies, build output or
|
|
262
|
+
* hidden folders, and never below an app it found. Apps with a page win; a bare `index.html` with
|
|
263
|
+
* no package.json counts only when there is none, as the static page it is.
|
|
264
|
+
*/
|
|
265
|
+
export function findAppsBelow(io) {
|
|
266
|
+
const apps = [];
|
|
267
|
+
const pages = [];
|
|
268
|
+
let level = io.listDirs('.').filter(searchable);
|
|
269
|
+
let read = 0;
|
|
270
|
+
for (let depth = 1; depth <= SEARCH_DEPTH && level.length > 0 && read < SEARCH_BUDGET; depth++) {
|
|
271
|
+
const next = [];
|
|
272
|
+
for (const dir of level) {
|
|
273
|
+
if (++read > SEARCH_BUDGET)
|
|
274
|
+
break;
|
|
275
|
+
if (AppSignal.UI === appSignal(dir, io)) {
|
|
276
|
+
apps.push(dir);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!io.exists(`${dir}/${PACKAGE_JSON}`) && io.exists(`${dir}/index.html`)) {
|
|
280
|
+
pages.push(dir);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
next.push(...io
|
|
284
|
+
.listDirs(dir)
|
|
285
|
+
.filter(searchable)
|
|
286
|
+
.map((name) => `${dir}/${name}`));
|
|
287
|
+
}
|
|
288
|
+
level = next;
|
|
289
|
+
}
|
|
290
|
+
return apps.length > 0 ? apps : pages;
|
|
291
|
+
}
|
|
292
|
+
function searchable(name) {
|
|
293
|
+
return !name.startsWith('.') && !NEVER_SEARCHED.has(name);
|
|
294
|
+
}
|
|
240
295
|
/**
|
|
241
296
|
* Every package directory the workspace declares or holds, app-shaped or not.
|
|
242
297
|
*
|
|
@@ -290,3 +345,11 @@ export function enclosingWorkspaceRoot(cwd, io) {
|
|
|
290
345
|
}
|
|
291
346
|
return undefined;
|
|
292
347
|
}
|
|
348
|
+
/** The apps of the workspace at `root`, read through an io standing somewhere else in it. */
|
|
349
|
+
export function appsAt(root, io) {
|
|
350
|
+
return findWorkspaceApps({
|
|
351
|
+
exists: (p) => io.exists(join(root, p)),
|
|
352
|
+
readFile: (p) => io.readFile(join(root, p)),
|
|
353
|
+
listDirs: (p) => io.listDirs(join(root, p)),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { detect, Framework } from './detect.js';
|
|
13
13
|
import { detectNonJsEcosystem, noPackageJsonMessage } from './non-js-project.js';
|
|
14
|
-
import { findWorkspaceApps, PACKAGE_JSON, workspacePackageDirs } from './workspace-apps.js';
|
|
14
|
+
import { findAppsBelow, findWorkspaceApps, PACKAGE_JSON, workspacePackageDirs, } from './workspace-apps.js';
|
|
15
|
+
import { devScriptBody } from './dev-script.js';
|
|
15
16
|
import { projectIdOf, RETICLE_CONFIG_FILE } from './existing-config.js';
|
|
16
17
|
import { chooseWorkspaceApp, withoutTrailingSlashes } from './declared/app-choice.js';
|
|
17
18
|
const ROOT_CONFIG_LEAD = `This root's ${RETICLE_CONFIG_FILE} names`;
|
|
@@ -30,7 +31,31 @@ function appNamedByRootConfig(io) {
|
|
|
30
31
|
return 1 === named.length ? named[0] : undefined;
|
|
31
32
|
}
|
|
32
33
|
const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init` for the one you want:';
|
|
34
|
+
const BELOW_HEADER = 'No app in this folder. Several below it; re-run `reticle init` for the one you want:';
|
|
35
|
+
const HOME_HEADER = 'This is your home folder, so init wires nothing on its own here. Apps found below it; run one:';
|
|
36
|
+
const STATIC_PAGE = 'index.html';
|
|
37
|
+
/** The folder `init` was run in is the person's home: a place to find apps, never to wire one. */
|
|
38
|
+
function inHome(io) {
|
|
39
|
+
const trim = (p) => withoutTrailingSlashes(p.replace(/\\/g, '/'));
|
|
40
|
+
return trim(io.cwd()) === trim(io.homeDir());
|
|
41
|
+
}
|
|
42
|
+
/** Print the candidates as commands to paste, and stop: the choice is the person's. */
|
|
43
|
+
function printAppCommands(io, apps) {
|
|
44
|
+
for (const a of apps) {
|
|
45
|
+
const displayed = displayedWorkspaceAppCommand(a, process.platform);
|
|
46
|
+
if (displayed.label !== undefined)
|
|
47
|
+
io.print(` ${displayed.label}`);
|
|
48
|
+
io.print(` ${displayed.command}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function listApps(io, header, apps) {
|
|
52
|
+
io.print(header);
|
|
53
|
+
printAppCommands(io, apps);
|
|
54
|
+
return { ok: false, applied: 0, manual: apps.length, listedAppsBelow: true };
|
|
55
|
+
}
|
|
33
56
|
const WORKSPACE_APP_COMMAND = 'reticle init --app';
|
|
57
|
+
const ALREADY_WIRED = 'Already wired here:';
|
|
58
|
+
const WIRE_ANOTHER = 'To wire another app:';
|
|
34
59
|
const POWERSHELL_ONLY_LABEL = 'PowerShell only:';
|
|
35
60
|
const PLAIN_SHELL_ARG = /^[A-Za-z0-9_./-]+$/;
|
|
36
61
|
function posixShellArg(arg) {
|
|
@@ -88,7 +113,8 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
88
113
|
const named = options.app === undefined || '' === options.app ? undefined : options.app;
|
|
89
114
|
if (named !== undefined) {
|
|
90
115
|
const wanted = withoutTrailingSlashes(named);
|
|
91
|
-
|
|
116
|
+
// A plain page is an app too: `init` wires its index.html once it is standing in that folder.
|
|
117
|
+
if (io.exists(`${wanted}/${PACKAGE_JSON}`) || io.exists(`${wanted}/${STATIC_PAGE}`))
|
|
92
118
|
return enterApp(options, io, wanted, 'Wiring', runInit);
|
|
93
119
|
const inWanted = (file) => io.exists(`${wanted}/${file}`);
|
|
94
120
|
io.print(detectNonJsEcosystem(inWanted) === undefined
|
|
@@ -104,6 +130,22 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
104
130
|
const pointed = appNamedByRootConfig(io);
|
|
105
131
|
if (pointed !== undefined)
|
|
106
132
|
return enterApp(options, io, pointed, ROOT_CONFIG_LEAD, runInit);
|
|
133
|
+
// A plain page with a manifest that has a dev script is the app too: it renders through its own
|
|
134
|
+
// index.html, and the dev script that serves it lives in its package.json. A manifest that holds
|
|
135
|
+
// only tooling is not enough: the real app may sit in a nested folder, so discovery still runs.
|
|
136
|
+
//
|
|
137
|
+
// Discovery preferred a nested copy - a `backup/` folder holding another app - over the folder the
|
|
138
|
+
// person is standing in, and `init --dry-run` named the copy as the target. A root pointer stored
|
|
139
|
+
// by an earlier `--app` run is read first, above. Past that, the nested app is something to LIST,
|
|
140
|
+
// never to choose; the root is wired by the normal plan after this null.
|
|
141
|
+
if (io.exists(STATIC_PAGE) && io.exists(PACKAGE_JSON) && devScriptBody(pkg) !== undefined) {
|
|
142
|
+
const nested = findWorkspaceApps(io);
|
|
143
|
+
if (0 < nested.length) {
|
|
144
|
+
io.print(WIRE_ANOTHER);
|
|
145
|
+
printAppCommands(io, nested);
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
107
149
|
const apps = findWorkspaceApps(io);
|
|
108
150
|
// An explicitly named app answers the ambiguity. Refusing to guess is right, but "re-run inside the
|
|
109
151
|
// one you want" is not something a script, a CI step, or an agent that cannot change directory can
|
|
@@ -113,18 +155,34 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
113
155
|
io.print(chosen.message);
|
|
114
156
|
return { ok: false, applied: 0, manual: 1 };
|
|
115
157
|
}
|
|
158
|
+
// A home folder holds every project the person has; picking one for them is a guess.
|
|
159
|
+
if (chosen.app === undefined && inHome(io)) {
|
|
160
|
+
const found = 0 < apps.length ? apps : findAppsBelow(io);
|
|
161
|
+
return 0 < found.length ? listApps(io, HOME_HEADER, found) : null;
|
|
162
|
+
}
|
|
163
|
+
// No app one or two levels down and no manifest here: look deeper before giving up, so a repo
|
|
164
|
+
// whose web app sits at `client/web/app`, or a bare page in `site/`, is wired rather than refused.
|
|
165
|
+
if (0 === apps.length && !io.exists(PACKAGE_JSON) && chosen.app === undefined) {
|
|
166
|
+
const below = findAppsBelow(io);
|
|
167
|
+
if (1 === below.length && below[0] !== undefined)
|
|
168
|
+
return enterApp(options, io, below[0], 'No app in this directory — wiring', runInit);
|
|
169
|
+
if (1 < below.length)
|
|
170
|
+
return listApps(io, BELOW_HEADER, below);
|
|
171
|
+
}
|
|
116
172
|
const target = chosen.app ?? (1 === apps.length ? apps[0] : undefined);
|
|
117
173
|
if (target === undefined) {
|
|
118
174
|
if (0 === apps.length)
|
|
119
175
|
return null; // not a workspace — fall through to the normal HTML plan
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
io.print(
|
|
126
|
-
|
|
127
|
-
return
|
|
176
|
+
// Apps already wired answer a re-run here: nothing to do at the root, which is pinned to none.
|
|
177
|
+
const wired = apps.filter((a) => io.exists(`${a}/${RETICLE_CONFIG_FILE}`));
|
|
178
|
+
io.print(0 === wired.length ? AMBIGUOUS_HEADER : `${ALREADY_WIRED} ${wired.join(', ')}.`);
|
|
179
|
+
const rest = apps.filter((a) => !wired.includes(a));
|
|
180
|
+
if (0 < wired.length && 0 < rest.length)
|
|
181
|
+
io.print(WIRE_ANOTHER);
|
|
182
|
+
printAppCommands(io, 0 === wired.length ? apps : rest);
|
|
183
|
+
return 0 === wired.length
|
|
184
|
+
? { ok: false, applied: 0, manual: apps.length }
|
|
185
|
+
: { ok: true, applied: 0, manual: 0 };
|
|
128
186
|
}
|
|
129
187
|
return enterApp(options, io, target, 'No app in this directory — wiring', runInit);
|
|
130
188
|
}
|
|
@@ -146,5 +204,6 @@ function enterApp(options, io, target, lead, runInit) {
|
|
|
146
204
|
// Where the human is STANDING, kept across the redirect: their agent session runs here, so
|
|
147
205
|
// this is the only place a `/reticle` command file can be found by it.
|
|
148
206
|
agentRoot: options.agentRoot ?? options.cwd,
|
|
207
|
+
severalApps: true === options.severalApps || 1 < findWorkspaceApps(io).length,
|
|
149
208
|
}, io.scoped(target));
|
|
150
209
|
}
|
|
@@ -16,6 +16,11 @@ import { StepStatus } from '../plan/plan.js';
|
|
|
16
16
|
* the workspace redirect is kept from asking twice.
|
|
17
17
|
*/
|
|
18
18
|
export declare const FEEDBACK_HINT: string;
|
|
19
|
+
/**
|
|
20
|
+
* The last line of a successful `init`, after the ask: where the user can tell the maintainers what
|
|
21
|
+
* they build. Only on success: on a failure, the ask above is the thing to do.
|
|
22
|
+
*/
|
|
23
|
+
export declare const COMMUNITY_HINT = "Tell us what you are building: https://discord.gg/BwAbzv9ZRz";
|
|
19
24
|
/**
|
|
20
25
|
* The last two lines a user reads, and the order matters.
|
|
21
26
|
*
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and this decides what to say afterwards. The failure modes differ too — a wrong write breaks an
|
|
6
6
|
* install, a wrong sentence loses a user quietly.
|
|
7
7
|
*/
|
|
8
|
+
import { FEEDBACK_KIND_CHOICES } from '@reticlehq/core';
|
|
8
9
|
import { StepStatus } from '../plan/plan.js';
|
|
9
10
|
import { wasMcpRegistered } from '../register/mcp-registered.js';
|
|
10
11
|
import { CLI } from '../project/agent-rules.js';
|
|
@@ -19,12 +20,15 @@ import { CLI } from '../project/agent-rules.js';
|
|
|
19
20
|
*/
|
|
20
21
|
export const FEEDBACK_HINT = 'Anything wrong, missing, or awkward — in this setup or in Reticle itself? Tell us; it is the ' +
|
|
21
22
|
'one thing that decides what gets fixed:\n' +
|
|
22
|
-
` ${CLI} feedback --agent --kind
|
|
23
|
+
` ${CLI} feedback --agent --kind ${FEEDBACK_KIND_CHOICES} "what happened" (agents)\n` +
|
|
23
24
|
` ${CLI} feedback "what worked, what didn't" (humans)`;
|
|
25
|
+
/**
|
|
26
|
+
* The last line of a successful `init`, after the ask: where the user can tell the maintainers what
|
|
27
|
+
* they build. Only on success: on a failure, the ask above is the thing to do.
|
|
28
|
+
*/
|
|
29
|
+
export const COMMUNITY_HINT = 'Tell us what you are building: https://discord.gg/BwAbzv9ZRz';
|
|
24
30
|
/** User-facing closing copy for `restartHint` — one named home per sentence block. */
|
|
25
31
|
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
32
|
PROVE: 'Then run `npx @reticlehq/server status` — it confirms the app connected, or says exactly why ' +
|
|
29
33
|
'it has not.',
|
|
30
34
|
ALREADY_REGISTERED_ALL: 'The Reticle MCP server was already registered on this machine, so there is no restart to do ' +
|
|
@@ -33,9 +37,12 @@ const RestartHintCopy = {
|
|
|
33
37
|
'do and the tools are available there now. They are NOT available in the clients whose ' +
|
|
34
38
|
'step above still needs a hand edit (',
|
|
35
39
|
ALREADY_REGISTERED_CLAUDE_TAIL: ') until that is done.',
|
|
36
|
-
|
|
40
|
+
CLOSING_NO_MCP: 'Once it shows a session, drive one flow and report the verdict — that is the install finished.',
|
|
41
|
+
CLOSING_ALREADY_NO_MANUAL: 'Once it shows a session, drive one flow and report the verdict — that is the install ' +
|
|
42
|
+
'finished. Carry straight on; ' +
|
|
37
43
|
'nothing here is waiting on a human.',
|
|
38
|
-
CLOSING_ALREADY_WITH_MANUAL: 'Once it shows a session, drive
|
|
44
|
+
CLOSING_ALREADY_WITH_MANUAL: 'Once it shows a session, drive one flow and report the verdict where the tools are already ' +
|
|
45
|
+
'available — that is the ' +
|
|
39
46
|
'install finished on that client. The hand-edit step above still needs a human for the other ' +
|
|
40
47
|
'clients named there.',
|
|
41
48
|
};
|
|
@@ -100,13 +107,14 @@ manualClients = []) {
|
|
|
100
107
|
//
|
|
101
108
|
// So the close hands the agent the sequence instead. It is deliberately one drive of one real
|
|
102
109
|
// flow ending in a verdict, because an install that ends in "the tools are available" is an
|
|
103
|
-
// install that ends in nothing.
|
|
104
|
-
|
|
110
|
+
// install that ends in nothing. It is said ONCE, last: after the status check and after the client
|
|
111
|
+
// restart, because that is the order in which a reader can do it.
|
|
105
112
|
const prove = RestartHintCopy.PROVE;
|
|
106
113
|
// Nothing registered this run (`--no-mcp`, or a client that needs a manual edit): a restart picks
|
|
107
114
|
// up nothing, so advice about one would be advice about something we did not do.
|
|
108
|
-
if (!wasMcpRegistered(mcpStatus))
|
|
109
|
-
return `${dev}\n${
|
|
115
|
+
if (!wasMcpRegistered(mcpStatus)) {
|
|
116
|
+
return `${dev}\n${prove}\n${RestartHintCopy.CLOSING_NO_MCP}`;
|
|
117
|
+
}
|
|
110
118
|
// ALREADY: the tools are reachable RIGHT NOW, and this is the branch that used to lie.
|
|
111
119
|
//
|
|
112
120
|
// `wasMcpRegistered` is true for both APPLY and ALREADY, which is wrong here: the two cases have
|
|
@@ -125,10 +133,9 @@ manualClients = []) {
|
|
|
125
133
|
const closing = 0 === manualClients.length
|
|
126
134
|
? RestartHintCopy.CLOSING_ALREADY_NO_MANUAL
|
|
127
135
|
: RestartHintCopy.CLOSING_ALREADY_WITH_MANUAL;
|
|
128
|
-
return `${dev}\n${
|
|
136
|
+
return `${dev}\n${availability}\n${prove}\n${closing}`;
|
|
129
137
|
}
|
|
130
138
|
return (`${dev}\n` +
|
|
131
|
-
`${demo}\n` +
|
|
132
139
|
'Then restart your agent so it picks up the new MCP server. On Claude Code and Codex, do NOT ' +
|
|
133
140
|
'ask a human to do it: `npx @reticlehq/server init --relaunch` prints the exact resume command ' +
|
|
134
141
|
'for THIS conversation — run that command and the restart continues the work on the other ' +
|
|
@@ -20,6 +20,8 @@ export declare const InitFailure: {
|
|
|
20
20
|
readonly APP_CONNECTION: "app_connection";
|
|
21
21
|
readonly BRIDGE_OCCUPIED: "bridge_occupied";
|
|
22
22
|
readonly RUNTIME_ERROR: "runtime_error";
|
|
23
|
+
/** Ctrl-C (or a SIGTERM) during the runtime phase, before it could say how setup went. */
|
|
24
|
+
readonly INTERRUPTED: "interrupted";
|
|
23
25
|
readonly OTHER: "other";
|
|
24
26
|
};
|
|
25
27
|
export type InitFailure = (typeof InitFailure)[keyof typeof InitFailure];
|
|
@@ -20,5 +20,7 @@ export const InitFailure = {
|
|
|
20
20
|
APP_CONNECTION: 'app_connection',
|
|
21
21
|
BRIDGE_OCCUPIED: 'bridge_occupied',
|
|
22
22
|
RUNTIME_ERROR: 'runtime_error',
|
|
23
|
+
/** Ctrl-C (or a SIGTERM) during the runtime phase, before it could say how setup went. */
|
|
24
|
+
INTERRUPTED: 'interrupted',
|
|
23
25
|
OTHER: 'other',
|
|
24
26
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InitOptions, InitIo } from './run-types.js';
|
|
2
|
+
import { type PlanInput } from './plan/plan.js';
|
|
3
|
+
/**
|
|
4
|
+
* The directory the human's agent runs in, when it is not the app's directory.
|
|
5
|
+
*
|
|
6
|
+
* `undefined` for a single-package repo, which keeps every agent-file path project-relative exactly
|
|
7
|
+
* as it was — the two roots are the same there, which is why writing them beside the app went
|
|
8
|
+
* unnoticed until a repo with its app at `src/admin` reported it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function agentRootOf(options: InitOptions): string | undefined;
|
|
11
|
+
export declare function gatherPlanInput(options: InitOptions, io: InitIo, pkg: unknown): PlanInput;
|