@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/detect/detect.d.ts
CHANGED
|
@@ -194,6 +194,14 @@ export declare const ANGULAR_WORKSPACE_FILE = "angular.json";
|
|
|
194
194
|
interface FrameworkSignals {
|
|
195
195
|
/** `package.json` dependency names that name this framework outright. */
|
|
196
196
|
readonly deps: readonly string[];
|
|
197
|
+
/**
|
|
198
|
+
* Dependencies that disprove this framework's dependency signal, without affecting an explicit
|
|
199
|
+
* config-file signal.
|
|
200
|
+
*
|
|
201
|
+
* Vinext carries `next` for API compatibility but builds through Vite, so treating `next` as
|
|
202
|
+
* conclusive there would install wiring that Vinext never evaluates.
|
|
203
|
+
*/
|
|
204
|
+
readonly depsUnless?: readonly string[];
|
|
197
205
|
/** Root config-file basenames that name it when the dependency is absent. */
|
|
198
206
|
readonly configs: readonly string[];
|
|
199
207
|
/**
|
package/dist/detect/detect.js
CHANGED
|
@@ -164,7 +164,7 @@ const VITE_INDEX_HTML = 'index.html';
|
|
|
164
164
|
* asserted in `framework-adapter.test.ts`.
|
|
165
165
|
*/
|
|
166
166
|
export const FRAMEWORK_SIGNALS = {
|
|
167
|
-
[Framework.NEXT]: { deps: ['next'], configs: NEXT_CONFIGS },
|
|
167
|
+
[Framework.NEXT]: { deps: ['next'], depsUnless: ['vinext'], configs: NEXT_CONFIGS },
|
|
168
168
|
[Framework.NUXT]: { deps: ['nuxt'], configs: NUXT_CONFIGS },
|
|
169
169
|
[Framework.SVELTEKIT]: {
|
|
170
170
|
deps: ['@sveltejs/kit'],
|
|
@@ -201,7 +201,7 @@ export const FRAMEWORK_SIGNALS = {
|
|
|
201
201
|
deps: ['@tanstack/react-start', '@tanstack/start'],
|
|
202
202
|
configs: [],
|
|
203
203
|
},
|
|
204
|
-
[Framework.VITE]: { deps: ['vite'], configs: VITE_CONFIGS },
|
|
204
|
+
[Framework.VITE]: { deps: ['vite', 'vinext'], configs: VITE_CONFIGS },
|
|
205
205
|
/** CRA has no config file at all, so the dependency is the only signal. */
|
|
206
206
|
[Framework.CRA]: { deps: ['react-scripts'], configs: [] },
|
|
207
207
|
/** Nothing identifies plain HTML; it is where the chain below ends. */
|
|
@@ -325,8 +325,11 @@ function declaredDependencies(pkg) {
|
|
|
325
325
|
function detectFramework(input) {
|
|
326
326
|
for (const framework of DETECTION_ORDER) {
|
|
327
327
|
const signals = FRAMEWORK_SIGNALS[framework];
|
|
328
|
-
|
|
328
|
+
const hasDisqualifyingDependency = true === signals.depsUnless?.some((d) => depVersion(input.pkg, d) !== undefined);
|
|
329
|
+
if (signals.deps.some((d) => depVersion(input.pkg, d) !== undefined) &&
|
|
330
|
+
!hasDisqualifyingDependency) {
|
|
329
331
|
return framework;
|
|
332
|
+
}
|
|
330
333
|
if (hasAnyConfig(input.configFiles, signals.configs) &&
|
|
331
334
|
!hasAnyConfig(input.configFiles, signals.configsUnless ?? [])) {
|
|
332
335
|
return framework;
|
|
@@ -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
|
+
}
|
|
@@ -34,6 +34,22 @@ export declare function detectDjangoProject(exists: (file: string) => boolean, r
|
|
|
34
34
|
export declare function djangoSetupMessage(): string;
|
|
35
35
|
/** The ecosystem this directory looks like, or undefined when nothing says. */
|
|
36
36
|
export declare function detectNonJsEcosystem(exists: (file: string) => boolean): string | undefined;
|
|
37
|
+
/** Which answer `init` gives when there is no package.json. */
|
|
38
|
+
export declare const NoPackageJsonCase: {
|
|
39
|
+
/** No ecosystem marker and no page: the only honest answer is "run `init` somewhere else". */
|
|
40
|
+
readonly WRONG_DIRECTORY: "wrong_directory";
|
|
41
|
+
/** A page with no build step, which takes the script-tag snippet. */
|
|
42
|
+
readonly STATIC_PAGE: "static_page";
|
|
43
|
+
/** Flutter web paints into a canvas, so there is nothing to instrument. */
|
|
44
|
+
readonly FLUTTER: "flutter";
|
|
45
|
+
/** A recognised non-JS ecosystem, which can load the SDK from a URL. */
|
|
46
|
+
readonly NON_JS: "non_js";
|
|
47
|
+
};
|
|
48
|
+
export type NoPackageJsonCase = (typeof NoPackageJsonCase)[keyof typeof NoPackageJsonCase];
|
|
49
|
+
export interface NoPackageJsonAnswer {
|
|
50
|
+
kind: NoPackageJsonCase;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
37
53
|
/**
|
|
38
54
|
* What `init` should say when there is no package.json.
|
|
39
55
|
*
|
|
@@ -42,3 +58,10 @@ export declare function detectNonJsEcosystem(exists: (file: string) => boolean):
|
|
|
42
58
|
* second needs to know that no path exists and why.
|
|
43
59
|
*/
|
|
44
60
|
export declare function noPackageJsonMessage(exists: (file: string) => boolean, rootFiles?: readonly string[]): string;
|
|
61
|
+
/**
|
|
62
|
+
* {@link noPackageJsonMessage}, plus which case it picked.
|
|
63
|
+
*
|
|
64
|
+
* `init` acts on the case (it writes nothing for {@link NoPackageJsonCase.WRONG_DIRECTORY}), so the
|
|
65
|
+
* choice is made here once rather than re-derived beside the message and kept in sync by hand.
|
|
66
|
+
*/
|
|
67
|
+
export declare function noPackageJsonAnswer(exists: (file: string) => boolean, rootFiles?: readonly string[]): NoPackageJsonAnswer;
|
|
@@ -97,6 +97,17 @@ export function detectNonJsEcosystem(exists) {
|
|
|
97
97
|
}
|
|
98
98
|
return undefined;
|
|
99
99
|
}
|
|
100
|
+
/** Which answer `init` gives when there is no package.json. */
|
|
101
|
+
export const NoPackageJsonCase = {
|
|
102
|
+
/** No ecosystem marker and no page: the only honest answer is "run `init` somewhere else". */
|
|
103
|
+
WRONG_DIRECTORY: 'wrong_directory',
|
|
104
|
+
/** A page with no build step, which takes the script-tag snippet. */
|
|
105
|
+
STATIC_PAGE: 'static_page',
|
|
106
|
+
/** Flutter web paints into a canvas, so there is nothing to instrument. */
|
|
107
|
+
FLUTTER: 'flutter',
|
|
108
|
+
/** A recognised non-JS ecosystem, which can load the SDK from a URL. */
|
|
109
|
+
NON_JS: 'non_js',
|
|
110
|
+
};
|
|
100
111
|
/**
|
|
101
112
|
* What `init` should say when there is no package.json.
|
|
102
113
|
*
|
|
@@ -105,38 +116,59 @@ export function detectNonJsEcosystem(exists) {
|
|
|
105
116
|
* second needs to know that no path exists and why.
|
|
106
117
|
*/
|
|
107
118
|
export function noPackageJsonMessage(exists, rootFiles = []) {
|
|
119
|
+
return noPackageJsonAnswer(exists, rootFiles).message;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* {@link noPackageJsonMessage}, plus which case it picked.
|
|
123
|
+
*
|
|
124
|
+
* `init` acts on the case (it writes nothing for {@link NoPackageJsonCase.WRONG_DIRECTORY}), so the
|
|
125
|
+
* choice is made here once rather than re-derived beside the message and kept in sync by hand.
|
|
126
|
+
*/
|
|
127
|
+
export function noPackageJsonAnswer(exists, rootFiles = []) {
|
|
108
128
|
const ecosystem = detectNonJsEcosystem(exists);
|
|
109
129
|
if (ecosystem === undefined) {
|
|
110
130
|
if (exists('index.html') || rootFiles.some((name) => name.endsWith('.html'))) {
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
'
|
|
131
|
+
return {
|
|
132
|
+
kind: NoPackageJsonCase.STATIC_PAGE,
|
|
133
|
+
message: 'This is a static page with no build step, so there is nothing for `reticle init` to wire. ' +
|
|
134
|
+
'Add the script-tag snippet below to the page while you develop, then reload it. Remove the ' +
|
|
135
|
+
'snippet before publishing: a static file has no development build to keep it out of production.',
|
|
136
|
+
};
|
|
114
137
|
}
|
|
115
|
-
return
|
|
116
|
-
|
|
138
|
+
return {
|
|
139
|
+
kind: NoPackageJsonCase.WRONG_DIRECTORY,
|
|
140
|
+
message: 'No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
|
|
141
|
+
"your app's directory, or from a repo root that contains it.",
|
|
142
|
+
};
|
|
117
143
|
}
|
|
118
144
|
if (ecosystem === FLUTTER) {
|
|
119
145
|
// An agent on a Flutter workspace asked us to "support Flutter, or clearly identify it as
|
|
120
146
|
// unsupported during init". The second is the truthful one, and saying it by name in one line
|
|
121
147
|
// is the difference between an agent that stops and an agent that spends an hour looking for
|
|
122
148
|
// the package.json we told it was missing.
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
'
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
return {
|
|
150
|
+
kind: NoPackageJsonCase.FLUTTER,
|
|
151
|
+
message: 'This is a Flutter project. Reticle cannot instrument it, and no directory or flag will ' +
|
|
152
|
+
'change that: Reticle reads the DOM of a running page, and Flutter web paints its entire UI ' +
|
|
153
|
+
'into a single canvas element with no DOM behind it. Nothing here is missing or misconfigured. ' +
|
|
154
|
+
'If this repo also contains an ordinary web app with its own package.json, run `reticle init` ' +
|
|
155
|
+
'inside that directory instead.',
|
|
156
|
+
};
|
|
128
157
|
}
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
return {
|
|
159
|
+
kind: NoPackageJsonCase.NON_JS,
|
|
160
|
+
message: `This looks like a ${ecosystem} project, and there is no package.json here or in any app ` +
|
|
161
|
+
'directory beneath it. That is not a blocker: Reticle needs its SDK loaded by the page, not ' +
|
|
162
|
+
'built by npm, and a page with no build step can load it from a URL. ' +
|
|
163
|
+
// The old message ended at "there is no directory you could run this from that would change
|
|
164
|
+
// that", which read as a refusal and was acted on as one — agents went looking for a bundler,
|
|
165
|
+
// or for a standalone build to inject by hand, or gave up and used a different tool. The
|
|
166
|
+
// capability was there the whole time; only the sentence was missing. Proven end to end on a
|
|
167
|
+
// page served by `python3 -m http.server` before this line was written.
|
|
168
|
+
'Add the script-tag snippet below to a template you only serve in development, then reload the ' +
|
|
169
|
+
'page. ' +
|
|
170
|
+
'If this project ALSO has a JS front end (a `frontend/`, `client/` or `assets/` directory with ' +
|
|
171
|
+
'its own package.json), running `reticle init` there instead gives you source mapping and ' +
|
|
172
|
+
'framework state as well: point at it with `--app <dir>`.',
|
|
173
|
+
};
|
|
142
174
|
}
|
|
@@ -26,6 +26,11 @@ export declare function workspaceParents(sources: WorkspaceSources): string[];
|
|
|
26
26
|
*/
|
|
27
27
|
/** Deps that mark a directory as a runnable web app even when it has no bundler config file. */
|
|
28
28
|
export declare const APP_DEPS: readonly ["next", "vite", "electron-vite"];
|
|
29
|
+
/**
|
|
30
|
+
* Whether `dir` is itself an app with a page: what the daemon asks before wiring a folder unasked,
|
|
31
|
+
* so a library or API package it happens to run in is left alone.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isWebApp(dir: string, io: Pick<InitIo, 'exists' | 'readFile'>): boolean;
|
|
29
34
|
/**
|
|
30
35
|
* App directories under a workspace root.
|
|
31
36
|
*
|
|
@@ -35,6 +40,15 @@ export declare const APP_DEPS: readonly ["next", "vite", "electron-vite"];
|
|
|
35
40
|
* it knows it is in a workspace; this is the matching walk DOWN.
|
|
36
41
|
*/
|
|
37
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[];
|
|
38
52
|
/**
|
|
39
53
|
* Every package directory the workspace declares or holds, app-shaped or not.
|
|
40
54
|
*
|
|
@@ -56,4 +70,6 @@ export declare function workspacePackageDirs(io: Pick<InitIo, 'readFile' | 'list
|
|
|
56
70
|
* config into a stranger's root.
|
|
57
71
|
*/
|
|
58
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[];
|
|
59
75
|
export {};
|
|
@@ -179,6 +179,13 @@ function appSignal(dir, io) {
|
|
|
179
179
|
// backend. A package with a dev script and no page is only a candidate when nothing else is.
|
|
180
180
|
return hasPage(dir, pkgRaw, io) ? AppSignal.UI : AppSignal.SERVED;
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether `dir` is itself an app with a page: what the daemon asks before wiring a folder unasked,
|
|
184
|
+
* so a library or API package it happens to run in is left alone.
|
|
185
|
+
*/
|
|
186
|
+
export function isWebApp(dir, io) {
|
|
187
|
+
return AppSignal.UI === appSignal(dir, io);
|
|
188
|
+
}
|
|
182
189
|
/**
|
|
183
190
|
* App directories under a workspace root.
|
|
184
191
|
*
|
|
@@ -230,6 +237,61 @@ export function findWorkspaceApps(io) {
|
|
|
230
237
|
// app is exactly that — so it is demoted, never dropped.
|
|
231
238
|
return [...new Set(found.length > 0 ? found : served)];
|
|
232
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
|
+
}
|
|
233
295
|
/**
|
|
234
296
|
* Every package directory the workspace declares or holds, app-shaped or not.
|
|
235
297
|
*
|
|
@@ -283,3 +345,11 @@ export function enclosingWorkspaceRoot(cwd, io) {
|
|
|
283
345
|
}
|
|
284
346
|
return undefined;
|
|
285
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,6 +11,18 @@
|
|
|
11
11
|
import type { InitIo, InitOptions, InitResult } from '../run-types.js';
|
|
12
12
|
/** Re-enter `init`, scoped to one directory of the workspace. */
|
|
13
13
|
type RunInit = (options: InitOptions, io: InitIo) => InitResult;
|
|
14
|
+
declare const POWERSHELL_ONLY_LABEL: "PowerShell only:";
|
|
15
|
+
interface DisplayedWorkspaceAppCommand {
|
|
16
|
+
command: string;
|
|
17
|
+
label?: typeof POWERSHELL_ONLY_LABEL;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Render one discovered workspace app as a command the user can paste back into a shell.
|
|
21
|
+
*
|
|
22
|
+
* Plain paths need no quoting. Complex Windows paths are explicitly PowerShell-only because
|
|
23
|
+
* cmd.exe and PowerShell use different quoting grammars.
|
|
24
|
+
*/
|
|
25
|
+
export declare function displayedWorkspaceAppCommand(app: string, platform: NodeJS.Platform): DisplayedWorkspaceAppCommand;
|
|
14
26
|
/**
|
|
15
27
|
* When the current directory is a workspace root with no app of its own, wire the app instead of the
|
|
16
28
|
* root. One candidate is wired silently (there is nothing to ask about); several are listed, because
|
|
@@ -11,7 +11,7 @@
|
|
|
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
15
|
import { projectIdOf, RETICLE_CONFIG_FILE } from './existing-config.js';
|
|
16
16
|
import { chooseWorkspaceApp, withoutTrailingSlashes } from './declared/app-choice.js';
|
|
17
17
|
const ROOT_CONFIG_LEAD = `This root's ${RETICLE_CONFIG_FILE} names`;
|
|
@@ -29,7 +29,57 @@ function appNamedByRootConfig(io) {
|
|
|
29
29
|
const named = workspacePackageDirs(io).filter((dir) => projectIdOf(io.readFile(`${dir}/${RETICLE_CONFIG_FILE}`)) === wanted);
|
|
30
30
|
return 1 === named.length ? named[0] : undefined;
|
|
31
31
|
}
|
|
32
|
-
const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init`
|
|
32
|
+
const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init` for the one you want:';
|
|
33
|
+
const BELOW_HEADER = 'No app in this folder. Several below it; re-run `reticle init` for the one you want:';
|
|
34
|
+
const HOME_HEADER = 'This is your home folder, so init wires nothing on its own here. Apps found below it; run one:';
|
|
35
|
+
const STATIC_PAGE = 'index.html';
|
|
36
|
+
/** The folder `init` was run in is the person's home: a place to find apps, never to wire one. */
|
|
37
|
+
function inHome(io) {
|
|
38
|
+
const trim = (p) => withoutTrailingSlashes(p.replace(/\\/g, '/'));
|
|
39
|
+
return trim(io.cwd()) === trim(io.homeDir());
|
|
40
|
+
}
|
|
41
|
+
/** Print the candidates as commands to paste, and stop: the choice is the person's. */
|
|
42
|
+
function listApps(io, header, apps) {
|
|
43
|
+
io.print(header);
|
|
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
|
+
return { ok: false, applied: 0, manual: apps.length, listedAppsBelow: true };
|
|
51
|
+
}
|
|
52
|
+
const WORKSPACE_APP_COMMAND = 'reticle init --app';
|
|
53
|
+
const ALREADY_WIRED = 'Already wired here:';
|
|
54
|
+
const WIRE_ANOTHER = 'To wire another app:';
|
|
55
|
+
const POWERSHELL_ONLY_LABEL = 'PowerShell only:';
|
|
56
|
+
const PLAIN_SHELL_ARG = /^[A-Za-z0-9_./-]+$/;
|
|
57
|
+
function posixShellArg(arg) {
|
|
58
|
+
return `'${arg.replaceAll("'", "'\"'\"'")}'`;
|
|
59
|
+
}
|
|
60
|
+
function powerShellArg(arg) {
|
|
61
|
+
return `'${arg.replaceAll("'", "''")}'`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Render one discovered workspace app as a command the user can paste back into a shell.
|
|
65
|
+
*
|
|
66
|
+
* Plain paths need no quoting. Complex Windows paths are explicitly PowerShell-only because
|
|
67
|
+
* cmd.exe and PowerShell use different quoting grammars.
|
|
68
|
+
*/
|
|
69
|
+
export function displayedWorkspaceAppCommand(app, platform) {
|
|
70
|
+
if (PLAIN_SHELL_ARG.test(app)) {
|
|
71
|
+
return { command: `${WORKSPACE_APP_COMMAND} ${app}` };
|
|
72
|
+
}
|
|
73
|
+
if ('win32' === platform) {
|
|
74
|
+
return {
|
|
75
|
+
label: POWERSHELL_ONLY_LABEL,
|
|
76
|
+
command: `${WORKSPACE_APP_COMMAND} ${powerShellArg(app)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
command: `${WORKSPACE_APP_COMMAND} ${posixShellArg(app)}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
33
83
|
/**
|
|
34
84
|
* When the current directory is a workspace root with no app of its own, wire the app instead of the
|
|
35
85
|
* root. One candidate is wired silently (there is nothing to ask about); several are listed, because
|
|
@@ -59,7 +109,8 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
59
109
|
const named = options.app === undefined || '' === options.app ? undefined : options.app;
|
|
60
110
|
if (named !== undefined) {
|
|
61
111
|
const wanted = withoutTrailingSlashes(named);
|
|
62
|
-
|
|
112
|
+
// A plain page is an app too: `init` wires its index.html once it is standing in that folder.
|
|
113
|
+
if (io.exists(`${wanted}/${PACKAGE_JSON}`) || io.exists(`${wanted}/${STATIC_PAGE}`))
|
|
63
114
|
return enterApp(options, io, wanted, 'Wiring', runInit);
|
|
64
115
|
const inWanted = (file) => io.exists(`${wanted}/${file}`);
|
|
65
116
|
io.print(detectNonJsEcosystem(inWanted) === undefined
|
|
@@ -84,16 +135,39 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
|
|
|
84
135
|
io.print(chosen.message);
|
|
85
136
|
return { ok: false, applied: 0, manual: 1 };
|
|
86
137
|
}
|
|
138
|
+
// A home folder holds every project the person has; picking one for them is a guess.
|
|
139
|
+
if (chosen.app === undefined && inHome(io)) {
|
|
140
|
+
const found = 0 < apps.length ? apps : findAppsBelow(io);
|
|
141
|
+
return 0 < found.length ? listApps(io, HOME_HEADER, found) : null;
|
|
142
|
+
}
|
|
143
|
+
// No app one or two levels down and no manifest here: look deeper before giving up, so a repo
|
|
144
|
+
// whose web app sits at `client/web/app`, or a bare page in `site/`, is wired rather than refused.
|
|
145
|
+
if (0 === apps.length && !io.exists(PACKAGE_JSON) && chosen.app === undefined) {
|
|
146
|
+
const below = findAppsBelow(io);
|
|
147
|
+
if (1 === below.length && below[0] !== undefined)
|
|
148
|
+
return enterApp(options, io, below[0], 'No app in this directory — wiring', runInit);
|
|
149
|
+
if (1 < below.length)
|
|
150
|
+
return listApps(io, BELOW_HEADER, below);
|
|
151
|
+
}
|
|
87
152
|
const target = chosen.app ?? (1 === apps.length ? apps[0] : undefined);
|
|
88
153
|
if (target === undefined) {
|
|
89
154
|
if (0 === apps.length)
|
|
90
155
|
return null; // not a workspace — fall through to the normal HTML plan
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
156
|
+
// Apps already wired answer a re-run here: nothing to do at the root, which is pinned to none.
|
|
157
|
+
const wired = apps.filter((a) => io.exists(`${a}/${RETICLE_CONFIG_FILE}`));
|
|
158
|
+
io.print(0 === wired.length ? AMBIGUOUS_HEADER : `${ALREADY_WIRED} ${wired.join(', ')}.`);
|
|
159
|
+
const rest = apps.filter((a) => !wired.includes(a));
|
|
160
|
+
if (0 < wired.length && 0 < rest.length)
|
|
161
|
+
io.print(WIRE_ANOTHER);
|
|
162
|
+
for (const a of 0 === wired.length ? apps : rest) {
|
|
163
|
+
const displayed = displayedWorkspaceAppCommand(a, process.platform);
|
|
164
|
+
if (displayed.label !== undefined)
|
|
165
|
+
io.print(` ${displayed.label}`);
|
|
166
|
+
io.print(` ${displayed.command}`);
|
|
167
|
+
}
|
|
168
|
+
return 0 === wired.length
|
|
169
|
+
? { ok: false, applied: 0, manual: apps.length }
|
|
170
|
+
: { ok: true, applied: 0, manual: 0 };
|
|
97
171
|
}
|
|
98
172
|
return enterApp(options, io, target, 'No app in this directory — wiring', runInit);
|
|
99
173
|
}
|
|
@@ -115,5 +189,6 @@ function enterApp(options, io, target, lead, runInit) {
|
|
|
115
189
|
// Where the human is STANDING, kept across the redirect: their agent session runs here, so
|
|
116
190
|
// this is the only place a `/reticle` command file can be found by it.
|
|
117
191
|
agentRoot: options.agentRoot ?? options.cwd,
|
|
192
|
+
severalApps: true === options.severalApps || 1 < findWorkspaceApps(io).length,
|
|
118
193
|
}, io.scoped(target));
|
|
119
194
|
}
|
|
@@ -31,4 +31,10 @@ export declare const FEEDBACK_HINT: string;
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function restartHint(mcpStatus: StepStatus | undefined,
|
|
33
33
|
/** The project's own dev command, when package.json named one. */
|
|
34
|
-
devCommand?: string
|
|
34
|
+
devCommand?: string,
|
|
35
|
+
/**
|
|
36
|
+
* Titles of other clients' registration steps this run left for the reader (the `⚠` rows). The
|
|
37
|
+
* Claude Code row says nothing about them, so without these the closing line contradicts the
|
|
38
|
+
* report above it.
|
|
39
|
+
*/
|
|
40
|
+
manualClients?: readonly string[]): string;
|