dsh-remote-panel 0.3.0 → 0.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 CHANGED
@@ -64,6 +64,22 @@ Caddy 的 `pki/authorities/local/root.crt`)。
64
64
  - Android:设置 → 安全 → 加密与凭据 → 安装证书 → CA 证书。
65
65
  - iOS:下载后到 设置 → 通用 → 关于本机 → 证书信任设置 里打开信任。
66
66
 
67
+ ## 安全模型:令牌只在门禁之后
68
+
69
+ 手机端有两道门:Caddy 的口令门禁(`dsh_gate` Cookie)+ DSH 自己的会话令牌。两者的取用顺序是:
70
+
71
+ ```
72
+ 扫码/点链接 → /pair?k=口令(种门禁 Cookie) → /remote-panel/login(要 Cookie,Caddy 直接发)
73
+ → /?token=…(换取 DSH 会话)
74
+ ```
75
+
76
+ - 二维码里**只有口令**,没有令牌;
77
+ - 带令牌的跳转页 `login.html` 放在门禁**之后**,没有 Cookie 拿不到(实测 401);
78
+ - 免鉴权的 `/remote-panel/app/*` 只放静态资源(manifest / 图标 / 根证书 / APK),不含任何凭据;
79
+ - 旧版 APK 的入口 `go.html` 已改成「请更新」提示页,不会再泄露令牌。
80
+
81
+ APK 里内置了门禁口令(启动即过 `/pair`),所以**换口令后需要重新构建壳**。
82
+
67
83
  ## 手机 App(WebView 壳)
68
84
 
69
85
  PWA「安装应用」在国行 Android 上装不成:WebAPK 打包依赖 Google Play 服务,且打包服务器要能从公网
@@ -25,7 +25,7 @@
25
25
  handle /pair {
26
26
  header Set-Cookie "dsh_gate=@@ACCESS_KEY@@; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax"
27
27
  header Content-Type "text/html; charset=utf-8"
28
- respond `<!doctype html><meta charset="utf-8"><title>正在连接…</title><script>const t=new URLSearchParams(location.search).get("token");location.replace(t?"/?token="+encodeURIComponent(t):"/")</script>` 200
28
+ respond `<!doctype html><meta charset="utf-8"><title>正在连接…</title><script>const t=new URLSearchParams(location.search).get("token");location.replace(t?"/?token="+encodeURIComponent(t):"/remote-panel/login")</script>` 200
29
29
  }
30
30
 
31
31
  # 诊断用:不经过任何认证
@@ -62,6 +62,12 @@
62
62
  # Caddy 默认没有 cookie 匹配器,用 Header 通配匹配替代
63
63
  @paired header Cookie "*dsh_gate=@@ACCESS_KEY@@*"
64
64
  handle @paired {
65
+ # 带令牌的跳转页:必须过了门禁才能拿
66
+ handle /remote-panel/login* {
67
+ root * @@APP_DIR@@
68
+ rewrite * /login.html
69
+ file_server
70
+ }
65
71
  # 关键:不重写 Host/Origin。DSH 通过 --trusted-host 接受这个 authority,
66
72
  # Cookie 因此天然同源,不需要任何注入或伪造。
67
73
  reverse_proxy 127.0.0.1:@@UPSTREAM_PORT@@
package/lib/client.js CHANGED
@@ -545,11 +545,14 @@ window.__ModuleLoader__.load({
545
545
  const [probe, setProbe] = useState(null)
546
546
  const [rejects, setRejects] = useState([])
547
547
  const [busy, setBusy] = useState(false)
548
+ const [shell, setShell] = useState(null)
548
549
 
549
550
  const refresh = useCallback(async () => {
550
551
  setBusy(true)
551
552
  try {
552
- setProbe((await callApi('/probe')).probe)
553
+ const [probed, status] = await Promise.all([callApi('/probe'), callApi('/status')])
554
+ setProbe(probed.probe)
555
+ setShell(status.shell ?? null)
553
556
  setRejects((await callApi('/rejects')).entries)
554
557
  } catch {
555
558
  /* 状态探测失败时保留上一帧 */
@@ -572,6 +575,8 @@ window.__ModuleLoader__.load({
572
575
  // 内部 CA 的叶子证书只有 12 小时,续签失败时探活照样握手成功(rejectUnauthorized:false),
573
576
  // 所以单独看它:剩余不足 2 小时或 SAN 不匹配都算红灯
574
577
  [certHours === null ? '证书' : `证书 ${certHours}h`, cert === undefined ? undefined : { ok: cert.ok === true }],
578
+ // 手机外壳依赖 DSH 的 hash 类名:DSH 升级后这里会红,提示需要更新插件
579
+ ['外壳适配', shell === null ? undefined : { ok: shell.ok === true }],
575
580
  ]
576
581
 
577
582
  return jsxs(Card, {
@@ -591,6 +596,12 @@ window.__ModuleLoader__.load({
591
596
  }),
592
597
  ],
593
598
  }),
599
+ shell !== null && shell.ok !== true
600
+ ? jsx('p', {
601
+ style: { ...styles.hint, color: '#f2b8b5' },
602
+ children: `外壳挂点缺失:${(shell.missing ?? []).join(', ') || '未知'} —— DSH 升级后需要更新插件,否则手机样式会错位`,
603
+ })
604
+ : null,
594
605
  cert !== undefined && cert.ok !== true
595
606
  ? jsx('p', {
596
607
  style: { ...styles.hint, color: '#f2b8b5' },
@@ -1068,7 +1079,7 @@ window.__ModuleLoader__.load({
1068
1079
  inset: 0 auto 0 0;
1069
1080
  height: auto !important;
1070
1081
  z-index: 900;
1071
- padding-bottom: env(safe-area-inset-bottom, 0px);
1082
+ padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px));
1072
1083
  border-radius: 0 16px 16px 0 !important;
1073
1084
  box-shadow: 0 0 28px rgba(0, 0, 0, .32) !important;
1074
1085
  }
@@ -1076,7 +1087,10 @@ window.__ModuleLoader__.load({
1076
1087
  body { overscroll-behavior: none; }
1077
1088
 
1078
1089
  /* 顶部安全区:默认取 env();WebView 里由壳把真实切角高度写进 --dsh-safe-top */
1079
- :root { --dsh-safe-top: env(safe-area-inset-top, 0px); }
1090
+ :root {
1091
+ --dsh-safe-top: env(safe-area-inset-top, 0px);
1092
+ --dsh-safe-bottom: env(safe-area-inset-bottom, 0px);
1093
+ }
1080
1094
 
1081
1095
  /* 顶栏:52px;刘海屏/独立窗口把状态栏让出来 */
1082
1096
  .wSkVaW_header {
@@ -1156,11 +1170,16 @@ window.__ModuleLoader__.load({
1156
1170
  .pXSMma_previewBadge { display: none !important; }
1157
1171
  /* 原生把「问候语 + 输入条」一起居中;手机按原型拆开:
1158
1172
  问候语留在屏幕中间(原生居中),输入条钉到底部 */
1173
+ /* 会话里的输入条座位:底部要让开手势条(WebView 里 env() 常为 0,由壳注入) */
1174
+ .wSkVaW_composerSeat {
1175
+ padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)) !important;
1176
+ }
1177
+
1159
1178
  .wSkVaW_composerHero .uV2eYG_root {
1160
1179
  position: fixed;
1161
1180
  left: 0;
1162
1181
  right: 0;
1163
- bottom: max(18px, env(safe-area-inset-bottom, 0px));
1182
+ bottom: max(18px, max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)));
1164
1183
  z-index: 55;
1165
1184
  }
1166
1185
 
@@ -1436,7 +1455,7 @@ window.__ModuleLoader__.load({
1436
1455
  overflow-y: auto;
1437
1456
  -webkit-overflow-scrolling: touch;
1438
1457
  overscroll-behavior: contain;
1439
- padding-bottom: env(safe-area-inset-bottom, 0px) !important;
1458
+ padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)) !important;
1440
1459
  }
1441
1460
 
1442
1461
  /* 长会话滚动:只在窄屏做滚动容器层面的优化,桌面保持原样 */
@@ -1678,6 +1697,32 @@ window.__ModuleLoader__.load({
1678
1697
  }
1679
1698
  }
1680
1699
 
1700
+ /**
1701
+ * 外壳挂点自检:DSH 升级后这些 hash 类名可能全变,样式就整体失配。
1702
+ * 启动后检查一遍 DOM,把结果报给宿主,面板里能直接看到「外壳适配」这盏灯。
1703
+ */
1704
+ function installShellContractReport() {
1705
+ if (typeof document === 'undefined' || typeof window === 'undefined') return () => {}
1706
+ // 只查「任何页面都该存在」的挂点;抽屉里的 hHd-Xa_ 只在打开时才有,不参与判定
1707
+ const markers = ['wSkVaW_', 'uV2eYG_', 'pXSMma_', 'pI_x6G_']
1708
+ let timer = window.setTimeout(async () => {
1709
+ try {
1710
+ const missing = markers.filter((marker) => document.querySelector(`[class*="${marker}"]`) === null)
1711
+ if (missing.length > 0) {
1712
+ console.warn('[remote-panel] 外壳挂点缺失,手机样式可能失配:', missing.join(', '))
1713
+ }
1714
+ await callApi('/shell', {
1715
+ method: 'POST',
1716
+ headers: { 'content-type': 'application/json' },
1717
+ body: JSON.stringify({ missing }),
1718
+ })
1719
+ } catch {
1720
+ /* 上报失败不影响使用 */
1721
+ }
1722
+ }, 1500)
1723
+ return () => window.clearTimeout(timer)
1724
+ }
1725
+
1681
1726
  /**
1682
1727
  * 本机反馈:任务跑完 / 等你确认时,如果页面在后台,闪一下标题 + 震动。
1683
1728
  * 不依赖推送服务(v1 不做推送),也不需要服务端配合。
@@ -2225,6 +2270,7 @@ window.__ModuleLoader__.load({
2225
2270
  const cleanupNet = installConnectivity()
2226
2271
  const cleanupFocus = installComposerFocusGuard()
2227
2272
  const cleanupFps = installFpsMeter()
2273
+ const cleanupContract = installShellContractReport()
2228
2274
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanupClear, 'remote-panel: workspace clear')
2229
2275
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanupBack, 'remote-panel: back gesture')
2230
2276
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanupAttention, 'remote-panel: attention')
@@ -2232,6 +2278,7 @@ window.__ModuleLoader__.load({
2232
2278
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanupNet, 'remote-panel: connectivity')
2233
2279
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanupFocus, 'remote-panel: composer focus guard')
2234
2280
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanupFps, 'remote-panel: fps meter')
2281
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanupContract, 'remote-panel: shell contract report')
2235
2282
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanup, 'remote-panel: mobile shell css')
2236
2283
 
2237
2284
  ctx.slots.inject('shell.overlay', () =>
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 外壳契约检查:确认 DSH 前端还在用我们依赖的那套 hash 类名。
3
+ *
4
+ * 手机外壳的样式全靠 DSH 构建产物里的私有类名(`wSkVaW_header` 这种),
5
+ * 它们随每次构建变化。DSH 一升级,样式可能整体失配——所以在宿主侧做一次
6
+ * 「挂点还在不在」的体检,失配时面板显红,而不是等用户发现界面错位。
7
+ *
8
+ * 只读:拉一次 index.html 和它引用的 CSS,做纯字符串匹配。
9
+ */
10
+
11
+ import { request } from 'node:http'
12
+
13
+ /** 外壳依赖的类名前缀(对应 lib/client.js 里的选择器)。 */
14
+ export const SHELL_MARKERS = ['wSkVaW_', 'hHd-Xa_', 'uV2eYG_', 'pXSMma_', 'pI_x6G_']
15
+
16
+ /**
17
+ * @param {{ port: number, token: string, host?: string, timeoutMs?: number }} options
18
+ * @returns {Promise<{ ok: boolean, missing: string[], asset?: string, reason?: string, checkedAt: number }>}
19
+ */
20
+ export async function checkShellContract({ port, token, host = '127.0.0.1', timeoutMs = 4000 }) {
21
+ const checkedAt = Date.now()
22
+ const base = `http://${host}:${port}`
23
+ // DSH 的令牌是「先 303 种 Cookie,再访问首页」两步走,这里手工跟一次
24
+ const login = await requestText(`${base}/?token=${encodeURIComponent(token)}`, { timeoutMs })
25
+ const cookie = login.cookie
26
+ if (cookie === undefined) return { ok: false, missing: [...SHELL_MARKERS], reason: 'login-failed', checkedAt }
27
+ const html = await requestText(`${base}/`, { timeoutMs, cookie })
28
+ if (html.body === undefined) return { ok: false, missing: [...SHELL_MARKERS], reason: 'index-unreachable', checkedAt }
29
+ // 页面通常引多个 CSS(vendor + 业务),挂点类名在业务那个里,所以全取回来一起看
30
+ const assets = [...html.body.matchAll(/href="\.?\/?(assets\/[^"']+\.css)"/g)].map((m) => m[1])
31
+ if (assets.length === 0) return { ok: false, missing: [...SHELL_MARKERS], reason: 'no-css-asset', checkedAt }
32
+ let css = ''
33
+ let matched
34
+ for (const asset of assets) {
35
+ const body = await requestText(`${base}/${asset}`, { timeoutMs, cookie })
36
+ if (body.body === undefined) continue
37
+ css += body.body
38
+ if (matched === undefined && SHELL_MARKERS.some((marker) => body.body.includes(marker))) matched = asset
39
+ }
40
+ if (css === '') return { ok: false, missing: [...SHELL_MARKERS], reason: 'css-unreachable', checkedAt }
41
+ const missing = SHELL_MARKERS.filter((marker) => !css.includes(marker))
42
+ return { ok: missing.length === 0, missing, asset: matched ?? assets[0], assets, checkedAt }
43
+ }
44
+
45
+ /**
46
+ * 简单 GET:返回 { body, cookie },超时/失败给 undefined。
47
+ * 体检不该把状态接口带崩,所以这里不抛异常。
48
+ */
49
+ function requestText(url, { timeoutMs, cookie } = {}) {
50
+ return new Promise((resolve) => {
51
+ const headers = cookie === undefined ? {} : { cookie }
52
+ const req = request(url, { method: 'GET', headers, timeout: timeoutMs }, (res) => {
53
+ const jar = (res.headers['set-cookie'] ?? []).map((entry) => entry.split(';')[0]).join('; ')
54
+ if ((res.statusCode ?? 500) >= 400) {
55
+ res.resume()
56
+ resolve({ cookie: jar === '' ? undefined : jar })
57
+ return
58
+ }
59
+ let body = ''
60
+ res.setEncoding('utf8')
61
+ res.on('data', (chunk) => {
62
+ body += chunk
63
+ })
64
+ res.on('end', () => resolve({ body, cookie: jar === '' ? undefined : jar }))
65
+ })
66
+ req.on('timeout', () => {
67
+ req.destroy()
68
+ resolve({})
69
+ })
70
+ req.on('error', () => resolve({}))
71
+ req.end()
72
+ })
73
+ }
package/lib/index.js CHANGED
@@ -110,6 +110,7 @@ export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app'))
110
110
  /* 缺某个尺寸不算致命 */
111
111
  }
112
112
  }
113
+ writeLandingPage(dir)
113
114
  const ca = localCaPath()
114
115
  if (ca !== undefined) {
115
116
  try {
@@ -131,23 +132,52 @@ export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app'))
131
132
  * @param {string|undefined} token 当前进程的 DSH 令牌
132
133
  * @param {string|undefined} accessKey 门禁口令
133
134
  */
134
- export function writeGoPage(dir, token, accessKey) {
135
- const target =
136
- token === undefined || !accessKey
137
- ? '/'
138
- : `/pair?k=${encodeURIComponent(accessKey)}&token=${encodeURIComponent(token)}`
135
+ /**
136
+ * 写 `/remote-panel/login` 背后的跳转页(由 Caddy 放在门禁之后发送)。
137
+ *
138
+ * 之前这个页面放在免鉴权的 `/remote-panel/app/go.html` 里,等于把当前进程的令牌
139
+ * 暴露给网内任何人——口令门禁被绕过。现在令牌只在通过门禁之后才能拿到:
140
+ *
141
+ * 扫码 / 点链接 → /pair?k=口令(种 Cookie) → /remote-panel/login(要 Cookie) → /?token=
142
+ *
143
+ * @param {string} dir 资源目录
144
+ * @param {string|undefined} token 当前进程的 DSH 令牌
145
+ * @returns {string} 跳转目标
146
+ */
147
+ export function writeLoginPage(dir, token) {
148
+ const target = token === undefined ? '/' : `/?token=${encodeURIComponent(token)}`
139
149
  const html =
140
150
  '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
141
151
  `<title>DSH</title><meta http-equiv="refresh" content="0;url=${target}">` +
142
152
  '<style>html,body{height:100%;margin:0;background:#000;color:#888;font:14px system-ui;display:flex;align-items:center;justify-content:center}</style>' +
143
153
  '<p>正在进入 DSH…</p>' +
144
- // ?fps=1 时把开关记进 sessionStorage,跳转后由客户端插件读取(用于真机性能排查)
154
+ // ?fps=1 时把开关记进 sessionStorage,跳转后由客户端插件读取(真机性能排查用)
145
155
  '<script>try{if(/[?&]fps=1/.test(location.search))sessionStorage.setItem("dsh-fps","1")}catch(e){}' +
146
156
  `location.replace(${JSON.stringify(target)})</script>`
147
- writeFileSync(join(dir, 'go.html'), html)
157
+ writeFileSync(join(dir, 'login.html'), html)
148
158
  return target
149
159
  }
150
160
 
161
+ /**
162
+ * 旧版手机壳的入口页:不再带任何凭据,只提示升级。
163
+ *
164
+ * v0.2.x 的 APK 启动地址是这里;那些版本会把令牌写在本页里(安全缺陷),
165
+ * 所以直接停用,让页面告诉用户装新版壳。
166
+ */
167
+ export function writeLandingPage(dir) {
168
+ const html =
169
+ '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
170
+ '<title>DSH</title>' +
171
+ '<style>html,body{height:100%;margin:0;background:#000;color:#ddd;font:14px system-ui;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center}</style>' +
172
+ '<div><p style="font-size:16px;font-weight:600;margin:0 0 8px">手机壳需要更新</p>' +
173
+ '<p style="color:#888;margin:0">旧版入口已停用(安全升级)。<br>请在浏览器打开面板重新下载 DSH 壳。</p></div>'
174
+ try {
175
+ writeFileSync(join(dir, 'go.html'), html)
176
+ } catch {
177
+ /* 目录不可写就算了 */
178
+ }
179
+ }
180
+
151
181
  /**
152
182
  * 注入 index 的 head 行:主题色 + iOS 独立窗口 + 桌面图标 + 我们自己的 manifest。
153
183
  *
@@ -339,7 +369,7 @@ routes.set('GET /pair', async ({ req }) => {
339
369
  const scheme = requestScheme(req) === 'https' || detectTls() ? 'https' : 'http'
340
370
  try {
341
371
  const dir = join(homedir(), '.dsh', 'remote-panel-app')
342
- if (existsSync(dir)) writeGoPage(dir, token, state.accessKey)
372
+ if (existsSync(dir)) writeLoginPage(dir, token)
343
373
  } catch {
344
374
  /* 目录还没建好就算了 */
345
375
  }
@@ -455,6 +485,17 @@ routes.set('GET /probe', async ({ probe: runProbe = probe }) => {
455
485
  return { ok: true, state, probe: await runProbe(state, { tls: detectTls() }) }
456
486
  })
457
487
 
488
+ /** 客户端上报的外壳挂点自检结果(内存里保留最近一次)。 */
489
+ let lastShellReport = null
490
+
491
+ routes.set('POST /shell', async ({ req }) => {
492
+ const body = await readJson(req)
493
+ const missing = Array.isArray(body?.missing) ? body.missing.filter((item) => typeof item === 'string') : null
494
+ if (missing === null) throw new BadRequest('missing 必须是字符串数组')
495
+ lastShellReport = { ok: missing.length === 0, missing, checkedAt: Date.now() }
496
+ return { ok: true, ...lastShellReport }
497
+ })
498
+
458
499
  routes.set('GET /status', ({ query }) => {
459
500
  const tlsEnabled = detectTls()
460
501
  return {
@@ -467,6 +508,8 @@ routes.set('GET /status', ({ query }) => {
467
508
  scheme: tlsEnabled ? 'https' : 'http',
468
509
  // 轻量证书信息:只读最近一次 /probe 的缓存,不在 status 里做网络探测
469
510
  tls: tlsStatus(tlsEnabled, lastCertSnapshot()),
511
+ // 手机外壳的挂点自检:客户端启动后上报一次,用来说明「DSH 升级后样式还对不对得上」
512
+ shell: lastShellReport,
470
513
  echo: query.get('echo') ?? null,
471
514
  }
472
515
  })
@@ -525,7 +568,7 @@ export function apply(ctx) {
525
568
  const state = readState()
526
569
  const ports = [Number(state?.port) || 8710, Number(state?.upstream) || 3080]
527
570
  const tokens = await findTokens({ ports })
528
- writeGoPage(appDir, tokens.at(-1), state?.accessKey)
571
+ writeLoginPage(appDir, tokens.at(-1))
529
572
  } catch (error) {
530
573
  console.warn('[remote-panel] write go.html failed', error)
531
574
  }
package/lib/pair.js CHANGED
@@ -98,13 +98,13 @@ export function pairingUrls({ ip, port, accessKey, token, scheme = 'http' }) {
98
98
  return {
99
99
  gate,
100
100
  token: tokenUrl,
101
- // 一张二维码走完两步:先种门禁 Cookie,再由 Caddy 转发到令牌地址
101
+ // 登录入口:受门禁保护,Caddy 直接发一份带当前令牌的跳转页。
102
+ // 二维码只带门禁口令,口令换来的 Cookie 才能打开它——令牌不落在二维码/链接里。
103
+ login: `${base}/remote-panel/login`,
104
+ // 一张二维码走完两步:先种门禁 Cookie,再由 Caddy 转发到登录入口
102
105
  // 走专用路径:Caddy 会回一个同源页面,由它发起跳转——
103
106
  // 跨站导航不会携带 SameSite=Strict 的 DSH Cookie,非要同源页面自己发起才行
104
- combo:
105
- accessKey !== undefined && token !== undefined
106
- ? `${base}/pair?k=${accessKey}&token=${token}`
107
- : undefined,
107
+ combo: accessKey !== undefined ? `${base}/pair?k=${accessKey}` : undefined,
108
108
  }
109
109
  }
110
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-panel",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH 远程访问控制面:部署向导、状态面板与一键配对(EasyTier + Caddy),数据面保持进程外。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,7 +33,8 @@
33
33
  "dsh": ">=0.2.1-rc.3"
34
34
  },
35
35
  "scripts": {
36
- "test": "node --test \"test/*.test.js\""
36
+ "test": "node --test \"test/*.test.js\"",
37
+ "e2e": "node test/e2e/shell.mjs"
37
38
  },
38
39
  "dependencies": {
39
40
  "qrcode": "^1.5.4"