feat: allow to enable secure websocket for server channel (#1014)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
LemonNeko
2026-02-01 00:35:03 +08:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 9b1b2420e7
commit e4add70e13
25 changed files with 375 additions and 25 deletions
+8
View File
@@ -201,6 +201,14 @@ CAPACITOR_DEV_SERVER_URL=https://<your-ip-address>:5273 pnpm open:ios
Then Xcode will open and you can click the "Run" button to run the app on your iPhone.
If you need to connect server channel on pocket in wireless mode, you need to start tamagotchi as root:
```shell
sudo pnpm dev:tamagotchi
```
Then enable secure websocket in tamagotchi `settings/system/general`.
### Documentation Site
```shell
@@ -130,8 +130,14 @@ export default defineConfig({
VueRouter({
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
routesFolder: [
{
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
exclude: base => [
...base,
'**/settings/system/general.vue',
],
},
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
],
exclude: ['**/components/**'],
}),
+1
View File
@@ -97,6 +97,7 @@
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"mediabunny": "^1.29.0",
"mkcert": "catalog:",
"node-vibrant": "^4.0.3",
"nprogress": "^0.2.0",
"onnxruntime-web": "^1.23.2",
+2 -2
View File
@@ -13,7 +13,7 @@ import icon from '../../resources/icon.png?asset'
import { openDebugger, setupDebugger } from './app/debugger'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
import { setElectronMainDirname } from './libs/electron/location'
import { setupServerChannel } from './services/airi/channel-server'
import { setupServerChannelHandlers } from './services/airi/channel-server'
import { setupAutoUpdater } from './services/electron/auto-updater'
import { setupTray } from './tray'
import { setupAboutWindowReusable } from './windows/about'
@@ -72,7 +72,7 @@ initScreenCaptureForMain()
app.whenReady().then(async () => {
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannel())
const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannelHandlers())
const autoUpdater = injeca.provide('services:auto-updater', () => setupAutoUpdater())
const widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager())
const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager())
@@ -1,26 +1,139 @@
import { env } from 'node:process'
import { execSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { env, platform } from 'node:process'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { app, ipcMain } from 'electron'
import { createCA, createCert } from 'mkcert'
import { electronRestartWebSocketServer, electronStartWebSocketServer } from '../../../../shared/eventa'
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
export async function setupServerChannel() {
let serverInstance: { close: (closeActiveConnections?: boolean) => Promise<void> } | null = null
async function installCACertificate(caCert: string) {
const userDataPath = app.getPath('userData')
const caCertPath = join(userDataPath, 'websocket-ca-cert.pem')
writeFileSync(caCertPath, caCert)
try {
if (platform === 'darwin') {
execSync(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${caCertPath}"`, { stdio: 'ignore' })
}
else if (platform === 'win32') {
execSync(`certutil -addstore -f "Root" "${caCertPath}"`, { stdio: 'ignore' })
}
else if (platform === 'linux') {
const caDir = '/usr/local/share/ca-certificates'
const caFileName = 'airi-websocket-ca.crt'
try {
writeFileSync(join(caDir, caFileName), caCert)
execSync('update-ca-certificates', { stdio: 'ignore' })
}
catch {
const userCaDir = join(env.HOME || '', '.local/share/ca-certificates')
try {
if (!existsSync(userCaDir)) {
execSync(`mkdir -p "${userCaDir}"`, { stdio: 'ignore' })
}
writeFileSync(join(userCaDir, caFileName), caCert)
}
catch {
// Ignore errors
}
}
}
}
catch {
// Ignore installation errors
}
}
async function generateCertificate() {
const userDataPath = app.getPath('userData')
const caCertPath = join(userDataPath, 'websocket-ca-cert.pem')
const caKeyPath = join(userDataPath, 'websocket-ca-key.pem')
let ca: { key: string, cert: string }
if (existsSync(caCertPath) && existsSync(caKeyPath)) {
ca = {
cert: readFileSync(caCertPath, 'utf-8'),
key: readFileSync(caKeyPath, 'utf-8'),
}
}
else {
ca = await createCA({
organization: 'AIRI',
countryCode: 'US',
state: 'Development',
locality: 'Local',
validity: 365,
})
writeFileSync(caCertPath, ca.cert)
writeFileSync(caKeyPath, ca.key)
await installCACertificate(ca.cert)
}
const cert = await createCert({
ca: { key: ca.key, cert: ca.cert },
domains: ['localhost', '127.0.0.1', env.SERVER_RUNTIME_HOSTNAME || 'localhost'],
validity: 365,
})
return {
cert: cert.cert,
key: cert.key,
}
}
async function getOrCreateCertificate() {
const userDataPath = app.getPath('userData')
const certPath = join(userDataPath, 'websocket-cert.pem')
const keyPath = join(userDataPath, 'websocket-key.pem')
if (existsSync(certPath) && existsSync(keyPath)) {
return {
cert: readFileSync(certPath, 'utf-8'),
key: readFileSync(keyPath, 'utf-8'),
}
}
const { cert, key } = await generateCertificate()
writeFileSync(certPath, cert)
writeFileSync(keyPath, key)
return { cert, key }
}
export async function setupServerChannel(options?: { websocketSecureEnabled?: boolean }) {
const log = useLogg('main/server-runtime').useGlobalConfig()
// Start the server-runtime server with WebSocket support
const secureEnabled = options?.websocketSecureEnabled ?? false
try {
// Dynamically import the server-runtime and listhen
const serverRuntime = await import('@proj-airi/server-runtime')
const { serve } = await import('h3')
const { plugin: ws } = await import('crossws/server')
const { serve } = await import('h3')
const app = serverRuntime.setupApp()
const h3App = serverRuntime.setupApp()
const serverInstance = serve(app, {
// TODO: add proper crossws typing upstream
plugins: [ws({ resolve: async req => (await app.fetch(req) as any).crossws })],
port: env.PORT ? Number(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || 'localhost',
const port = env.PORT ? Number(env.PORT) : 6121
const hostname = env.SERVER_RUNTIME_HOSTNAME || 'localhost'
// FIXME: should prompt user to grant permission to save certificate files on macOS
const tls = secureEnabled ? await getOrCreateCertificate() : undefined
const instance = serve(h3App.app, {
// @ts-expect-error - the .crossws property wasn't extended in types
plugins: [ws({ resolve: async req => (await h3App.app.fetch(req)).crossws })],
port,
hostname,
tls,
reusePort: true,
silent: true,
manual: true,
@@ -30,7 +143,17 @@ export async function setupServerChannel() {
},
})
const servePromise = serverInstance.serve()
serverInstance = {
close: async (closeActiveConnections = false) => {
log.log('closing all peers')
h3App.closeAllPeers()
log.log('closing server instance')
await instance.close(closeActiveConnections)
log.log('server instance closed')
},
}
const servePromise = instance.serve()
if (servePromise instanceof Promise) {
servePromise.catch((error) => {
const nodejsError = error as NodeJS.ErrnoException
@@ -43,6 +166,9 @@ export async function setupServerChannel() {
})
}
const protocol = secureEnabled ? 'wss' : 'ws'
log.log(`@proj-airi/server-runtime started on ${protocol}://${hostname}:${port}`)
onAppBeforeQuit(async () => {
if (serverInstance && typeof serverInstance.close === 'function') {
try {
@@ -59,10 +185,38 @@ export async function setupServerChannel() {
}
}
})
log.log('@proj-airi/server-runtime started on ws://localhost:6121')
}
catch (error) {
log.withError(error).error('failed to start WebSocket server')
}
}
export async function restartServerChannel(options?: { websocketSecureEnabled?: boolean }) {
const log = useLogg('main/server-runtime').useGlobalConfig()
log.log('restarting server channel', { options })
if (serverInstance && typeof serverInstance.close === 'function') {
try {
log.log('closing existing server instance')
await serverInstance.close(true)
log.log('existing server instance closed')
}
catch {
// Ignore errors when closing
}
}
serverInstance = null
await setupServerChannel(options)
}
export function setupServerChannelHandlers() {
const { context } = createContext(ipcMain)
defineInvokeHandler(context, electronStartWebSocketServer, async (req) => {
await setupServerChannel({ websocketSecureEnabled: req?.websocketSecureEnabled })
})
defineInvokeHandler(context, electronRestartWebSocketServer, async (req) => {
await restartServerChannel({ websocketSecureEnabled: req?.websocketSecureEnabled })
})
}
+5 -3
View File
@@ -21,8 +21,8 @@ import { toast, Toaster } from 'vue-sonner'
import ResizeHandler from './components/ResizeHandler.vue'
import { electronOpenSettings, electronStartTrackMousePosition } from '../shared/eventa'
import { useElectronEventaContext } from './composables/electron-vueuse'
import { electronOpenSettings, electronStartTrackMousePosition, electronStartWebSocketServer } from '../shared/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from './composables/electron-vueuse'
const { isDark: dark } = useTheme()
const i18n = useI18n()
@@ -49,7 +49,8 @@ watch(dark, () => updateThemeColor(), { immediate: true })
watch(route, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
// FIXME: store settings to file
const startWebSocketServer = useElectronEventaInvoke(electronStartWebSocketServer)
onMounted(async () => {
analyticsStore.initialize()
cardStore.initialize()
@@ -58,6 +59,7 @@ onMounted(async () => {
await chatSessionStore.initialize()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await startWebSocketServer({ websocketSecureEnabled: settingsStore.websocketSecureEnabled })
await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
await contextBridgeStore.initialize()
@@ -0,0 +1,46 @@
<script setup lang="ts">
import SettingsGeneralFields from '@proj-airi/stage-pages/components/settings-general-fields.vue'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox } from '@proj-airi/ui'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronRestartWebSocketServer } from '../../../../shared/eventa'
import { useElectronEventaInvoke } from '../../../composables/electron-vueuse'
const settings = useSettings()
const { t } = useI18n()
const restartServer = useElectronEventaInvoke(electronRestartWebSocketServer)
watch(() => settings.websocketSecureEnabled, async (newValue) => {
await restartServer({ websocketSecureEnabled: newValue })
})
</script>
<template>
<SettingsGeneralFields>
<template #additional-fields>
<FieldCheckbox
v-model="settings.websocketSecureEnabled"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (5 * 10)"
:delay="5 * 50"
:label="t('settings.websocket-secure-enabled.title')"
:description="t('settings.websocket-secure-enabled.description')"
/>
</template>
</SettingsGeneralFields>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.system.general.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>
@@ -7,6 +7,8 @@ export const electronOpenSettings = defineInvokeEventa('eventa:invoke:electron:w
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
export const electronStartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:start-websocket-server')
export const electronRestartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:restart-websocket-server')
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
@@ -930,6 +930,10 @@ vrm:
skybox:
skybox-intensity: SkyBox Intensity
skybox-specular-mix: Specular Mix
websocket-secure-enabled:
title: Enable Secure WebSocket (WSS)
description: >-
Enable WSS (WebSocket Secure) to allow HTTPS connections for mobile (pocket) in development mode. This will automatically generate a self-signed certificate.
wip:
title: Work in Progress
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: SkyBox Intensity
skybox-specular-mix: Specular Mix
websocket-secure-enabled:
title: Habilitar WebSocket Seguro (WSS)
description: >-
Habilite WSS (WebSocket Secure) para permitir conexiones HTTPS para móvil (pocket) en modo de desarrollo. Esto generará automáticamente un certificado autofirmado.
wip:
title: Work in Progress
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: Intensité de la SkyBox
skybox-specular-mix: Mélange spéculaire
websocket-secure-enabled:
title: Activer WebSocket Sécurisé (WSS)
description: >-
Activez WSS (WebSocket Secure) pour permettre les connexions HTTPS pour mobile (pocket) en mode développement. Cela générera automatiquement un certificat auto-signé.
wip:
title: Work in Progress
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: スカイボックスの強度
skybox-specular-mix: 鏡面反射の混合
websocket-secure-enabled:
title: セキュア WebSocket (WSS) を有効にする
description: >-
開発モードでモバイル(pocket)のHTTPS接続を許可するために、WSSWebSocket Secure)を有効にします。これにより自己署名証明書が自動的に生成されます。
wip:
title: 開発中
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: SkyBox Intensity
skybox-specular-mix: Specular Mix
websocket-secure-enabled:
title: 보안 WebSocket (WSS) 활성화
description: >-
개발 모드에서 모바일(pocket)의 HTTPS 연결을 허용하기 위해 WSS(WebSocket Secure)를 활성화합니다. 자체 서명 인증서가 자동으로 생성됩니다.
wip:
title: Work in Progress
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: SkyBox Intensity
skybox-specular-mix: Specular Mix
websocket-secure-enabled:
title: Включить безопасный WebSocket (WSS)
description: >-
Включите WSS (WebSocket Secure), чтобы разрешить HTTPS-подключения для мобильных устройств (pocket) в режиме разработки. Это автоматически сгенерирует самоподписанный сертификат.
wip:
title: Work in Progress
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: Cường Độ SkyBox
skybox-specular-mix: Độ Trộn Phản Chiếu
websocket-secure-enabled:
title: Bật WebSocket Bảo Mật (WSS)
description: >-
Bật WSS (WebSocket Secure) để cho phép kết nối HTTPS cho di động (pocket) trong chế độ phát triển. Điều này sẽ tự động tạo chứng chỉ tự ký.
wip:
title: Work in Progress
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: 天空盒光照强度
skybox-specular-mix: 漫反射/镜面反射混合系数
websocket-secure-enabled:
title: 启用安全 WebSocket (WSS)
description: >-
启用 WSSWebSocket Secure)以允许开发模式下移动端(pocket)的 HTTPS 连接。这将自动生成自签名证书。
wip:
title: 正在开发中
description: >-
@@ -903,6 +903,10 @@ vrm:
skybox:
skybox-intensity: 天空盒光照強度
skybox-specular-mix: 漫反射/鏡面反射混合係數
websocket-secure-enabled:
title: 啟用安全 WebSocket (WSS)
description: >-
啟用 WSSWebSocket Secure)以允許開發模式下行動裝置(pocket)的 HTTPS 連線。這將自動產生自簽名憑證。
wip:
title: Work in Progress
description: >-
+2 -1
View File
@@ -40,6 +40,7 @@
"crossws": "^0.4.3",
"h3": "^2.0.1-rc.11",
"listhen": "^1.9.0",
"nanoid": "catalog:"
"nanoid": "catalog:",
"srvx": "^0.10.1"
}
}
+15 -4
View File
@@ -86,7 +86,7 @@ export function setupApp(options?: {
readTimeout?: number
message?: MessageHeartbeat | string
}
}): H3 {
}): { app: H3, closeAllPeers: () => void } {
const instanceId = options?.instanceId || optionOrEnv(undefined, 'SERVER_INSTANCE_ID', nanoid())
const authToken = optionOrEnv(options?.auth?.token, 'AUTHENTICATION_TOKEN', '')
@@ -121,7 +121,7 @@ export function setupApp(options?: {
if (now - peerInfo.lastHeartbeatAt > heartbeatTtlMs) {
logger.withFields({ peer: id, peerName: peerInfo.name }).debug('heartbeat expired, dropping peer')
try {
(peerInfo.peer as Peer & { close?: () => void }).close?.()
peerInfo.peer.close?.()
}
catch (error) {
logger.withFields({ peer: id, peerName: peerInfo.name }).withError(error as Error).debug('failed to close expired peer')
@@ -382,7 +382,18 @@ export function setupApp(options?: {
},
}))
return app
function closeAllPeers() {
console.log('closing all peers', peers.size)
for (const peer of peers.values()) {
console.log('closing peer', peer.peer.id)
peer.peer.close?.()
}
}
return {
app,
closeAllPeers,
}
}
export const app = setupApp() as H3
export const { app, closeAllPeers: _ } = setupApp()
@@ -8,6 +8,7 @@ export interface Peer {
send: (data: unknown, options?: {
compress?: boolean
}) => number | void | undefined
close?: () => void
/**
* WebSocket lifecycle state (mirrors WebSocket.readyState)
*/
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldSelect, useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const settings = useSettings()
const { t } = useI18n()
const { isDark: dark } = useTheme()
const languages = computed(() => {
return Object.entries(all).map(([value, label]) => ({ value, label }))
})
</script>
<template>
<div class="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800">
<FieldCheckbox
v-model="dark"
v-motion
:class="['mb-2']"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (2 * 10)"
:delay="2 * 50"
:label="t('settings.theme.title')"
:description="t('settings.theme.description')"
/>
<FieldSelect
v-model="settings.language"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (3 * 10)"
:delay="3 * 50"
:class="['transition-all', 'ease-in-out', 'duration-250']"
:label="t('settings.language.title')"
:description="t('settings.language.description')"
:options="languages"
/>
<slot name="additional-fields" />
<div
v-motion
:class="['text-neutral-200/50', 'dark:text-neutral-600/20', 'pointer-events-none', 'fixed', 'top-[65dvh]', 'right--15', 'z--1', 'flex', 'items-center', 'justify-center']"
:initial="{ scale: 0.9, opacity: 0, rotate: 30 }"
:enter="{ scale: 1, opacity: 1, rotate: 0 }"
:duration="250"
>
<div :class="['text-60', 'i-solar:emoji-funny-square-bold-duotone']" />
</div>
</div>
</template>
@@ -31,6 +31,8 @@ export const useSettingsGeneral = defineStore('settings-general', () => {
const disableTransitions = useLocalStorageManualReset<boolean>('settings/disable-transitions', true)
const usePageSpecificTransitions = useLocalStorageManualReset<boolean>('settings/use-page-specific-transitions', true)
const websocketSecureEnabled = useLocalStorageManualReset<boolean>('settings/websocket/secure-enabled', false)
function getLanguage() {
let language = localStorage.getItem('settings/language')
@@ -53,6 +55,7 @@ export const useSettingsGeneral = defineStore('settings-general', () => {
language.reset()
disableTransitions.reset()
usePageSpecificTransitions.reset()
websocketSecureEnabled.reset()
}
onMounted(() => language.value = getLanguage())
@@ -61,6 +64,7 @@ export const useSettingsGeneral = defineStore('settings-general', () => {
language,
disableTransitions,
usePageSpecificTransitions,
websocketSecureEnabled,
getLanguage,
resetState,
}
@@ -50,6 +50,7 @@ export const useSettings = defineStore('settings', () => {
disableTransitions: generalRefs.disableTransitions,
usePageSpecificTransitions: generalRefs.usePageSpecificTransitions,
language: generalRefs.language,
websocketSecureEnabled: generalRefs.websocketSecureEnabled,
// Stage model settings
stageModelRenderer: stageModelRefs.stageModelRenderer,
+19
View File
@@ -144,6 +144,9 @@ catalogs:
knip:
specifier: ^5.82.1
version: 5.82.1
mkcert:
specifier: ^3.2.0
version: 3.2.0
nano-staged:
specifier: ^0.9.0
version: 0.9.0
@@ -1025,6 +1028,9 @@ importers:
mediabunny:
specifier: ^1.29.0
version: 1.29.0
mkcert:
specifier: 'catalog:'
version: 3.2.0
node-vibrant:
specifier: ^4.0.3
version: 4.0.3(encoding@0.1.13)
@@ -1945,6 +1951,9 @@ importers:
nanoid:
specifier: 'catalog:'
version: 5.1.6
srvx:
specifier: ^0.10.1
version: 0.10.1
packages/server-sdk:
dependencies:
@@ -13235,6 +13244,11 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkcert@3.2.0:
resolution: {integrity: sha512-026Eivq9RoOjOuLJGzbhGwXUAjBxRX11Z7Jbm4/7lqT/Av+XNy9SPrJte6+UpEt7i+W3e/HZYxQqlQcqXZWSzg==}
engines: {node: '>=16'}
hasBin: true
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@@ -27981,6 +27995,11 @@ snapshots:
mitt@3.0.1: {}
mkcert@3.2.0:
dependencies:
commander: 11.1.0
node-forge: 1.3.3
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
+1
View File
@@ -70,6 +70,7 @@ catalog:
injeca: ^0.1.7
is-network-error: ^1.3.0
knip: ^5.82.1
mkcert: ^3.2.0
nano-staged: ^0.9.0
nanoid: 5.1.6
ofetch: ^1.5.1