Add system updates page to admin section

Add a new API endpoint and UI section for checking system updates, including internationalized locale strings and necessary dependency updates.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1e6de894-7c78-4557-b01a-a2c1c01f4155
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-17 14:59:36 +00:00
parent 191e072353
commit b5efd9eb12
12 changed files with 568 additions and 3 deletions
+2
View File
@@ -35,6 +35,7 @@
"pino-http": "^10",
"playwright-core": "^1.59.1",
"sanitize-html": "^2.17.3",
"semver": "^7.6.3",
"socket.io": "^4.8.3",
"web-push": "^3.6.7",
"zod": "catalog:"
@@ -50,6 +51,7 @@
"@types/nodemailer": "^8.0.0",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1",
"@types/semver": "^7.5.8",
"@types/web-push": "^3.6.4",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
+2
View File
@@ -17,6 +17,7 @@ import rolesRouter from "./roles";
import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings";
import pushRouter from "./push";
import systemRouter from "./system";
const router: IRouter = Router();
@@ -38,5 +39,6 @@ router.use(rolesRouter);
router.use(auditRouter);
router.use(executiveMeetingsRouter);
router.use(pushRouter);
router.use(systemRouter);
export default router;
+113
View File
@@ -0,0 +1,113 @@
import { Router, type IRouter } from "express";
import semver from "semver";
import { requireAdmin } from "../middlewares/auth";
import versionFile from "../../../../version.json" with { type: "json" };
import { GetSystemVersionResponse } from "@workspace/api-zod";
const router: IRouter = Router();
const CURRENT_VERSION: string = versionFile.version;
const CURRENT_BUILD: string = versionFile.build;
const LATEST_URL = process.env.LATEST_VERSION_URL ?? "";
const FETCH_TIMEOUT_MS = 5000;
type LatestPayload = {
version?: unknown;
build?: unknown;
};
router.get("/system/version", requireAdmin, async (_req, res): Promise<void> => {
const checkedAt = new Date().toISOString();
if (!LATEST_URL) {
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: null,
status: "not-configured",
errorMessage: null,
checkedAt,
configured: false,
}),
);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(LATEST_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
if (!response.ok) {
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: null,
status: "error",
errorMessage: `HTTP ${response.status}`,
checkedAt,
configured: true,
}),
);
return;
}
const body = (await response.json()) as LatestPayload;
const rawVersion = typeof body.version === "string" ? body.version : "";
const rawBuild = typeof body.build === "string" ? body.build : "";
const cleaned = semver.valid(semver.coerce(rawVersion));
const localCleaned = semver.valid(semver.coerce(CURRENT_VERSION));
if (!cleaned || !localCleaned) {
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: { version: rawVersion, build: rawBuild },
status: "error",
errorMessage: "Invalid version format",
checkedAt,
configured: true,
}),
);
return;
}
// semver.gt: strictly greater → update available. Equal or older →
// up to date (we never tell the user to "downgrade" — the desktop
// build is the source of truth for that scenario).
const status = semver.gt(cleaned, localCleaned)
? "update-available"
: "up-to-date";
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: { version: rawVersion, build: rawBuild },
status,
errorMessage: null,
checkedAt,
configured: true,
}),
);
} catch (err) {
const message =
err instanceof Error
? err.name === "AbortError"
? "Request timed out"
: err.message
: "Unknown error";
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: null,
status: "error",
errorMessage: message,
checkedAt,
configured: true,
}),
);
} finally {
clearTimeout(timer);
}
});
export default router;
+19
View File
@@ -528,8 +528,27 @@
"auditLog": "سجل التدقيق",
"userManagement": "إدارة المستخدمين",
"settings": "الإعدادات",
"systemUpdates": "تحديثات النظام",
"menu": "القائمة"
},
"systemUpdates": {
"title": "تحديثات النظام",
"subtitle": "تحقّق فقط من توفر إصدار أحدث — لا يقوم النظام بتثبيت أي تحديث.",
"currentVersion": "الإصدار الحالي",
"buildNumber": "رقم البناء",
"latestVersion": "أحدث إصدار",
"updateStatus": "حالة التحديث",
"statusUpToDate": "النظام محدّث",
"statusUpdateAvailable": "يتوفّر تحديث جديد",
"statusNotConfigured": "لم يتم تهيئة مصدر التحديثات",
"statusError": "تعذّر التحقق من التحديثات",
"notConfiguredHint": "اطلب من المسؤول ضبط متغيّر البيئة LATEST_VERSION_URL على رابط ملف latest.json الخارجي لتفعيل التحقق.",
"checkNow": "تحقّق الآن",
"loading": "جاري التحقق من الإصدار…",
"lastChecked": "آخر تحقق",
"toastErrorTitle": "تعذّر التحقق",
"toastErrorDesc": "لم نتمكن من الوصول إلى مصدر التحديثات. حاول مرة أخرى لاحقاً."
},
"apps": {
"counts": {
"groups": "{{count}} مجموعة",
+19
View File
@@ -504,8 +504,27 @@
"auditLog": "Audit log",
"userManagement": "User Management",
"settings": "Settings",
"systemUpdates": "System Updates",
"menu": "Menu"
},
"systemUpdates": {
"title": "System Updates",
"subtitle": "Check whether a newer version is available — this page never installs an update.",
"currentVersion": "Current Version",
"buildNumber": "Build Number",
"latestVersion": "Latest Version",
"updateStatus": "Update Status",
"statusUpToDate": "System is up to date",
"statusUpdateAvailable": "New update available",
"statusNotConfigured": "Update source not configured",
"statusError": "Unable to check for updates",
"notConfiguredHint": "Ask the administrator to set the LATEST_VERSION_URL environment variable to the external latest.json URL to enable checking.",
"checkNow": "Check Now",
"loading": "Checking version…",
"lastChecked": "Last checked",
"toastErrorTitle": "Check failed",
"toastErrorDesc": "Could not reach the update source. Please try again later."
},
"apps": {
"counts": {
"groups": "{{count}} groups",
+195 -3
View File
@@ -27,6 +27,8 @@ import {
useUpdateAppSettings,
useGetAdminStats,
getGetAdminStatsQueryKey,
useGetSystemVersion,
getGetSystemVersionQueryKey,
useGetAdminAppOpensByApp,
getGetAdminAppOpensByAppQueryKey,
getAdminAppOpensByApp,
@@ -124,6 +126,7 @@ import type {
ServiceDependentOrdersPage,
UserDependentNotesPage,
UserDependentOrdersPage,
SystemVersionResponse,
} from "@workspace/api-client-react";
import { ListUsersStatus } from "@workspace/api-client-react";
import { useQueryClient, useQuery } from "@tanstack/react-query";
@@ -131,7 +134,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { isBuiltinAppSlug } from "@workspace/db/built-in-apps";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, type LucideIcon } from "lucide-react";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, Package, RefreshCw, CheckCircle2, AlertCircle, type LucideIcon } from "lucide-react";
import * as LucideIcons from "lucide-react";
function resolveAppIcon(name: string): LucideIcon | null {
@@ -184,7 +187,7 @@ async function fetchAdminApps(): Promise<App[]> {
return res.json();
}
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings";
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings" | "system-updates";
function LeaderboardAvatar({
src,
@@ -1081,6 +1084,11 @@ export default function AdminPage() {
],
},
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
// #575: Read-only "System Updates" page — checks the running build
// against a remote latest.json (configured via LATEST_VERSION_URL on
// the server) and shows whether a newer version is available. It
// never performs an update — pure status surface.
{ key: "system-updates", label: t("admin.nav.systemUpdates"), Icon: Package },
];
// Sync section to URL hash so deep links work
@@ -1090,7 +1098,7 @@ export default function AdminPage() {
const sectionMatch = hash.match(/section=([a-z-]+)/);
if (sectionMatch) {
const s = sectionMatch[1] as AdminSection;
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) {
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings", "system-updates"].includes(s)) {
setSection(s);
}
}
@@ -2211,6 +2219,8 @@ export default function AdminPage() {
<SiteSettingsPanel />
</div>
)}
{section === "system-updates" && <SystemUpdatesPanel />}
</div>
</div>
@@ -2331,6 +2341,188 @@ export default function AdminPage() {
);
}
// #575: System Updates panel — read-only version checker. Reads the
// local build (server returns version.json embedded at build time)
// and, if the server has LATEST_VERSION_URL configured, compares it
// against the remote latest.json via semver on the server side. We
// never perform an update from this screen — pure status surface.
// The bell icon and other admin features stay untouched.
function SystemUpdatesPanel() {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const queryKey = getGetSystemVersionQueryKey();
const { data, isLoading, isFetching, refetch } = useGetSystemVersion({
query: { queryKey, refetchOnWindowFocus: false },
});
const handleCheckNow = async () => {
// Invalidate first so React Query treats the next fetch as fresh
// (no stale cache reuse) — this is the user explicitly asking for
// a re-check, so we want a real network hit.
await queryClient.invalidateQueries({ queryKey });
const result = await refetch();
if (result.error) {
toast({
title: t("admin.systemUpdates.toastErrorTitle"),
description: t("admin.systemUpdates.toastErrorDesc"),
variant: "destructive",
});
}
};
const formatChecked = (iso: string | null | undefined) => {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat(i18n.language === "ar" ? "ar" : "en", {
dateStyle: "medium",
timeStyle: "short",
}).format(d);
};
const renderStatusBadge = (resp: SystemVersionResponse | undefined) => {
if (!resp) return null;
const map: Record<
SystemVersionResponse["status"],
{ label: string; cls: string; Icon: typeof CheckCircle2 }
> = {
"up-to-date": {
label: t("admin.systemUpdates.statusUpToDate"),
cls: "bg-emerald-50 text-emerald-700 border-emerald-200",
Icon: CheckCircle2,
},
"update-available": {
label: t("admin.systemUpdates.statusUpdateAvailable"),
cls: "bg-amber-50 text-amber-800 border-amber-200",
Icon: AlertCircle,
},
"not-configured": {
label: t("admin.systemUpdates.statusNotConfigured"),
cls: "bg-slate-50 text-slate-700 border-slate-200",
Icon: HelpCircle,
},
error: {
label: t("admin.systemUpdates.statusError"),
cls: "bg-rose-50 text-rose-700 border-rose-200",
Icon: AlertTriangle,
},
};
const cfg = map[resp.status];
const Icon = cfg.Icon;
return (
<span
className={cn(
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border",
cfg.cls,
)}
data-testid="system-updates-status-badge"
>
<Icon size={14} />
{cfg.label}
</span>
);
};
return (
<div className="space-y-4" data-testid="system-updates-panel" dir={i18n.language === "ar" ? "rtl" : "ltr"}>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-foreground">
{t("admin.systemUpdates.title")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("admin.systemUpdates.subtitle")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleCheckNow}
disabled={isFetching}
data-testid="system-updates-check-now"
>
{isFetching ? (
<Loader2 size={14} className="animate-spin me-2" />
) : (
<RefreshCw size={14} className="me-2" />
)}
{t("admin.systemUpdates.checkNow")}
</Button>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-6">
<Loader2 size={14} className="animate-spin" />
{t("admin.systemUpdates.loading")}
</div>
) : data ? (
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("admin.systemUpdates.currentVersion")}
</div>
<div
className="text-xl font-semibold mt-1 tabular-nums"
data-testid="system-updates-current-version"
>
{data.current.version}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t("admin.systemUpdates.buildNumber")}:{" "}
<span className="tabular-nums" data-testid="system-updates-current-build">
{data.current.build}
</span>
</div>
</div>
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("admin.systemUpdates.latestVersion")}
</div>
<div
className="text-xl font-semibold mt-1 tabular-nums"
data-testid="system-updates-latest-version"
>
{data.latest?.version ?? "—"}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t("admin.systemUpdates.buildNumber")}:{" "}
<span className="tabular-nums" data-testid="system-updates-latest-build">
{data.latest?.build ?? "—"}
</span>
</div>
</div>
<div className="sm:col-span-2 rounded-xl border border-slate-200 p-4">
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
{t("admin.systemUpdates.updateStatus")}
</div>
{renderStatusBadge(data)}
{data.status === "error" && data.errorMessage && (
<div className="text-xs text-rose-700 mt-2" data-testid="system-updates-error-message">
{data.errorMessage}
</div>
)}
{data.status === "not-configured" && (
<div className="text-xs text-muted-foreground mt-2">
{t("admin.systemUpdates.notConfiguredHint")}
</div>
)}
{data.checkedAt && (
<div className="text-xs text-muted-foreground mt-3">
{t("admin.systemUpdates.lastChecked")}: {formatChecked(data.checkedAt)}
</div>
)}
</div>
</div>
) : null}
</div>
</div>
);
}
function SiteSettingsPanel() {
const { t } = useTranslation();
const { toast } = useToast();
@@ -9,6 +9,32 @@ export interface HealthStatus {
status: string;
}
export interface SystemVersionInfo {
version: string;
build: string;
}
export type SystemVersionResponseStatus =
(typeof SystemVersionResponseStatus)[keyof typeof SystemVersionResponseStatus];
export const SystemVersionResponseStatus = {
"up-to-date": "up-to-date",
"update-available": "update-available",
"not-configured": "not-configured",
error: "error",
} as const;
export interface SystemVersionResponse {
current: SystemVersionInfo;
latest: SystemVersionInfo | null;
status: SystemVersionResponseStatus;
/** @nullable */
errorMessage: string | null;
/** @nullable */
checkedAt: string | null;
configured: boolean;
}
export interface ErrorResponse {
error: string;
}
+81
View File
@@ -110,6 +110,7 @@ import type {
ServiceOrder,
SubscribePushBody,
SuccessResponse,
SystemVersionResponse,
UnsubscribePushBody,
UpdateAppBody,
UpdateAppSettingsBody,
@@ -217,6 +218,86 @@ export function useHealthCheck<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Returns the local build version (read from version.json embedded at
build time) and, if `LATEST_VERSION_URL` is configured on the
server, the latest published version fetched from that URL. Admin
only. Never performs an update — pure read.
* @summary Get current and latest system version
*/
export const getGetSystemVersionUrl = () => {
return `/api/system/version`;
};
export const getSystemVersion = async (
options?: RequestInit,
): Promise<SystemVersionResponse> => {
return customFetch<SystemVersionResponse>(getGetSystemVersionUrl(), {
...options,
method: "GET",
});
};
export const getGetSystemVersionQueryKey = () => {
return [`/api/system/version`] as const;
};
export const getGetSystemVersionQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemVersion>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemVersion>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSystemVersionQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemVersion>>
> = ({ signal }) => getSystemVersion({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getSystemVersion>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemVersionQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemVersion>>
>;
export type GetSystemVersionQueryError = ErrorType<unknown>;
/**
* @summary Get current and latest system version
*/
export function useGetSystemVersion<
TData = Awaited<ReturnType<typeof getSystemVersion>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemVersion>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemVersionQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Register a new user
*/
+58
View File
@@ -30,6 +30,8 @@ tags:
description: Object storage upload and serving endpoints
- name: audit
description: Admin audit log
- name: system
description: System metadata (version, update availability)
paths:
/healthz:
@@ -45,6 +47,24 @@ paths:
schema:
$ref: "#/components/schemas/HealthStatus"
/system/version:
get:
operationId: getSystemVersion
tags: [system]
summary: Get current and latest system version
description: |
Returns the local build version (read from version.json embedded at
build time) and, if `LATEST_VERSION_URL` is configured on the
server, the latest published version fetched from that URL. Admin
only. Never performs an update — pure read.
responses:
"200":
description: System version info
content:
application/json:
schema:
$ref: "#/components/schemas/SystemVersionResponse"
# Auth
/auth/register:
post:
@@ -2927,6 +2947,44 @@ components:
required:
- status
SystemVersionInfo:
type: object
properties:
version:
type: string
build:
type: string
required:
- version
- build
SystemVersionResponse:
type: object
properties:
current:
$ref: "#/components/schemas/SystemVersionInfo"
latest:
oneOf:
- $ref: "#/components/schemas/SystemVersionInfo"
- type: "null"
status:
type: string
enum: [up-to-date, update-available, not-configured, error]
errorMessage:
type: ["string", "null"]
checkedAt:
type: ["string", "null"]
format: date-time
configured:
type: boolean
required:
- current
- latest
- status
- errorMessage
- checkedAt
- configured
ErrorResponse:
type: object
properties:
+31
View File
@@ -14,6 +14,37 @@ export const HealthCheckResponse = zod.object({
status: zod.string(),
});
/**
* Returns the local build version (read from version.json embedded at
build time) and, if `LATEST_VERSION_URL` is configured on the
server, the latest published version fetched from that URL. Admin
only. Never performs an update — pure read.
* @summary Get current and latest system version
*/
export const GetSystemVersionResponse = zod.object({
current: zod.object({
version: zod.string(),
build: zod.string(),
}),
latest: zod.union([
zod.object({
version: zod.string(),
build: zod.string(),
}),
zod.null(),
]),
status: zod.enum([
"up-to-date",
"update-available",
"not-configured",
"error",
]),
errorMessage: zod.string().nullable(),
checkedAt: zod.coerce.date().nullable(),
configured: zod.boolean(),
});
/**
* @summary Register a new user
*/
+18
View File
@@ -135,6 +135,9 @@ importers:
sanitize-html:
specifier: ^2.17.3
version: 2.17.3
semver:
specifier: ^7.6.3
version: 7.8.0
socket.io:
specifier: ^4.8.3
version: 4.8.3
@@ -175,6 +178,9 @@ importers:
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
'@types/semver':
specifier: ^7.5.8
version: 7.7.1
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
@@ -2996,6 +3002,9 @@ packages:
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -4668,6 +4677,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -7540,6 +7554,8 @@ snapshots:
dependencies:
htmlparser2: 10.1.0
'@types/semver@7.7.1': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 25.3.5
@@ -9221,6 +9237,8 @@ snapshots:
semver@6.3.1: {}
semver@7.8.0: {}
send@1.2.1:
dependencies:
debug: 4.4.3
+4
View File
@@ -0,0 +1,4 @@
{
"version": "0.1.0-dev",
"build": "20260517"
}