@reticlehq/init 3.8.2 → 3.10.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/LICENSE +201 -104
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/dist/detect/detect.js +4 -1
- package/dist/detect/dev-script.js +24 -5
- package/dist/detect/non-js-project.js +17 -7
- package/dist/detect/workspace-apps.d.ts +1 -1
- package/dist/detect/workspace-apps.js +1 -1
- package/dist/diagnose/containerised-dev-server.js +1 -1
- package/dist/gather-plan-input.js +9 -1
- package/dist/host.d.ts +6 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +8 -4
- package/dist/no-package-json.js +53 -16
- package/dist/patch/angular.js +4 -0
- package/dist/patch/brace-scan.d.ts +15 -1
- package/dist/patch/brace-scan.js +133 -7
- package/dist/patch/vite-config.d.ts +12 -1
- package/dist/patch/vite-config.js +188 -20
- package/dist/plan/connect-steps.d.ts +2 -0
- package/dist/plan/connect-steps.js +9 -0
- package/dist/plan/plan-types.d.ts +8 -0
- package/dist/plan/plan-vite.js +4 -2
- package/dist/plan/plan.js +56 -12
- package/dist/register/mcp-clients.d.ts +4 -4
- package/dist/register/mcp-clients.js +47 -29
- package/dist/register/mcp.d.ts +54 -19
- package/dist/register/mcp.js +137 -46
- package/dist/register/slash-command.d.ts +1 -1
- package/dist/register/slash-command.js +4 -3
- package/dist/run-types.d.ts +5 -0
- package/dist/run.js +5 -1
- package/package.json +5 -4
package/dist/no-package-json.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* Django, several pages) and stops.
|
|
8
8
|
*/
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
|
+
import { ReticleTool } from '@reticlehq/core';
|
|
10
11
|
import { PackageManager, Framework } from './detect/detect.js';
|
|
11
12
|
import { PACKAGE_JSON } from './detect/workspace-apps.js';
|
|
12
13
|
import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, NoPackageJsonCase, noPackageJsonAnswer, streamlitSetupMessage, } from './detect/non-js-project.js';
|
|
13
14
|
import { InitFailure } from './diagnose/init-failure.js';
|
|
14
|
-
import { HTML_INDEX_PATH, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
|
|
15
|
+
import { HTML_INDEX_PATH, hasStaticSnippet, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
|
|
15
16
|
import { connectArg, reticleConfigContent, staticPageSnippetWithLocalToken, } from './patch/snippets.js';
|
|
16
17
|
import { djangoMiddlewareSnippet, pythonConnectArg, streamlitPageSnippet, } from './patch/python-snippets.js';
|
|
17
18
|
import { RETICLE_CONFIG_FILE } from './detect/existing-config.js';
|
|
@@ -25,6 +26,14 @@ const SNIPPET_WRITTEN = `Wrote the dev-only connect snippet into ${HTML_INDEX_PA
|
|
|
25
26
|
const TOKEN_MODULE_WRITTEN = `This machine’s pairing token is in ${STATIC_TOKEN_MODULE}, which is gitignored. Serve it beside ` +
|
|
26
27
|
'the page: the snippet imports it. Keep it out of anything you publish.';
|
|
27
28
|
const SNIPPET_ALREADY = `${HTML_INDEX_PATH} already loads the Reticle SDK — nothing to change.`;
|
|
29
|
+
/**
|
|
30
|
+
* Said when Reticle's static server serves the page, so the file stays as the user wrote it (#1544).
|
|
31
|
+
* The lease route is named first because it changes no file at all.
|
|
32
|
+
*/
|
|
33
|
+
const SNIPPET_SERVED = `${HTML_INDEX_PATH} is unchanged. Reticle's static server adds the dev-only connect snippet to ` +
|
|
34
|
+
`the page that it serves. This machine's pairing token is in ${STATIC_TOKEN_MODULE}, which is ` +
|
|
35
|
+
'gitignored. To serve the folder another way, change no file: serve it, then call ' +
|
|
36
|
+
`\`${ReticleTool.LEASE} { action: "acquire", url }\`.`;
|
|
28
37
|
/** How far up `init` looks for the app it was run inside. The same bound as the workspace walk. */
|
|
29
38
|
const MAX_ANCESTOR_DEPTH = 8;
|
|
30
39
|
const HOME_FOLDER = 'This is your home folder, not a project. Go to the folder of your app (the one with its ' +
|
|
@@ -41,14 +50,17 @@ const insideApp = (dir) => `No package.json here, but this folder is inside the
|
|
|
41
50
|
function namedDirectory(io) {
|
|
42
51
|
const home = io.homeDir();
|
|
43
52
|
let dir = io.cwd();
|
|
44
|
-
if (dir === home)
|
|
53
|
+
if (dir === home) {
|
|
45
54
|
return HOME_FOLDER;
|
|
55
|
+
}
|
|
46
56
|
for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth++) {
|
|
47
57
|
const parent = dirname(dir);
|
|
48
|
-
if (parent === dir || parent === home)
|
|
58
|
+
if (parent === dir || parent === home) {
|
|
49
59
|
return undefined;
|
|
50
|
-
|
|
60
|
+
}
|
|
61
|
+
if (null !== io.readFile(join(parent, PACKAGE_JSON))) {
|
|
51
62
|
return insideApp(parent);
|
|
63
|
+
}
|
|
52
64
|
dir = parent;
|
|
53
65
|
}
|
|
54
66
|
return undefined;
|
|
@@ -76,24 +88,46 @@ function printedSnippet(streamlit, django, connect) {
|
|
|
76
88
|
}
|
|
77
89
|
return staticPageSnippetWithLocalToken(connect);
|
|
78
90
|
}
|
|
91
|
+
/** The line `wireStaticPage` prints, in the order of its cases. */
|
|
92
|
+
function staticPageLine(written, moved, port, served) {
|
|
93
|
+
if (null !== written) {
|
|
94
|
+
return SNIPPET_WRITTEN;
|
|
95
|
+
}
|
|
96
|
+
if (null !== moved && port !== undefined) {
|
|
97
|
+
return `Updated ${HTML_INDEX_PATH}: ${portMoveDetail(moved.from, port)}.`;
|
|
98
|
+
}
|
|
99
|
+
return served ? SNIPPET_SERVED : SNIPPET_ALREADY;
|
|
100
|
+
}
|
|
101
|
+
/** What to say for this kind of project before its snippet. */
|
|
102
|
+
function setupMessage(streamlit, django, otherwise) {
|
|
103
|
+
if (streamlit) {
|
|
104
|
+
return streamlitSetupMessage();
|
|
105
|
+
}
|
|
106
|
+
if (django) {
|
|
107
|
+
return djangoSetupMessage();
|
|
108
|
+
}
|
|
109
|
+
return otherwise;
|
|
110
|
+
}
|
|
79
111
|
function wireStaticPage(options, io, connect) {
|
|
80
112
|
writeTokenModule(io);
|
|
81
113
|
const html = io.readFile(HTML_INDEX_PATH) ?? '';
|
|
114
|
+
// Setup serves the page with Reticle's static server when the user names no url of their own.
|
|
115
|
+
// That server adds the snippet to the page it serves, so the file the user hands over is not
|
|
116
|
+
// edited (#1544). With `--url` or `--files-only`, nothing of ours serves it, so the file gets it.
|
|
117
|
+
const served = true === options.continuesToRuntime && undefined === options.url;
|
|
82
118
|
// A page that already connects still has its port compared: the snippet bakes the daemon URL, and
|
|
83
119
|
// a re-run with a new `--port` that answered "already" left the page dialling the old daemon.
|
|
84
|
-
const written = withStaticSnippet(html, connect);
|
|
120
|
+
const written = served ? null : withStaticSnippet(html, connect);
|
|
85
121
|
const moved = null === written ? retargetPort(html, options.port) : null;
|
|
86
122
|
const patched = written ?? moved?.code ?? null;
|
|
87
|
-
if (null !== patched)
|
|
123
|
+
if (null !== patched) {
|
|
88
124
|
io.writeFile(HTML_INDEX_PATH, patched);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
: null !== moved && options.port !== undefined
|
|
92
|
-
? `Updated ${HTML_INDEX_PATH}: ${portMoveDetail(moved.from, options.port)}.`
|
|
93
|
-
: SNIPPET_ALREADY);
|
|
125
|
+
}
|
|
126
|
+
io.print(staticPageLine(written, moved, options.port, served && !hasStaticSnippet(html)));
|
|
94
127
|
const outcome = { ok: true, stack: Framework.HTML };
|
|
95
|
-
if (true !== options.deferOutcome)
|
|
128
|
+
if (true !== options.deferOutcome) {
|
|
96
129
|
io.host.reportOutcome(outcome);
|
|
130
|
+
}
|
|
97
131
|
return {
|
|
98
132
|
ok: true,
|
|
99
133
|
applied: null === patched ? 0 : 1,
|
|
@@ -118,8 +152,9 @@ export function initWithoutPackageJson(options, io) {
|
|
|
118
152
|
const answer = noPackageJsonAnswer(existsHere, io.rootFiles());
|
|
119
153
|
if (!streamlit && !django && answer.kind === NoPackageJsonCase.WRONG_DIRECTORY) {
|
|
120
154
|
io.print(namedDirectory(io) ?? answer.message);
|
|
121
|
-
if (!options.dryRun)
|
|
155
|
+
if (!options.dryRun) {
|
|
122
156
|
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
157
|
+
}
|
|
123
158
|
return { ok: false, applied: 0, manual: 0 };
|
|
124
159
|
}
|
|
125
160
|
// Scope the project on disk before anything else, so the daemon can serve this page.
|
|
@@ -146,8 +181,9 @@ export function initWithoutPackageJson(options, io) {
|
|
|
146
181
|
io.writeFile(RETICLE_CONFIG_FILE, moved.code);
|
|
147
182
|
io.print(`Updated ${RETICLE_CONFIG_FILE}: ${portMoveDetail(moved.from, options.port)}.`);
|
|
148
183
|
}
|
|
149
|
-
if (io.exists(RETICLE_CONFIG_FILE))
|
|
184
|
+
if (io.exists(RETICLE_CONFIG_FILE)) {
|
|
150
185
|
return;
|
|
186
|
+
}
|
|
151
187
|
io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
|
|
152
188
|
rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
|
|
153
189
|
io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
|
|
@@ -170,7 +206,7 @@ export function initWithoutPackageJson(options, io) {
|
|
|
170
206
|
// a path problem and goes looking for a directory that cannot exist — reported from a
|
|
171
207
|
// Streamlit app, where the search continued into hunting for a browser bundle to inject by
|
|
172
208
|
// hand before the real answer surfaced.
|
|
173
|
-
streamlit
|
|
209
|
+
setupMessage(streamlit, django, answer.message));
|
|
174
210
|
writeConfig();
|
|
175
211
|
// The message says "add the snippet below". Print the snippet, or the message is the same broken
|
|
176
212
|
// promise in the other direction.
|
|
@@ -182,7 +218,8 @@ export function initWithoutPackageJson(options, io) {
|
|
|
182
218
|
}
|
|
183
219
|
// The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
|
|
184
220
|
// from someone who never ran the command — the two failure modes with the most different fixes.
|
|
185
|
-
if (!options.dryRun)
|
|
221
|
+
if (!options.dryRun) {
|
|
186
222
|
io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
|
|
223
|
+
}
|
|
187
224
|
return { ok: false, applied: 0, manual: 0 };
|
|
188
225
|
}
|
package/dist/patch/angular.js
CHANGED
|
@@ -143,6 +143,10 @@ export default {
|
|
|
143
143
|
'${ANGULAR_TOKEN_PATH}': {
|
|
144
144
|
target: 'http://127.0.0.1:9',
|
|
145
145
|
bypass(req, res) {
|
|
146
|
+
// webpack-dev-server calls this for every request. Answer only this path.
|
|
147
|
+
if ((req.url ?? '').split('?')[0] !== '${ANGULAR_TOKEN_PATH}') {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
146
150
|
const site = req.headers['sec-fetch-site'];
|
|
147
151
|
const allowed =
|
|
148
152
|
LOOPBACK.has(req.socket.remoteAddress ?? '') && (site === undefined || site === 'same-origin');
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A count, not a parser. A `{` or `}` inside a string literal would fool it. That only ever
|
|
5
5
|
* shortens or lengthens the window we search, so the worst case is a MANUAL bail or a missed
|
|
6
|
-
* merge — never a corrupted file written as APPLY.
|
|
6
|
+
* merge — never a corrupted file written as APPLY. A caller that must not be fooled runs the count
|
|
7
|
+
* on `maskStringsAndComments(source)`, which keeps every index where it was.
|
|
7
8
|
*/
|
|
8
9
|
/**
|
|
9
10
|
* The text from just inside an opening brace to its match.
|
|
@@ -22,3 +23,16 @@ export declare function matchingBraceEnd(source: string, at: number): number | u
|
|
|
22
23
|
* `undefined` when the parens never balance.
|
|
23
24
|
*/
|
|
24
25
|
export declare function matchingParenEnd(source: string, openAt: number): number | undefined;
|
|
26
|
+
/** How many `{` are open at `index`, counted from the start of `source`. */
|
|
27
|
+
export declare function braceDepth(source: string, index: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* The source with every string literal, regex literal and comment blanked to spaces, newlines kept,
|
|
30
|
+
* so each index still points at the same place in the real source. Null when the scan does not end
|
|
31
|
+
* cleanly: a string, comment or regex that never closes, or braces that do not balance.
|
|
32
|
+
*
|
|
33
|
+
* The counts below are fooled by a `{` inside a string, and a key search by a `plugins: [` in a
|
|
34
|
+
* comment. A patcher that runs them on this masked copy is not, and edits the real source at the
|
|
35
|
+
* same indices. The Vite patcher used to add a second `plugins` key whose list Vite never reads
|
|
36
|
+
* (#2134). A source this scan cannot read is a manual step for the caller, never a guess.
|
|
37
|
+
*/
|
|
38
|
+
export declare function maskStringsAndComments(source: string): string | null;
|
package/dist/patch/brace-scan.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A count, not a parser. A `{` or `}` inside a string literal would fool it. That only ever
|
|
5
5
|
* shortens or lengthens the window we search, so the worst case is a MANUAL bail or a missed
|
|
6
|
-
* merge — never a corrupted file written as APPLY.
|
|
6
|
+
* merge — never a corrupted file written as APPLY. A caller that must not be fooled runs the count
|
|
7
|
+
* on `maskStringsAndComments(source)`, which keeps every index where it was.
|
|
7
8
|
*/
|
|
8
9
|
/**
|
|
9
10
|
* The text from just inside an opening brace to its match.
|
|
@@ -31,16 +32,141 @@ export function matchingParenEnd(source, openAt) {
|
|
|
31
32
|
return undefined;
|
|
32
33
|
return matchingCloser(source, openAt + 1, '(', ')');
|
|
33
34
|
}
|
|
35
|
+
/** What `c` does to the open count: +1 for the opener, -1 for the closer, 0 for anything else. */
|
|
36
|
+
function depthStep(c, open, close) {
|
|
37
|
+
if (c === open) {
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
return c === close ? -1 : 0;
|
|
41
|
+
}
|
|
34
42
|
function matchingCloser(source, innerStart, open, close) {
|
|
35
43
|
let depth = 1;
|
|
36
44
|
for (let i = innerStart; i < source.length; i++) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
depth -= 1;
|
|
41
|
-
if (0 === depth)
|
|
42
|
-
return i;
|
|
45
|
+
depth += depthStep(source[i], open, close);
|
|
46
|
+
if (0 === depth) {
|
|
47
|
+
return i;
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
return undefined;
|
|
46
51
|
}
|
|
52
|
+
/** How many `{` are open at `index`, counted from the start of `source`. */
|
|
53
|
+
export function braceDepth(source, index) {
|
|
54
|
+
let depth = 0;
|
|
55
|
+
for (let i = 0; i < index; i += 1) {
|
|
56
|
+
depth += depthStep(source[i], '{', '}');
|
|
57
|
+
}
|
|
58
|
+
return depth;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The source with every string literal, regex literal and comment blanked to spaces, newlines kept,
|
|
62
|
+
* so each index still points at the same place in the real source. Null when the scan does not end
|
|
63
|
+
* cleanly: a string, comment or regex that never closes, or braces that do not balance.
|
|
64
|
+
*
|
|
65
|
+
* The counts below are fooled by a `{` inside a string, and a key search by a `plugins: [` in a
|
|
66
|
+
* comment. A patcher that runs them on this masked copy is not, and edits the real source at the
|
|
67
|
+
* same indices. The Vite patcher used to add a second `plugins` key whose list Vite never reads
|
|
68
|
+
* (#2134). A source this scan cannot read is a manual step for the caller, never a guess.
|
|
69
|
+
*/
|
|
70
|
+
export function maskStringsAndComments(source) {
|
|
71
|
+
const out = source.split('');
|
|
72
|
+
const blank = (from, to) => {
|
|
73
|
+
for (let k = from; k < to && k < out.length; k += 1) {
|
|
74
|
+
if ('\n' !== out[k]) {
|
|
75
|
+
out[k] = ' ';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/** The last character before `at` that is not whitespace, in the masked output so far. */
|
|
80
|
+
const previousToken = (at) => {
|
|
81
|
+
for (let k = at - 1; k >= 0; k -= 1) {
|
|
82
|
+
const c = out[k] ?? '';
|
|
83
|
+
if (!/\s/.test(c)) {
|
|
84
|
+
return c;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
};
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < source.length) {
|
|
91
|
+
const ch = source[i] ?? '';
|
|
92
|
+
const next = source[i + 1];
|
|
93
|
+
let stop;
|
|
94
|
+
if ('/' === ch && '/' === next) {
|
|
95
|
+
const end = source.indexOf('\n', i);
|
|
96
|
+
stop = -1 === end ? source.length : end;
|
|
97
|
+
}
|
|
98
|
+
else if ('/' === ch && '*' === next) {
|
|
99
|
+
const end = source.indexOf('*/', i + 2);
|
|
100
|
+
stop = -1 === end ? null : end + 2;
|
|
101
|
+
}
|
|
102
|
+
else if ("'" === ch || '"' === ch || '`' === ch) {
|
|
103
|
+
stop = closingQuote(source, i, ch);
|
|
104
|
+
}
|
|
105
|
+
else if ('/' === ch && REGEX_MAY_FOLLOW.includes(previousToken(i))) {
|
|
106
|
+
stop = closingRegex(source, i);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (null === stop) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
blank(i, stop);
|
|
116
|
+
i = stop;
|
|
117
|
+
}
|
|
118
|
+
const masked = out.join('');
|
|
119
|
+
return 0 === braceDepth(masked, masked.length) ? masked : null;
|
|
120
|
+
}
|
|
121
|
+
/** Where a `/` starts a regex literal rather than a division: after one of these, or at the start. */
|
|
122
|
+
const REGEX_MAY_FOLLOW = ['', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';'];
|
|
123
|
+
/** The index just past the quote that closes the string opened at `at`, or null. */
|
|
124
|
+
function closingQuote(source, at, quote) {
|
|
125
|
+
let j = at + 1;
|
|
126
|
+
while (j < source.length) {
|
|
127
|
+
const c = source[j];
|
|
128
|
+
if ('\\' === c) {
|
|
129
|
+
j += 2;
|
|
130
|
+
}
|
|
131
|
+
else if (quote === c) {
|
|
132
|
+
return j + 1;
|
|
133
|
+
}
|
|
134
|
+
else if ('\n' === c && '`' !== quote) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
j += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/** The index just past the flags of the regex literal opened at `at`, or null. */
|
|
144
|
+
function closingRegex(source, at) {
|
|
145
|
+
let j = at + 1;
|
|
146
|
+
let inClass = false;
|
|
147
|
+
while (j < source.length) {
|
|
148
|
+
const c = source[j];
|
|
149
|
+
if ('\\' === c) {
|
|
150
|
+
j += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if ('\n' === c) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if ('[' === c) {
|
|
157
|
+
inClass = true;
|
|
158
|
+
}
|
|
159
|
+
else if (']' === c) {
|
|
160
|
+
inClass = false;
|
|
161
|
+
}
|
|
162
|
+
else if ('/' === c && !inClass) {
|
|
163
|
+
j += 1;
|
|
164
|
+
while (j < source.length && /[a-z]/i.test(source[j] ?? '')) {
|
|
165
|
+
j += 1;
|
|
166
|
+
}
|
|
167
|
+
return j;
|
|
168
|
+
}
|
|
169
|
+
j += 1;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
@@ -14,10 +14,21 @@ export declare const VitePatchKind: {
|
|
|
14
14
|
};
|
|
15
15
|
export type VitePatchKind = PatchKind;
|
|
16
16
|
type VitePatch = SourcePatch;
|
|
17
|
+
/**
|
|
18
|
+
* The package a new config imports `defineConfig` from.
|
|
19
|
+
*
|
|
20
|
+
* A Vite+ app imports it from `vite-plus`. Under pnpm it may not declare `vite` at all, so an
|
|
21
|
+
* import from `vite` would not resolve, and Vite+'s own lint rule refuses it.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DefineConfigSource: {
|
|
24
|
+
readonly VITE: "vite";
|
|
25
|
+
readonly VITE_PLUS: "vite-plus";
|
|
26
|
+
};
|
|
27
|
+
export type DefineConfigSource = (typeof DefineConfigSource)[keyof typeof DefineConfigSource];
|
|
17
28
|
/**
|
|
18
29
|
* A whole config for a plain Vite app that has none: Vite's defaults plus the plugin. Written out
|
|
19
30
|
* rather than patched from an empty one, because it is a file the person will open and read.
|
|
20
31
|
*/
|
|
21
|
-
export declare function newViteConfig(port?: number, captureBodies?: boolean, inject?: boolean, sourceMapping?: boolean): string;
|
|
32
|
+
export declare function newViteConfig(port?: number, captureBodies?: boolean, inject?: boolean, sourceMapping?: boolean, defineConfigFrom?: DefineConfigSource): string;
|
|
22
33
|
export declare function patchViteConfig(source: string, port?: number, captureBodies?: boolean, inject?: boolean, sourceMapping?: boolean): VitePatch;
|
|
23
34
|
export {};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* bails to a `manual` result so we never half-edit a build config (a broken config is worse than a
|
|
5
5
|
* documented manual step).
|
|
6
6
|
*/
|
|
7
|
+
import { braceDepth, maskStringsAndComments, matchingBraceEnd } from './brace-scan.js';
|
|
7
8
|
import { PatchKind } from './patch-kind.js';
|
|
8
9
|
export const VITE_IMPORT = "import { reticle } from '@reticlehq/vite-plugin';";
|
|
9
10
|
const RETICLE_MARKER = '@reticlehq/vite-plugin';
|
|
@@ -42,14 +43,23 @@ function reticlePluginCall(opts) {
|
|
|
42
43
|
];
|
|
43
44
|
return 0 === options.length ? 'reticle()' : `reticle({ ${options.join(', ')} })`;
|
|
44
45
|
}
|
|
45
|
-
/**
|
|
46
|
-
const
|
|
46
|
+
/** A `plugins:` key, up to the start of its value. */
|
|
47
|
+
const PLUGINS_KEY_COLON = /\bplugins\s*:\s*/g;
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
49
|
+
* The start of a plugin list we can extend, read at the start of the key's value: a plain array
|
|
50
|
+
* literal, or the same array returned by Vite+'s `lazyPlugins(() => [...])`.
|
|
51
|
+
*
|
|
52
|
+
* `vp migrate` and `vp create` wrap the app's plugins in `lazyPlugins`, so Vite+ can read the lint
|
|
53
|
+
* and format blocks without loading every plugin. The list inside is still the app's plugin list,
|
|
54
|
+
* and Vite loads it for `vp dev`. Without this form, init left a migrated Laravel app as a manual
|
|
55
|
+
* step (#2134).
|
|
56
|
+
*/
|
|
57
|
+
const PLUGIN_LIST_START = /^(?:lazyPlugins\s*\(\s*(?:async\s*)?\(\s*\)\s*=>\s*)?\[/;
|
|
58
|
+
/**
|
|
59
|
+
* The shorthand `{ plugins }` key. When it holds, the key is there but not as an array literal, so
|
|
60
|
+
* adding our own key would make a second one. The later key wins, and one of the two lists is lost.
|
|
51
61
|
*/
|
|
52
|
-
const
|
|
62
|
+
const PLUGINS_SHORTHAND = /\bplugins\s*[,}]/;
|
|
53
63
|
/** Matches an ES import statement (used to place our import after the last one). */
|
|
54
64
|
const IMPORT_LINE = /^import\s.+from\s+['"][^'"]+['"];?\s*$/gm;
|
|
55
65
|
/**
|
|
@@ -62,9 +72,15 @@ const IMPORT_LINE = /^import\s.+from\s+['"][^'"]+['"];?\s*$/gm;
|
|
|
62
72
|
* bails — the rule stays "only edit a shape we can see whole".
|
|
63
73
|
*/
|
|
64
74
|
const CONFIG_OBJECT = /(export\s+default\s+(?:defineConfig\s*\(\s*)?)\{/;
|
|
75
|
+
/** Where the default export starts, for a config that is not an object literal. */
|
|
76
|
+
const EXPORT_DEFAULT = /\bexport\s+default\b/;
|
|
65
77
|
/** Alias kept so existing call sites read in Vite terms; the vocabulary is shared (see patch-kind). */
|
|
66
78
|
export const VitePatchKind = PatchKind;
|
|
79
|
+
const UNREADABLE_REASON = 'init could not read this config safely (a string, comment or regex that it could not follow), ' +
|
|
80
|
+
'so it did not change it. Add `reticle()` to the `plugins` list of the exported config.';
|
|
67
81
|
const NO_PLUGINS_REASON = "couldn't find a `plugins: [...]` array to extend";
|
|
82
|
+
const AMBIGUOUS_PLUGINS_REASON = 'the config function builds more than one `plugins` list at the same level, so init did not ' +
|
|
83
|
+
'pick one. Add `reticle()` to the `plugins` list of the config that the function returns.';
|
|
68
84
|
const PLUGINS_NOT_ARRAY_REASON = 'the `plugins` key is not an array literal, so init did not change it. Add `reticle()` to the ' +
|
|
69
85
|
'list that the key builds. Do not add a second `plugins` key: the later key replaces the first.';
|
|
70
86
|
function insertImport(source) {
|
|
@@ -83,15 +99,144 @@ function insertImport(source) {
|
|
|
83
99
|
* what a formatter rewrites, turning a one-line install into a diff against the user's own style. A
|
|
84
100
|
* single-line array needs the space, or the result reads `[reticle(),react()]`.
|
|
85
101
|
*/
|
|
86
|
-
function insertPlugin(source, call) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
function insertPlugin(source, call, at) {
|
|
103
|
+
const next = source[at] ?? '';
|
|
104
|
+
const separator = '' === next || /\s/.test(next) ? '' : ' ';
|
|
105
|
+
return `${source.slice(0, at)}${call},${separator}${source.slice(at)}`;
|
|
106
|
+
}
|
|
107
|
+
/** No single `plugins` key can be told to be the config's own: two lists compete for it. */
|
|
108
|
+
const AMBIGUOUS = 'ambiguous';
|
|
109
|
+
/**
|
|
110
|
+
* Where the value of the config's OWN `plugins` key starts, undefined when the config has none, or
|
|
111
|
+
* AMBIGUOUS when two lists compete and nothing says which one Vite reads.
|
|
112
|
+
*
|
|
113
|
+
* Other blocks can hold a `plugins` key too: Vite+'s `lint.plugins` names oxlint plugins, and
|
|
114
|
+
* `worker.plugins` and `build.rollupOptions.plugins` are other bundlers' lists. The first match in
|
|
115
|
+
* the file used to win. On a `vp create` React app that is `lint.plugins`, so init wrote
|
|
116
|
+
* `reticle()` into the lint settings, reported the step done, and the app never connected (#2134).
|
|
117
|
+
*
|
|
118
|
+
* So when the exported object is a literal we can see, only a key at that literal's own level, inside
|
|
119
|
+
* it, is its key. A config written as a function is read by `functionConfigPluginsAt`.
|
|
120
|
+
*/
|
|
121
|
+
function vitePluginsValueAt(source) {
|
|
122
|
+
const literal = exportedLiteralStart(source);
|
|
123
|
+
if (literal !== undefined) {
|
|
124
|
+
// The literal is in view, so only its own keys count. A key outside it is another object's: a
|
|
125
|
+
// hoisted `const lint = { plugins: [...] }` that the config names by shorthand.
|
|
126
|
+
return ownPluginsKeyAt(source, literal);
|
|
127
|
+
}
|
|
128
|
+
return functionConfigPluginsAt(source, exportedRegion(source));
|
|
129
|
+
}
|
|
130
|
+
/** Where the value of the `plugins` key of the object literal whose inner start is `literal` begins. */
|
|
131
|
+
function ownPluginsKeyAt(source, literal) {
|
|
132
|
+
const end = matchingBraceEnd(source, literal) ?? source.length;
|
|
133
|
+
const depth = braceDepth(source, literal);
|
|
134
|
+
for (const match of source.matchAll(PLUGINS_KEY_COLON)) {
|
|
135
|
+
if (match.index > literal && match.index < end && depth === braceDepth(source, match.index)) {
|
|
136
|
+
return match.index + match[0].length;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
/** An object literal a config function gives back: `return {` or an arrow's `=> ({`. */
|
|
142
|
+
const RETURNED_OBJECT = /\breturn\s*\(?\s*\{|=>\s*\(\s*\{/g;
|
|
143
|
+
/**
|
|
144
|
+
* The `plugins` value of a config built by a function (`defineConfig(({ mode }) => { ... })`).
|
|
145
|
+
*
|
|
146
|
+
* The config is the object the function returns. A body can build other objects with a `plugins`
|
|
147
|
+
* key first (`const x = { plugins: [legacy()] }`), at the same depth as the returned one, and the
|
|
148
|
+
* key that came first used to win. So the returned object literals are read first: one with its own
|
|
149
|
+
* `plugins` key is the config's list, and two (a config per mode) are AMBIGUOUS. A returned object
|
|
150
|
+
* inside another one is that object's value, such as an inline plugin's hook, and does not count.
|
|
151
|
+
*
|
|
152
|
+
* A function that returns a name (`return config`) or a call has no literal to read, and a returned
|
|
153
|
+
* literal may take its list from a spread (`...shared`). Then the key with the fewest open braces in
|
|
154
|
+
* the exported expression wins, and a tie is AMBIGUOUS. A
|
|
155
|
+
* const declared above the export (`const lint = { plugins: [...] }`) is never in that region.
|
|
156
|
+
*/
|
|
157
|
+
function functionConfigPluginsAt(source, region) {
|
|
158
|
+
const returned = [];
|
|
159
|
+
for (const match of source.matchAll(RETURNED_OBJECT)) {
|
|
160
|
+
const start = match.index + match[0].length;
|
|
161
|
+
if (match.index < region.from || match.index >= region.to) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (returned.some((outer) => start > outer.start && start <= outer.end)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
returned.push({ start, end: matchingBraceEnd(source, start) ?? source.length });
|
|
168
|
+
}
|
|
169
|
+
const keys = returned
|
|
170
|
+
.map((object) => ownPluginsKeyAt(source, object.start))
|
|
171
|
+
.filter((at) => at !== undefined);
|
|
172
|
+
if (1 < keys.length) {
|
|
173
|
+
return AMBIGUOUS;
|
|
174
|
+
}
|
|
175
|
+
if (1 === keys.length) {
|
|
176
|
+
return keys[0];
|
|
177
|
+
}
|
|
178
|
+
// A returned literal with no `plugins` key of its own is the whole config, and it has no list:
|
|
179
|
+
// a `plugins` key elsewhere in the body is another object's. Only a spread (`...shared`) can
|
|
180
|
+
// bring one in, and then the body's own keys are all there is to go on.
|
|
181
|
+
if (0 < returned.length &&
|
|
182
|
+
!returned.some((object) => source.slice(object.start, object.end).includes('...'))) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
let best;
|
|
186
|
+
for (const match of source.matchAll(PLUGINS_KEY_COLON)) {
|
|
187
|
+
if (match.index < region.from || match.index >= region.to) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const depth = braceDepth(source, match.index);
|
|
191
|
+
if (best === undefined || depth < best.depth) {
|
|
192
|
+
best = { depth, valueAt: match.index + match[0].length, tied: false };
|
|
193
|
+
}
|
|
194
|
+
else if (depth === best.depth) {
|
|
195
|
+
best.tied = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (best !== undefined && best.tied) {
|
|
199
|
+
return AMBIGUOUS;
|
|
200
|
+
}
|
|
201
|
+
return best?.valueAt;
|
|
202
|
+
}
|
|
203
|
+
/** The name a default export refers to: `export default config` or `export default defineConfig(config)`. */
|
|
204
|
+
const EXPORTED_NAME = /\bexport\s+default\s+(?:defineConfig\s*\(\s*)?([A-Za-z_$][\w$]*)\s*(?:[;)]|$)/m;
|
|
205
|
+
/** Where `const|let|var <name> =` declares the name the default export refers to, if it does. */
|
|
206
|
+
function exportedDeclaration(source) {
|
|
207
|
+
const name = EXPORTED_NAME.exec(source)?.[1];
|
|
208
|
+
if (name === undefined) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
212
|
+
return new RegExp(`\\b(?:const|let|var)\\s+${escaped}\\s*(?::[^=;]*)?=\\s*(?:defineConfig\\s*\\(\\s*)?`).exec(source);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The index just past the `{` of the exported config object literal: written in the export itself,
|
|
216
|
+
* or in the declaration of the name it exports (`const config = defineConfig({...})`).
|
|
217
|
+
*/
|
|
218
|
+
function exportedLiteralStart(source) {
|
|
219
|
+
const inline = CONFIG_OBJECT.exec(source);
|
|
220
|
+
if (null !== inline) {
|
|
221
|
+
return inline.index + inline[0].length;
|
|
222
|
+
}
|
|
223
|
+
const declared = exportedDeclaration(source);
|
|
224
|
+
if (null !== declared && '{' === source[declared.index + declared[0].length]) {
|
|
225
|
+
return declared.index + declared[0].length + 1;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The part of the file that holds the exported config: from the declaration of the name it exports
|
|
231
|
+
* up to the export, or from the export to the end of the file.
|
|
232
|
+
*/
|
|
233
|
+
function exportedRegion(source) {
|
|
234
|
+
const exported = EXPORT_DEFAULT.exec(source)?.index;
|
|
235
|
+
const declared = exportedDeclaration(source)?.index;
|
|
236
|
+
if (declared !== undefined && exported !== undefined && declared < exported) {
|
|
237
|
+
return { from: declared, to: exported };
|
|
238
|
+
}
|
|
239
|
+
return { from: exported ?? 0, to: source.length };
|
|
95
240
|
}
|
|
96
241
|
/**
|
|
97
242
|
* Add a whole `plugins: [reticle()]` key to a config object that has none, matching the layout of
|
|
@@ -106,23 +251,46 @@ function insertPluginsKey(source, call) {
|
|
|
106
251
|
return multiline ? `${prefix}{\n${indent}${key}` : `${prefix}{ ${key}`;
|
|
107
252
|
});
|
|
108
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* The package a new config imports `defineConfig` from.
|
|
256
|
+
*
|
|
257
|
+
* A Vite+ app imports it from `vite-plus`. Under pnpm it may not declare `vite` at all, so an
|
|
258
|
+
* import from `vite` would not resolve, and Vite+'s own lint rule refuses it.
|
|
259
|
+
*/
|
|
260
|
+
export const DefineConfigSource = {
|
|
261
|
+
VITE: 'vite',
|
|
262
|
+
VITE_PLUS: 'vite-plus',
|
|
263
|
+
};
|
|
109
264
|
/**
|
|
110
265
|
* A whole config for a plain Vite app that has none: Vite's defaults plus the plugin. Written out
|
|
111
266
|
* rather than patched from an empty one, because it is a file the person will open and read.
|
|
112
267
|
*/
|
|
113
|
-
export function newViteConfig(port, captureBodies = false, inject = true, sourceMapping = true) {
|
|
268
|
+
export function newViteConfig(port, captureBodies = false, inject = true, sourceMapping = true, defineConfigFrom = DefineConfigSource.VITE) {
|
|
114
269
|
const call = reticlePluginCall({ port, captureBodies, inject, sourceMapping });
|
|
115
|
-
return `import { defineConfig } from '
|
|
270
|
+
return `import { defineConfig } from '${defineConfigFrom}';\n${VITE_IMPORT}\n\nexport default defineConfig({\n plugins: [${call}],\n});\n`;
|
|
116
271
|
}
|
|
117
272
|
export function patchViteConfig(source, port, captureBodies = false, inject = true, sourceMapping = true) {
|
|
118
273
|
if (source.includes(RETICLE_MARKER)) {
|
|
119
274
|
return { kind: VitePatchKind.ALREADY };
|
|
120
275
|
}
|
|
121
276
|
const call = reticlePluginCall({ port, captureBodies, inject, sourceMapping });
|
|
122
|
-
|
|
123
|
-
|
|
277
|
+
const masked = maskStringsAndComments(source);
|
|
278
|
+
if (null === masked) {
|
|
279
|
+
return { kind: VitePatchKind.MANUAL, reason: UNREADABLE_REASON };
|
|
280
|
+
}
|
|
281
|
+
const valueAt = vitePluginsValueAt(masked);
|
|
282
|
+
if (AMBIGUOUS === valueAt) {
|
|
283
|
+
return { kind: VitePatchKind.MANUAL, reason: AMBIGUOUS_PLUGINS_REASON };
|
|
284
|
+
}
|
|
285
|
+
if (valueAt !== undefined) {
|
|
286
|
+
const list = PLUGIN_LIST_START.exec(source.slice(valueAt));
|
|
287
|
+
if (null === list) {
|
|
288
|
+
return { kind: VitePatchKind.MANUAL, reason: PLUGINS_NOT_ARRAY_REASON };
|
|
289
|
+
}
|
|
290
|
+
const code = insertPlugin(source, call, valueAt + list[0].length);
|
|
291
|
+
return { kind: VitePatchKind.APPLY, code: insertImport(code) };
|
|
124
292
|
}
|
|
125
|
-
if (
|
|
293
|
+
if (PLUGINS_SHORTHAND.test(masked)) {
|
|
126
294
|
return { kind: VitePatchKind.MANUAL, reason: PLUGINS_NOT_ARRAY_REASON };
|
|
127
295
|
}
|
|
128
296
|
if (CONFIG_OBJECT.test(source)) {
|
|
@@ -62,3 +62,5 @@ export declare const STEP_TITLES: readonly StepTitle[];
|
|
|
62
62
|
export declare const CONNECT_STEP_TITLES: ReadonlySet<StepTitle>;
|
|
63
63
|
/** True when this step is what makes the app dial the daemon. */
|
|
64
64
|
export declare function isConnectStep(title: string): boolean;
|
|
65
|
+
/** True when this step patches the Vite config. */
|
|
66
|
+
export declare function isViteConfigStep(title: string): boolean;
|
|
@@ -115,3 +115,12 @@ export const CONNECT_STEP_TITLES = new Set([
|
|
|
115
115
|
export function isConnectStep(title) {
|
|
116
116
|
return CONNECT_STEP_TITLES.has(title);
|
|
117
117
|
}
|
|
118
|
+
/** The steps that patch the Vite config the dev server loads. */
|
|
119
|
+
const VITE_CONFIG_STEP_TITLES = new Set([
|
|
120
|
+
StepTitle.VITE_PLUGIN,
|
|
121
|
+
StepTitle.ELECTRON_VITE_PLUGIN,
|
|
122
|
+
]);
|
|
123
|
+
/** True when this step patches the Vite config. */
|
|
124
|
+
export function isViteConfigStep(title) {
|
|
125
|
+
return VITE_CONFIG_STEP_TITLES.has(title);
|
|
126
|
+
}
|