feat(billmanager): профиль FirstByte — ДЦ, оплата и specs VPS
Docker / build (push) Failing after 19s

Парсинг страны/города и paidUntil из list vds; CPU/RAM/диск из vds.order по pricelist_id, иначе vds.edit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-19 22:38:33 +07:00
co-authored by Cursor
parent 4dda77f118
commit 83ed3b8a42
9 changed files with 553 additions and 4 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { elemToObject, extractList } from './parsers.js'
import { elemToObject, extractList, parseDatacenterName } from './parsers.js'
import { mapVdsToVps } from './mappers.js'
import {
DEFAULT_PROFILE,
@@ -9,6 +9,10 @@ import {
waicoreOverrides,
} from './profiles/index.js'
import { fetchVds, mapVdsWithProfile } from './operations.js'
import {
enrichMappedVpsFromTariffs,
parseSpecsFromVdsEdit,
} from './vds-specs.js'
/** Minimal Waicore-style bjson (no credentials / addon noise). */
const WAICORE_VDS_FIXTURE = {
@@ -33,6 +37,44 @@ const WAICORE_VDS_FIXTURE = {
],
}
const FIRSTBYTE_VDS_FIXTURE = {
func: 'vds',
elem: [
{
id: '4478979',
ip: '45.95.202.221',
domain: 'auth.shnt.top',
expiredate: '2026-10-15',
real_expiredate: '2026-10-15',
billdaily: 'off',
datacentername: '1 Датацентр Россия, Москва',
ostempl: 'Debian-13-amd64',
pricelist: 'MSK-KVM-SSD-START',
pricelist_id: '5228',
currency_str: 'RUB',
createdate: '2026-07-15',
item_status_orig: '2',
cost: '75.00 RUB / Месяц',
},
{
id: '4208964',
ip: '193.168.227.234',
domain: 'vt.shnt.top',
expiredate: 'Ежедневное списание',
real_expiredate: '2026-07-20',
billdaily: 'on',
datacentername: '1 Датацентр Россия, Москва',
ostempl: 'Debian-13-amd64',
pricelist: 'MSK-KVM-SAS-1',
pricelist_id: '564',
currency_str: 'RUB',
createdate: '2026-04-21',
item_status_orig: '2',
cost: '129.00 RUB / Месяц',
},
],
}
describe('billmanager profiles', () => {
afterEach(() => {
vi.unstubAllGlobals()
@@ -53,6 +95,13 @@ describe('billmanager profiles', () => {
)
})
it('resolve: firstbyte hostname → firstbyte profile', () => {
const p = resolveBillmanagerProfile('https://my.firstbyte.ru/')
expect(p.id).toBe('firstbyte')
expect(p.funcs.listVds).toBe('vds')
expect(p.map.enrichVds).toBeTypeOf('function')
})
it('resolve: unknown hoster → default', () => {
const p = resolveBillmanagerProfile('https://bill.hoster.ru/')
expect(p.id).toBe('default')
@@ -67,6 +116,13 @@ describe('billmanager profiles', () => {
expect(merged.map.vds).toBe(DEFAULT_PROFILE.map.vds)
})
it('parseDatacenterName: FirstByte «1 Датацентр Россия, Москва»', () => {
expect(parseDatacenterName('1 Датацентр Россия, Москва')).toEqual({
country: 'Россия',
location: 'Москва',
})
})
it('Waicore fixture elem → MappedVps fields', () => {
const profile = resolveBillmanagerProfile('https://my.waicore.com/')
const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey)
@@ -81,6 +137,82 @@ describe('billmanager profiles', () => {
expect(vps.status).toBe('active')
})
it('FirstByte: country/city + paidUntil for monthly and daily', () => {
const profile = resolveBillmanagerProfile('https://bill.firstbyte.ru/')
const elems = extractList(FIRSTBYTE_VDS_FIXTURE, profile.extract.listVdsKey)
expect(elems).toHaveLength(2)
const monthly = mapVdsWithProfile(
profile,
elemToObject(elems[0]!),
'prov-fb',
'acc-fb',
)
expect(monthly.country).toBe('Россия')
expect(monthly.city).toBe('Москва')
expect(monthly.datacenter).toBe('1 Датацентр Россия, Москва')
expect(monthly.paidUntil).toBe('2026-10-15')
expect(monthly.tariffType).toBe('monthly')
expect(monthly.diskType).toBe('SSD')
expect(monthly.virtualization).toBe('KVM')
expect(monthly.vcpu).toBe(0)
const daily = mapVdsWithProfile(
profile,
elemToObject(elems[1]!),
'prov-fb',
'acc-fb',
)
expect(daily.country).toBe('Россия')
expect(daily.city).toBe('Москва')
expect(daily.paidUntil).toBe('2026-07-20')
expect(daily.tariffType).toBe('daily')
expect(daily.diskType).toBe('SAS')
})
it('FirstByte: specs from vds.order by pricelist_id', () => {
const profile = resolveBillmanagerProfile('https://my.firstbyte.ru/')
expect(profile.options?.fetchVdsEditForSpecs).toBe(true)
const item = elemToObject(FIRSTBYTE_VDS_FIXTURE.elem[0]!)
let mapped = mapVdsWithProfile(profile, item, 'p', 'a')
mapped = enrichMappedVpsFromTariffs(item, mapped, [
{
externalId: '5228',
name: 'START',
desc: 'START<br/>Процессор: 1 ядро; Память: 1 GB; Диск: 15 GB SSD',
vcpu: 1,
ramGb: 1,
diskGb: 15,
diskType: 'SSD',
virtualization: 'KVM',
},
])
expect(mapped.vcpu).toBe(1)
expect(mapped.ramGb).toBe(1)
expect(mapped.diskGb).toBe(15)
})
it('parseSpecsFromVdsEdit reads addon labels', () => {
const specs = parseSpecsFromVdsEdit({
model: {
addon_11: '2',
addon_12: '2048',
addon_13: '40',
pricelist: 'MSK-KVM-SSD-START',
},
messages: [
{ name: 'addon_11', msg: 'Количество ядер процессора' },
{ name: 'addon_12', msg: 'Память (MB)' },
{ name: 'addon_13', msg: 'Диск SSD (GB)' },
],
})
expect(specs.vcpu).toBe(2)
expect(specs.ramGb).toBe(2)
expect(specs.diskGb).toBe(40)
expect(specs.diskType).toBe('SSD')
})
it('fetchVds for waicore URL uses func=vds.vps', async () => {
const calls: string[] = []
vi.stubGlobal(
@@ -44,3 +44,10 @@ export const myHosterOverrides: BillmanagerProfileOverrides = {
`resolveBillmanagerProfile(apiBaseUrl)``merge(DEFAULT, override)` или `DEFAULT`.
Sync и operations **не** содержат `if (hoster)` — только профиль.
## Готовые профили
| id | Match | Что переопределяет |
|----|-------|-------------------|
| `waicore` | `waicore.com` / keyword | `funcs.listVds = vds.vps` |
| `firstbyte` | `firstbyte.ru`, `firstbyte.club`, `1byte.ru` | `enrichVds`: страна/город, `paidUntil` ISO, `billdaily`→daily, diskType/KVM из имени; `options.fetchVdsEditForSpecs`; sync: specs из `vds.order` по `pricelist_id`, иначе `vds.edit` |
@@ -0,0 +1,77 @@
import { parseDatacenterName } from '../parsers.js'
import type { MappedVps } from '../mappers.js'
import type { BillmanagerProfileOverrides } from './types.js'
/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
function pickPaidUntilDate(item: Record<string, string>): string {
for (const key of ['real_expiredate', 'expiredate'] as const) {
const raw = String(item[key] ?? '').trim()
const m = raw.match(/^(\d{4}-\d{2}-\d{2})/)
if (m) return m[1]!
}
return ''
}
/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
export function inferFirstbyteHardwareHints(item: Record<string, string>): {
diskType?: string
virtualization?: string
} {
const hay = `${item.pricelist || ''} ${item.intname || ''} ${item.name || ''}`
let diskType: string | undefined
let virtualization: string | undefined
if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
return { diskType, virtualization }
}
/**
* FirstByte — standard func=vds, but list has no CPU/RAM/disk numbers.
*
* Sync additionally fills specs via:
* 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
* 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
*
* This enrich: geo, paidUntil, daily billing, diskType/KVM from tariff name.
*/
export function enrichFirstbyteVds(
item: Record<string, string>,
mapped: MappedVps,
): MappedVps {
const dcRaw = (item.datacentername || item.datacenter || mapped.datacenter || '').trim()
const { country, location } = parseDatacenterName(dcRaw)
const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
const tariffType =
String(item.billdaily || '').toLowerCase() === 'on' ? 'daily' : mapped.tariffType
const hints = inferFirstbyteHardwareHints(item)
return {
...mapped,
country: country || mapped.country,
city: location || mapped.city,
datacenter: dcRaw || mapped.datacenter,
paidUntil,
tariffType,
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
virtualization: hints.virtualization || mapped.virtualization,
}
}
export const firstbyteOverrides: BillmanagerProfileOverrides = {
id: 'firstbyte',
match: {
hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
keywords: ['firstbyte'],
},
map: {
enrichVds: enrichFirstbyteVds,
},
options: {
fetchVdsEditForSpecs: true,
},
}
@@ -1,4 +1,5 @@
export { DEFAULT_PROFILE } from './default.js'
export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js'
export { mergeProfile } from './merge.js'
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
export type {
@@ -46,5 +46,9 @@ export function mergeProfile(
...overrides.requestParams?.orderPricelist,
},
},
options: {
...defaults.options,
...overrides.options,
},
}
}
@@ -1,4 +1,5 @@
import { DEFAULT_PROFILE } from './default.js'
import { firstbyteOverrides } from './firstbyte.js'
import { mergeProfile } from './merge.js'
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
import { waicoreOverrides } from './waicore.js'
@@ -7,7 +8,10 @@ import { waicoreOverrides } from './waicore.js'
* Hoster override list — first match wins.
* Add new hosters here after creating profiles/<id>.ts.
*/
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [waicoreOverrides]
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [
waicoreOverrides,
firstbyteOverrides,
]
function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean {
const match = override.match
@@ -48,6 +48,14 @@ export type BillmanagerRequestParams = {
orderPricelist?: Record<string, string | number>
}
export type BillmanagerProfileOptions = {
/**
* When list `func=vds` has no CPU/RAM/disk — after tariff match,
* call `func=vds.edit&elid=` per VPS (FirstByte and similar).
*/
fetchVdsEditForSpecs?: boolean
}
export type BillmanagerProfile = {
id: string
match: BillmanagerMatch
@@ -55,6 +63,7 @@ export type BillmanagerProfile = {
extract: BillmanagerExtract
map: BillmanagerMap
requestParams?: BillmanagerRequestParams
options?: BillmanagerProfileOptions
}
/** Deep-partial for hoster override files (only divergences). */
@@ -65,4 +74,5 @@ export type BillmanagerProfileOverrides = {
extract?: Partial<BillmanagerExtract>
map?: Partial<BillmanagerMap>
requestParams?: BillmanagerRequestParams
options?: BillmanagerProfileOptions
}
+70 -2
View File
@@ -18,6 +18,12 @@ import {
type TariffItem,
} from './operations.js'
import { resolveBillmanagerProfile } from './profiles/index.js'
import {
applyEditSpecs,
enrichMappedVpsFromTariffs,
fetchVdsEditSpecs,
needsHardwareSpecs,
} from './vds-specs.js'
export interface SyncFromBillmanagerOptions {
skipTariffs?: boolean
@@ -54,12 +60,33 @@ const SYNC_UPDATE_FIELDS = [
'paidUntil',
] as const
const SYNC_HARDWARE_FIELDS = ['vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization'] as const
function normVal(v: unknown): string {
if (v == null || v === '') return ''
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
return String(v)
}
async function mapVdsWithSpecs(
profile: ReturnType<typeof resolveBillmanagerProfile>,
item: Record<string, string>,
providerId: string,
accountId: string,
tariffItems: TariffItem[],
apiBaseUrl: string,
authinfo: string,
) {
let vps = mapVdsWithProfile(profile, item, providerId, accountId)
vps = enrichMappedVpsFromTariffs(item, vps, tariffItems)
if (needsHardwareSpecs(vps) && profile.options?.fetchVdsEditForSpecs) {
const elid = String(item.id || vps.externalId || '').trim()
const specs = await fetchVdsEditSpecs(apiBaseUrl, authinfo, elid)
if (specs) vps = applyEditSpecs(vps, specs)
}
return vps
}
export async function syncFromBillmanager(
account: BillmanagerSyncAccount,
opts: SyncFromBillmanagerOptions = {},
@@ -111,7 +138,15 @@ export async function syncFromBillmanager(
if (fetchVpsPayments) {
for (const item of vdsItems) {
const vps = mapVdsWithProfile(profile, item, providerId, accountId)
const vps = await mapVdsWithSpecs(
profile,
item,
providerId,
accountId,
tariffItems,
apiBaseUrl,
authinfo,
)
const id = `vps-bm-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate
@@ -153,13 +188,41 @@ export async function syncFromBillmanager(
monthlyRate,
paidUntil,
notes,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
diskType: vps.diskType,
virtualization: vps.virtualization,
}
for (const f of SYNC_UPDATE_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
for (const f of SYNC_HARDWARE_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
} else {
// Keep previous non-zero hardware if API still returns empty
const next = merged[f]
const prev = existing[f as keyof typeof existing]
if (
(next == null || next === 0 || next === '') &&
prev != null &&
prev !== 0 &&
prev !== ''
) {
merged[f] = prev as never
}
}
}
const compareFields = [
'ip',
'ipv6',
'dns',
...SYNC_UPDATE_FIELDS,
...SYNC_HARDWARE_FIELDS,
] as const
const changedFields = compareFields.filter(
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
)
@@ -184,6 +247,11 @@ export async function syncFromBillmanager(
monthlyRate: merged.monthlyRate,
paidUntil: merged.paidUntil,
notes: merged.notes,
vcpu: merged.vcpu,
ramGb: merged.ramGb,
diskGb: merged.diskGb,
diskType: merged.diskType,
virtualization: merged.virtualization,
})
.where(eq(schema.vps.id, existing.id))
.run()
@@ -0,0 +1,246 @@
/**
* Enrich VPS hardware specs when list `func=vds` has no CPU/RAM/disk
* (typical for FirstByte: only pricelist / pricelist_id).
*/
import { billmanagerRequest } from './client.js'
import { elemToObject } from './parsers.js'
import type { MappedVps } from './mappers.js'
/** Minimal tariff shape for matching (from vds.order). */
export type TariffSpecsSource = {
externalId: string
name: string
desc: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
}
export function needsHardwareSpecs(mapped: MappedVps): boolean {
return !mapped.vcpu || !mapped.ramGb || !mapped.diskGb
}
/** Match VDS list row → tariff from vds.order by pricelist_id / name. */
export function findTariffForVdsItem(
item: Record<string, string>,
tariffs: TariffSpecsSource[],
): TariffSpecsSource | undefined {
if (!tariffs.length) return undefined
const id = String(item.pricelist_id || '').trim()
if (id) {
const byId = tariffs.find((t) => String(t.externalId) === id)
if (byId && (byId.vcpu || byId.ramGb || byId.diskGb)) return byId
}
const names = [
String(item.pricelist || '').trim(),
String(item.intname || '').trim(),
].filter(Boolean)
for (const raw of names) {
const needle = raw.toLowerCase()
const hit = tariffs.find((t) => {
if (t.vcpu || t.ramGb || t.diskGb) {
const tName = (t.name || '').toLowerCase()
const tDesc = (t.desc || '').toLowerCase()
return (
tName === needle ||
tName.includes(needle) ||
needle.includes(tName) ||
tDesc.includes(needle)
)
}
return false
})
if (hit) return hit
}
// Same id even if specs empty (caller may still use name hints)
if (id) return tariffs.find((t) => String(t.externalId) === id)
return undefined
}
export function applyTariffSpecs(mapped: MappedVps, tariff: TariffSpecsSource): MappedVps {
return {
...mapped,
vcpu: mapped.vcpu || tariff.vcpu || 0,
ramGb: mapped.ramGb || tariff.ramGb || 0,
diskGb: mapped.diskGb || tariff.diskGb || 0,
diskType:
mapped.diskGb && mapped.diskType
? mapped.diskType
: tariff.diskType || mapped.diskType,
virtualization: mapped.virtualization || tariff.virtualization || 'KVM',
}
}
export function enrichMappedVpsFromTariffs(
item: Record<string, string>,
mapped: MappedVps,
tariffs: TariffSpecsSource[],
): MappedVps {
if (!needsHardwareSpecs(mapped)) return mapped
const tariff = findTariffForVdsItem(item, tariffs)
return tariff ? applyTariffSpecs(mapped, tariff) : mapped
}
function flattenModel(data: Record<string, unknown>): Record<string, string> {
const raw =
(data.model as Record<string, unknown> | undefined) ??
((data.doc as Record<string, unknown> | undefined)?.model as
| Record<string, unknown>
| undefined) ??
data
return elemToObject(raw)
}
function parseRamToGb(raw: string): number {
const s = String(raw || '').trim()
if (!s) return 0
const n = parseFloat(s.replace(/[^\d.]/g, ''))
if (!Number.isFinite(n) || n <= 0) return 0
// BM often stores RAM in MB for addons
if (/mb/i.test(s) || (!/gb/i.test(s) && n >= 128)) {
return Math.max(0.5, Math.round((n / 1024) * 10) / 10)
}
return n
}
function parseDiskGb(raw: string): number {
const n = parseFloat(String(raw || '').replace(/[^\d.]/g, ''))
return Number.isFinite(n) && n > 0 ? n : 0
}
function labelForAddon(
key: string,
data: Record<string, unknown>,
): string {
const messages = data.messages ?? (data.doc as Record<string, unknown> | undefined)?.messages
if (Array.isArray(messages)) {
for (const m of messages) {
if (!m || typeof m !== 'object') continue
const row = m as Record<string, unknown>
const name = String(row.name ?? row.k ?? '')
if (name === key || name.endsWith(key)) {
return String(row.msg ?? row.v ?? row.$ ?? '')
}
}
}
// metadata on fields sometimes: metadata.addon_N
const meta = data.metadata as Record<string, unknown> | undefined
if (meta && typeof meta[key] === 'object' && meta[key]) {
const m = meta[key] as Record<string, unknown>
return String(m.label ?? m.name ?? m.msg ?? '')
}
return ''
}
/** Parse CPU/RAM/disk from `func=vds.edit&elid=` form response. */
export function parseSpecsFromVdsEdit(
data: Record<string, unknown>,
): Pick<MappedVps, 'vcpu' | 'ramGb' | 'diskGb' | 'diskType' | 'virtualization'> {
const model = flattenModel(data)
let vcpu = 0
let ramGb = 0
let diskGb = 0
let diskType = ''
let virtualization = ''
for (const key of ['ncpu', 'cpu', 'vcpu', 'cpus', 'cpu_count'] as const) {
const n = parseInt(String(model[key] || ''), 10)
if (n > 0) {
vcpu = n
break
}
}
for (const key of ['ram', 'memory', 'mem', 'ram_mb', 'ram_gb'] as const) {
if (model[key]) {
ramGb = parseRamToGb(model[key]!)
if (ramGb) break
}
}
for (const key of ['disc', 'disk', 'hdd', 'disksize', 'disk_gb', 'hdd_gb'] as const) {
if (model[key]) {
diskGb = parseDiskGb(model[key]!)
if (diskGb) break
}
}
for (const [key, val] of Object.entries(model)) {
if (!key.startsWith('addon_') || !val) continue
const label = labelForAddon(key, data).toLowerCase()
const n = parseFloat(String(val).replace(/[^\d.]/g, ''))
if (!Number.isFinite(n) || n <= 0) continue
if (/ядер|процессор|cpu|v?cpu|core/i.test(label) && !vcpu) {
vcpu = Math.round(n)
} else if (/память|memory|ram|озу/i.test(label) && !ramGb) {
ramGb = parseRamToGb(String(val))
} else if (/диск|disk|hdd|ssd|nvme|sas|хранилище/i.test(label) && !diskGb) {
diskGb = parseDiskGb(String(val))
if (/nvme/i.test(label)) diskType = 'NVMe'
else if (/sas/i.test(label)) diskType = 'SAS'
else if (/hdd/i.test(label)) diskType = 'HDD'
else if (/ssd/i.test(label)) diskType = 'SSD'
}
}
const hay = `${model.pricelist || ''} ${model.intname || ''} ${model.name || ''}`
if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
if (!diskType) {
if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
}
return {
vcpu,
ramGb,
diskGb,
diskType: diskType || 'SSD',
virtualization: virtualization || 'KVM',
}
}
export function applyEditSpecs(
mapped: MappedVps,
specs: ReturnType<typeof parseSpecsFromVdsEdit>,
): MappedVps {
return {
...mapped,
vcpu: mapped.vcpu || specs.vcpu || 0,
ramGb: mapped.ramGb || specs.ramGb || 0,
diskGb: mapped.diskGb || specs.diskGb || 0,
diskType: mapped.diskGb ? mapped.diskType : specs.diskType || mapped.diskType,
virtualization: mapped.virtualization || specs.virtualization || 'KVM',
}
}
export async function fetchVdsEditSpecs(
baseUrl: string,
authinfo: string,
elid: string,
): Promise<ReturnType<typeof parseSpecsFromVdsEdit> | null> {
const id = String(elid || '').trim()
if (!id) return null
try {
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.edit', { elid: id })
const specs = parseSpecsFromVdsEdit(data)
if (!specs.vcpu && !specs.ramGb && !specs.diskGb) return null
return specs
} catch (err) {
console.warn(
'fetchVdsEditSpecs failed:',
id,
err instanceof Error ? err.message : err,
)
return null
}
}