@tetherto/mdk-skill 0.0.0 → 0.12.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/LICENSE +202 -0
- package/README.md +133 -0
- package/dist/skills/mdk/SKILL.md +114 -0
- package/dist/skills/mdk/references/architecture.md +89 -0
- package/dist/skills/mdk/references/glossary.md +27 -0
- package/dist/skills/mdk/references/mdk-contract.schema.json +138 -0
- package/dist/skills/mdk/references/package-index.md +53 -0
- package/dist/skills/mdk/references/protocol.md +95 -0
- package/dist/skills/mdk-create-app/SKILL.md +231 -0
- package/dist/skills/mdk-deployment/SKILL.md +161 -0
- package/dist/skills/mdk-gateway-plugin/SKILL.md +192 -0
- package/dist/skills/mdk-gateway-plugin/references/controller-patterns.md +99 -0
- package/dist/skills/mdk-gateway-plugin/references/plugin-authoring.md +121 -0
- package/dist/skills/mdk-publish-security/SKILL.md +177 -0
- package/dist/skills/mdk-publish-security/references/vuln-classes.md +151 -0
- package/dist/skills/mdk-publish-security/scripts/scan-packages.mjs +544 -0
- package/dist/skills/mdk-site-sizing/SKILL.md +160 -0
- package/dist/skills/mdk-site-sizing/eval/held-out.json +104 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers-mixed.json +24 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-10devices-1workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-15devices-2workers.json +24 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-200devices-4workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-500devices-4workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-50devices-2workers.json +23 -0
- package/dist/skills/mdk-site-sizing/references/envelope.json +284 -0
- package/dist/skills/mdk-site-sizing/references/envelope.md +42 -0
- package/dist/skills/mdk-site-sizing/scripts/compile-envelope.mjs +45 -0
- package/dist/skills/mdk-site-sizing/scripts/eval-held-out.mjs +50 -0
- package/dist/skills/mdk-site-sizing/scripts/lib.mjs +591 -0
- package/dist/skills/mdk-site-sizing/scripts/parse-site.mjs +25 -0
- package/dist/skills/mdk-site-sizing/scripts/size-site.mjs +49 -0
- package/dist/skills/mdk-ui-component/SKILL.md +207 -0
- package/dist/skills/mdk-ui-component/references/page-recipe.md +78 -0
- package/dist/skills/mdk-ui-component/references/ui-registry.json +1 -0
- package/dist/skills/mdk-worker-plugin/SKILL.md +198 -0
- package/dist/skills/mdk-worker-plugin/assets/mdk-contract.template.json +45 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/README.md +93 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/mdk-contract.json +26 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/mock/server.js +93 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/package.json +23 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/smoke.config.js +41 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/client.js +47 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/reboot.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/set-power-mode.js +10 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/hashrate-rt.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power-mode.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/temperature.js +5 -0
- package/dist/skills/mdk-worker-plugin/references/contract-authoring.md +135 -0
- package/dist/skills/mdk-worker-plugin/references/device-families.md +96 -0
- package/dist/skills/mdk-worker-plugin/references/local-testing.md +134 -0
- package/dist/skills/mdk-worker-plugin/references/mdk-contract.schema.json +138 -0
- package/dist/skills/mdk-worker-plugin/references/worker-base-api.md +152 -0
- package/dist/skills/mdk-worker-plugin/scripts/validate-contract.mjs +129 -0
- package/dist/skills/mdk-worker-plugin/scripts/worker-smoke.mjs +256 -0
- package/package.json +47 -1
- package/src/assemble.mjs +11 -0
- package/src/index.d.ts +57 -0
- package/src/index.mjs +254 -0
- package/src/install.mjs +57 -0
- package/src/sources.map.json +34 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
// Shared parse / compile / size / match logic for mdk-site-sizing.
|
|
2
|
+
// Zero-dependency. The CLI wrappers and the held-out eval all import this.
|
|
3
|
+
|
|
4
|
+
export const THRESHOLDS = {
|
|
5
|
+
headroomGreenBelow: 0.7,
|
|
6
|
+
headroomAmberBelow: 1.0,
|
|
7
|
+
actionSubmitP99Ms: 2000,
|
|
8
|
+
steadyCpuPctOfOneCore: 80
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FAMILIES = [
|
|
12
|
+
{
|
|
13
|
+
type: 'mdk-worker-whatsminer',
|
|
14
|
+
aliases: ['whatsminer', 'whatsminers', 'microbt'],
|
|
15
|
+
models: ['m56s', 'm50s', 'm30s', 'm60']
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
type: 'mdk-worker-antminer',
|
|
19
|
+
aliases: ['antminer', 'antminers', 'bitmain'],
|
|
20
|
+
models: ['s19xp', 's19 xp', 's19', 's21']
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'mdk-worker-avalon',
|
|
24
|
+
aliases: ['avalon', 'avalons', 'canaan'],
|
|
25
|
+
models: []
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const ONES = {
|
|
30
|
+
zero: 0, a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5,
|
|
31
|
+
six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12,
|
|
32
|
+
thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17,
|
|
33
|
+
eighteen: 18, nineteen: 19, handful: 5
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TENS = {
|
|
37
|
+
twenty: 20, thirty: 30, forty: 40, fifty: 50,
|
|
38
|
+
sixty: 60, seventy: 70, eighty: 80, ninety: 90
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATUS_RANK = { green: 0, amber: 1, red: 2 }
|
|
42
|
+
|
|
43
|
+
export function typeKey (workers) {
|
|
44
|
+
return [...new Set((workers || []).map((w) => w.type).filter(Boolean))].sort().join(',')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function familyForAlias (token) {
|
|
48
|
+
const t = String(token || '').toLowerCase().replace(/\s+/g, '')
|
|
49
|
+
for (const fam of FAMILIES) {
|
|
50
|
+
if (fam.type === token) return fam
|
|
51
|
+
if (fam.aliases.some((a) => a.replace(/\s+/g, '') === t)) return fam
|
|
52
|
+
if (fam.models.some((m) => m.replace(/\s+/g, '') === t)) return fam
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseNumberPhrase (raw) {
|
|
58
|
+
if (raw == null) return null
|
|
59
|
+
const text = String(raw).toLowerCase().replace(/,/g, '').trim()
|
|
60
|
+
if (!text) return null
|
|
61
|
+
const numeric = Number(text)
|
|
62
|
+
if (Number.isFinite(numeric)) return numeric
|
|
63
|
+
if (ONES[text] != null) return ONES[text]
|
|
64
|
+
if (TENS[text] != null) return TENS[text]
|
|
65
|
+
const hundred = text.match(/^(one|two|three|four|five|six|seven|eight|nine)\s+hundred$/)
|
|
66
|
+
if (hundred) return ONES[hundred[1]] * 100
|
|
67
|
+
const compound = text.match(/^(twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)[-\s](one|two|three|four|five|six|seven|eight|nine)$/)
|
|
68
|
+
if (compound) return TENS[compound[1]] + ONES[compound[2]]
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const NUMBER_TOKEN = String.raw`(?:\d[\d,]*|two\s+hundred|three\s+hundred|four\s+hundred|five\s+hundred|six\s+hundred|seven\s+hundred|eight\s+hundred|nine\s+hundred|one\s+hundred|twenty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|thirty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|forty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|fifty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|sixty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|seventy(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|eighty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|ninety(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|handful)`
|
|
73
|
+
|
|
74
|
+
function normalizeModel (raw) {
|
|
75
|
+
if (!raw) return null
|
|
76
|
+
return String(raw).toLowerCase().replace(/\s+/g, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseSiteDescription (text) {
|
|
80
|
+
const src = String(text || '').replace(/\s+/g, ' ').trim()
|
|
81
|
+
const devices = []
|
|
82
|
+
|
|
83
|
+
const familyAlt = FAMILIES.flatMap((f) => f.aliases).join('|')
|
|
84
|
+
const modelAlt = FAMILIES.flatMap((f) => f.models).map((m) => m.replace(/\s+/g, '\\s*')).join('|')
|
|
85
|
+
const pairRe = new RegExp(
|
|
86
|
+
`(${NUMBER_TOKEN})\\s+(?:of\\s+)?(${familyAlt})s?(?:\\s+(${modelAlt}))?`,
|
|
87
|
+
'gi'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for (const m of src.matchAll(pairRe)) {
|
|
91
|
+
const count = parseNumberPhrase(m[1])
|
|
92
|
+
const fam = familyForAlias(m[2])
|
|
93
|
+
if (count == null || !fam) continue
|
|
94
|
+
devices.push({ type: fam.type, model: normalizeModel(m[3]) || defaultModel(fam), count })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!devices.length) {
|
|
98
|
+
const modelRe = new RegExp(`(${NUMBER_TOKEN})\\s+(${modelAlt})\\b`, 'gi')
|
|
99
|
+
for (const m of src.matchAll(modelRe)) {
|
|
100
|
+
const count = parseNumberPhrase(m[1])
|
|
101
|
+
const fam = familyForAlias(normalizeModel(m[2]))
|
|
102
|
+
if (count == null || !fam) continue
|
|
103
|
+
devices.push({ type: fam.type, model: normalizeModel(m[2]), count })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const mentioned = new Set()
|
|
108
|
+
for (const fam of FAMILIES) {
|
|
109
|
+
const re = new RegExp(`\\b(?:${fam.aliases.join('|')})s?\\b`, 'i')
|
|
110
|
+
if (re.test(src)) mentioned.add(fam.type)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const totalFromDevices = devices.reduce((s, d) => s + (d.count || 0), 0)
|
|
114
|
+
let deviceCount = totalFromDevices || null
|
|
115
|
+
|
|
116
|
+
if (!devices.length && mentioned.size) {
|
|
117
|
+
const generic = src.match(new RegExp(`(${NUMBER_TOKEN})\\s+(?:miners?|asics?|devices?|boxes|units)\\b`, 'i'))
|
|
118
|
+
const count = generic ? parseNumberPhrase(generic[1]) : null
|
|
119
|
+
deviceCount = count
|
|
120
|
+
for (const type of mentioned) {
|
|
121
|
+
const fam = FAMILIES.find((f) => f.type === type)
|
|
122
|
+
devices.push({ type, model: defaultModel(fam), count: mentioned.size === 1 ? count : null })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!devices.length) {
|
|
127
|
+
const generic = src.match(new RegExp(`(${NUMBER_TOKEN})\\s+(?:miners?|asics?|devices?)\\b`, 'i'))
|
|
128
|
+
if (generic) {
|
|
129
|
+
deviceCount = parseNumberPhrase(generic[1])
|
|
130
|
+
devices.push({ type: 'mdk-worker-whatsminer', model: 'm56s', count: deviceCount, assumed: true })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const cores =
|
|
135
|
+
matchNumber(src, /(\d+)\s*-?\s*core\b/i) ??
|
|
136
|
+
matchNumber(src, /(\d+)\s+physical\s+cores?\b/i) ??
|
|
137
|
+
matchNumber(src, /(\d+)\s+cores?\b/i)
|
|
138
|
+
|
|
139
|
+
const ramGiB =
|
|
140
|
+
matchNumber(src, /(\d+(?:\.\d+)?)\s*(?:gi?b)\b/i) ??
|
|
141
|
+
matchNumber(src, /(\d+(?:\.\d+)?)\s*gb\b/i)
|
|
142
|
+
|
|
143
|
+
let diskType = null
|
|
144
|
+
if (/\bnvme\b/i.test(src)) diskType = 'nvme'
|
|
145
|
+
else if (/\bsata\b/i.test(src)) diskType = 'sata'
|
|
146
|
+
else if (/\bhdd\b/i.test(src)) diskType = 'hdd'
|
|
147
|
+
else if (/\b(?:sd(?:-card)?|microsd|sd-class)\b/i.test(src)) diskType = 'sd'
|
|
148
|
+
|
|
149
|
+
let tier = null
|
|
150
|
+
if (/\bsite[-\s]?server\b/i.test(src)) tier = 'site-server'
|
|
151
|
+
else if (/\bedge\b/i.test(src) || /\b(?:raspberry\s*)?pi\b/i.test(src)) tier = 'edge'
|
|
152
|
+
else tier = inferTier({ cpuCoresPhysical: cores, ramGiB, diskType })
|
|
153
|
+
|
|
154
|
+
const workerHint =
|
|
155
|
+
matchNumber(src, new RegExp(`(${NUMBER_TOKEN})\\s+worker\\s+processes`, 'i')) ??
|
|
156
|
+
matchNumber(src, new RegExp(`(${NUMBER_TOKEN})\\s+workers\\b`, 'i'))
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
description: src,
|
|
160
|
+
devices,
|
|
161
|
+
deviceCount: deviceCount ?? totalFromDevices ?? null,
|
|
162
|
+
host: {
|
|
163
|
+
tier,
|
|
164
|
+
cpuCoresPhysical: cores,
|
|
165
|
+
ramGiB,
|
|
166
|
+
diskType
|
|
167
|
+
},
|
|
168
|
+
workerCountHint: workerHint,
|
|
169
|
+
kernels: 1,
|
|
170
|
+
gateways: 1
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function defaultModel (fam) {
|
|
175
|
+
if (!fam) return null
|
|
176
|
+
if (fam.type === 'mdk-worker-whatsminer') return 'm56s'
|
|
177
|
+
if (fam.type === 'mdk-worker-antminer') return 's19xp'
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function matchNumber (src, re) {
|
|
182
|
+
const m = src.match(re)
|
|
183
|
+
return m ? parseNumberPhrase(m[1]) : null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function inferTier (host = {}) {
|
|
187
|
+
if (host.tier) return host.tier
|
|
188
|
+
const cores = host.cpuCoresPhysical
|
|
189
|
+
const ram = host.ramGiB
|
|
190
|
+
const disk = host.diskType
|
|
191
|
+
if (disk === 'sd' || disk === 'hdd') return 'edge'
|
|
192
|
+
if (cores != null && cores <= 8 && ram != null && ram <= 16) return 'edge'
|
|
193
|
+
if ((cores != null && cores >= 16) || (ram != null && ram >= 32) || disk === 'nvme') return 'site-server'
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function fromHarnessReport (raw) {
|
|
198
|
+
if (!raw || typeof raw !== 'object') throw new Error('profile is not an object')
|
|
199
|
+
if (raw.rssSumMiB != null && raw.status && raw.workers) {
|
|
200
|
+
return {
|
|
201
|
+
profileId: raw.profileId,
|
|
202
|
+
tier: raw.tier || raw.hardware?.referenceTier || 'custom',
|
|
203
|
+
hardware: raw.hardware || {},
|
|
204
|
+
deviceCount: raw.deviceCount,
|
|
205
|
+
workerCount: raw.workerCount,
|
|
206
|
+
workers: raw.workers,
|
|
207
|
+
status: raw.status,
|
|
208
|
+
headroomRatio: raw.headroomRatio,
|
|
209
|
+
rssSumMiB: raw.rssSumMiB,
|
|
210
|
+
cpuSumPct: raw.cpuSumPct,
|
|
211
|
+
readP99Ms: raw.readP99Ms,
|
|
212
|
+
actionP99Ms: raw.actionP99Ms,
|
|
213
|
+
source: raw.source || 'fixture'
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const summaries = Object.values(raw.resourceSummary || {})
|
|
218
|
+
const rssSum = summaries.reduce((s, x) => s + (x.avgRssMiB || 0), 0)
|
|
219
|
+
const cpuSum = summaries.reduce((s, x) => s + (x.avgCpuPct || 0), 0)
|
|
220
|
+
return {
|
|
221
|
+
profileId: raw.profileId,
|
|
222
|
+
tier: raw.hardware?.referenceTier || 'custom',
|
|
223
|
+
hardware: raw.hardware || {},
|
|
224
|
+
deviceCount: raw.deviceCount,
|
|
225
|
+
workerCount: raw.workerCount,
|
|
226
|
+
workers: (raw.workersBreakdown || []).map((w) => ({
|
|
227
|
+
type: w.type,
|
|
228
|
+
model: w.model || null,
|
|
229
|
+
deviceCount: w.deviceCount
|
|
230
|
+
})),
|
|
231
|
+
status: raw.verdict?.overall,
|
|
232
|
+
headroomRatio: raw.headroomRatio,
|
|
233
|
+
rssSumMiB: rssSum || null,
|
|
234
|
+
cpuSumPct: cpuSum || null,
|
|
235
|
+
readP99Ms: raw.latencies?.telemetrySingle?.p99Ms ?? raw.latencies?.gatewayTelemetrySingle?.p99Ms,
|
|
236
|
+
actionP99Ms: raw.latencies?.actionSubmit?.p99Ms ?? raw.latencies?.gatewayActionSubmit?.p99Ms,
|
|
237
|
+
source: 'measured'
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function fitLinear (points, yKey) {
|
|
242
|
+
const usable = points.filter((p) => p.deviceCount > 0 && Number.isFinite(p[yKey]))
|
|
243
|
+
if (usable.length < 2) {
|
|
244
|
+
const one = usable[0]
|
|
245
|
+
if (!one) return null
|
|
246
|
+
return { base: 0, perDevice: one[yKey] / one.deviceCount }
|
|
247
|
+
}
|
|
248
|
+
const sorted = [...usable].sort((a, b) => a.deviceCount - b.deviceCount)
|
|
249
|
+
const lo = sorted[0]
|
|
250
|
+
const hi = sorted[sorted.length - 1]
|
|
251
|
+
const span = hi.deviceCount - lo.deviceCount
|
|
252
|
+
if (span === 0) return { base: 0, perDevice: lo[yKey] / lo.deviceCount }
|
|
253
|
+
const perDevice = (hi[yKey] - lo[yKey]) / span
|
|
254
|
+
const base = lo[yKey] - lo.deviceCount * perDevice
|
|
255
|
+
return { base, perDevice }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function compileEnvelope (rawProfiles, { compiledAt = new Date().toISOString(), provenance } = {}) {
|
|
259
|
+
const profiles = rawProfiles.map(fromHarnessReport).sort((a, b) => a.profileId.localeCompare(b.profileId))
|
|
260
|
+
const measured = profiles.some((p) => p.source === 'measured')
|
|
261
|
+
const supportedUpTo = {}
|
|
262
|
+
const formulas = {}
|
|
263
|
+
|
|
264
|
+
const groups = new Map()
|
|
265
|
+
for (const p of profiles) {
|
|
266
|
+
const key = `${p.tier}|${typeKey(p.workers)}`
|
|
267
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
268
|
+
groups.get(key).push(p)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const [key, list] of groups) {
|
|
272
|
+
const green = list.filter((p) => p.status === 'green').sort((a, b) => b.deviceCount - a.deviceCount)
|
|
273
|
+
const densest = green[0]
|
|
274
|
+
const [tier, types] = key.split('|')
|
|
275
|
+
if (!supportedUpTo[tier]) supportedUpTo[tier] = {}
|
|
276
|
+
supportedUpTo[tier][types || '*'] = densest
|
|
277
|
+
? { devices: densest.deviceCount, workers: densest.workerCount, profileId: densest.profileId }
|
|
278
|
+
: null
|
|
279
|
+
formulas[key] = {
|
|
280
|
+
rssMiB: fitLinear(list.filter((p) => p.status !== 'red'), 'rssSumMiB'),
|
|
281
|
+
cpuPct: fitLinear(list.filter((p) => p.status !== 'red'), 'cpuSumPct')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
compiledAt,
|
|
287
|
+
provenance: provenance || {
|
|
288
|
+
kind: measured ? 'measured' : 'fixture',
|
|
289
|
+
note: measured
|
|
290
|
+
? 'Compiled from harness reports.'
|
|
291
|
+
: 'Fixture envelope for skill eval. Replace eval/profiles with committed harness reports (PE-1) and re-run compile-envelope.mjs.'
|
|
292
|
+
},
|
|
293
|
+
thresholds: THRESHOLDS,
|
|
294
|
+
profiles,
|
|
295
|
+
supportedUpTo,
|
|
296
|
+
formulas
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function scaleFrom (profile, totalD) {
|
|
301
|
+
if (!profile || !profile.deviceCount) return { rssSumMiB: null, cpuSumPct: null, readP99Ms: null, actionP99Ms: null }
|
|
302
|
+
const t = totalD / profile.deviceCount
|
|
303
|
+
return {
|
|
304
|
+
rssSumMiB: profile.rssSumMiB == null ? null : profile.rssSumMiB * t,
|
|
305
|
+
cpuSumPct: profile.cpuSumPct == null ? null : profile.cpuSumPct * t,
|
|
306
|
+
readP99Ms: profile.readP99Ms ?? null,
|
|
307
|
+
actionP99Ms: profile.actionP99Ms ?? null,
|
|
308
|
+
interpolatedBetween: [profile.profileId]
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function interpolateResources (totalD, smaller, larger) {
|
|
313
|
+
if (!larger) return { rssSumMiB: null, cpuSumPct: null, readP99Ms: null, actionP99Ms: null, interpolatedBetween: [] }
|
|
314
|
+
if (!smaller || smaller.profileId === larger.profileId) return scaleFrom(larger, totalD)
|
|
315
|
+
const span = larger.deviceCount - smaller.deviceCount
|
|
316
|
+
if (span <= 0) return scaleFrom(larger, totalD)
|
|
317
|
+
const t = (totalD - smaller.deviceCount) / span
|
|
318
|
+
const mix = (a, b) => (a == null || b == null ? b ?? a : a + t * (b - a))
|
|
319
|
+
return {
|
|
320
|
+
rssSumMiB: mix(smaller.rssSumMiB, larger.rssSumMiB),
|
|
321
|
+
cpuSumPct: mix(smaller.cpuSumPct, larger.cpuSumPct),
|
|
322
|
+
readP99Ms: mix(smaller.readP99Ms, larger.readP99Ms),
|
|
323
|
+
actionP99Ms: mix(smaller.actionP99Ms, larger.actionP99Ms),
|
|
324
|
+
interpolatedBetween: [smaller.profileId, larger.profileId]
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function unmeasured (facts, reason) {
|
|
329
|
+
return {
|
|
330
|
+
status: 'unmeasured',
|
|
331
|
+
reason,
|
|
332
|
+
tier: facts.host?.tier || null,
|
|
333
|
+
deviceCount: facts.deviceCount,
|
|
334
|
+
workerCount: null,
|
|
335
|
+
devicesPerWorker: null,
|
|
336
|
+
kernels: facts.kernels ?? 1,
|
|
337
|
+
gateways: facts.gateways ?? 1,
|
|
338
|
+
matchingProfileId: null,
|
|
339
|
+
rssSumMiB: null,
|
|
340
|
+
cpuSumPct: null,
|
|
341
|
+
readP99Ms: null,
|
|
342
|
+
actionP99Ms: null,
|
|
343
|
+
envelope: { supportedUpTo: null, nearestProfileId: null },
|
|
344
|
+
caveats: [
|
|
345
|
+
reason === 'family-not-in-envelope'
|
|
346
|
+
? 'No measured profile covers this device family. Run the capacity harness before quoting a size.'
|
|
347
|
+
: 'Not enough measured data to size this site. Run the capacity harness on a matching host.'
|
|
348
|
+
]
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function beyondEnvelope ({ facts, nearest, supportedUpTo, resources, workerCount, devicesPerWorker }) {
|
|
353
|
+
return {
|
|
354
|
+
status: 'beyond-envelope',
|
|
355
|
+
reason: 'requested-devices-exceed-green-and-amber',
|
|
356
|
+
tier: facts.host?.tier || nearest?.tier || null,
|
|
357
|
+
deviceCount: facts.deviceCount,
|
|
358
|
+
workerCount,
|
|
359
|
+
devicesPerWorker,
|
|
360
|
+
kernels: facts.kernels ?? 1,
|
|
361
|
+
gateways: facts.gateways ?? 1,
|
|
362
|
+
matchingProfileId: nearest?.profileId || null,
|
|
363
|
+
rssSumMiB: resources.rssSumMiB,
|
|
364
|
+
cpuSumPct: resources.cpuSumPct,
|
|
365
|
+
readP99Ms: resources.readP99Ms,
|
|
366
|
+
actionP99Ms: resources.actionP99Ms,
|
|
367
|
+
envelope: {
|
|
368
|
+
supportedUpTo: supportedUpTo
|
|
369
|
+
? { devices: supportedUpTo.devices, workers: supportedUpTo.workers, profileId: supportedUpTo.profileId }
|
|
370
|
+
: null,
|
|
371
|
+
nearestProfileId: nearest?.profileId || null
|
|
372
|
+
},
|
|
373
|
+
caveats: [
|
|
374
|
+
`Requested ${facts.deviceCount} devices is past the densest green profile` +
|
|
375
|
+
(supportedUpTo ? ` (${supportedUpTo.profileId}, ${supportedUpTo.devices} devices)` : '') +
|
|
376
|
+
'. Do not claim this layout is supported. Run the harness at this size.'
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function sizeSite (facts, envelope) {
|
|
382
|
+
const devices = facts.devices || []
|
|
383
|
+
const totalD = facts.deviceCount ?? devices.reduce((s, d) => s + (Number(d.count) || 0), 0)
|
|
384
|
+
const types = [...new Set(devices.map((d) => d.type).filter(Boolean))].sort()
|
|
385
|
+
const tier = facts.host?.tier || inferTier(facts.host)
|
|
386
|
+
facts = { ...facts, deviceCount: totalD, host: { ...facts.host, tier } }
|
|
387
|
+
|
|
388
|
+
if (!types.length) return unmeasured(facts, 'family-not-in-envelope')
|
|
389
|
+
const knownTypes = new Set(envelope.profiles.flatMap((p) => p.workers.map((w) => w.type)))
|
|
390
|
+
if (types.some((t) => !knownTypes.has(t))) return unmeasured(facts, 'family-not-in-envelope')
|
|
391
|
+
if (!totalD) return unmeasured(facts, 'device-count-unknown')
|
|
392
|
+
|
|
393
|
+
const mixKey = types.join(',')
|
|
394
|
+
const sameMix = envelope.profiles.filter((p) => typeKey(p.workers) === mixKey)
|
|
395
|
+
if (!sameMix.length) return unmeasured(facts, 'family-not-in-envelope')
|
|
396
|
+
|
|
397
|
+
let candidates = sameMix.filter((p) => !tier || p.tier === tier || p.tier === 'custom')
|
|
398
|
+
if (!candidates.length) candidates = sameMix
|
|
399
|
+
|
|
400
|
+
const covering = [...candidates]
|
|
401
|
+
.filter((p) => p.deviceCount >= totalD)
|
|
402
|
+
.sort((a, b) => (STATUS_RANK[a.status] - STATUS_RANK[b.status]) || (a.deviceCount - b.deviceCount))
|
|
403
|
+
|
|
404
|
+
const greenCovering = covering.filter((p) => p.status === 'green')
|
|
405
|
+
const amberCovering = covering.filter((p) => p.status === 'amber')
|
|
406
|
+
const maxGreen = [...candidates].filter((p) => p.status === 'green').sort((a, b) => b.deviceCount - a.deviceCount)[0]
|
|
407
|
+
const maxNonRed = [...candidates].filter((p) => p.status !== 'red').sort((a, b) => b.deviceCount - a.deviceCount)[0]
|
|
408
|
+
const maxAny = [...candidates].sort((a, b) => b.deviceCount - a.deviceCount)[0]
|
|
409
|
+
|
|
410
|
+
const supported = maxGreen
|
|
411
|
+
? { devices: maxGreen.deviceCount, workers: maxGreen.workerCount, profileId: maxGreen.profileId }
|
|
412
|
+
: null
|
|
413
|
+
|
|
414
|
+
if (greenCovering.length) {
|
|
415
|
+
const chosen = greenCovering[0]
|
|
416
|
+
const smaller = [...candidates]
|
|
417
|
+
.filter((p) => p.status === 'green' && p.deviceCount <= totalD)
|
|
418
|
+
.sort((a, b) => b.deviceCount - a.deviceCount)[0] || chosen
|
|
419
|
+
const devicesPerWorker = chosen.deviceCount / chosen.workerCount
|
|
420
|
+
const workerCount = Math.max(1, Math.ceil(totalD / devicesPerWorker))
|
|
421
|
+
const resources = interpolateResources(totalD, smaller, chosen)
|
|
422
|
+
return {
|
|
423
|
+
status: 'green',
|
|
424
|
+
reason: totalD === chosen.deviceCount ? 'exact-profile' : 'within-green-envelope',
|
|
425
|
+
tier: chosen.tier === 'custom' ? (tier || 'custom') : chosen.tier,
|
|
426
|
+
deviceCount: totalD,
|
|
427
|
+
workerCount,
|
|
428
|
+
devicesPerWorker,
|
|
429
|
+
kernels: facts.kernels ?? 1,
|
|
430
|
+
gateways: facts.gateways ?? 1,
|
|
431
|
+
matchingProfileId: chosen.profileId,
|
|
432
|
+
workers: scaleWorkers(devices, workerCount, totalD),
|
|
433
|
+
rssSumMiB: resources.rssSumMiB,
|
|
434
|
+
cpuSumPct: resources.cpuSumPct,
|
|
435
|
+
readP99Ms: resources.readP99Ms,
|
|
436
|
+
actionP99Ms: resources.actionP99Ms,
|
|
437
|
+
interpolatedBetween: resources.interpolatedBetween,
|
|
438
|
+
envelope: {
|
|
439
|
+
supportedUpTo: supported,
|
|
440
|
+
nearestProfileId: chosen.profileId
|
|
441
|
+
},
|
|
442
|
+
caveats: chosen.source === 'fixture'
|
|
443
|
+
? ['Envelope is fixture data until PE-1 commits a measured harness report.']
|
|
444
|
+
: []
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (amberCovering.length) {
|
|
449
|
+
const chosen = amberCovering[0]
|
|
450
|
+
const devicesPerWorker = chosen.deviceCount / chosen.workerCount
|
|
451
|
+
const workerCount = Math.max(1, Math.ceil(totalD / devicesPerWorker))
|
|
452
|
+
const resources = scaleFrom(chosen, totalD)
|
|
453
|
+
return {
|
|
454
|
+
status: 'amber',
|
|
455
|
+
reason: 'within-amber-envelope',
|
|
456
|
+
tier: chosen.tier === 'custom' ? (tier || 'custom') : chosen.tier,
|
|
457
|
+
deviceCount: totalD,
|
|
458
|
+
workerCount,
|
|
459
|
+
devicesPerWorker,
|
|
460
|
+
kernels: facts.kernels ?? 1,
|
|
461
|
+
gateways: facts.gateways ?? 1,
|
|
462
|
+
matchingProfileId: chosen.profileId,
|
|
463
|
+
workers: scaleWorkers(devices, workerCount, totalD),
|
|
464
|
+
rssSumMiB: resources.rssSumMiB,
|
|
465
|
+
cpuSumPct: resources.cpuSumPct,
|
|
466
|
+
readP99Ms: resources.readP99Ms,
|
|
467
|
+
actionP99Ms: resources.actionP99Ms,
|
|
468
|
+
interpolatedBetween: resources.interpolatedBetween,
|
|
469
|
+
envelope: {
|
|
470
|
+
supportedUpTo: supported,
|
|
471
|
+
nearestProfileId: chosen.profileId
|
|
472
|
+
},
|
|
473
|
+
caveats: [
|
|
474
|
+
`${chosen.profileId} is amber at ${chosen.deviceCount} devices. Treat as little spare capacity, not a supported-up-to bound.`
|
|
475
|
+
]
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const nearest = maxNonRed || maxAny
|
|
480
|
+
const devicesPerWorker = nearest && nearest.workerCount
|
|
481
|
+
? nearest.deviceCount / nearest.workerCount
|
|
482
|
+
: null
|
|
483
|
+
const workerCount = devicesPerWorker ? Math.max(1, Math.ceil(totalD / devicesPerWorker)) : null
|
|
484
|
+
return beyondEnvelope({
|
|
485
|
+
facts,
|
|
486
|
+
nearest: maxAny,
|
|
487
|
+
supportedUpTo: supported,
|
|
488
|
+
resources: nearest ? scaleFrom(nearest, totalD) : {},
|
|
489
|
+
workerCount,
|
|
490
|
+
devicesPerWorker
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function scaleWorkers (devices, workerCount, totalD) {
|
|
495
|
+
if (!devices.length || !workerCount) return []
|
|
496
|
+
if (devices.every((d) => d.count == null) && totalD) {
|
|
497
|
+
const share = Math.ceil(totalD / devices.length)
|
|
498
|
+
return devices.map((d, i) => ({
|
|
499
|
+
type: d.type,
|
|
500
|
+
model: d.model,
|
|
501
|
+
deviceCount: i === devices.length - 1 ? totalD - share * (devices.length - 1) : share,
|
|
502
|
+
workerCount: 1
|
|
503
|
+
}))
|
|
504
|
+
}
|
|
505
|
+
return devices.map((d) => ({
|
|
506
|
+
type: d.type,
|
|
507
|
+
model: d.model,
|
|
508
|
+
deviceCount: d.count,
|
|
509
|
+
workerCount: Math.max(1, Math.ceil((d.count || 0) / Math.max(1, Math.ceil(totalD / workerCount))))
|
|
510
|
+
}))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function matchRecommendation (rec, expected, { rssTolerance = 0.2 } = {}) {
|
|
514
|
+
const errors = []
|
|
515
|
+
if (expected.status && rec.status !== expected.status) {
|
|
516
|
+
errors.push(`status: got ${rec.status}, expected ${expected.status}`)
|
|
517
|
+
}
|
|
518
|
+
if (expected.tier && rec.tier !== expected.tier) {
|
|
519
|
+
errors.push(`tier: got ${rec.tier}, expected ${expected.tier}`)
|
|
520
|
+
}
|
|
521
|
+
const kernels = expected.kernels ?? 1
|
|
522
|
+
const gateways = expected.gateways ?? 1
|
|
523
|
+
if (rec.kernels !== kernels) errors.push(`kernels: got ${rec.kernels}, expected ${kernels}`)
|
|
524
|
+
if (rec.gateways !== gateways) errors.push(`gateways: got ${rec.gateways}, expected ${gateways}`)
|
|
525
|
+
|
|
526
|
+
if (expected.workerCount != null) {
|
|
527
|
+
const [lo, hi] = Array.isArray(expected.workerCount)
|
|
528
|
+
? expected.workerCount
|
|
529
|
+
: [expected.workerCount, expected.workerCount]
|
|
530
|
+
if (rec.workerCount < lo || rec.workerCount > hi) {
|
|
531
|
+
errors.push(`workerCount: got ${rec.workerCount}, expected [${lo}, ${hi}]`)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (expected.matchingProfileId && rec.matchingProfileId !== expected.matchingProfileId) {
|
|
536
|
+
errors.push(`matchingProfileId: got ${rec.matchingProfileId}, expected ${expected.matchingProfileId}`)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (expected.supportedUpToDevices != null) {
|
|
540
|
+
const got = rec.envelope?.supportedUpTo?.devices
|
|
541
|
+
if (got !== expected.supportedUpToDevices) {
|
|
542
|
+
errors.push(`supportedUpTo.devices: got ${got}, expected ${expected.supportedUpToDevices}`)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (expected.rssSumMiB != null && rec.rssSumMiB != null) {
|
|
547
|
+
const [lo, hi] = Array.isArray(expected.rssSumMiB)
|
|
548
|
+
? expected.rssSumMiB
|
|
549
|
+
: [expected.rssSumMiB * (1 - rssTolerance), expected.rssSumMiB * (1 + rssTolerance)]
|
|
550
|
+
if (rec.rssSumMiB < lo || rec.rssSumMiB > hi) {
|
|
551
|
+
errors.push(`rssSumMiB: got ${rec.rssSumMiB}, expected [${lo}, ${hi}]`)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { ok: errors.length === 0, errors }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function renderRecommendation (rec) {
|
|
559
|
+
const rss = rec.rssSumMiB == null ? '_' : `${Math.round(rec.rssSumMiB)} MiB`
|
|
560
|
+
const cpu = rec.cpuSumPct == null ? '_' : `${rec.cpuSumPct.toFixed(1)}% of 1 core (sum)`
|
|
561
|
+
const lines = [
|
|
562
|
+
`# Site sizing recommendation`,
|
|
563
|
+
``,
|
|
564
|
+
`| Field | Value |`,
|
|
565
|
+
`| --- | --- |`,
|
|
566
|
+
`| Status | **${rec.status}** |`,
|
|
567
|
+
`| Reference tier | ${rec.tier ?? '_'} |`,
|
|
568
|
+
`| Devices | ${rec.deviceCount ?? '_'} |`,
|
|
569
|
+
`| Workers | ${rec.workerCount ?? '_'} |`,
|
|
570
|
+
`| Devices per Worker | ${rec.devicesPerWorker == null ? '_' : rec.devicesPerWorker} |`,
|
|
571
|
+
`| Kernels | ${rec.kernels} |`,
|
|
572
|
+
`| Gateways | ${rec.gateways} |`,
|
|
573
|
+
`| Matching profile | ${rec.matchingProfileId ?? '_'} |`,
|
|
574
|
+
`| Estimated RSS (site) | ${rss} |`,
|
|
575
|
+
`| Estimated CPU (site) | ${cpu} |`,
|
|
576
|
+
`| Read p99 | ${rec.readP99Ms == null ? '_' : `${Math.round(rec.readP99Ms)} ms`} |`,
|
|
577
|
+
`| Action p99 | ${rec.actionP99Ms == null ? '_' : `${Math.round(rec.actionP99Ms)} ms`} |`
|
|
578
|
+
]
|
|
579
|
+
if (rec.envelope?.supportedUpTo) {
|
|
580
|
+
const s = rec.envelope.supportedUpTo
|
|
581
|
+
lines.push(``, `Supported up to (densest green): **${s.devices} devices / ${s.workers} Workers** (\`${s.profileId}\`).`)
|
|
582
|
+
}
|
|
583
|
+
if (rec.caveats?.length) {
|
|
584
|
+
lines.push(``, `## Caveats`, ...rec.caveats.map((c) => `- ${c}`))
|
|
585
|
+
}
|
|
586
|
+
lines.push(
|
|
587
|
+
``,
|
|
588
|
+
`Numbers come from the measured envelope, not from a guess. Hand off to \`mdk-deployment\` to write \`mdk.yaml\`.`
|
|
589
|
+
)
|
|
590
|
+
return lines.join('\n') + '\n'
|
|
591
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Parse a plain site description into sizing facts JSON (stdout).
|
|
3
|
+
//
|
|
4
|
+
// node parse-site.mjs "ten Whatsminer M56S on an edge box, 4 cores, 8 GiB, SATA"
|
|
5
|
+
// node parse-site.mjs < site.txt (preferred for operator-written text)
|
|
6
|
+
|
|
7
|
+
import { parseSiteDescription } from './lib.mjs'
|
|
8
|
+
|
|
9
|
+
const fromArgs = process.argv.slice(2).join(' ').trim()
|
|
10
|
+
const text = fromArgs || await readStdin()
|
|
11
|
+
if (!text) {
|
|
12
|
+
console.error('usage: node parse-site.mjs <site description>')
|
|
13
|
+
process.exit(2)
|
|
14
|
+
}
|
|
15
|
+
process.stdout.write(JSON.stringify(parseSiteDescription(text), null, 2) + '\n')
|
|
16
|
+
|
|
17
|
+
function readStdin () {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
if (process.stdin.isTTY) return resolve('')
|
|
20
|
+
let buf = ''
|
|
21
|
+
process.stdin.setEncoding('utf8')
|
|
22
|
+
process.stdin.on('data', (c) => { buf += c })
|
|
23
|
+
process.stdin.on('end', () => resolve(buf.trim()))
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Size a site from facts JSON or a plain description. Prints JSON (default)
|
|
3
|
+
// or Markdown (--md). Never invents numbers: lookup is envelope-only.
|
|
4
|
+
//
|
|
5
|
+
// node size-site.mjs facts.json
|
|
6
|
+
// node size-site.mjs --description-file site.txt
|
|
7
|
+
// node size-site.mjs --description "eighty Whatsminer M56S on a site-server"
|
|
8
|
+
//
|
|
9
|
+
// Prefer --description-file for operator-written text: it never passes through
|
|
10
|
+
// a shell, so quotes, `$( )` or backticks in the description are inert.
|
|
11
|
+
// node size-site.mjs --envelope ../references/envelope.json facts.json --md
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { parseSiteDescription, sizeSite, renderRecommendation } from './lib.mjs'
|
|
17
|
+
|
|
18
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const DEFAULT_ENVELOPE = path.resolve(SCRIPT_DIR, '..', 'references', 'envelope.json')
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
let envelopePath = DEFAULT_ENVELOPE
|
|
23
|
+
let description = null
|
|
24
|
+
let factsPath = null
|
|
25
|
+
let asMd = false
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const a = args[i]
|
|
29
|
+
if (a === '--md') asMd = true
|
|
30
|
+
else if (a === '--envelope') envelopePath = args[++i]
|
|
31
|
+
else if (a === '--description') description = args[++i]
|
|
32
|
+
else if (a === '--description-file') description = fs.readFileSync(args[++i], 'utf8').trim()
|
|
33
|
+
else if (a.startsWith('-')) {
|
|
34
|
+
console.error(`unknown flag ${a}`)
|
|
35
|
+
process.exit(2)
|
|
36
|
+
} else factsPath = a
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!description && !factsPath) {
|
|
40
|
+
console.error('usage: node size-site.mjs [--envelope <path>] [--md] <facts.json | --description-file <file> | --description "...">')
|
|
41
|
+
process.exit(2)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const envelope = JSON.parse(fs.readFileSync(envelopePath, 'utf8'))
|
|
45
|
+
const facts = description
|
|
46
|
+
? parseSiteDescription(description)
|
|
47
|
+
: JSON.parse(fs.readFileSync(factsPath, 'utf8'))
|
|
48
|
+
const rec = sizeSite(facts, envelope)
|
|
49
|
+
process.stdout.write(asMd ? renderRecommendation(rec) : JSON.stringify({ facts, recommendation: rec }, null, 2) + '\n')
|