ghostterm 2.3.0 → 2.3.1
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 +1 -1
- package/bin/ghostterm-p2p.js +312 -288
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ ghostterm
|
|
|
68
68
|
|
|
69
69
|
1. First time: browser opens for Google sign-in (one-time, remembered after)
|
|
70
70
|
2. Open **[ghostterm.pages.dev](https://ghostterm.pages.dev)** on your phone
|
|
71
|
-
3. Sign in with the same Google account → **auto-connects
|
|
71
|
+
3. Sign in with the same Google account → **auto-connects** or enter the 10-digit pairing code
|
|
72
72
|
|
|
73
73
|
> Don't want to install? Use `npx ghostterm` to run without installing.
|
|
74
74
|
|
package/bin/ghostterm-p2p.js
CHANGED
|
@@ -1,288 +1,312 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// Prevent crash from node-pty conpty_console_list_agent on Windows background processes
|
|
5
|
-
process.on('uncaughtException', (err) => {
|
|
6
|
-
if (err.message && err.message.includes('AttachConsole')) {
|
|
7
|
-
// Ignore — this is a known node-pty issue in background mode
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
console.error('Uncaught error:', err.message);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
const WebSocket = require('ws');
|
|
14
|
-
const
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
--
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
let
|
|
61
|
-
let
|
|
62
|
-
let
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
ws
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
break;
|
|
151
|
-
|
|
152
|
-
case '
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
webrtc.on('
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (reconnectTimer)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Prevent crash from node-pty conpty_console_list_agent on Windows background processes
|
|
5
|
+
process.on('uncaughtException', (err) => {
|
|
6
|
+
if (err.message && err.message.includes('AttachConsole')) {
|
|
7
|
+
// Ignore — this is a known node-pty issue in background mode
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
console.error('Uncaught error:', err.message);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const WebSocket = require('ws');
|
|
14
|
+
const qrcode = require('qrcode-terminal');
|
|
15
|
+
const { CompanionWebRTC } = require('../lib/webrtc-peer');
|
|
16
|
+
const { PtyManager } = require('../lib/pty-manager');
|
|
17
|
+
const { getToken, saveToken } = require('../lib/auth');
|
|
18
|
+
|
|
19
|
+
// --- Configuration ---
|
|
20
|
+
const DEFAULT_SIGNALING = 'wss://ghostterm-p2p-signal.fly.dev';
|
|
21
|
+
const SITE_URL = 'https://ghostterm.pages.dev';
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const signalingUrl = getArg('--signal', '-s') || DEFAULT_SIGNALING;
|
|
25
|
+
const siteUrl = getArg('--site') || SITE_URL;
|
|
26
|
+
const helpRequested = args.includes('--help') || args.includes('-h');
|
|
27
|
+
|
|
28
|
+
function getArg(long, short) {
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
if (args[i] === long || (short && args[i] === short)) {
|
|
31
|
+
return args[i + 1];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (helpRequested) {
|
|
38
|
+
console.log(`
|
|
39
|
+
GhostTerm P2P - Control your terminal from your phone via WebRTC
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
npx ghostterm-p2p [options]
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
-s, --signal <url> Signaling server URL (default: ${DEFAULT_SIGNALING})
|
|
46
|
+
--site <url> Mobile site URL (default: ${SITE_URL})
|
|
47
|
+
-h, --help Show this help
|
|
48
|
+
|
|
49
|
+
How it works:
|
|
50
|
+
1. This tool connects to the signaling server and gets a 6-digit pair code
|
|
51
|
+
2. Open the mobile site on your phone and enter the code
|
|
52
|
+
3. A direct P2P WebRTC connection is established
|
|
53
|
+
4. All terminal data flows directly between your phone and PC (encrypted)
|
|
54
|
+
5. The signaling server never sees your terminal data
|
|
55
|
+
`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- State ---
|
|
60
|
+
let ws = null;
|
|
61
|
+
let webrtc = null;
|
|
62
|
+
let ptyManager = null;
|
|
63
|
+
let pairCode = null;
|
|
64
|
+
let reconnecting = false;
|
|
65
|
+
let authToken = null; // Google or long token
|
|
66
|
+
|
|
67
|
+
// --- Main ---
|
|
68
|
+
|
|
69
|
+
async function start() {
|
|
70
|
+
console.log('\n GhostTerm P2P');
|
|
71
|
+
console.log(' =============');
|
|
72
|
+
|
|
73
|
+
// Try to get auth token (optional — won't block if no login)
|
|
74
|
+
try {
|
|
75
|
+
authToken = await getToken();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.log(' Skipping login (anonymous mode)');
|
|
78
|
+
authToken = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(' Connecting to signaling server...');
|
|
82
|
+
connectSignaling();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function connectSignaling() {
|
|
86
|
+
const url = `${signalingUrl}?role=companion`;
|
|
87
|
+
ws = new WebSocket(url);
|
|
88
|
+
|
|
89
|
+
ws.on('open', () => {
|
|
90
|
+
console.log(' Connected to signaling server.');
|
|
91
|
+
reconnecting = false;
|
|
92
|
+
resetReconnectDelay();
|
|
93
|
+
// Send auth token if available
|
|
94
|
+
if (authToken) {
|
|
95
|
+
ws.send(JSON.stringify({ type: 'auth', token: authToken }));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
ws.on('message', (raw) => {
|
|
100
|
+
let msg;
|
|
101
|
+
try {
|
|
102
|
+
msg = JSON.parse(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
handleSignalingMessage(msg);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
ws.on('close', () => {
|
|
110
|
+
if (!reconnecting) {
|
|
111
|
+
console.log('\n Signaling server disconnected.');
|
|
112
|
+
}
|
|
113
|
+
scheduleReconnect();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
ws.on('error', (err) => {
|
|
117
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
|
|
118
|
+
console.error(` Cannot reach signaling server at ${signalingUrl}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleSignalingMessage(msg) {
|
|
124
|
+
switch (msg.type) {
|
|
125
|
+
case 'pair_code':
|
|
126
|
+
pairCode = msg.code;
|
|
127
|
+
displayPairCode(msg.code);
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
case 'mobile_connected':
|
|
131
|
+
console.log('\n Mobile device paired! Establishing P2P connection...');
|
|
132
|
+
initiateWebRTC();
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case 'webrtc_answer':
|
|
136
|
+
case 'webrtc_ice':
|
|
137
|
+
if (webrtc) {
|
|
138
|
+
webrtc.handleSignaling(msg);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
case 'mobile_disconnected':
|
|
143
|
+
// Ignore if P2P is active — mobile closed signaling WS after P2P established (expected)
|
|
144
|
+
if (webrtc && webrtc.isActive) {
|
|
145
|
+
// P2P still alive, signaling disconnect is normal
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
console.log('\n Mobile disconnected.');
|
|
149
|
+
cleanupWebRTC();
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case 'pair_expired':
|
|
153
|
+
console.log('\n Pair code expired. Reconnecting for a new code...');
|
|
154
|
+
ws.close();
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case 'auth_ok':
|
|
158
|
+
console.log(` Authenticated as: ${msg.email}`);
|
|
159
|
+
// Save long token for future sessions
|
|
160
|
+
if (msg.longToken) {
|
|
161
|
+
saveToken(null, msg.longToken);
|
|
162
|
+
authToken = msg.longToken;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case 'auth_error':
|
|
167
|
+
console.error(` Auth failed: ${msg.message}`);
|
|
168
|
+
break;
|
|
169
|
+
|
|
170
|
+
case 'error':
|
|
171
|
+
console.error(` Server error: ${msg.message}`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function displayPairCode(code) {
|
|
177
|
+
const mobileUrl = `${siteUrl}?code=${code}`;
|
|
178
|
+
|
|
179
|
+
console.log('\n +--------------------------+');
|
|
180
|
+
console.log(` | Pair Code: ${code} |`);
|
|
181
|
+
console.log(' +--------------------------+');
|
|
182
|
+
console.log(`\n Or scan QR code with your phone:\n`);
|
|
183
|
+
|
|
184
|
+
qrcode.generate(mobileUrl, { small: true }, (qr) => {
|
|
185
|
+
// Indent each line
|
|
186
|
+
const lines = qr.split('\n').map(l => ' ' + l).join('\n');
|
|
187
|
+
console.log(lines);
|
|
188
|
+
console.log(`\n Mobile URL: ${mobileUrl}`);
|
|
189
|
+
console.log('\n Waiting for phone to connect...\n');
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function initiateWebRTC() {
|
|
194
|
+
cleanupWebRTC();
|
|
195
|
+
|
|
196
|
+
webrtc = new CompanionWebRTC();
|
|
197
|
+
ptyManager = new PtyManager();
|
|
198
|
+
|
|
199
|
+
// Wire PTY output to WebRTC
|
|
200
|
+
ptyManager.on('send', (msg) => {
|
|
201
|
+
if (webrtc && webrtc.isActive) {
|
|
202
|
+
webrtc.send(msg);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Wire WebRTC messages to PTY
|
|
207
|
+
webrtc.on('message', (msg) => {
|
|
208
|
+
if (msg.type === 'shutdown') {
|
|
209
|
+
console.log('\n Shutdown requested from mobile. Bye!');
|
|
210
|
+
cleanupWebRTC();
|
|
211
|
+
if (ws) ws.close();
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
if (msg.type === 'resize') {
|
|
215
|
+
console.log(` [resize] cols=${msg.cols} rows=${msg.rows} sid=${msg.sessionId || 'attached'}`);
|
|
216
|
+
}
|
|
217
|
+
if (ptyManager) {
|
|
218
|
+
const responses = ptyManager.handleMessage(msg);
|
|
219
|
+
for (const resp of responses) {
|
|
220
|
+
webrtc.send(resp);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
webrtc.on('open', () => {
|
|
226
|
+
console.log(' P2P connection established! Terminal is now accessible from your phone.');
|
|
227
|
+
console.log(' All data flows directly between your devices (encrypted).');
|
|
228
|
+
console.log(' Press Ctrl+C to stop.\n');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
webrtc.on('close', () => {
|
|
232
|
+
console.log('\n P2P connection closed.');
|
|
233
|
+
cleanupPty();
|
|
234
|
+
scheduleReconnect();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
webrtc.on('timeout', () => {
|
|
238
|
+
console.log('\n P2P heartbeat timeout. Connection lost.');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
webrtc.on('error', (err) => {
|
|
242
|
+
console.error(` WebRTC error: ${err}`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Initiate the connection (sends offer via signaling)
|
|
246
|
+
webrtc.initiate((msg) => {
|
|
247
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
248
|
+
ws.send(JSON.stringify(msg));
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function cleanupWebRTC() {
|
|
254
|
+
if (webrtc) {
|
|
255
|
+
webrtc.removeAllListeners();
|
|
256
|
+
webrtc.close();
|
|
257
|
+
webrtc = null;
|
|
258
|
+
}
|
|
259
|
+
cleanupPty();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanupPty() {
|
|
263
|
+
if (ptyManager) {
|
|
264
|
+
ptyManager.removeAllListeners();
|
|
265
|
+
ptyManager.destroy();
|
|
266
|
+
ptyManager = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let reconnectTimer = null;
|
|
271
|
+
let reconnectDelay = 1000; // start at 1s
|
|
272
|
+
const RECONNECT_MAX = 30000; // cap at 30s
|
|
273
|
+
|
|
274
|
+
function scheduleReconnect() {
|
|
275
|
+
if (reconnectTimer) return;
|
|
276
|
+
reconnecting = true;
|
|
277
|
+
const delay = reconnectDelay;
|
|
278
|
+
console.log(` Reconnecting in ${(delay / 1000).toFixed(0)}s...`);
|
|
279
|
+
reconnectTimer = setTimeout(() => {
|
|
280
|
+
reconnectTimer = null;
|
|
281
|
+
connectSignaling();
|
|
282
|
+
}, delay);
|
|
283
|
+
// Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped)
|
|
284
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function resetReconnectDelay() {
|
|
288
|
+
reconnectDelay = 1000;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Graceful shutdown ---
|
|
292
|
+
|
|
293
|
+
process.on('SIGINT', () => {
|
|
294
|
+
console.log('\n Shutting down...');
|
|
295
|
+
cleanupWebRTC();
|
|
296
|
+
if (ws) {
|
|
297
|
+
ws.close();
|
|
298
|
+
}
|
|
299
|
+
if (reconnectTimer) {
|
|
300
|
+
clearTimeout(reconnectTimer);
|
|
301
|
+
}
|
|
302
|
+
process.exit(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
process.on('SIGTERM', () => {
|
|
306
|
+
cleanupWebRTC();
|
|
307
|
+
if (ws) ws.close();
|
|
308
|
+
process.exit(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// --- Go ---
|
|
312
|
+
start();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghostterm",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Control your PC terminal from your phone — direct P2P, no server in between",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ghostterm": "bin/ghostterm-p2p.js"
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"node-datachannel": "^0.12.0",
|
|
16
16
|
"node-pty": "^1.0.0",
|
|
17
|
-
"ws": "^8.0.0"
|
|
17
|
+
"ws": "^8.0.0",
|
|
18
|
+
"qrcode-terminal": "^0.12.0"
|
|
18
19
|
},
|
|
19
20
|
"engines": {
|
|
20
21
|
"node": ">=18.0.0"
|