spinuptui 0.19.0

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,161 @@
1
+ // Pure helpers for the "create a server" flow (backlog item 5, build 1).
2
+ // No I/O — the API calls live in the client; the orchestration in the store.
3
+ // Here: map a source server's provider/specs onto the provider metadata catalog,
4
+ // format specs + cost, and suggest a server name from the existing fleet.
5
+
6
+ import type { Server, ProviderMetadata, ProviderSize, ProviderRegion } from "../api/types.ts"
7
+
8
+ // The provider keys the API recognizes for metadata + create.
9
+ export type ProviderKey = "digitalocean" | "vultr" | "linode" | "hetzner"
10
+ export const PROVIDER_KEYS: ProviderKey[] = ["digitalocean", "vultr", "linode", "hetzner"]
11
+
12
+ // All regions across continents, flattened in catalog order (for the picker).
13
+ export function allRegions(md: ProviderMetadata | undefined): ProviderRegion[] {
14
+ if (!md) return []
15
+ return Object.values(md.regions).flat()
16
+ }
17
+
18
+ // A sensible default region (first available, else first overall).
19
+ export function firstRegion(md: ProviderMetadata | undefined): ProviderRegion | undefined {
20
+ const all = allRegions(md)
21
+ return all.find((r) => r.available !== false) ?? all[0]
22
+ }
23
+
24
+ // Normalize the API's free-form provider_name ("DigitalOcean", "Akamai/Linode",
25
+ // "Hetzner Cloud") to the key the metadata + create endpoints use.
26
+ export function providerKeyFromName(name: string | null | undefined): ProviderKey | null {
27
+ const n = (name ?? "").toLowerCase()
28
+ if (n.includes("digitalocean") || n.includes("digital ocean")) return "digitalocean"
29
+ if (n.includes("vultr")) return "vultr"
30
+ if (n.includes("linode") || n.includes("akamai")) return "linode"
31
+ if (n.includes("hetzner")) return "hetzner"
32
+ return null
33
+ }
34
+
35
+ // A human label for a provider key (for the form + confirm screens).
36
+ export function providerLabel(key: ProviderKey): string {
37
+ return { digitalocean: "DigitalOcean", vultr: "Vultr", linode: "Linode", hetzner: "Hetzner" }[key]
38
+ }
39
+
40
+ export function sizeBySlug(md: ProviderMetadata | undefined, slug: string | null | undefined): ProviderSize | undefined {
41
+ if (!md || !slug) return undefined
42
+ return md.sizes.find((s) => s.slug === slug)
43
+ }
44
+
45
+ // Flatten the continent-grouped regions to find one by slug. Case-insensitive
46
+ // because a Server's `region` comes back as a code ("HIL") while the metadata
47
+ // slugs are lowercase ("hil").
48
+ export function regionBySlug(md: ProviderMetadata | undefined, slug: string | null | undefined): ProviderRegion | undefined {
49
+ if (!md || !slug) return undefined
50
+ const target = slug.toLowerCase()
51
+ for (const group of Object.values(md.regions)) {
52
+ const hit = group.find((r) => r.slug.toLowerCase() === target)
53
+ if (hit) return hit
54
+ }
55
+ return undefined
56
+ }
57
+
58
+ // Parse a Server's human-readable `size` ("8 GB / 4 vCPUs") into specs. The API
59
+ // returns this display string, NOT a size slug, so matching to the catalog is by
60
+ // vCPU + memory rather than by slug.
61
+ export function parseSizeSpec(display: string | null | undefined): { vcpus: number | null; memoryMb: number | null } {
62
+ const d = display ?? ""
63
+ const mem = /(\d+(?:\.\d+)?)\s*(gb|mb)/i.exec(d)
64
+ const memoryMb = mem ? Math.round(parseFloat(mem[1]) * (/gb/i.test(mem[2]) ? 1024 : 1)) : null
65
+ const cpu = /(\d+)\s*v?cpu/i.exec(d)
66
+ const vcpus = cpu ? parseInt(cpu[1], 10) : null
67
+ return { vcpus, memoryMb }
68
+ }
69
+
70
+ // Match a vCPU+memory spec to the closest size slug from a CANDIDATE list. Pass
71
+ // the region's available sizes (sizesForRegion) — NOT the whole catalog — so we
72
+ // never match a size that isn't offered in the chosen region. (A provider can have
73
+ // several sizes with identical specs across region-specific lines, e.g. Hetzner's
74
+ // EU-only `cx33` vs the global `cpx31`, both 4 vCPU / 8 GB; matching globally can
75
+ // pick one the region doesn't sell, which then gets dropped.) This is the basis
76
+ // for carrying a chosen spec across providers when switching. Returns null only
77
+ // when the candidate list is empty.
78
+ export function matchSizeBySpec(sizes: ProviderSize[], vcpus: number | null, memoryMb: number | null): string | null {
79
+ if (sizes.length === 0) return null
80
+ const byCpu = vcpus != null ? sizes.filter((s) => s.vcpus === vcpus) : []
81
+ const pool = byCpu.length ? byCpu : sizes
82
+ if (memoryMb == null) return pool[0].slug
83
+ let best = pool[0]
84
+ let bestDiff = Infinity
85
+ for (const s of pool) {
86
+ const diff = Math.abs(s.memory - memoryMb)
87
+ if (diff < bestDiff) {
88
+ bestDiff = diff
89
+ best = s
90
+ }
91
+ }
92
+ return best.slug
93
+ }
94
+
95
+ // Convenience: match a Server's display `size` string ("8 GB / 4 vCPUs") against a
96
+ // candidate list.
97
+ export function matchSizeSlug(sizes: ProviderSize[], display: string | null | undefined): string | null {
98
+ const { vcpus, memoryMb } = parseSizeSpec(display)
99
+ return matchSizeBySpec(sizes, vcpus, memoryMb)
100
+ }
101
+
102
+ // The sizes a given region offers, as full size objects, in catalog order.
103
+ // Falls back to all sizes if the region isn't found (older/unknown region slug).
104
+ export function sizesForRegion(md: ProviderMetadata | undefined, regionSlug: string | null | undefined): ProviderSize[] {
105
+ if (!md) return []
106
+ const region = regionBySlug(md, regionSlug)
107
+ if (!region) return md.sizes
108
+ const allowed = new Set(region.sizes)
109
+ const inRegion = md.sizes.filter((s) => allowed.has(s.slug))
110
+ return inRegion.length ? inRegion : md.sizes
111
+ }
112
+
113
+ // "2 vCPU / 4 GB / 80 GB" — memory comes back in MB.
114
+ export function formatSize(s: ProviderSize): string {
115
+ const mem = s.memory >= 1024 ? `${Math.round(s.memory / 1024)} GB` : `${s.memory} MB`
116
+ return `${s.vcpus} vCPU / ${mem} / ${s.disk} GB`
117
+ }
118
+
119
+ // "$8.49/mo" (+ backups when enabled and priced).
120
+ export function formatCost(s: ProviderSize | undefined, backups = false): string {
121
+ if (!s || typeof s.priceMonthly !== "number") return "—"
122
+ let total = s.priceMonthly
123
+ if (backups && typeof s.backupPriceMonthly === "number") total += s.backupPriceMonthly
124
+ return `$${total.toFixed(2)}/mo`
125
+ }
126
+
127
+ // Parse a name into (prefix, number, suffix) when it has a numbered component,
128
+ // e.g. "web11.example.com" → ["web", 11, ".example.com"]. Null when unnumbered.
129
+ function parseNumbered(name: string): { prefix: string; num: number; suffix: string } | null {
130
+ const m = /^(.*?)(\d+)(\D.*)?$/.exec(name ?? "")
131
+ if (!m) return null
132
+ return { prefix: m[1], num: Number(m[2]), suffix: m[3] ?? "" }
133
+ }
134
+
135
+ // Suggest the next server name from the fleet's naming convention. Detects a
136
+ // numbered pattern (prefix + number + optional suffix, e.g. "web11.example.com")
137
+ // and proposes the next in the dominant series ("web12.example.com"). Returns ""
138
+ // when no numbered convention is detectable — the user names it themselves.
139
+ export function suggestServerName(servers: Server[]): string {
140
+ // Group numbered names by (prefix, suffix); track the count + the max number.
141
+ const groups = new Map<string, { prefix: string; suffix: string; max: number; count: number }>()
142
+ for (const s of servers) {
143
+ const p = parseNumbered(s.name ?? "")
144
+ if (!p) continue
145
+ const key = JSON.stringify([p.prefix, p.suffix]) // unambiguous composite key
146
+ const g = groups.get(key)
147
+ if (!g) groups.set(key, { prefix: p.prefix, suffix: p.suffix, max: p.num, count: 1 })
148
+ else {
149
+ g.max = Math.max(g.max, p.num)
150
+ g.count += 1
151
+ }
152
+ }
153
+ if (groups.size === 0) return ""
154
+ // Prefer the convention used by the most servers; break ties by higher number.
155
+ let best: { prefix: string; suffix: string; max: number; count: number } | null = null
156
+ for (const g of groups.values()) {
157
+ if (!best || g.count > best.count || (g.count === best.count && g.max > best.max)) best = g
158
+ }
159
+ if (!best) return ""
160
+ return `${best.prefix}${best.max + 1}${best.suffix}`
161
+ }
@@ -0,0 +1,260 @@
1
+ // The site doctor (site monitoring Phase 2) — an on-demand, READ-ONLY diagnosis
2
+ // of the "200 OK but wrong page" failure class. Pure HTTP: no SSH, no Kuma, no
3
+ // writes, so it works on any site with zero setup.
4
+ //
5
+ // The centerpiece is the cache differential. SpinupWP's nginx page cache
6
+ // answers with `fastcgi-cache: HIT|MISS|BYPASS` and honors the default
7
+ // `wordpress_no_cache` cookie exclusion (both verified live on production
8
+ // boxes, and the header set is what the spinupwp plugin registers with WP
9
+ // core's Site Health page-cache test). So the doctor fetches the page twice —
10
+ // once normally (cache-eligible) and once with the bypass cookie (same URL,
11
+ // only the cache layer differs) — and compares WP's body_class() template
12
+ // tokens between the two. A mismatch is PROOF the cache is serving a different
13
+ // template than PHP renders right now: the purge-fixable condition, diagnosed
14
+ // rather than guessed. The headers tell us which layer answered each request,
15
+ // so the differential asserts its own validity instead of assuming it.
16
+ //
17
+ // Verdict precedence: down > partial-outage > stale-cache > recalibrate >
18
+ // healthy, with "inconclusive" when the evidence doesn't support any of them.
19
+ // The doctor ends at diagnosis + a copyable runbook — deliberately no auto-heal
20
+ // (the operator does the surgery; builder-specific lines appear only on
21
+ // positive evidence in the served markup).
22
+
23
+ import { bodyClassOf } from "./siteFingerprint.ts"
24
+
25
+ export interface DoctorCheck {
26
+ label: string
27
+ status: "ok" | "warn" | "bad" | "info"
28
+ detail: string
29
+ }
30
+
31
+ // "partial-outage" is the nastiest verdict: cached pages serve 200 while fresh
32
+ // renders (or the wp-admin door) throw 5xx — anonymous visitors look fine, but
33
+ // admins, logged-in users and everything uncached is failing. Verified real:
34
+ // SpinupWP's default object-cache drop-in makes a dead Redis FATAL on every
35
+ // page-cache miss, and a plugin/theme fatal produces the same signature.
36
+ // Page-watching monitors sleep straight through it; this is where the doctor
37
+ // catches it.
38
+ export type DoctorVerdict = "healthy" | "stale-cache" | "recalibrate" | "partial-outage" | "down" | "inconclusive"
39
+
40
+ export interface DoctorReport {
41
+ verdict: DoctorVerdict
42
+ summary: string
43
+ checks: DoctorCheck[]
44
+ runbook: string[] // shell lines (plus comments) when something is fixable; empty otherwise
45
+ }
46
+
47
+ interface Probe {
48
+ ok: boolean
49
+ status: number
50
+ ms: number
51
+ html: string
52
+ cache: string | null // fastcgi-cache header, uppercased
53
+ bypassReason: string | null
54
+ error?: string
55
+ }
56
+
57
+ const FETCH_TIMEOUT_MS = 20_000
58
+ const MAX_HTML_BYTES = 512 * 1024
59
+
60
+ // The body_class() tokens that identify WHICH template won — the set the
61
+ // differential compares. Cosmetic/theme classes are deliberately excluded so a
62
+ // plugin adding a class doesn't read as a template change.
63
+ const TEMPLATE_TOKEN =
64
+ /^(home|blog|front-page|search|search-results|search-no-results|error404|single|singular|page|archive|category|tag|author|date|attachment|page-id-\d+|postid-\d+|single-[a-z0-9_-]+)$/
65
+
66
+ async function probe(url: string, cookie?: string): Promise<Probe> {
67
+ const started = performance.now()
68
+ try {
69
+ const res = await fetch(url, {
70
+ redirect: "follow",
71
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
72
+ headers: {
73
+ "user-agent": "spinup-tui (site doctor)",
74
+ "cache-control": "no-store",
75
+ ...(cookie ? { cookie } : {}),
76
+ },
77
+ })
78
+ const html = (await res.text()).slice(0, MAX_HTML_BYTES)
79
+ return {
80
+ ok: res.ok,
81
+ status: res.status,
82
+ ms: Math.round(performance.now() - started),
83
+ html,
84
+ cache: res.headers.get("fastcgi-cache")?.toUpperCase() ?? null,
85
+ bypassReason: res.headers.get("fastcgi-cache-bypass-reason"),
86
+ }
87
+ } catch (err) {
88
+ return { ok: false, status: 0, ms: Math.round(performance.now() - started), html: "", cache: null, bypassReason: null, error: (err as Error).message }
89
+ }
90
+ }
91
+
92
+ function templateTokens(html: string): string[] | null {
93
+ const cls = bodyClassOf(html)
94
+ if (!cls) return null
95
+ const tokens = cls.tokens.filter((t) => TEMPLATE_TOKEN.test(t)).sort()
96
+ return tokens.length > 0 ? tokens : null
97
+ }
98
+
99
+ export async function runSiteDoctor(opts: {
100
+ url: string
101
+ expectedKeyword?: string | null // the calibrated fingerprint, when one exists
102
+ pageCacheEnabled?: boolean | null // from the SpinupWP API; null = unknown
103
+ sshTarget?: string | null // "site_user@host" for the runbook's ssh line
104
+ isWordPress?: boolean // enables the wp-login.php door probe
105
+ loginPath?: string // where the login lives (Bedrock relocates it to /wp/wp-login.php)
106
+ }): Promise<DoctorReport> {
107
+ const checks: DoctorCheck[] = []
108
+ const runbook: string[] = []
109
+
110
+ const partialOutageRunbook = (why: string) => {
111
+ if (opts.sshTarget) runbook.push(`ssh ${opts.sshTarget}`)
112
+ runbook.push("tail -n 50 ~/logs/*error*.log # the fatal will be here")
113
+ runbook.push(`# ${why}`)
114
+ runbook.push("# front pages are cached, so visitors look fine — admins and logged-in users are not")
115
+ }
116
+
117
+ const cached = await probe(opts.url)
118
+ if (!cached.ok) {
119
+ checks.push({ label: "reachability", status: "bad", detail: cached.error ?? `answered ${cached.status}` })
120
+ return { verdict: "down", summary: "The site isn't answering — this isn't a cache problem.", checks, runbook }
121
+ }
122
+ checks.push({ label: "reachability", status: "ok", detail: `200 in ${cached.ms}ms` })
123
+
124
+ const fresh = await probe(opts.url, "wordpress_no_cache=1")
125
+
126
+ // What layer served the plain request?
127
+ if (cached.cache === "HIT") {
128
+ checks.push({ label: "page cache", status: "ok", detail: "served from the page cache (HIT)" })
129
+ } else if (cached.cache) {
130
+ checks.push({ label: "page cache", status: "info", detail: `${cached.cache} — freshly rendered, nothing stale to compare` })
131
+ } else if (opts.pageCacheEnabled === false) {
132
+ checks.push({ label: "page cache", status: "info", detail: "disabled for this site (per SpinupWP)" })
133
+ } else {
134
+ checks.push({ label: "page cache", status: "warn", detail: "no fastcgi-cache header — cache layer not verifiable here" })
135
+ }
136
+
137
+ // Did the bypass actually bypass? The header proves it either way. With the
138
+ // page cache off, every render is fresh and there's nothing to bypass — that
139
+ // is expected state, not a warning.
140
+ const cacheOff = opts.pageCacheEnabled === false && !cached.cache
141
+ const bypassConfirmed = fresh.ok && fresh.cache === "BYPASS"
142
+ if (bypassConfirmed) {
143
+ checks.push({ label: "bypass", status: "ok", detail: `fresh render confirmed (BYPASS: ${fresh.bypassReason ?? "cookie"}) in ${fresh.ms}ms` })
144
+ } else if (cacheOff && fresh.ok) {
145
+ checks.push({ label: "bypass", status: "info", detail: "page cache off — every render is already fresh" })
146
+ } else if (fresh.ok) {
147
+ checks.push({ label: "bypass", status: "warn", detail: `couldn't confirm a fresh render (${fresh.cache ?? "no cache header"}) — differential unverified` })
148
+ } else if (fresh.status >= 500 || fresh.status === 0) {
149
+ // Cached 200 + fresh 5xx IS the partial outage (verified live: a dead Redis
150
+ // is fatal on cache misses with SpinupWP's default drop-in; a plugin/theme
151
+ // fatal looks identical). Nothing further is measurable — diagnose and stop.
152
+ checks.push({ label: "fresh render", status: "bad", detail: fresh.error ?? `PHP fails when the cache is bypassed (HTTP ${fresh.status})` })
153
+ partialOutageRunbook("if the server's redis monitor is red, Redis is the cause; otherwise it's a PHP fatal")
154
+ return {
155
+ verdict: "partial-outage",
156
+ summary: "Cached pages serve fine, but fresh renders are failing — a partial outage the cached-page monitors can't see.",
157
+ checks,
158
+ runbook,
159
+ }
160
+ } else {
161
+ checks.push({ label: "bypass", status: "warn", detail: `bypass request answered ${fresh.status} — differential unverified` })
162
+ }
163
+
164
+ // The differential: cached template identity vs freshly rendered identity.
165
+ const cachedTokens = templateTokens(cached.html)
166
+ const freshTokens = fresh.ok ? templateTokens(fresh.html) : null
167
+ let staleCache = false
168
+ if (cached.cache === "HIT" && bypassConfirmed && cachedTokens && freshTokens) {
169
+ if (cachedTokens.join(" ") === freshTokens.join(" ")) {
170
+ checks.push({ label: "template", status: "ok", detail: `cached and fresh renders agree (${freshTokens.slice(0, 3).join(" ")})` })
171
+ } else {
172
+ staleCache = true
173
+ checks.push({ label: "template", status: "bad", detail: `cached says “${cachedTokens.join(" ")}” but PHP renders “${freshTokens.join(" ")}”` })
174
+ }
175
+ } else if (cachedTokens && freshTokens) {
176
+ checks.push({ label: "template", status: "info", detail: `both renders show “${freshTokens.slice(0, 3).join(" ")}” (differential not provable this pass)` })
177
+ } else {
178
+ checks.push({ label: "template", status: "info", detail: "no body_class() tokens to compare — theme doesn't emit them" })
179
+ }
180
+
181
+ // The calibrated fingerprint, when the site has one.
182
+ let recalibrate = false
183
+ if (opts.expectedKeyword) {
184
+ const inCached = cached.html.includes(opts.expectedKeyword)
185
+ const inFresh = fresh.ok ? fresh.html.includes(opts.expectedKeyword) : null
186
+ if (inCached) {
187
+ checks.push({ label: "fingerprint", status: "ok", detail: "calibrated fingerprint present on the live page" })
188
+ } else if (inFresh) {
189
+ staleCache = true
190
+ checks.push({ label: "fingerprint", status: "bad", detail: "missing from the cached page but present in a fresh render" })
191
+ } else {
192
+ recalibrate = true
193
+ checks.push({ label: "fingerprint", status: "warn", detail: "gone from the live site entirely — the page changed, not the cache" })
194
+ }
195
+ }
196
+
197
+ // The wp-admin door. A fatal confined to admin-facing code produces "site
198
+ // serves fine, wp-admin shows the critical-error screen" — a real incident
199
+ // shape. /wp-login.php is never page-cached (default `wp-.*.php` path
200
+ // exclusion) and renders through a full WP load, so a 5xx here is unambiguous.
201
+ // Anything else non-200 (basic auth, relocated login, hardening) is normal
202
+ // protection — report it, judge nothing.
203
+ let loginFatal = false
204
+ if (opts.isWordPress) {
205
+ let loginUrl: string | null = null
206
+ try {
207
+ loginUrl = new URL(opts.loginPath ?? "/wp-login.php", opts.url).toString()
208
+ } catch {
209
+ // Unparseable base URL — skip the probe.
210
+ }
211
+ if (loginUrl) {
212
+ const login = await probe(loginUrl)
213
+ if (login.ok) {
214
+ checks.push({ label: "wp-admin door", status: "ok", detail: `login page renders (${login.ms}ms)` })
215
+ } else if (login.status >= 500) {
216
+ loginFatal = true
217
+ checks.push({ label: "wp-admin door", status: "bad", detail: `login page throws HTTP ${login.status} — admin-facing fatal` })
218
+ } else if (login.status > 0) {
219
+ checks.push({ label: "wp-admin door", status: "info", detail: `answered ${login.status} — protected or relocated; can't judge` })
220
+ } else {
221
+ checks.push({ label: "wp-admin door", status: "warn", detail: login.error ?? "login probe failed" })
222
+ }
223
+ }
224
+ }
225
+
226
+ if (fresh.ok) {
227
+ checks.push({
228
+ label: "timing",
229
+ status: "info",
230
+ detail: cached.cache === "HIT" ? `cached ${cached.ms}ms · uncached ${fresh.ms}ms` : `renders: ${cached.ms}ms · ${fresh.ms}ms`,
231
+ })
232
+ }
233
+
234
+ if (loginFatal) {
235
+ partialOutageRunbook("the front end renders fine — the fatal is in admin-facing code (a plugin's admin hooks, most likely)")
236
+ return {
237
+ verdict: "partial-outage",
238
+ summary: "The site serves fine, but wp-admin is throwing a fatal — visitors won't notice; you can't log in.",
239
+ checks,
240
+ runbook,
241
+ }
242
+ }
243
+ if (staleCache) {
244
+ // Builder-specific runbook lines only on positive evidence in the markup.
245
+ const elementor = /wp-content\/plugins\/elementor\/|elementor-kit-\d/.test(fresh.ok ? fresh.html : cached.html)
246
+ if (opts.sshTarget) runbook.push(`ssh ${opts.sshTarget}`)
247
+ if (elementor) runbook.push("wp elementor flush_css # elementor detected — this also purges SpinupWP's caches")
248
+ else runbook.push("wp spinupwp cache purge-site")
249
+ runbook.push("# or: press P on this site (purge via API), then re-run d")
250
+ return { verdict: "stale-cache", summary: "The page cache is serving a different page than PHP renders — purge it.", checks, runbook }
251
+ }
252
+ if (recalibrate) {
253
+ return { verdict: "recalibrate", summary: "The site renders fine but no longer matches its calibrated fingerprint — press f to recalibrate.", checks, runbook }
254
+ }
255
+ if (cachedTokens || opts.expectedKeyword) {
256
+ const proved = cached.cache === "HIT" && bypassConfirmed
257
+ return { verdict: "healthy", summary: proved ? "Serving the right page, and the cache agrees with a fresh render." : "Serving the right page.", checks, runbook }
258
+ }
259
+ return { verdict: "inconclusive", summary: "Reachable, but there's no template identity or fingerprint to judge against.", checks, runbook }
260
+ }
@@ -0,0 +1,108 @@
1
+ // Derive a template-identity fingerprint from a site's LIVE front page — the
2
+ // calibration step behind the front-page Kuma monitor (site monitoring Phase 1).
3
+ //
4
+ // Why not a hand-picked headline keyword: content is the one thing a site owner
5
+ // is allowed to change, so a headline monitor breaks the day someone edits the
6
+ // copy. WordPress instead stamps the winning template into body_class() on every
7
+ // request (`home` / `page-id-N` / `front-page` vs `search` / `error404`), so a
8
+ // template-derived token catches "the page cache is serving the wrong page" —
9
+ // verified live 2026-07-04: a page-cache incident served `class="search …"`
10
+ // where `class="home …"` belonged, with HTTP 200 the whole time.
11
+ //
12
+ // The candidate ladder (most → least distinctive) is read from what the page
13
+ // ACTUALLY serves right now — the page is presumed healthy at calibration time:
14
+ // 1. body class starting with `home`/`blog` → `class="home` (prefix-anchored,
15
+ // can't false-match in prose)
16
+ // 2. a `page-id-N` token → `page-id-N`
17
+ // 3. a `front-page` token → `front-page`
18
+ // 4. the canonical <link> tag → its exact text
19
+ // Non-WP sites and body_class-less themes fall down the ladder naturally
20
+ // (canonical), and a page with none of these reports WHY instead of registering
21
+ // a junk monitor.
22
+ //
23
+ // Validation: a throwaway search URL (`?s=…` — bypasses the page cache and, on
24
+ // WP, renders a different template) is fetched and the chosen keyword must be
25
+ // ABSENT there; a keyword that also matches the search template couldn't
26
+ // discriminate. Sites that ignore `?s=` (serve the same page back) make the
27
+ // probe inconclusive — the fingerprint is then accepted unvalidated, with a note.
28
+
29
+ export interface Fingerprint {
30
+ kind: "body-class" | "canonical"
31
+ keyword: string // the literal substring the Kuma keyword monitor asserts
32
+ detail: string // human-readable description for the overlay / done message
33
+ }
34
+
35
+ export type DeriveResult = { ok: true; fingerprint: Fingerprint; validated: boolean; note?: string } | { ok: false; error: string }
36
+
37
+ const FETCH_TIMEOUT_MS = 20_000
38
+ // The body tag and canonical link live near the top of the document; capping the
39
+ // read keeps a pathological page from ballooning memory.
40
+ const MAX_HTML_BYTES = 512 * 1024
41
+
42
+ async function fetchHtml(url: string): Promise<{ ok: true; html: string } | { ok: false; error: string }> {
43
+ try {
44
+ const res = await fetch(url, {
45
+ redirect: "follow",
46
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
47
+ headers: { "user-agent": "spinup-tui (front-page fingerprint calibration)" },
48
+ })
49
+ if (!res.ok) return { ok: false, error: `The page answered ${res.status} — calibrate while it's healthy.` }
50
+ return { ok: true, html: (await res.text()).slice(0, MAX_HTML_BYTES) }
51
+ } catch (err) {
52
+ return { ok: false, error: `Couldn't fetch ${url}: ${(err as Error).message}` }
53
+ }
54
+ }
55
+
56
+ export function bodyClassOf(html: string): { quote: string; tokens: string[] } | null {
57
+ const body = html.match(/<body[^>]*>/i)?.[0]
58
+ if (!body) return null
59
+ const cls = body.match(/class\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
60
+ if (!cls) return null
61
+ const value = (cls[1] ?? cls[2] ?? "").trim()
62
+ if (!value) return null
63
+ return { quote: cls[1] != null ? '"' : "'", tokens: value.split(/\s+/) }
64
+ }
65
+
66
+ function candidatesFrom(html: string): Fingerprint[] {
67
+ const out: Fingerprint[] = []
68
+ const cls = bodyClassOf(html)
69
+ if (cls) {
70
+ const first = cls.tokens[0]
71
+ if (first === "home" || first === "blog") {
72
+ out.push({ kind: "body-class", keyword: `class=${cls.quote}${first}`, detail: `body class “${first}”` })
73
+ }
74
+ const pageId = cls.tokens.find((t) => /^page-id-\d+$/.test(t))
75
+ if (pageId) out.push({ kind: "body-class", keyword: pageId, detail: `body class “${pageId}”` })
76
+ if (cls.tokens.includes("front-page")) out.push({ kind: "body-class", keyword: "front-page", detail: "body class “front-page”" })
77
+ }
78
+ const canonical = html.match(/<link[^>]*rel=["']canonical["'][^>]*>/i)?.[0]
79
+ // A canonical tag is only a usable keyword when it's a sane length (some SEO
80
+ // plugins emit data-bloated tags that would make a brittle, unreadable match).
81
+ if (canonical && canonical.length <= 300) out.push({ kind: "canonical", keyword: canonical, detail: "canonical link tag" })
82
+ return out
83
+ }
84
+
85
+ export async function deriveFingerprint(homeUrl: string): Promise<DeriveResult> {
86
+ const home = await fetchHtml(homeUrl)
87
+ if (!home.ok) return { ok: false, error: home.error }
88
+ const candidates = candidatesFrom(home.html)
89
+ if (candidates.length === 0) {
90
+ return { ok: false, error: "No template fingerprint on the front page (no WP body classes or canonical link) — this site would need a hand-made keyword monitor in Kuma." }
91
+ }
92
+
93
+ const sep = homeUrl.includes("?") ? "&" : "?"
94
+ const probe = await fetchHtml(`${homeUrl}${sep}s=spinup-fingerprint-probe`)
95
+ if (!probe.ok) return { ok: true, fingerprint: candidates[0]!, validated: false, note: "comparison probe unreachable — fingerprint unvalidated" }
96
+
97
+ const homeCls = bodyClassOf(home.html)
98
+ const probeCls = bodyClassOf(probe.html)
99
+ if (homeCls && probeCls && homeCls.tokens.join(" ") === probeCls.tokens.join(" ")) {
100
+ // The site serves the same page for `?s=` (no search feature) — the probe
101
+ // can't tell a distinctive keyword from a useless one.
102
+ return { ok: true, fingerprint: candidates[0]!, validated: false, note: "site ignores search URLs — fingerprint unvalidated" }
103
+ }
104
+
105
+ const distinct = candidates.find((c) => !probe.html.includes(c.keyword))
106
+ if (distinct) return { ok: true, fingerprint: distinct, validated: true }
107
+ return { ok: false, error: "Every candidate fingerprint also appears on a non-front page — nothing distinctive to monitor." }
108
+ }
@@ -0,0 +1,33 @@
1
+ // Deep links into the SpinupWP web app.
2
+ //
3
+ // A recurring pattern: when the API can't *act* on something (e.g. it exposes
4
+ // `upgrade_required` but has no upgrade endpoint), give the user a one-keystroke
5
+ // jump to the exact page in SpinupWP where they can. URLs are account-scoped:
6
+ //
7
+ // https://spinupwp.app/{accountSlug}/servers/{id}
8
+ // https://spinupwp.app/{accountSlug}/sites/{id}
9
+ //
10
+ // The API doesn't expose the account slug, so it comes from config. Without it
11
+ // we fall back to the dashboard root rather than guessing a broken URL.
12
+
13
+ const WEB_BASE = "https://spinupwp.app"
14
+
15
+ export function serverWebUrl(id: number, accountSlug: string | null): string {
16
+ return accountSlug ? `${WEB_BASE}/${accountSlug}/servers/${id}` : WEB_BASE
17
+ }
18
+
19
+ export function siteWebUrl(id: number, accountSlug: string | null): string {
20
+ return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}` : WEB_BASE
21
+ }
22
+
23
+ // Account Settings → Server Providers (where the server_provider ids live).
24
+ export function serverProvidersSettingsUrl(accountSlug: string | null): string {
25
+ return accountSlug ? `${WEB_BASE}/${accountSlug}/settings#server-providers` : WEB_BASE
26
+ }
27
+
28
+ // A site's SFTP & SSH section — where you add an SSH key to the SITE USER so
29
+ // Spinup can connect to that site (the vanity flow deep-links here for the manual
30
+ // key step). The key is per-site, not a server sudo user.
31
+ export function siteSftpUrl(id: number, accountSlug: string | null): string {
32
+ return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#sftp` : WEB_BASE
33
+ }