react-x11 2.48.0 → 2.50.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/README.md +9 -8
- package/package.json +7 -4
- package/src/ClickToComponent.js +11 -132
- package/src/Reconciler.js +4 -1
- package/src/cli/dev.js +578 -0
- package/src/cli/errorwindow.js +368 -0
- package/src/cli/index.js +81 -0
- package/src/cocoa/app.js +12 -3
- package/src/cocoa/presenter.js +3 -1
- package/src/cocoa/promotion.js +10 -11
- package/src/cocoa/relaunch.js +110 -13
- package/src/currentcolor.js +28 -0
- package/src/decorations.js +21 -9
- package/src/editor.js +151 -0
- package/src/errors.js +8 -0
- package/src/foreignnodes.js +8 -1
- package/src/gloverlay.js +2 -1
- package/src/node.d.ts +5 -0
- package/src/nodes/animation.js +31 -3
- package/src/nodes/boxpaint.js +34 -8
- package/src/nodes/cascade.js +50 -3
- package/src/nodes/damage.js +2 -0
- package/src/nodes/layout.js +9 -17
- package/src/nodes/moveblit.js +5 -5
- package/src/nodes/node.js +8 -0
- package/src/nodes/paint.js +4 -2
- package/src/nodes/queries.js +46 -34
- package/src/nodes/scrollblit.js +10 -7
- package/src/nodes/styling.js +176 -21
- package/src/nodes/window/capabilities.js +1 -1
- package/src/nodes/window/floors.js +63 -18
- package/src/nodes/window/size.js +94 -19
- package/src/refresh/loader.d.ts +16 -0
- package/src/refresh/loader.js +84 -14
- package/src/refresh/register.js +8 -5
- package/src/shorthands.js +1266 -0
- package/src/styles.js +370 -131
- package/src/types/style.d.ts +163 -39
package/src/cli/dev.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// `react-x11 dev <file>` — hot reload with nothing to set up.
|
|
2
|
+
//
|
|
3
|
+
// What `node --import react-x11/refresh/register main.jsx` leaves to the app,
|
|
4
|
+
// this does itself, and each piece is here because the --import route gets it
|
|
5
|
+
// wrong without one:
|
|
6
|
+
//
|
|
7
|
+
// - **It is the entry.** The file named on a command line is the one module
|
|
8
|
+
// hot-module-replacement never watches — it starts watching a module when
|
|
9
|
+
// another module imports it — so under --import an app in one file never
|
|
10
|
+
// reloads, and nothing says so. Here this module imports the file, which
|
|
11
|
+
// makes it an ordinary hot module: one file, and saving it reloads it.
|
|
12
|
+
// - **It mounts the file's default export**, so the file has no createRoot
|
|
13
|
+
// and no named-import rule to learn before its first save. A file that
|
|
14
|
+
// mounts a root of its own while it is imported runs as it is, the way the
|
|
15
|
+
// --import route runs it.
|
|
16
|
+
// - **It supplies a `<window>` when the tree has none.** Whether a tree has
|
|
17
|
+
// one cannot be read off the export — a component returns another
|
|
18
|
+
// component, or a window only once a state settles — but the renderer
|
|
19
|
+
// knows the moment it matters: a drawn element at the root throws
|
|
20
|
+
// NOT_IN_WINDOW before anything commits. So the component is mounted as it
|
|
21
|
+
// is, and on that error mounted again inside a window of ours, with nothing
|
|
22
|
+
// lost: the failed render committed nothing. Decided again on every reload,
|
|
23
|
+
// both ways, so the first answer does not outlive the code that gave it.
|
|
24
|
+
// - **It asks before it installs the toolchain.** Babel and friends are
|
|
25
|
+
// optional peer dependencies, 7.6 MB that an app's users should not
|
|
26
|
+
// download for a tool only its developers run, so `dev` finds them
|
|
27
|
+
// missing, names them with the versions the loader is written against, and
|
|
28
|
+
// installs them with the project's own package manager when the developer
|
|
29
|
+
// says yes. Without a terminal to ask on it prints the command instead.
|
|
30
|
+
// - **It runs the project's react-x11.** A second copy of the renderer is a
|
|
31
|
+
// second React root nobody refreshes, so a global or npx-cached
|
|
32
|
+
// `react-x11` hands over to the copy the file itself imports.
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
35
|
+
import * as nodeModule from 'node:module';
|
|
36
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
37
|
+
import { createInterface } from 'node:readline/promises';
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
39
|
+
import { THEME_SCOPE } from '../nodes/kinds.js';
|
|
40
|
+
|
|
41
|
+
const OWN_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
42
|
+
const OWN_PACKAGE = JSON.parse(
|
|
43
|
+
readFileSync(join(OWN_ROOT, 'package.json'), 'utf8'),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the dev window is when the tree brings none. No size: a `<window>`
|
|
48
|
+
* without one is sized from its content, which is the honest frame for a
|
|
49
|
+
* component shown alone, and the floor only keeps a lone button's window big
|
|
50
|
+
* enough to find and grab.
|
|
51
|
+
*/
|
|
52
|
+
const DEV_WINDOW = { minWidth: 320, minHeight: 200 };
|
|
53
|
+
|
|
54
|
+
/** A mistake the developer can fix from the message: printed bare. */
|
|
55
|
+
export class DevError extends Error {
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'DevError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run `file` with hot reload. Resolves once the app is mounted; the process
|
|
64
|
+
* then lives as long as the app's connection does.
|
|
65
|
+
*
|
|
66
|
+
* `options.args` is what the app sees after its file in `process.argv`, as
|
|
67
|
+
* under `node file.jsx …args`. `createRoot` and `log` are the seams the
|
|
68
|
+
* tests replace: the root is made against their mock app, and what this
|
|
69
|
+
* prints is what they read.
|
|
70
|
+
*/
|
|
71
|
+
export async function runDev(file, options = {}) {
|
|
72
|
+
const {
|
|
73
|
+
args = [],
|
|
74
|
+
createRoot = null,
|
|
75
|
+
log = (line) => console.error(`react-x11 dev: ${line}`),
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
const path = resolve(file);
|
|
79
|
+
const rel = (p) => relative(process.cwd(), p) || p;
|
|
80
|
+
if (!existsSync(path)) {
|
|
81
|
+
throw new DevError(`${file}: no such file.`);
|
|
82
|
+
}
|
|
83
|
+
const ext = extname(path);
|
|
84
|
+
if (!['.jsx', '.js', '.mjs'].includes(ext)) {
|
|
85
|
+
throw new DevError(
|
|
86
|
+
`${rel(path)}: the hot loader compiles .jsx files, and runs .js and ` +
|
|
87
|
+
`.mjs as Node does — ${ext || 'a file with no extension'} is ` +
|
|
88
|
+
'neither. TypeScript is not supported yet; rename the file to .jsx.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (process.env.NODE_ENV === 'production') {
|
|
92
|
+
throw new DevError(
|
|
93
|
+
"NODE_ENV is 'production', and react-refresh ships a no-op build " +
|
|
94
|
+
'for production, so nothing would reload. Unset NODE_ENV (or set ' +
|
|
95
|
+
"it to 'development') to run react-x11 dev.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (typeof nodeModule.registerHooks !== 'function') {
|
|
99
|
+
throw new DevError(
|
|
100
|
+
'hot reload needs Node.js 22.15 or newer, for module.registerHooks — ' +
|
|
101
|
+
`this is Node ${process.versions.node}. The app itself runs on ` +
|
|
102
|
+
'Node 20.19: `node` (or `npx tsx`) with your own entry file.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const theirs = reactX11For(path);
|
|
107
|
+
if (theirs === null) {
|
|
108
|
+
throw new DevError(
|
|
109
|
+
`${rel(path)} cannot import react-x11: it is not installed in the ` +
|
|
110
|
+
'project the file is in. Install it there:\n\n' +
|
|
111
|
+
' npm install react-x11 react\n',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (realpathSync(theirs) !== realpathSync(OWN_ROOT)) {
|
|
115
|
+
await handOver(theirs);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const projectDir = projectDirOf(dirname(path));
|
|
120
|
+
if ((await ensureToolchain({ projectDir, log })) === 'installed') {
|
|
121
|
+
restart();
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Before anything below loads: a module's source maps are read when it
|
|
126
|
+
// loads, and only once this is on.
|
|
127
|
+
process.setSourceMapsEnabled(true);
|
|
128
|
+
const { onTransform, registerRefresh } = await import('../refresh/loader.js');
|
|
129
|
+
await registerRefresh();
|
|
130
|
+
// The runtime patches React DevTools' global hook and then hands the
|
|
131
|
+
// renderer to it, which has to happen before the first root commits.
|
|
132
|
+
const { default: Refresh } = await import('../refresh/index.js');
|
|
133
|
+
const { default: RefreshRuntime } = await import('react-refresh/runtime');
|
|
134
|
+
const { default: React } = await import('react');
|
|
135
|
+
const ReactX11 = await import('../index.js');
|
|
136
|
+
const { NOT_IN_WINDOW } = await import('../errors.js');
|
|
137
|
+
const { liveApps } = await import('../trace-registry.js');
|
|
138
|
+
const { ErrorWindow, describeError } = await import('./errorwindow.js');
|
|
139
|
+
|
|
140
|
+
// What `node file.jsx …args` would hand the app.
|
|
141
|
+
process.argv = [process.argv[0], path, ...args];
|
|
142
|
+
|
|
143
|
+
const appsBefore = liveApps().length;
|
|
144
|
+
const mod = await import(pathToFileURL(path).href);
|
|
145
|
+
const name = rel(path);
|
|
146
|
+
const compiled = ext === '.jsx';
|
|
147
|
+
|
|
148
|
+
// A module reloads in place only when everything it exports is a
|
|
149
|
+
// component (src/refresh/index.js, `moduleReady`); otherwise the edit goes
|
|
150
|
+
// up to its importer, which is this module, and stops there.
|
|
151
|
+
const notComponents = Object.keys(mod).filter(
|
|
152
|
+
(key) => !RefreshRuntime.isLikelyComponentType(mod[key]),
|
|
153
|
+
);
|
|
154
|
+
const boundary = Object.keys(mod).length > 0 && notComponents.length === 0;
|
|
155
|
+
|
|
156
|
+
if (liveApps().length > appsBefore) {
|
|
157
|
+
log(
|
|
158
|
+
`${name} mounted a root of its own, so it runs as it is: saving the ` +
|
|
159
|
+
`.jsx modules it imports reloads them, and ${name} itself needs a ` +
|
|
160
|
+
'restart.',
|
|
161
|
+
);
|
|
162
|
+
if (compiled && boundary) {
|
|
163
|
+
log(
|
|
164
|
+
`${name} also exports only components, so saving it re-runs it — ` +
|
|
165
|
+
'and its createRoot call, which opens another window. Leave the ' +
|
|
166
|
+
'mounting to react-x11 dev (delete the createRoot call), or guard ' +
|
|
167
|
+
'it: if (!import.meta.hot) { … }.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const App = mod.default;
|
|
174
|
+
const componentLike =
|
|
175
|
+
typeof App === 'function' ||
|
|
176
|
+
(typeof App === 'object' && App !== null && '$$typeof' in App);
|
|
177
|
+
if (!componentLike) {
|
|
178
|
+
throw new DevError(
|
|
179
|
+
`${name} has no component as its default export, and mounted no root ` +
|
|
180
|
+
'of its own, so there is nothing to show. Export the component:\n\n' +
|
|
181
|
+
' export default function App() { … }\n',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (!compiled) {
|
|
185
|
+
log(
|
|
186
|
+
`${name} is not compiled by the hot loader (it compiles .jsx files), ` +
|
|
187
|
+
'so saving it needs a restart; the .jsx modules it imports reload.',
|
|
188
|
+
);
|
|
189
|
+
} else if (!boundary) {
|
|
190
|
+
const reasons = [];
|
|
191
|
+
if (notComponents.includes('default')) {
|
|
192
|
+
reasons.push(
|
|
193
|
+
'its default export is not a component to React Refresh, which ' +
|
|
194
|
+
'goes by a capitalized name — export default function App() { … }',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const others = notComponents.filter((key) => key !== 'default');
|
|
198
|
+
if (others.length > 0) {
|
|
199
|
+
reasons.push(
|
|
200
|
+
`${others.join(', ')} ${others.length === 1 ? 'is' : 'are'} not — ` +
|
|
201
|
+
'move what is not a component to a module of its own',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
log(
|
|
205
|
+
`saving ${name} will not reload it in place: a module does that only ` +
|
|
206
|
+
`when everything it exports is a component, and ${reasons.join('; ')}. ` +
|
|
207
|
+
'Until then, saving it needs a restart.',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const title = `${basename(path)} — react-x11 dev`;
|
|
212
|
+
let wrapped = false;
|
|
213
|
+
let mixedNoted = false;
|
|
214
|
+
// What the error window shows: a render error took the app's tree down,
|
|
215
|
+
// and the app stays down until the next reload tries it again; a compile
|
|
216
|
+
// error is a save that did not compile, beside an app still running the
|
|
217
|
+
// module's last version. Both at once shows the compile error, which is
|
|
218
|
+
// the newer news.
|
|
219
|
+
let renderFailure = null;
|
|
220
|
+
let compileFailure = null;
|
|
221
|
+
// Set when a reload starts while a render error is showing: that reload is
|
|
222
|
+
// the one to try the app again on. Not the reload that threw — react-refresh
|
|
223
|
+
// calls the reload listeners in the same batch as the render that failed,
|
|
224
|
+
// and retrying there would only throw it a second time.
|
|
225
|
+
let retry = false;
|
|
226
|
+
let root = null;
|
|
227
|
+
// Two children, always in these places: an error window opening or
|
|
228
|
+
// closing beside the app is then a sibling coming and going, and the app's
|
|
229
|
+
// state is untouched by it.
|
|
230
|
+
const tree = () => {
|
|
231
|
+
const app = React.createElement(App);
|
|
232
|
+
const failure = compileFailure ?? renderFailure;
|
|
233
|
+
return React.createElement(
|
|
234
|
+
React.Fragment,
|
|
235
|
+
null,
|
|
236
|
+
renderFailure
|
|
237
|
+
? null
|
|
238
|
+
: wrapped
|
|
239
|
+
? React.createElement('window', { ...DEV_WINDOW, title }, app)
|
|
240
|
+
: app,
|
|
241
|
+
failure
|
|
242
|
+
? React.createElement(ErrorWindow, {
|
|
243
|
+
failure,
|
|
244
|
+
title: `${basename(failure.filename ?? path)} — error`,
|
|
245
|
+
// Beside a running app it is a second window, whose closing is
|
|
246
|
+
// ours to answer: it goes until the next error. Standing in for
|
|
247
|
+
// the app it is the only one, and closing it ends the app.
|
|
248
|
+
onCloseRequest: renderFailure
|
|
249
|
+
? undefined
|
|
250
|
+
: () => {
|
|
251
|
+
compileFailure = null;
|
|
252
|
+
render();
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
: null,
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
const render = () => root.render(tree());
|
|
259
|
+
|
|
260
|
+
root = await (createRoot ?? ReactX11.createRoot)({
|
|
261
|
+
onUncaughtError(error, info) {
|
|
262
|
+
if (error?.code === NOT_IN_WINDOW && !wrapped && !renderFailure) {
|
|
263
|
+
wrapped = true;
|
|
264
|
+
log(
|
|
265
|
+
`${name} renders no <window>, so it is shown in one of react-x11 ` +
|
|
266
|
+
"dev's; the first <window> it renders replaces it.",
|
|
267
|
+
);
|
|
268
|
+
setImmediate(render);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (info?.handler) {
|
|
272
|
+
// from an event handler: React was not on the stack and nothing
|
|
273
|
+
// unmounted (src/errors.js, reportHandlerError)
|
|
274
|
+
console.error(
|
|
275
|
+
`react-x11 dev: ${info.handler} on ${info.element} threw; ` +
|
|
276
|
+
'dispatch continues.',
|
|
277
|
+
error,
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
console.error(error);
|
|
282
|
+
const chain = describeError({
|
|
283
|
+
kind: 'render',
|
|
284
|
+
error,
|
|
285
|
+
componentStack: info?.componentStack,
|
|
286
|
+
}).components;
|
|
287
|
+
// Already showing one: this came out of the error window itself, or
|
|
288
|
+
// the tree failed again before a reload — the terminal has it, and a
|
|
289
|
+
// window answering its own failure by mounting itself again would
|
|
290
|
+
// never stop.
|
|
291
|
+
if (renderFailure) return;
|
|
292
|
+
log(
|
|
293
|
+
(chain.length > 0 ? `thrown rendering ${chain.join(' › ')}. ` : '') +
|
|
294
|
+
`Fix it and save: ${name} is mounted again on the next reload.`,
|
|
295
|
+
);
|
|
296
|
+
renderFailure = {
|
|
297
|
+
kind: 'render',
|
|
298
|
+
error,
|
|
299
|
+
componentStack: info?.componentStack,
|
|
300
|
+
};
|
|
301
|
+
setImmediate(render);
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// A save that does not compile: the reload library prints it and keeps the
|
|
306
|
+
// module's last version, so this is the only place it can be shown. Before
|
|
307
|
+
// the mount, the import of the file itself throws, and the bin prints it.
|
|
308
|
+
//
|
|
309
|
+
// Every reload compiles at least the module it stops at, so this is also
|
|
310
|
+
// where a reload is first seen.
|
|
311
|
+
onTransform(({ filename, error }) => {
|
|
312
|
+
if (error) {
|
|
313
|
+
compileFailure = { kind: 'compile', error, filename };
|
|
314
|
+
setImmediate(render);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (renderFailure) retry = true;
|
|
318
|
+
if (compileFailure?.filename === filename) {
|
|
319
|
+
compileFailure = null;
|
|
320
|
+
setImmediate(render);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// After a reload: try an app an error took down again, and take the dev
|
|
325
|
+
// window away from a tree that now brings its own. The other direction
|
|
326
|
+
// needs nothing here — a tree that lost its window throws NOT_IN_WINDOW,
|
|
327
|
+
// and the handler above wraps it.
|
|
328
|
+
const settle = () => {
|
|
329
|
+
if (renderFailure) {
|
|
330
|
+
if (!retry) return;
|
|
331
|
+
retry = false;
|
|
332
|
+
renderFailure = null;
|
|
333
|
+
render();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!wrapped) return;
|
|
337
|
+
const devWindow = (root.app._rootChildren ?? [])[0];
|
|
338
|
+
if (!devWindow) return;
|
|
339
|
+
const { windows, drawn } = rootContent(devWindow);
|
|
340
|
+
if (windows > 0 && drawn === 0) {
|
|
341
|
+
wrapped = false;
|
|
342
|
+
mixedNoted = false;
|
|
343
|
+
log(
|
|
344
|
+
`${name} renders its own <window> now, so it is mounted without ` +
|
|
345
|
+
"react-x11 dev's; that resets its state.",
|
|
346
|
+
);
|
|
347
|
+
render();
|
|
348
|
+
} else if (windows > 0 && !mixedNoted) {
|
|
349
|
+
mixedNoted = true;
|
|
350
|
+
log(
|
|
351
|
+
`${name} renders a <window> beside content that is in none, so ` +
|
|
352
|
+
"both stay in react-x11 dev's window. Put that content in a " +
|
|
353
|
+
'<window> of its own.',
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
Refresh.onReload(({ urls, refreshed }) => {
|
|
358
|
+
if (refreshed) {
|
|
359
|
+
log(`reloaded ${urls.map((url) => rel(fileURLToPath(url))).join(', ')}`);
|
|
360
|
+
}
|
|
361
|
+
setImmediate(settle);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
render();
|
|
365
|
+
log(`${name} is running; saving it reloads in place. Ctrl+C stops it.`);
|
|
366
|
+
return root;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What the tree put inside the dev window, by the rule the root itself
|
|
371
|
+
* keeps: windows (popups among them), and anything drawn. A
|
|
372
|
+
* `<ThemeProvider>` is neither — inside a window it is a box that holds what
|
|
373
|
+
* it wraps — so its children are counted in its place.
|
|
374
|
+
*/
|
|
375
|
+
function rootContent(node) {
|
|
376
|
+
let windows = 0;
|
|
377
|
+
let drawn = 0;
|
|
378
|
+
for (const child of node?.children ?? []) {
|
|
379
|
+
if (child.isWindow) windows++;
|
|
380
|
+
else if (child._reactFiber?.type === THEME_SCOPE) {
|
|
381
|
+
const inner = rootContent(child);
|
|
382
|
+
windows += inner.windows;
|
|
383
|
+
drawn += inner.drawn;
|
|
384
|
+
} else drawn++;
|
|
385
|
+
}
|
|
386
|
+
return { windows, drawn };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The react-x11 package `path` imports, or null when it imports none. */
|
|
390
|
+
export function reactX11For(path) {
|
|
391
|
+
let entry;
|
|
392
|
+
try {
|
|
393
|
+
entry = nodeModule.createRequire(path).resolve('react-x11');
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
for (let dir = dirname(entry); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
398
|
+
const manifest = join(dir, 'package.json');
|
|
399
|
+
if (!existsSync(manifest)) continue;
|
|
400
|
+
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'react-x11') {
|
|
401
|
+
return dir;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Hand the command over to the project's own copy of react-x11, which reads
|
|
409
|
+
* the same `process.argv`.
|
|
410
|
+
*/
|
|
411
|
+
async function handOver(theirRoot) {
|
|
412
|
+
const pkg = JSON.parse(readFileSync(join(theirRoot, 'package.json'), 'utf8'));
|
|
413
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['react-x11'];
|
|
414
|
+
if (!bin) {
|
|
415
|
+
throw new DevError(
|
|
416
|
+
`this project's react-x11 is ${pkg.version} (${theirRoot}), which has ` +
|
|
417
|
+
`no react-x11 dev — it arrived in ${OWN_PACKAGE.version}. Update ` +
|
|
418
|
+
'it:\n\n npm install react-x11@latest\n',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
await import(pathToFileURL(join(theirRoot, bin)).href);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The nearest directory at or above `dir` with a package.json. */
|
|
425
|
+
function projectDirOf(dir) {
|
|
426
|
+
for (let at = dir; ; at = dirname(at)) {
|
|
427
|
+
if (existsSync(join(at, 'package.json'))) return at;
|
|
428
|
+
if (dirname(at) === at) return dir;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const LOCKFILES = [
|
|
433
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
434
|
+
['yarn.lock', 'yarn'],
|
|
435
|
+
['bun.lock', 'bun'],
|
|
436
|
+
['bun.lockb', 'bun'],
|
|
437
|
+
['package-lock.json', 'npm'],
|
|
438
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The package manager a project is installed with: its lockfile, nearest
|
|
443
|
+
* first, then the one that launched this command (`npx`, `pnpm dlx`, `bunx`
|
|
444
|
+
* set `npm_config_user_agent`), then npm.
|
|
445
|
+
*/
|
|
446
|
+
export function packageManagerFor(
|
|
447
|
+
dir,
|
|
448
|
+
userAgent = process.env.npm_config_user_agent,
|
|
449
|
+
) {
|
|
450
|
+
for (let at = dir; ; at = dirname(at)) {
|
|
451
|
+
for (const [file, manager] of LOCKFILES) {
|
|
452
|
+
if (existsSync(join(at, file))) return manager;
|
|
453
|
+
}
|
|
454
|
+
if (dirname(at) === at) break;
|
|
455
|
+
}
|
|
456
|
+
return /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The command that adds `packages` as dev dependencies, as argv. */
|
|
460
|
+
export function installCommand(manager, packages) {
|
|
461
|
+
switch (manager) {
|
|
462
|
+
case 'pnpm':
|
|
463
|
+
return ['pnpm', 'add', '--save-dev', ...packages];
|
|
464
|
+
case 'yarn':
|
|
465
|
+
return ['yarn', 'add', '--dev', ...packages];
|
|
466
|
+
case 'bun':
|
|
467
|
+
return ['bun', 'add', '--dev', ...packages];
|
|
468
|
+
default:
|
|
469
|
+
return ['npm', 'install', '--save-dev', ...packages];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `argv` as one line a POSIX shell (or cmd.exe) reads back the same. */
|
|
474
|
+
function shellLine(argv) {
|
|
475
|
+
const quote = (arg) =>
|
|
476
|
+
/^[\w@./:=-]+$/.test(arg)
|
|
477
|
+
? arg
|
|
478
|
+
: process.platform === 'win32'
|
|
479
|
+
? `"${arg}"`
|
|
480
|
+
: `'${arg}'`;
|
|
481
|
+
return argv.map(quote).join(' ');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Make sure the hot-reload toolchain resolves from this copy of react-x11,
|
|
486
|
+
* which is where the loader imports it from. `'ready'` when it does,
|
|
487
|
+
* `'installed'` when it was just installed (the caller restarts: a package
|
|
488
|
+
* manager may have moved react-x11 itself to satisfy the new peers), and a
|
|
489
|
+
* DevError carrying the command when it is missing and was not installed.
|
|
490
|
+
*
|
|
491
|
+
* Every way out of this is a seam for the tests: `resolveSpecifier`,
|
|
492
|
+
* `interactive`, `ask` and `run`.
|
|
493
|
+
*/
|
|
494
|
+
export async function ensureToolchain({
|
|
495
|
+
projectDir,
|
|
496
|
+
log,
|
|
497
|
+
resolveSpecifier = (specifier) => import.meta.resolve(specifier),
|
|
498
|
+
interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
499
|
+
ask = askOnTerminal,
|
|
500
|
+
run = (argv, cwd) =>
|
|
501
|
+
spawnSync(argv[0], argv.slice(1), {
|
|
502
|
+
cwd,
|
|
503
|
+
stdio: 'inherit',
|
|
504
|
+
// npm and friends are .cmd scripts there, which only a shell runs
|
|
505
|
+
shell: process.platform === 'win32',
|
|
506
|
+
}).status,
|
|
507
|
+
}) {
|
|
508
|
+
const { TOOLCHAIN } = await import('../refresh/loader.js');
|
|
509
|
+
const missing = TOOLCHAIN.filter(({ specifier }) => {
|
|
510
|
+
try {
|
|
511
|
+
resolveSpecifier(specifier);
|
|
512
|
+
return false;
|
|
513
|
+
} catch {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
if (missing.length === 0) return 'ready';
|
|
518
|
+
|
|
519
|
+
// The versions the loader is written against, from react-x11's own
|
|
520
|
+
// peerDependencies: an unpinned install takes whatever major is newest.
|
|
521
|
+
const packages = missing.map(
|
|
522
|
+
({ name }) => `${name}@${OWN_PACKAGE.peerDependencies[name]}`,
|
|
523
|
+
);
|
|
524
|
+
const argv = installCommand(packageManagerFor(projectDir), packages);
|
|
525
|
+
const line = shellLine(argv);
|
|
526
|
+
const what =
|
|
527
|
+
`hot reload needs ${missing.length === 1 ? 'a package' : 'packages'} ` +
|
|
528
|
+
`this project does not have: ${missing.map((m) => m.name).join(', ')}. ` +
|
|
529
|
+
'They are optional peer dependencies of react-x11, so an app that never ' +
|
|
530
|
+
'hot-reloads does not ship them to its users.';
|
|
531
|
+
if (!interactive) {
|
|
532
|
+
throw new DevError(
|
|
533
|
+
`${what} Install them as dev dependencies:\n\n ${line}\n`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
log(what);
|
|
537
|
+
const answer = (await ask(`Run \`${line}\` in ${projectDir}? [Y/n] `))
|
|
538
|
+
.trim()
|
|
539
|
+
.toLowerCase();
|
|
540
|
+
if (answer !== '' && answer !== 'y' && answer !== 'yes') {
|
|
541
|
+
throw new DevError(
|
|
542
|
+
`not installed. To install them yourself:\n\n ${line}\n`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const status = run(argv, projectDir);
|
|
546
|
+
if (status !== 0) {
|
|
547
|
+
throw new DevError(
|
|
548
|
+
`\`${line}\` failed${status == null ? '' : ` (exit ${status})`}; its ` +
|
|
549
|
+
'output is above.',
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return 'installed';
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function askOnTerminal(question) {
|
|
556
|
+
const terminal = createInterface({
|
|
557
|
+
input: process.stdin,
|
|
558
|
+
output: process.stderr,
|
|
559
|
+
});
|
|
560
|
+
try {
|
|
561
|
+
return await terminal.question(question);
|
|
562
|
+
} finally {
|
|
563
|
+
terminal.close();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Start this command again, once the toolchain is installed: the new process
|
|
569
|
+
* resolves the packages, and react-x11 itself, from scratch.
|
|
570
|
+
*/
|
|
571
|
+
function restart() {
|
|
572
|
+
const { status, signal } = spawnSync(
|
|
573
|
+
process.execPath,
|
|
574
|
+
[...process.execArgv, ...process.argv.slice(1)],
|
|
575
|
+
{ stdio: 'inherit' },
|
|
576
|
+
);
|
|
577
|
+
process.exitCode = status ?? (signal ? 1 : 0);
|
|
578
|
+
}
|