@reticlehq/init 3.7.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-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-redirect.js +23 -8
- package/dist/diagnose/closing-hint.d.ts +5 -0
- package/dist/diagnose/closing-hint.js +18 -11
- package/dist/gather-plan-input.js +70 -18
- package/dist/host.d.ts +6 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +8 -2
- package/dist/no-package-json.js +62 -14
- package/dist/patch/next-patch.d.ts +19 -11
- package/dist/patch/next-patch.js +33 -6
- 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 +9 -30
- package/dist/patch/snippets.js +40 -103
- package/dist/patch/static-page.js +3 -5
- package/dist/patch/vite-config.js +11 -0
- package/dist/plan/connect-steps.d.ts +1 -0
- package/dist/plan/connect-steps.js +1 -0
- package/dist/plan/framework-adapter.d.ts +10 -0
- package/dist/plan/framework-adapter.js +120 -1
- package/dist/plan/notices.js +1 -1
- package/dist/plan/plan-framework.js +101 -32
- package/dist/plan/plan-types.d.ts +27 -0
- package/dist/plan/plan.d.ts +1 -10
- package/dist/plan/plan.js +128 -131
- package/dist/plan/stop-hook-step.d.ts +11 -0
- package/dist/plan/stop-hook-step.js +82 -6
- package/dist/project/agent-rules.d.ts +1 -1
- package/dist/project/agent-rules.js +8 -8
- package/dist/project/refresh-rules.js +2 -1
- 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 +4 -0
- package/dist/run.js +4 -1
- 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)
|
|
@@ -15,18 +15,17 @@ interface ReticleDevLocation {
|
|
|
15
15
|
importSpecifier: string;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* Whether Next treats the project as TypeScript: Next's own rule, not the package list (#1527).
|
|
19
19
|
*
|
|
20
|
-
*
|
|
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.
|
|
21
24
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* somewhere that is not a route directory. App Router routes on FILENAME (`page`/`layout`/
|
|
25
|
-
* `route`), so an extra file beside the layout is inert and can stay there.
|
|
26
|
-
* - **The extension is not cosmetic.** A `.tsx` file in a JavaScript project makes Next auto-install
|
|
27
|
-
* TypeScript on the next `next dev` — and on Next 13 that takes its require-hook down with it, so
|
|
28
|
-
* 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.
|
|
29
27
|
*/
|
|
28
|
+
export declare function nextUsesTypeScript(mountPath: string | null, hasTsconfig: boolean): boolean;
|
|
30
29
|
export declare function reticleDevLocation(mountPath: string, typescript: boolean): ReticleDevLocation;
|
|
31
30
|
export declare function patchNextConfig(source: string, sourceMapping?: boolean): SourcePatch;
|
|
32
31
|
/**
|
|
@@ -42,9 +41,13 @@ export declare function patchRootLayout(source: string): SourcePatch;
|
|
|
42
41
|
* ReticleDev component has to land NEXT TO the layout or the relative import it generates is dead.
|
|
43
42
|
*/
|
|
44
43
|
export declare const NEXT_LAYOUT_CANDIDATES: string[];
|
|
45
|
-
/**
|
|
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
|
+
*/
|
|
46
48
|
export type RouteGroupLayouts = {
|
|
47
49
|
path: string;
|
|
50
|
+
unmounted: string[];
|
|
48
51
|
} | {
|
|
49
52
|
candidates: string[];
|
|
50
53
|
};
|
|
@@ -58,6 +61,9 @@ type ListingIo = Pick<InitIo, 'listDirs' | 'listFiles' | 'readFile'>;
|
|
|
58
61
|
* `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
|
|
59
62
|
* else the only group layout there is. Several and none of those is returned as candidates, so the
|
|
60
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.
|
|
61
67
|
*/
|
|
62
68
|
export declare function findRouteGroupRootLayout(io: ListingIo): RouteGroupLayouts | null;
|
|
63
69
|
/**
|
|
@@ -68,10 +74,12 @@ export declare const NEXT_PAGES_APP_CANDIDATES: string[];
|
|
|
68
74
|
/**
|
|
69
75
|
* The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
|
|
70
76
|
* layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
|
|
71
|
-
* root layouts and none could be chosen, so the manual step can name them
|
|
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.
|
|
72
79
|
*/
|
|
73
80
|
export declare function findNextMountFile(io: ListingIo & Pick<InitIo, 'exists'>): {
|
|
74
81
|
path: string | null;
|
|
75
82
|
candidates?: string[];
|
|
83
|
+
unmounted?: string[];
|
|
76
84
|
};
|
|
77
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('/');
|
|
@@ -247,6 +263,9 @@ function servesRoot(io, dir) {
|
|
|
247
263
|
* `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
|
|
248
264
|
* else the only group layout there is. Several and none of those is returned as candidates, so the
|
|
249
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.
|
|
250
269
|
*/
|
|
251
270
|
export function findRouteGroupRootLayout(io) {
|
|
252
271
|
for (const appDir of NEXT_APP_DIRS) {
|
|
@@ -262,14 +281,21 @@ export function findRouteGroupRootLayout(io) {
|
|
|
262
281
|
});
|
|
263
282
|
if (0 === layouts.length)
|
|
264
283
|
continue;
|
|
265
|
-
const
|
|
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));
|
|
266
292
|
if (wired !== undefined)
|
|
267
|
-
return
|
|
293
|
+
return chosen(wired.path);
|
|
268
294
|
const root = layouts.filter((l) => servesRoot(io, l.dir));
|
|
269
295
|
if (1 === root.length && root[0] !== undefined)
|
|
270
|
-
return
|
|
296
|
+
return chosen(root[0].path);
|
|
271
297
|
if (1 === layouts.length && layouts[0] !== undefined)
|
|
272
|
-
return
|
|
298
|
+
return chosen(layouts[0].path);
|
|
273
299
|
return { candidates: layouts.map((l) => l.path) };
|
|
274
300
|
}
|
|
275
301
|
return null;
|
|
@@ -289,7 +315,8 @@ export const NEXT_PAGES_APP_CANDIDATES = [
|
|
|
289
315
|
/**
|
|
290
316
|
* The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
|
|
291
317
|
* layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
|
|
292
|
-
* root layouts and none could be chosen, so the manual step can name them
|
|
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.
|
|
293
320
|
*/
|
|
294
321
|
export function findNextMountFile(io) {
|
|
295
322
|
const top = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p));
|
|
@@ -297,7 +324,7 @@ export function findNextMountFile(io) {
|
|
|
297
324
|
return { path: top };
|
|
298
325
|
const groups = findRouteGroupRootLayout(io);
|
|
299
326
|
if (groups !== null && 'path' in groups)
|
|
300
|
-
return { path: groups.path };
|
|
327
|
+
return { path: groups.path, unmounted: groups.unmounted };
|
|
301
328
|
const pagesApp = NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ?? null;
|
|
302
329
|
return null === groups ? { path: pagesApp } : { path: pagesApp, candidates: groups.candidates };
|
|
303
330
|
}
|
|
@@ -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
|
+
}
|
package/dist/patch/snippets.d.ts
CHANGED
|
@@ -35,19 +35,16 @@ export declare const CDN_SDK_URL: string;
|
|
|
35
35
|
* (so the app is identifiable across port changes). Empty string only when neither applies.
|
|
36
36
|
*/
|
|
37
37
|
export declare function connectArg(port: number | undefined, projectId?: string): string;
|
|
38
|
+
/** The gitignored module beside the page that holds this machine's pairing token. */
|
|
39
|
+
export declare const LOCAL_TOKEN_MODULE = "reticle.local.js";
|
|
38
40
|
/**
|
|
39
|
-
*
|
|
41
|
+
* A connect literal with `token` set to a JS expression, never to the token itself.
|
|
40
42
|
*
|
|
41
|
-
* The token
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* socket with AUTH_FAILED and no session ever appears: see Bridge's hello handler.
|
|
45
|
-
*
|
|
46
|
-
* An empty token is omitted rather than emitted as `token: ''`. A daemon that could not write to
|
|
47
|
-
* $HOME runs without auth and trusts loopback, and an empty string would fail the comparison against
|
|
48
|
-
* one that does hold a token.
|
|
43
|
+
* The token used to be a literal in every printed snippet. It then stayed in terminal history,
|
|
44
|
+
* in agent transcripts, and in every file the snippet was pasted into. The snippets now read it at
|
|
45
|
+
* run time: from the gitignored module, or, in Python, from the token file.
|
|
49
46
|
*/
|
|
50
|
-
export declare function
|
|
47
|
+
export declare function withTokenExpression(connectArgLiteral: string, tokenExpression: string): string;
|
|
51
48
|
export declare function usesReactKit(uiLibrary: UiLibrary, framework?: Framework): boolean;
|
|
52
49
|
/**
|
|
53
50
|
* Which SDK package the GENERATED code should import, and whether `install()` applies.
|
|
@@ -152,26 +149,8 @@ export declare const NEXT_LAYOUT_MANUAL = "Mount <ReticleDev /> in your root lay
|
|
|
152
149
|
* opposite sign, so the two share a builder rather than a copy.
|
|
153
150
|
*/
|
|
154
151
|
export declare function staticPageSnippet(connectArgLiteral: string): string;
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
* it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
|
|
158
|
-
* so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
|
|
159
|
-
* idempotent and is removed after an import failure so the next rerun can retry.
|
|
160
|
-
*/
|
|
161
|
-
export declare function streamlitPageSnippet(connectArgLiteral: string): string;
|
|
162
|
-
/**
|
|
163
|
-
* DEBUG-only middleware that injects Reticle into every Django-rendered page.
|
|
164
|
-
*
|
|
165
|
-
* Django serves templates, so the generic script tag has no single home: an app has many templates
|
|
166
|
-
* and a base template it may not have. Middleware has exactly one, runs for every page, and is the
|
|
167
|
-
* idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
|
|
168
|
-
* a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
|
|
169
|
-
*
|
|
170
|
-
* Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
|
|
171
|
-
* does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
|
|
172
|
-
* `<head>`, so the SDK sees a parsed document.
|
|
173
|
-
*/
|
|
174
|
-
export declare function djangoMiddlewareSnippet(connectArgLiteral: string): string;
|
|
152
|
+
/** The same snippet, with the token read from the gitignored module beside the page. */
|
|
153
|
+
export declare function staticPageSnippetWithLocalToken(connectArgLiteral: string): string;
|
|
175
154
|
export declare function htmlManual(port: number | undefined, projectId?: string, pairingToken?: string, uiLibrary?: UiLibrary): string;
|
|
176
155
|
export declare const NEXT_RETICLE_DEV_PATH = "app/reticle-dev.tsx";
|
|
177
156
|
export declare const SVELTEKIT_HOOKS_PATH = "src/hooks.client.ts";
|