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,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
+ }
@@ -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
+ }