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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
package/src/lib/stack.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Tier-1 site "stack" classification — pure functions over data we already
|
|
2
|
+
// fetch (no SSH, no extra API calls). Signals: `is_wordpress` and
|
|
3
|
+
// `public_folder` on the Site object, validated against the live fleet.
|
|
4
|
+
//
|
|
5
|
+
// is_wordpress === false → Non-WP
|
|
6
|
+
// is_wordpress && "/web/" → Bedrock
|
|
7
|
+
// is_wordpress && anything else → Standard WP
|
|
8
|
+
//
|
|
9
|
+
// Important: `public_folder` is the user-set "Public Folder" (the suffix after
|
|
10
|
+
// `~/sites/{domain}/files`), NOT a SpinupWP-enforced stack flag. So this is a
|
|
11
|
+
// heuristic keyed on a convention: webroot `/web/` means Bedrock. The
|
|
12
|
+
// is_wordpress guard matters — a couple of non-WP custom apps also use "/web/",
|
|
13
|
+
// so Bedrock requires WP too. Any other WP webroot (incl. the bare `~/files`
|
|
14
|
+
// default, `/public/`, `/`) is treated as Standard WP. Tier-2 (on-demand SSH
|
|
15
|
+
// probe that inspects the filesystem) is the authoritative correction layer.
|
|
16
|
+
|
|
17
|
+
import type { Site } from "../api/types.ts"
|
|
18
|
+
import type { ProbeKind } from "./probe.ts"
|
|
19
|
+
import { theme } from "./theme.ts"
|
|
20
|
+
|
|
21
|
+
export type Stack = "Standard WP" | "Bedrock" | "Non-WP"
|
|
22
|
+
|
|
23
|
+
// All buckets in display order (kept stable so the Stacks view doesn't reflow).
|
|
24
|
+
export const STACKS: Stack[] = ["Standard WP", "Bedrock", "Non-WP"]
|
|
25
|
+
|
|
26
|
+
export function classifyStack(site: Site): Stack {
|
|
27
|
+
if (!site.is_wordpress) return "Non-WP"
|
|
28
|
+
if (site.public_folder === "/web/") return "Bedrock"
|
|
29
|
+
return "Standard WP"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The bucket a site actually belongs in once Tier-2 is considered. A CONCLUSIVE
|
|
33
|
+
// probe (positive identification) overrides the Tier-1 heuristic; an
|
|
34
|
+
// inconclusive ("unknown"), failed, or absent probe falls back to Tier-1. This
|
|
35
|
+
// is how an API-mislabeled site (is_wordpress=false but really WordPress) moves
|
|
36
|
+
// into its true bucket after `d`.
|
|
37
|
+
export function effectiveStack(site: Site, probeKind?: ProbeKind | null): Stack {
|
|
38
|
+
switch (probeKind) {
|
|
39
|
+
case "wordpress":
|
|
40
|
+
return "Standard WP"
|
|
41
|
+
case "bedrock":
|
|
42
|
+
return "Bedrock"
|
|
43
|
+
case "whmcs":
|
|
44
|
+
case "laravel":
|
|
45
|
+
case "static":
|
|
46
|
+
return "Non-WP"
|
|
47
|
+
default:
|
|
48
|
+
return classifyStack(site)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `onSelection` brightens the colors that read poorly on the focused
|
|
53
|
+
// (bright-green) selection background (green-on-green Bedrock, the faint grey).
|
|
54
|
+
export function stackColor(stack: Stack, onSelection = false): string {
|
|
55
|
+
switch (stack) {
|
|
56
|
+
case "Standard WP":
|
|
57
|
+
return theme.accent // blue
|
|
58
|
+
case "Bedrock":
|
|
59
|
+
return onSelection ? theme.text : theme.good // green
|
|
60
|
+
case "Non-WP":
|
|
61
|
+
return onSelection ? theme.text : theme.textDim // gray
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compact tag for tight one-line list rows.
|
|
66
|
+
export function stackTag(stack: Stack): string {
|
|
67
|
+
switch (stack) {
|
|
68
|
+
case "Standard WP":
|
|
69
|
+
return "wp"
|
|
70
|
+
case "Bedrock":
|
|
71
|
+
return "bedrock"
|
|
72
|
+
case "Non-WP":
|
|
73
|
+
return "app"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// PHP version helpers (EOL check, sort key) live in ./phpEol.ts — EOL is
|
|
78
|
+
// computed from real dates vs today, refreshed from endoflife.date.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Disk-backed cache for Tier-2 probe results, keyed by site id.
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle (agreed model): hydrate-on-startup, write-on-probe.
|
|
4
|
+
// - load() reads stack-cache.json into memory once at startup (no SSH).
|
|
5
|
+
// - set() is called after a successful `d` probe; it upserts the entry and
|
|
6
|
+
// writes the whole file back immediately (write-through), so a probe
|
|
7
|
+
// survives even if the TUI is killed with q / Ctrl+C.
|
|
8
|
+
// Probing itself is always on-demand and per-site — this cache exists to avoid
|
|
9
|
+
// repeating the slow SSH round-trips across launches, not to save RAM. It's a
|
|
10
|
+
// deliberately small, reusable pattern for other expensive derived data later.
|
|
11
|
+
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
14
|
+
import { chmod, mkdir } from "node:fs/promises"
|
|
15
|
+
import { configDir } from "../config.ts"
|
|
16
|
+
import type { ProbeResult } from "./probe.ts"
|
|
17
|
+
|
|
18
|
+
const CACHE_VERSION = 1
|
|
19
|
+
|
|
20
|
+
export interface CachedProbe {
|
|
21
|
+
result: ProbeResult
|
|
22
|
+
detectedAt: number // epoch ms
|
|
23
|
+
signature: string // invalidation key (see siteSignature)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface CacheFile {
|
|
27
|
+
version: number
|
|
28
|
+
entries: Record<string, CachedProbe> // key: String(site.id)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stackCachePath(): string {
|
|
32
|
+
return join(configDir(), "stack-cache.json")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A cached probe is considered stale when the site's relevant shape changes.
|
|
36
|
+
// Keying on is_wordpress + public_folder catches the cases that would alter the
|
|
37
|
+
// probe's target paths or expected result.
|
|
38
|
+
export function siteSignature(site: { is_wordpress: boolean; public_folder: string | null }): string {
|
|
39
|
+
return `${site.is_wordpress}:${site.public_folder ?? ""}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class StackCache {
|
|
43
|
+
private entries = new Map<number, CachedProbe>()
|
|
44
|
+
// Serializes disk writes so concurrent probes (batch mode) can't tear the
|
|
45
|
+
// file or drop each other's updates. Each persist() runs after the previous.
|
|
46
|
+
private writeChain: Promise<void> = Promise.resolve()
|
|
47
|
+
|
|
48
|
+
// Read the cache file into memory. Never throws — a missing or corrupt file
|
|
49
|
+
// simply yields an empty cache.
|
|
50
|
+
load(): void {
|
|
51
|
+
try {
|
|
52
|
+
const path = stackCachePath()
|
|
53
|
+
if (!existsSync(path)) return
|
|
54
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
55
|
+
if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
|
|
56
|
+
for (const [k, v] of Object.entries(parsed.entries)) {
|
|
57
|
+
const id = Number(k)
|
|
58
|
+
if (Number.isFinite(id) && v?.result) this.entries.set(id, v)
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* ignore corrupt/unreadable cache — start empty */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Any cached entry (even if stale), for display.
|
|
66
|
+
get(siteId: number): CachedProbe | undefined {
|
|
67
|
+
return this.entries.get(siteId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// True when the cached entry exists but no longer matches the site's shape,
|
|
71
|
+
// so the UI can hint that a re-probe is warranted.
|
|
72
|
+
isStale(siteId: number, signature: string): boolean {
|
|
73
|
+
const e = this.entries.get(siteId)
|
|
74
|
+
return e != null && e.signature !== signature
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Upsert a probe result and persist immediately (write-through).
|
|
78
|
+
async set(siteId: number, result: ProbeResult, signature: string): Promise<void> {
|
|
79
|
+
this.entries.set(siteId, { result, detectedAt: Date.now(), signature })
|
|
80
|
+
await this.persist()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A copy of all entries (for rendering aggregates without exposing the map).
|
|
84
|
+
snapshot(): Map<number, CachedProbe> {
|
|
85
|
+
return new Map(this.entries)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
size(): number {
|
|
89
|
+
return this.entries.size
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Queue this write behind any in-flight one. Each write serializes the
|
|
93
|
+
// current entries map (which already includes all sets so far), so ordering
|
|
94
|
+
// only needs to prevent overlapping file writes, not preserve per-set diffs.
|
|
95
|
+
private persist(): Promise<void> {
|
|
96
|
+
// `.catch` keeps the chain alive if one write fails (cache is best-effort).
|
|
97
|
+
this.writeChain = this.writeChain.then(() => this.writeFile()).catch(() => {})
|
|
98
|
+
return this.writeChain
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async writeFile(): Promise<void> {
|
|
102
|
+
await mkdir(configDir(), { recursive: true })
|
|
103
|
+
const file: CacheFile = { version: CACHE_VERSION, entries: {} }
|
|
104
|
+
for (const [id, entry] of this.entries) file.entries[String(id)] = entry
|
|
105
|
+
const path = stackCachePath()
|
|
106
|
+
await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
|
|
107
|
+
try {
|
|
108
|
+
await chmod(path, 0o600)
|
|
109
|
+
} catch {
|
|
110
|
+
/* best-effort on filesystems without POSIX perms */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/lib/theme.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Centralized color palette + status helpers. Tweaking these restyles the whole app.
|
|
2
|
+
// SpinupWP's brand green anchors the theme.
|
|
3
|
+
|
|
4
|
+
export const theme = {
|
|
5
|
+
brand: "#00d18f", // SpinupWP green
|
|
6
|
+
brandDim: "#0a8f64",
|
|
7
|
+
accent: "#5ec8ff", // sky blue for highlights/links
|
|
8
|
+
text: "#e6edf3",
|
|
9
|
+
textDim: "#8b949e",
|
|
10
|
+
textFaint: "#586069",
|
|
11
|
+
bg: "#0d1117",
|
|
12
|
+
bgAlt: "#161b22",
|
|
13
|
+
bgPanel: "#11161d",
|
|
14
|
+
border: "#30363d",
|
|
15
|
+
borderActive: "#00d18f",
|
|
16
|
+
selectedBg: "#1f6f53",
|
|
17
|
+
good: "#3fb950",
|
|
18
|
+
warn: "#d29922",
|
|
19
|
+
update: "#ffd23f", // bright gold — "new version available" nudge; brighter/cleaner than warn-amber so it reads as "new", not "caution"
|
|
20
|
+
bad: "#f85149",
|
|
21
|
+
info: "#5ec8ff",
|
|
22
|
+
purple: "#bc8cff",
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
// Map an arbitrary status/connection string to a semantic color.
|
|
26
|
+
export function statusColor(status: string | null | undefined): string {
|
|
27
|
+
const s = (status ?? "").toLowerCase()
|
|
28
|
+
if (["connected", "deployed", "provisioned", "completed", "active", "success"].includes(s)) {
|
|
29
|
+
return theme.good
|
|
30
|
+
}
|
|
31
|
+
if (["connecting", "provisioning", "deploying", "queued", "running", "pending", "in_progress"].includes(s)) {
|
|
32
|
+
return theme.warn
|
|
33
|
+
}
|
|
34
|
+
if (["disconnected", "failed", "errored", "error", "offline"].includes(s)) {
|
|
35
|
+
return theme.bad
|
|
36
|
+
}
|
|
37
|
+
return theme.textDim
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A small colored dot used throughout the UI to denote status at a glance.
|
|
41
|
+
export function statusDot(status: string | null | undefined): string {
|
|
42
|
+
const s = (status ?? "").toLowerCase()
|
|
43
|
+
if (["connected", "deployed", "provisioned", "completed", "active", "success"].includes(s)) return "●"
|
|
44
|
+
if (["connecting", "provisioning", "deploying", "queued", "running", "pending", "in_progress"].includes(s)) return "◐"
|
|
45
|
+
if (["disconnected", "failed", "errored", "error", "offline"].includes(s)) return "✕"
|
|
46
|
+
return "○"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Color a disk-usage percentage: green < 70, amber < 90, red otherwise.
|
|
50
|
+
export function diskColor(pct: number): string {
|
|
51
|
+
if (pct < 70) return theme.good
|
|
52
|
+
if (pct < 90) return theme.warn
|
|
53
|
+
return theme.bad
|
|
54
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Ubuntu end-of-life data for the OS a SpinupWP server runs on, computed from
|
|
2
|
+
// real EOL *dates* vs the current date — same approach as php-eol (phpEol.ts).
|
|
3
|
+
//
|
|
4
|
+
// Hybrid sourcing:
|
|
5
|
+
// 1. An embedded table of Canonical's published LTS dates is the offline
|
|
6
|
+
// default, so flagging is correct with no network.
|
|
7
|
+
// 2. At startup we refresh from endoflife.date (cached to disk), which keeps
|
|
8
|
+
// the data current and adds releases published after this build.
|
|
9
|
+
//
|
|
10
|
+
// A version with no known date is NOT flagged — we don't guess. SpinupWP only
|
|
11
|
+
// provisions Ubuntu LTS releases, so only those are tracked.
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { mkdir } from "node:fs/promises"
|
|
16
|
+
import { configDir } from "../config.ts"
|
|
17
|
+
|
|
18
|
+
// Map of "YY.MM" → standard security-support EOL date (ISO). Source:
|
|
19
|
+
// Canonical's published LTS schedule. Refreshed at runtime from endoflife.date.
|
|
20
|
+
export const EMBEDDED_UBUNTU_EOL: Record<string, string> = {
|
|
21
|
+
"16.04": "2021-04-30",
|
|
22
|
+
"18.04": "2023-05-31",
|
|
23
|
+
"20.04": "2025-04-30",
|
|
24
|
+
"22.04": "2027-04-30",
|
|
25
|
+
"24.04": "2029-04-30",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Refresh from the network at most this often.
|
|
29
|
+
const REFRESH_AFTER_MS = 7 * 24 * 60 * 60 * 1000
|
|
30
|
+
const FETCH_TIMEOUT_MS = 6000
|
|
31
|
+
const ENDPOINT = "https://endoflife.date/api/ubuntu.json"
|
|
32
|
+
|
|
33
|
+
export type UbuntuEolDates = Record<string, string>
|
|
34
|
+
|
|
35
|
+
interface CacheFile {
|
|
36
|
+
fetchedAt: number
|
|
37
|
+
dates: UbuntuEolDates
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ubuntuEolCachePath(): string {
|
|
41
|
+
return join(configDir(), "ubuntu-eol.json")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Normalize "20.04.6" / "20.04" → "20.04" (the key Canonical schedules by).
|
|
45
|
+
function releaseKey(version: string): string | null {
|
|
46
|
+
const parts = version.split(".")
|
|
47
|
+
if (parts.length < 2) return null
|
|
48
|
+
const year = Number.parseInt(parts[0] ?? "", 10)
|
|
49
|
+
const month = parts[1] ?? ""
|
|
50
|
+
if (!Number.isFinite(year) || !/^\d{2}$/.test(month)) return null
|
|
51
|
+
return `${parts[0]}.${month}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// True when `version`'s known EOL date is before `asOf`. Unknown → not flagged.
|
|
55
|
+
export function isUbuntuEol(version: string | null | undefined, dates: UbuntuEolDates, asOf: Date = new Date()): boolean {
|
|
56
|
+
if (!version) return false
|
|
57
|
+
const key = releaseKey(version)
|
|
58
|
+
if (!key) return false
|
|
59
|
+
const eol = dates[key]
|
|
60
|
+
if (!eol) return false
|
|
61
|
+
const t = Date.parse(eol)
|
|
62
|
+
return Number.isFinite(t) && t < asOf.getTime()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadCache(): CacheFile | null {
|
|
66
|
+
try {
|
|
67
|
+
const path = ubuntuEolCachePath()
|
|
68
|
+
if (!existsSync(path)) return null
|
|
69
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
70
|
+
if (!parsed?.dates || typeof parsed.fetchedAt !== "number") return null
|
|
71
|
+
return parsed
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The dates to use right now: embedded defaults overlaid with cached fetch data.
|
|
78
|
+
export function resolveUbuntuEolDates(): UbuntuEolDates {
|
|
79
|
+
return { ...EMBEDDED_UBUNTU_EOL, ...(loadCache()?.dates ?? {}) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cacheIsFresh(): boolean {
|
|
83
|
+
const c = loadCache()
|
|
84
|
+
return c != null && Date.now() - c.fetchedAt < REFRESH_AFTER_MS
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fetch the live Ubuntu release schedule from endoflife.date. Returns null on
|
|
88
|
+
// any failure (offline, timeout, bad shape) so callers fall back to
|
|
89
|
+
// embedded/cached data.
|
|
90
|
+
async function fetchUbuntuEolDates(): Promise<UbuntuEolDates | null> {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(ENDPOINT, {
|
|
93
|
+
headers: { Accept: "application/json" },
|
|
94
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
95
|
+
})
|
|
96
|
+
if (!res.ok) return null
|
|
97
|
+
const cycles = (await res.json()) as { cycle?: string; eol?: string | boolean }[]
|
|
98
|
+
if (!Array.isArray(cycles)) return null
|
|
99
|
+
const dates: UbuntuEolDates = {}
|
|
100
|
+
for (const c of cycles) {
|
|
101
|
+
if (typeof c.cycle === "string" && typeof c.eol === "string") dates[c.cycle] = c.eol
|
|
102
|
+
}
|
|
103
|
+
return Object.keys(dates).length > 0 ? dates : null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Refresh the cache if stale; returns the freshly merged dates when it updated,
|
|
110
|
+
// or null when nothing changed (cache still fresh, or the fetch failed). Never
|
|
111
|
+
// throws — Ubuntu-EOL data is best-effort decoration.
|
|
112
|
+
export async function refreshUbuntuEolDates(): Promise<UbuntuEolDates | null> {
|
|
113
|
+
if (cacheIsFresh()) return null
|
|
114
|
+
const fetched = await fetchUbuntuEolDates()
|
|
115
|
+
if (!fetched) return null
|
|
116
|
+
try {
|
|
117
|
+
await mkdir(configDir(), { recursive: true })
|
|
118
|
+
const file: CacheFile = { fetchedAt: Date.now(), dates: fetched }
|
|
119
|
+
await Bun.write(ubuntuEolCachePath(), JSON.stringify(file, null, 2) + "\n")
|
|
120
|
+
} catch {
|
|
121
|
+
/* best-effort cache write */
|
|
122
|
+
}
|
|
123
|
+
return { ...EMBEDDED_UBUNTU_EOL, ...fetched }
|
|
124
|
+
}
|