@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/no-package-json.js
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* static page it can see whole, or prints the snippet for the stack it recognised (Streamlit,
|
|
7
7
|
* Django, several pages) and stops.
|
|
8
8
|
*/
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
9
10
|
import { PackageManager, Framework } from './detect/detect.js';
|
|
11
|
+
import { PACKAGE_JSON } from './detect/workspace-apps.js';
|
|
10
12
|
import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, NoPackageJsonCase, noPackageJsonAnswer, streamlitSetupMessage, } from './detect/non-js-project.js';
|
|
11
13
|
import { InitFailure } from './diagnose/init-failure.js';
|
|
12
14
|
import { HTML_INDEX_PATH, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
|
|
13
|
-
import { connectArg,
|
|
15
|
+
import { connectArg, reticleConfigContent, staticPageSnippetWithLocalToken, } from './patch/snippets.js';
|
|
16
|
+
import { djangoMiddlewareSnippet, pythonConnectArg, streamlitPageSnippet, } from './patch/python-snippets.js';
|
|
14
17
|
import { RETICLE_CONFIG_FILE } from './detect/existing-config.js';
|
|
15
18
|
import { deriveProjectId } from './project/project-id.js';
|
|
16
19
|
import { portMoveDetail, retargetConfigPort, retargetPort } from './plan/port-steps.js';
|
|
@@ -19,8 +22,37 @@ const SNIPPET_WRITTEN = `Wrote the dev-only connect snippet into ${HTML_INDEX_PA
|
|
|
19
22
|
`opened on localhost. This machine’s pairing token is in ${STATIC_TOKEN_MODULE}, which is ` +
|
|
20
23
|
'gitignored — keep it out of anything you publish, and let each teammate run `reticle init` ' +
|
|
21
24
|
'for their own.';
|
|
25
|
+
const TOKEN_MODULE_WRITTEN = `This machine’s pairing token is in ${STATIC_TOKEN_MODULE}, which is gitignored. Serve it beside ` +
|
|
26
|
+
'the page: the snippet imports it. Keep it out of anything you publish.';
|
|
22
27
|
const SNIPPET_ALREADY = `${HTML_INDEX_PATH} already loads the Reticle SDK — nothing to change.`;
|
|
23
|
-
|
|
28
|
+
/** How far up `init` looks for the app it was run inside. The same bound as the workspace walk. */
|
|
29
|
+
const MAX_ANCESTOR_DEPTH = 8;
|
|
30
|
+
const HOME_FOLDER = 'This is your home folder, not a project. Go to the folder of your app (the one with its ' +
|
|
31
|
+
'package.json) and run `reticle init` there.';
|
|
32
|
+
const insideApp = (dir) => `No package.json here, but this folder is inside the app at ${dir}. Run init from the app's ` +
|
|
33
|
+
`folder:\n cd "${dir}" && reticle init`;
|
|
34
|
+
/**
|
|
35
|
+
* The wrong-directory answer when the right directory can be named.
|
|
36
|
+
*
|
|
37
|
+
* Discovery looks DOWN for the app. Run from `src/` of that app, it found nothing and said "run
|
|
38
|
+
* `reticle init` from your app's directory" with no path, to a person one folder inside it. The
|
|
39
|
+
* walk up stops at the home folder: a package.json in the home folder is an accident, not an app.
|
|
40
|
+
*/
|
|
41
|
+
function namedDirectory(io) {
|
|
42
|
+
const home = io.homeDir();
|
|
43
|
+
let dir = io.cwd();
|
|
44
|
+
if (dir === home)
|
|
45
|
+
return HOME_FOLDER;
|
|
46
|
+
for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth++) {
|
|
47
|
+
const parent = dirname(dir);
|
|
48
|
+
if (parent === dir || parent === home)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (null !== io.readFile(join(parent, PACKAGE_JSON)))
|
|
51
|
+
return insideApp(parent);
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
24
56
|
/**
|
|
25
57
|
* Write the snippet into `index.html`, and hand over to setup as a success.
|
|
26
58
|
*
|
|
@@ -28,10 +60,24 @@ const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
|
|
|
28
60
|
* print the snippet and exit 1 with `no_package_json`, which read to the funnel — and to the person —
|
|
29
61
|
* as the setup failing on exactly the path that works.
|
|
30
62
|
*/
|
|
31
|
-
|
|
63
|
+
/** Write the gitignored token module and its ignore line, only where they change. */
|
|
64
|
+
function writeTokenModule(io) {
|
|
32
65
|
for (const [path, content] of Object.entries(staticTokenFiles(io.host.pairingToken(), (p) => io.readFile(p)))) {
|
|
33
66
|
io.writeFile(path, content);
|
|
34
67
|
}
|
|
68
|
+
}
|
|
69
|
+
/** The snippet for this kind of project. Each one reads the token at run time. */
|
|
70
|
+
function printedSnippet(streamlit, django, connect) {
|
|
71
|
+
if (streamlit) {
|
|
72
|
+
return streamlitPageSnippet(pythonConnectArg(connect));
|
|
73
|
+
}
|
|
74
|
+
if (django) {
|
|
75
|
+
return djangoMiddlewareSnippet(pythonConnectArg(connect));
|
|
76
|
+
}
|
|
77
|
+
return staticPageSnippetWithLocalToken(connect);
|
|
78
|
+
}
|
|
79
|
+
function wireStaticPage(options, io, connect) {
|
|
80
|
+
writeTokenModule(io);
|
|
35
81
|
const html = io.readFile(HTML_INDEX_PATH) ?? '';
|
|
36
82
|
// A page that already connects still has its port compared: the snippet bakes the daemon URL, and
|
|
37
83
|
// a re-run with a new `--port` that answered "already" left the page dialling the old daemon.
|
|
@@ -71,7 +117,7 @@ export function initWithoutPackageJson(options, io) {
|
|
|
71
117
|
// wrong, then printed a script-tag snippet under a message that asks for none (#1366).
|
|
72
118
|
const answer = noPackageJsonAnswer(existsHere, io.rootFiles());
|
|
73
119
|
if (!streamlit && !django && answer.kind === NoPackageJsonCase.WRONG_DIRECTORY) {
|
|
74
|
-
io.print(answer.message);
|
|
120
|
+
io.print(namedDirectory(io) ?? answer.message);
|
|
75
121
|
if (!options.dryRun)
|
|
76
122
|
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
77
123
|
return { ok: false, applied: 0, manual: 0 };
|
|
@@ -89,7 +135,7 @@ export function initWithoutPackageJson(options, io) {
|
|
|
89
135
|
const nonJsProjectId = deriveProjectId(undefined, io.cwd());
|
|
90
136
|
const writeConfig = () => {
|
|
91
137
|
if (options.dryRun) {
|
|
92
|
-
io.print('Dry run: no files written
|
|
138
|
+
io.print('Dry run: no files written.');
|
|
93
139
|
return;
|
|
94
140
|
}
|
|
95
141
|
// Except its port. A re-run with a new `--port` starts the daemon there, and a config still
|
|
@@ -106,16 +152,17 @@ export function initWithoutPackageJson(options, io) {
|
|
|
106
152
|
rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
|
|
107
153
|
io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
|
|
108
154
|
};
|
|
109
|
-
|
|
155
|
+
// No snippet below holds the token. A printed token stays in terminal history and transcripts.
|
|
156
|
+
const connect = connectArg(options.port, nonJsProjectId);
|
|
110
157
|
// One page at the root is a page we can see whole. Several, or none, is a choice that is not ours.
|
|
111
158
|
if (!streamlit && !django && io.exists(HTML_INDEX_PATH)) {
|
|
112
159
|
writeConfig();
|
|
113
160
|
if (options.dryRun) {
|
|
114
|
-
io.print(
|
|
161
|
+
io.print(staticPageSnippetWithLocalToken(connect));
|
|
115
162
|
return { ok: true, applied: 0, manual: 0 };
|
|
116
163
|
}
|
|
117
|
-
//
|
|
118
|
-
return wireStaticPage(options, io,
|
|
164
|
+
// The page is the deployable artifact, so the token goes beside it instead.
|
|
165
|
+
return wireStaticPage(options, io, connect);
|
|
119
166
|
}
|
|
120
167
|
io.print(
|
|
121
168
|
// Two genuinely different situations used to share one sentence: a JS developer in the wrong
|
|
@@ -127,11 +174,12 @@ export function initWithoutPackageJson(options, io) {
|
|
|
127
174
|
writeConfig();
|
|
128
175
|
// The message says "add the snippet below". Print the snippet, or the message is the same broken
|
|
129
176
|
// promise in the other direction.
|
|
130
|
-
io.print(streamlit
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
177
|
+
io.print(printedSnippet(streamlit, django, connect));
|
|
178
|
+
// The page snippet imports the token from this module. Python reads the token file instead.
|
|
179
|
+
if (!streamlit && !django && !options.dryRun) {
|
|
180
|
+
writeTokenModule(io);
|
|
181
|
+
io.print(TOKEN_MODULE_WRITTEN);
|
|
182
|
+
}
|
|
135
183
|
// The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
|
|
136
184
|
// from someone who never ran the command — the two failure modes with the most different fixes.
|
|
137
185
|
if (!options.dryRun)
|
package/dist/node-io.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { InitHost } from './host.js';
|
|
|
8
8
|
/**
|
|
9
9
|
* Every program `init` is ever allowed to run.
|
|
10
10
|
*
|
|
11
|
-
* The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude
|
|
11
|
+
* The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude and Codex CLIs —
|
|
12
12
|
* because those are the only things the plan can ask for. It is a named constant for the same reason
|
|
13
13
|
* every other wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
|
|
14
14
|
* through a shell, and a shell turns the command name into something the shell parses rather than a
|
package/dist/node-io.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* command, not the MCP stdio transport.
|
|
5
5
|
*/
|
|
6
6
|
import { terminalWidth, wrapForTerminal } from './diagnose/terminal-wrap.js';
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, accessSync, constants, } from 'node:fs';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, accessSync, constants, lstatSync, } from 'node:fs';
|
|
8
8
|
import { NodePlatform } from './detect/platform.js';
|
|
9
9
|
import { join, dirname, isAbsolute } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
@@ -24,7 +24,7 @@ function shellSafe(args) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Every program `init` is ever allowed to run.
|
|
26
26
|
*
|
|
27
|
-
* The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude
|
|
27
|
+
* The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude and Codex CLIs —
|
|
28
28
|
* because those are the only things the plan can ask for. It is a named constant for the same reason
|
|
29
29
|
* every other wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
|
|
30
30
|
* through a shell, and a shell turns the command name into something the shell parses rather than a
|
|
@@ -46,6 +46,7 @@ export const RUNNABLE_COMMANDS = [
|
|
|
46
46
|
'npx',
|
|
47
47
|
'corepack',
|
|
48
48
|
'claude',
|
|
49
|
+
'codex',
|
|
49
50
|
];
|
|
50
51
|
/** The command, proven to be one of `RUNNABLE_COMMANDS` — never the caller's string. */
|
|
51
52
|
function runnable(command) {
|
|
@@ -158,6 +159,14 @@ export function buildNodeIo(cwd, host, output = {}) {
|
|
|
158
159
|
return [];
|
|
159
160
|
}
|
|
160
161
|
},
|
|
162
|
+
isSymlink(rel) {
|
|
163
|
+
try {
|
|
164
|
+
return lstatSync(abs(rel)).isSymbolicLink();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
161
170
|
scoped(rel) {
|
|
162
171
|
// The host travels with the re-rooted IO: a monorepo redirect re-enters `runInit` through
|
|
163
172
|
// this, and an untraced, unreported inner run is the half of init that actually did the work.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* half-edit. These must not be left manual: both edits fail silently when skipped, and one of them
|
|
6
6
|
* is JSX.
|
|
7
7
|
*/
|
|
8
|
+
import type { InitIo } from '../run-types.js';
|
|
8
9
|
import { type SourcePatch } from './patch-kind.js';
|
|
9
10
|
/** Where the generated connect component goes, and how the mount file should import it. */
|
|
10
11
|
interface ReticleDevLocation {
|
|
@@ -14,18 +15,17 @@ interface ReticleDevLocation {
|
|
|
14
15
|
importSpecifier: string;
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
+
* Whether Next treats the project as TypeScript: Next's own rule, not the package list (#1527).
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
+
* Next reads a `tsconfig.json`, and a `.ts`/`.tsx` source file. It does not read a `typescript`
|
|
21
|
+
* dependency, which a JavaScript app often has for editor types or ESLint. Choosing `.tsx` from that
|
|
22
|
+
* dependency wrote a TypeScript file into a `jsconfig.json` app. On the next `next dev`, Next then
|
|
23
|
+
* generated a `tsconfig.json`, ignored the `jsconfig.json` from then on, and every `@/` import broke.
|
|
20
24
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* somewhere that is not a route directory. App Router routes on FILENAME (`page`/`layout`/
|
|
24
|
-
* `route`), so an extra file beside the layout is inert and can stay there.
|
|
25
|
-
* - **The extension is not cosmetic.** A `.tsx` file in a JavaScript project makes Next auto-install
|
|
26
|
-
* TypeScript on the next `next dev` — and on Next 13 that takes its require-hook down with it, so
|
|
27
|
-
* the dev server never starts. The install then looks like it broke the app, because it did.
|
|
25
|
+
* `mountPath` is the layout or `_app` file the component is mounted from, or `null` when none was
|
|
26
|
+
* found. Its extension is the project's own answer for the directory being written into.
|
|
28
27
|
*/
|
|
28
|
+
export declare function nextUsesTypeScript(mountPath: string | null, hasTsconfig: boolean): boolean;
|
|
29
29
|
export declare function reticleDevLocation(mountPath: string, typescript: boolean): ReticleDevLocation;
|
|
30
30
|
export declare function patchNextConfig(source: string, sourceMapping?: boolean): SourcePatch;
|
|
31
31
|
/**
|
|
@@ -41,9 +41,45 @@ export declare function patchRootLayout(source: string): SourcePatch;
|
|
|
41
41
|
* ReticleDev component has to land NEXT TO the layout or the relative import it generates is dead.
|
|
42
42
|
*/
|
|
43
43
|
export declare const NEXT_LAYOUT_CANDIDATES: string[];
|
|
44
|
+
/**
|
|
45
|
+
* What `findRouteGroupRootLayout` found: the layout to mount into, with the other group root layouts
|
|
46
|
+
* that do not mount `<ReticleDev />`, or the ones it could not choose between.
|
|
47
|
+
*/
|
|
48
|
+
export type RouteGroupLayouts = {
|
|
49
|
+
path: string;
|
|
50
|
+
unmounted: string[];
|
|
51
|
+
} | {
|
|
52
|
+
candidates: string[];
|
|
53
|
+
};
|
|
54
|
+
type ListingIo = Pick<InitIo, 'listDirs' | 'listFiles' | 'readFile'>;
|
|
55
|
+
/**
|
|
56
|
+
* The root layout of an App Router app with no top-level `app/layout.*`: its root layouts then live
|
|
57
|
+
* in route groups (`app/(site)/layout.tsx`), one per group, which Next documents as "multiple root
|
|
58
|
+
* layouts" (#1538). `null` when the app has none, so the Pages Router candidates are still asked.
|
|
59
|
+
*
|
|
60
|
+
* One layout is chosen, because a step patches one file: a group layout that already mounts
|
|
61
|
+
* `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
|
|
62
|
+
* else the only group layout there is. Several and none of those is returned as candidates, so the
|
|
63
|
+
* manual step can name them rather than say there is no root layout.
|
|
64
|
+
*
|
|
65
|
+
* Each group root layout renders its own routes, so the routes under the others render without
|
|
66
|
+
* `<ReticleDev />`. Those that do not mount it already come back as `unmounted`, for init to name.
|
|
67
|
+
*/
|
|
68
|
+
export declare function findRouteGroupRootLayout(io: ListingIo): RouteGroupLayouts | null;
|
|
44
69
|
/**
|
|
45
70
|
* Pages Router mount points, checked only when there is no App Router layout. A Pages app has no
|
|
46
71
|
* `app/` directory at all, so writing the component there produced a file nothing imported.
|
|
47
72
|
*/
|
|
48
73
|
export declare const NEXT_PAGES_APP_CANDIDATES: string[];
|
|
74
|
+
/**
|
|
75
|
+
* The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
|
|
76
|
+
* layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
|
|
77
|
+
* root layouts and none could be chosen, so the manual step can name them; `unmounted` names the
|
|
78
|
+
* other group root layouts when one was chosen, so init can say which routes go without it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function findNextMountFile(io: ListingIo & Pick<InitIo, 'exists'>): {
|
|
81
|
+
path: string | null;
|
|
82
|
+
candidates?: string[];
|
|
83
|
+
unmounted?: string[];
|
|
84
|
+
};
|
|
49
85
|
export {};
|
package/dist/patch/next-patch.js
CHANGED
|
@@ -28,6 +28,22 @@ const RETICLE_DEV_IMPORT = reticleDevImport(RETICLE_DEV_SIBLING);
|
|
|
28
28
|
* TypeScript on the next `next dev` — and on Next 13 that takes its require-hook down with it, so
|
|
29
29
|
* the dev server never starts. The install then looks like it broke the app, because it did.
|
|
30
30
|
*/
|
|
31
|
+
/** A TypeScript source file, as Next counts one. */
|
|
32
|
+
const TS_SOURCE = /\.tsx?$/;
|
|
33
|
+
/**
|
|
34
|
+
* Whether Next treats the project as TypeScript: Next's own rule, not the package list (#1527).
|
|
35
|
+
*
|
|
36
|
+
* Next reads a `tsconfig.json`, and a `.ts`/`.tsx` source file. It does not read a `typescript`
|
|
37
|
+
* dependency, which a JavaScript app often has for editor types or ESLint. Choosing `.tsx` from that
|
|
38
|
+
* dependency wrote a TypeScript file into a `jsconfig.json` app. On the next `next dev`, Next then
|
|
39
|
+
* generated a `tsconfig.json`, ignored the `jsconfig.json` from then on, and every `@/` import broke.
|
|
40
|
+
*
|
|
41
|
+
* `mountPath` is the layout or `_app` file the component is mounted from, or `null` when none was
|
|
42
|
+
* found. Its extension is the project's own answer for the directory being written into.
|
|
43
|
+
*/
|
|
44
|
+
export function nextUsesTypeScript(mountPath, hasTsconfig) {
|
|
45
|
+
return hasTsconfig || (null !== mountPath && TS_SOURCE.test(mountPath));
|
|
46
|
+
}
|
|
31
47
|
export function reticleDevLocation(mountPath, typescript) {
|
|
32
48
|
const ext = typescript ? '.tsx' : '.jsx';
|
|
33
49
|
const slash = mountPath.lastIndexOf('/');
|
|
@@ -223,6 +239,67 @@ export const NEXT_LAYOUT_CANDIDATES = [
|
|
|
223
239
|
'src/app/layout.jsx',
|
|
224
240
|
'src/app/layout.js',
|
|
225
241
|
];
|
|
242
|
+
/** The App Router roots, in the order `NEXT_LAYOUT_CANDIDATES` checks them. */
|
|
243
|
+
const NEXT_APP_DIRS = ['app', 'src/app'];
|
|
244
|
+
const LAYOUT_BASENAMES = ['layout.tsx', 'layout.jsx', 'layout.js'];
|
|
245
|
+
const PAGE_FILE = /^page\.(tsx|ts|jsx|js|mdx)$/;
|
|
246
|
+
/** `(marketing)`: a route group, which organises routes without adding a URL segment. */
|
|
247
|
+
const ROUTE_GROUP = /^\(.+\)$/;
|
|
248
|
+
/** Whether `dir` renders `/`: a page of its own, or one in a route group nested under it. */
|
|
249
|
+
function servesRoot(io, dir) {
|
|
250
|
+
if (io.listFiles(dir).some((name) => PAGE_FILE.test(name)))
|
|
251
|
+
return true;
|
|
252
|
+
return io
|
|
253
|
+
.listDirs(dir)
|
|
254
|
+
.filter((name) => ROUTE_GROUP.test(name))
|
|
255
|
+
.some((name) => servesRoot(io, `${dir}/${name}`));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The root layout of an App Router app with no top-level `app/layout.*`: its root layouts then live
|
|
259
|
+
* in route groups (`app/(site)/layout.tsx`), one per group, which Next documents as "multiple root
|
|
260
|
+
* layouts" (#1538). `null` when the app has none, so the Pages Router candidates are still asked.
|
|
261
|
+
*
|
|
262
|
+
* One layout is chosen, because a step patches one file: a group layout that already mounts
|
|
263
|
+
* `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
|
|
264
|
+
* else the only group layout there is. Several and none of those is returned as candidates, so the
|
|
265
|
+
* manual step can name them rather than say there is no root layout.
|
|
266
|
+
*
|
|
267
|
+
* Each group root layout renders its own routes, so the routes under the others render without
|
|
268
|
+
* `<ReticleDev />`. Those that do not mount it already come back as `unmounted`, for init to name.
|
|
269
|
+
*/
|
|
270
|
+
export function findRouteGroupRootLayout(io) {
|
|
271
|
+
for (const appDir of NEXT_APP_DIRS) {
|
|
272
|
+
const layouts = io
|
|
273
|
+
.listDirs(appDir)
|
|
274
|
+
.filter((name) => ROUTE_GROUP.test(name))
|
|
275
|
+
.sort()
|
|
276
|
+
.flatMap((group) => {
|
|
277
|
+
const dir = `${appDir}/${group}`;
|
|
278
|
+
const files = io.listFiles(dir);
|
|
279
|
+
const layout = LAYOUT_BASENAMES.find((name) => files.includes(name));
|
|
280
|
+
return layout === undefined ? [] : [{ dir, path: `${dir}/${layout}` }];
|
|
281
|
+
});
|
|
282
|
+
if (0 === layouts.length)
|
|
283
|
+
continue;
|
|
284
|
+
const mounted = new Set(layouts
|
|
285
|
+
.filter((l) => true === io.readFile(l.path)?.includes(RETICLE_DEV_COMPONENT))
|
|
286
|
+
.map((l) => l.path));
|
|
287
|
+
const chosen = (path) => ({
|
|
288
|
+
path,
|
|
289
|
+
unmounted: layouts.map((l) => l.path).filter((p) => p !== path && !mounted.has(p)),
|
|
290
|
+
});
|
|
291
|
+
const wired = layouts.find((l) => mounted.has(l.path));
|
|
292
|
+
if (wired !== undefined)
|
|
293
|
+
return chosen(wired.path);
|
|
294
|
+
const root = layouts.filter((l) => servesRoot(io, l.dir));
|
|
295
|
+
if (1 === root.length && root[0] !== undefined)
|
|
296
|
+
return chosen(root[0].path);
|
|
297
|
+
if (1 === layouts.length && layouts[0] !== undefined)
|
|
298
|
+
return chosen(layouts[0].path);
|
|
299
|
+
return { candidates: layouts.map((l) => l.path) };
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
226
303
|
/**
|
|
227
304
|
* Pages Router mount points, checked only when there is no App Router layout. A Pages app has no
|
|
228
305
|
* `app/` directory at all, so writing the component there produced a file nothing imported.
|
|
@@ -235,3 +312,19 @@ export const NEXT_PAGES_APP_CANDIDATES = [
|
|
|
235
312
|
'src/pages/_app.jsx',
|
|
236
313
|
'src/pages/_app.js',
|
|
237
314
|
];
|
|
315
|
+
/**
|
|
316
|
+
* The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
|
|
317
|
+
* layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
|
|
318
|
+
* root layouts and none could be chosen, so the manual step can name them; `unmounted` names the
|
|
319
|
+
* other group root layouts when one was chosen, so init can say which routes go without it.
|
|
320
|
+
*/
|
|
321
|
+
export function findNextMountFile(io) {
|
|
322
|
+
const top = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p));
|
|
323
|
+
if (top !== undefined)
|
|
324
|
+
return { path: top };
|
|
325
|
+
const groups = findRouteGroupRootLayout(io);
|
|
326
|
+
if (groups !== null && 'path' in groups)
|
|
327
|
+
return { path: groups.path, unmounted: groups.unmounted };
|
|
328
|
+
const pagesApp = NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ?? null;
|
|
329
|
+
return null === groups ? { path: pagesApp } : { path: pagesApp, candidates: groups.candidates };
|
|
330
|
+
}
|
|
@@ -130,6 +130,6 @@ and no index.html to inject into. Wire it with a dev-only CLIENT plugin, which i
|
|
|
130
130
|
|
|
131
131
|
The package is @reticlehq/browser — the framework-neutral sensor. DOM, network, console, routing and
|
|
132
132
|
source file:line all work in Vue. What you do not get is React component identity, which is the only
|
|
133
|
-
thing the React adapter adds. The install gate scaffolds a Nuxt app
|
|
133
|
+
thing the React adapter adds. The install gate scaffolds a Nuxt app whenever the install path changes and requires a session, so this wiring
|
|
134
134
|
is proven; if something does not work, please open an issue.`;
|
|
135
135
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep Reticle's local state out of the project's Prettier check.
|
|
3
|
+
*
|
|
4
|
+
* `reticle init` runs in the user's project, and every drive writes JSON under `.reticle/`
|
|
5
|
+
* (impact.json, runs/, intent/). A project's `prettier --check .` fails on those files —
|
|
6
|
+
* not just for missing trailing newlines, but because Prettier's JSON formatting differs from
|
|
7
|
+
* `JSON.stringify` (e.g. short arrays stay on one line). The nested `.reticle/.gitignore` does
|
|
8
|
+
* not help: Prettier only reads the ignore file of the directory it runs from.
|
|
9
|
+
*
|
|
10
|
+
* So when the project uses Prettier, `init` adds `.reticle/` to the project's `.prettierignore`.
|
|
11
|
+
* This is the root-cause fix: it covers every file Reticle writes, present and future,
|
|
12
|
+
* regardless of how each one is serialized.
|
|
13
|
+
*
|
|
14
|
+
* Idempotent: re-running `init` never duplicates the entry. Never removes anything the user
|
|
15
|
+
* had — it only appends the one line when it is absent.
|
|
16
|
+
*/
|
|
17
|
+
export declare const PRETTIERIGNORE_PATH = ".prettierignore";
|
|
18
|
+
/** The entry `init` manages in the project's `.prettierignore`. */
|
|
19
|
+
export declare const RETICLE_IGNORE_ENTRY = ".reticle/";
|
|
20
|
+
/**
|
|
21
|
+
* True when the `.prettierignore` content already excludes `.reticle/`.
|
|
22
|
+
*
|
|
23
|
+
* Matches the entry with or without trailing slash, ignoring comments and blank lines —
|
|
24
|
+
* a user who wrote `.reticle` by hand has already solved this; we must not add a duplicate.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ignoresReticle(content: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The `.prettierignore` content after `init` ensures `.reticle/` is excluded.
|
|
29
|
+
*
|
|
30
|
+
* - No existing file: create one with the managed header and the entry.
|
|
31
|
+
* - Existing file without the entry: append the entry (keeping a single blank line separator).
|
|
32
|
+
* - Existing file with the entry: returned unchanged (caller treats this as ALREADY).
|
|
33
|
+
*/
|
|
34
|
+
export declare function withReticleIgnored(existing: string | null): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep Reticle's local state out of the project's Prettier check.
|
|
3
|
+
*
|
|
4
|
+
* `reticle init` runs in the user's project, and every drive writes JSON under `.reticle/`
|
|
5
|
+
* (impact.json, runs/, intent/). A project's `prettier --check .` fails on those files —
|
|
6
|
+
* not just for missing trailing newlines, but because Prettier's JSON formatting differs from
|
|
7
|
+
* `JSON.stringify` (e.g. short arrays stay on one line). The nested `.reticle/.gitignore` does
|
|
8
|
+
* not help: Prettier only reads the ignore file of the directory it runs from.
|
|
9
|
+
*
|
|
10
|
+
* So when the project uses Prettier, `init` adds `.reticle/` to the project's `.prettierignore`.
|
|
11
|
+
* This is the root-cause fix: it covers every file Reticle writes, present and future,
|
|
12
|
+
* regardless of how each one is serialized.
|
|
13
|
+
*
|
|
14
|
+
* Idempotent: re-running `init` never duplicates the entry. Never removes anything the user
|
|
15
|
+
* had — it only appends the one line when it is absent.
|
|
16
|
+
*/
|
|
17
|
+
export const PRETTIERIGNORE_PATH = '.prettierignore';
|
|
18
|
+
/** The entry `init` manages in the project's `.prettierignore`. */
|
|
19
|
+
export const RETICLE_IGNORE_ENTRY = '.reticle/';
|
|
20
|
+
/**
|
|
21
|
+
* True when the `.prettierignore` content already excludes `.reticle/`.
|
|
22
|
+
*
|
|
23
|
+
* Matches the entry with or without trailing slash, ignoring comments and blank lines —
|
|
24
|
+
* a user who wrote `.reticle` by hand has already solved this; we must not add a duplicate.
|
|
25
|
+
*/
|
|
26
|
+
export function ignoresReticle(content) {
|
|
27
|
+
return content
|
|
28
|
+
.split('\n')
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter((line) => '' !== line && !line.startsWith('#'))
|
|
31
|
+
.some((line) => RETICLE_IGNORE_ENTRY === line || '.reticle' === line);
|
|
32
|
+
}
|
|
33
|
+
const MANAGED_HEADER = "# Added by `reticle init`: Reticle's local state (.reticle/) must not break `prettier --check`.";
|
|
34
|
+
/**
|
|
35
|
+
* The `.prettierignore` content after `init` ensures `.reticle/` is excluded.
|
|
36
|
+
*
|
|
37
|
+
* - No existing file: create one with the managed header and the entry.
|
|
38
|
+
* - Existing file without the entry: append the entry (keeping a single blank line separator).
|
|
39
|
+
* - Existing file with the entry: returned unchanged (caller treats this as ALREADY).
|
|
40
|
+
*/
|
|
41
|
+
export function withReticleIgnored(existing) {
|
|
42
|
+
if (null === existing) {
|
|
43
|
+
return `${MANAGED_HEADER}\n${RETICLE_IGNORE_ENTRY}\n`;
|
|
44
|
+
}
|
|
45
|
+
if (ignoresReticle(existing))
|
|
46
|
+
return existing;
|
|
47
|
+
const trimmed = existing.replace(/\n+$/, '');
|
|
48
|
+
return `${trimmed}\n\n${MANAGED_HEADER}\n${RETICLE_IGNORE_ENTRY}\n`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The connect literal for a Python snippet: the token is substituted when the page is served. */
|
|
2
|
+
export declare function pythonConnectArg(connectArgLiteral: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
|
|
5
|
+
* it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
|
|
6
|
+
* so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
|
|
7
|
+
* idempotent and is removed after an import failure so the next rerun can retry.
|
|
8
|
+
*/
|
|
9
|
+
export declare function streamlitPageSnippet(connectArgLiteral: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* DEBUG-only middleware that injects Reticle into every Django-rendered page.
|
|
12
|
+
*
|
|
13
|
+
* Django serves templates, so the generic script tag has no single home: an app has many templates
|
|
14
|
+
* and a base template it may not have. Middleware has exactly one, runs for every page, and is the
|
|
15
|
+
* idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
|
|
16
|
+
* a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
|
|
17
|
+
*
|
|
18
|
+
* Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
|
|
19
|
+
* does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
|
|
20
|
+
* `<head>`, so the SDK sees a parsed document.
|
|
21
|
+
*/
|
|
22
|
+
export declare function djangoMiddlewareSnippet(connectArgLiteral: string): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connect snippets for Python apps (Streamlit, Django), which have no JS build to inline the
|
|
3
|
+
* token. Each one reads this machine's pairing token from the token file when the page is served,
|
|
4
|
+
* so the token is never in `init` output or in the pasted code.
|
|
5
|
+
*/
|
|
6
|
+
import { CDN_SDK_URL, withTokenExpression } from './snippets.js';
|
|
7
|
+
/** Where the Python snippets put the token, until the page is served. */
|
|
8
|
+
const PYTHON_TOKEN_PLACEHOLDER = '__RETICLE_PAIRING_TOKEN__';
|
|
9
|
+
/** Python that reads this machine's pairing token at run time, the way the daemon finds it. */
|
|
10
|
+
const PYTHON_TOKEN_READER = `def _reticle_pairing_token():
|
|
11
|
+
folder = os.environ.get("RETICLE_PAIRING_TOKEN_DIR") or str(Path.home() / ".reticle")
|
|
12
|
+
try:
|
|
13
|
+
return (Path(folder) / "pairing-token").read_text().strip()
|
|
14
|
+
except OSError:
|
|
15
|
+
return ""`;
|
|
16
|
+
/** The connect literal for a Python snippet: the token is substituted when the page is served. */
|
|
17
|
+
export function pythonConnectArg(connectArgLiteral) {
|
|
18
|
+
return withTokenExpression(connectArgLiteral, PYTHON_TOKEN_PLACEHOLDER);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
|
|
22
|
+
* it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
|
|
23
|
+
* so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
|
|
24
|
+
* idempotent and is removed after an import failure so the next rerun can retry.
|
|
25
|
+
*/
|
|
26
|
+
export function streamlitPageSnippet(connectArgLiteral) {
|
|
27
|
+
return `import json
|
|
28
|
+
import os
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import streamlit as st
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
${PYTHON_TOKEN_READER}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
st.html(
|
|
38
|
+
"""
|
|
39
|
+
<script>
|
|
40
|
+
(() => {
|
|
41
|
+
if (document.getElementById('reticle-streamlit-connect')) return;
|
|
42
|
+
const marker = document.createElement('meta');
|
|
43
|
+
marker.id = 'reticle-streamlit-connect';
|
|
44
|
+
document.head.appendChild(marker);
|
|
45
|
+
void import('${CDN_SDK_URL}')
|
|
46
|
+
.then(({ reticle }) => reticle.connect(${connectArgLiteral}))
|
|
47
|
+
.catch((error) => {
|
|
48
|
+
marker.remove();
|
|
49
|
+
throw error;
|
|
50
|
+
});
|
|
51
|
+
})();
|
|
52
|
+
</script>
|
|
53
|
+
""".replace("${PYTHON_TOKEN_PLACEHOLDER}", json.dumps(_reticle_pairing_token())),
|
|
54
|
+
unsafe_allow_javascript=True,
|
|
55
|
+
)`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* DEBUG-only middleware that injects Reticle into every Django-rendered page.
|
|
59
|
+
*
|
|
60
|
+
* Django serves templates, so the generic script tag has no single home: an app has many templates
|
|
61
|
+
* and a base template it may not have. Middleware has exactly one, runs for every page, and is the
|
|
62
|
+
* idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
|
|
63
|
+
* a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
|
|
64
|
+
*
|
|
65
|
+
* Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
|
|
66
|
+
* does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
|
|
67
|
+
* `<head>`, so the SDK sees a parsed document.
|
|
68
|
+
*/
|
|
69
|
+
export function djangoMiddlewareSnippet(connectArgLiteral) {
|
|
70
|
+
return `# reticle_dev.py: add to MIDDLEWARE while DEBUG is on:
|
|
71
|
+
# MIDDLEWARE = [..., "reticle_dev.ReticleDevMiddleware"]
|
|
72
|
+
import json
|
|
73
|
+
import os
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
|
|
76
|
+
from django.conf import settings
|
|
77
|
+
|
|
78
|
+
_SNIPPET = """<script type="module">
|
|
79
|
+
import { reticle } from '${CDN_SDK_URL}';
|
|
80
|
+
reticle.connect(${connectArgLiteral});
|
|
81
|
+
</script>"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
${PYTHON_TOKEN_READER}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReticleDevMiddleware:
|
|
88
|
+
"""Inject the Reticle SDK into HTML responses during development only."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, get_response):
|
|
91
|
+
self.get_response = get_response
|
|
92
|
+
|
|
93
|
+
def __call__(self, request):
|
|
94
|
+
response = self.get_response(request)
|
|
95
|
+
if not settings.DEBUG:
|
|
96
|
+
return response
|
|
97
|
+
if "text/html" not in response.get("Content-Type", ""):
|
|
98
|
+
return response
|
|
99
|
+
# Streaming responses have no .content to rewrite; leave them alone.
|
|
100
|
+
if getattr(response, "streaming", False):
|
|
101
|
+
return response
|
|
102
|
+
body = response.content.decode(response.charset)
|
|
103
|
+
if "</body>" not in body:
|
|
104
|
+
return response
|
|
105
|
+
snippet = _SNIPPET.replace("${PYTHON_TOKEN_PLACEHOLDER}", json.dumps(_reticle_pairing_token()))
|
|
106
|
+
response.content = body.replace("</body>", snippet + "</body>", 1).encode(
|
|
107
|
+
response.charset
|
|
108
|
+
)
|
|
109
|
+
if response.has_header("Content-Length"):
|
|
110
|
+
response["Content-Length"] = str(len(response.content))
|
|
111
|
+
return response
|
|
112
|
+
`;
|
|
113
|
+
}
|