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,183 @@
1
+ // Tier-2 stack probe: on-demand SSH inspection of a single site's filesystem to
2
+ // NAME what Tier-1 can only bucket (especially Non-WP apps) and read the WP
3
+ // version. This is the authoritative layer over the public_folder heuristic in
4
+ // `stack.ts`.
5
+ //
6
+ // SpinupWP on-disk layout (verified live): the site's system user (`site_user`)
7
+ // home dir IS the site root; the project lives at `$HOME/files` and the webroot
8
+ // is `$HOME/files` + the site's `public_folder` (e.g. `/public/`, `/web/`, `/`).
9
+ //
10
+ // WHMCS → webroot has configuration.php AND vendor/whmcs/
11
+ // Bedrock → files-root composer.json references roots/bedrock (or config/application.php)
12
+ // Laravel → files-root has artisan
13
+ // WordPress → `wp core version` run from the webroot (works for Bedrock too,
14
+ // via the site's wp-cli.yml), with a wp-includes/version.php fallback
15
+ //
16
+ // Strictly read-only and non-interactive (BatchMode), exactly like the health
17
+ // view's SSH usage.
18
+
19
+ import type { Server, Site } from "../api/types.ts"
20
+ import { theme } from "./theme.ts"
21
+
22
+ export type ProbeKind = "wordpress" | "bedrock" | "whmcs" | "laravel" | "static" | "unknown"
23
+
24
+ // `onSelection` brightens the colors that are too low-contrast on the focused
25
+ // (bright-green) selection background — green-on-green and the faint greys.
26
+ export function probeKindColor(kind: ProbeKind, onSelection = false): string {
27
+ switch (kind) {
28
+ case "wordpress":
29
+ return theme.accent
30
+ case "bedrock":
31
+ return onSelection ? theme.text : theme.good
32
+ case "whmcs":
33
+ return theme.purple
34
+ case "laravel":
35
+ return theme.warn
36
+ case "static":
37
+ return onSelection ? theme.text : theme.textDim
38
+ case "unknown":
39
+ return onSelection ? theme.text : theme.textFaint
40
+ }
41
+ }
42
+
43
+ export interface ProbeResult {
44
+ kind: ProbeKind
45
+ app: string // app family: "WordPress" | "Bedrock" | "WHMCS" | "Laravel" | "Unknown"
46
+ version: string | null // WP/app version when known
47
+ label: string // display label, e.g. "WHMCS", "WordPress 7.0", "Bedrock · WP 6.9.4"
48
+ }
49
+
50
+ export type ProbeOutcome =
51
+ | { ok: true; target: string; result: ProbeResult }
52
+ | { ok: false; target: string; error: string }
53
+
54
+ // Per-site SSH target. Unlike the health view (which connects as any site_user
55
+ // on the server), a probe must run as THIS site's user so `$HOME` is its dir.
56
+ export function resolveSiteSshTarget(site: Site, server: Server | undefined, sshUser: string | null): string {
57
+ const ip = server?.ip_address
58
+ if (!ip) return ""
59
+ const user = sshUser || site.site_user || "root"
60
+ return `${user}@${ip}`
61
+ }
62
+
63
+ const SSH_OPTS = [
64
+ "-o", "BatchMode=yes",
65
+ "-o", "ConnectTimeout=7",
66
+ "-o", "StrictHostKeyChecking=accept-new",
67
+ "-o", "ControlMaster=auto",
68
+ "-o", "ControlPath=/tmp/spinup-cm-%r@%h:%p",
69
+ "-o", "ControlPersist=30s",
70
+ ]
71
+
72
+ // public_folder comes from the API; restrict it to a safe path charset before
73
+ // embedding it in the remote command. Anything unexpected falls back to "/".
74
+ function safePublicFolder(pf: string | null): string {
75
+ if (!pf || !/^[A-Za-z0-9/_.-]*$/.test(pf)) return "/"
76
+ return pf
77
+ }
78
+
79
+ function buildRemoteScript(publicFolder: string | null): string {
80
+ const pf = safePublicFolder(publicFolder)
81
+ return [
82
+ 'F="$HOME/files"',
83
+ `W="$HOME/files${pf}"`,
84
+ 'echo ===BEDROCK; grep -q "roots/bedrock" "$F/composer.json" 2>/dev/null && echo yes || echo no',
85
+ 'echo ===APPLICATION; test -f "$F/config/application.php" && echo yes || echo no',
86
+ 'echo ===ARTISAN; test -f "$F/artisan" && echo yes || echo no',
87
+ 'echo ===WHMCSCONF; test -f "$W/configuration.php" && echo yes || echo no',
88
+ 'echo ===WHMCSVENDOR; test -d "$W/vendor/whmcs" && echo yes || echo no',
89
+ 'echo ===WPVERSION; (cd "$W" 2>/dev/null && wp core version 2>/dev/null) || grep -oE "wp_version = .[0-9.]+" "$W/wp-includes/version.php" 2>/dev/null | grep -oE "[0-9][0-9.]*" | head -1',
90
+ 'echo ===INDEXHTML; test -f "$W/index.html" && echo yes || echo no',
91
+ 'echo ===INDEXPHP; test -f "$W/index.php" && echo yes || echo no',
92
+ "echo ===END",
93
+ ].join("; ")
94
+ }
95
+
96
+ export async function probeSite(
97
+ site: Site,
98
+ server: Server | undefined,
99
+ sshUser: string | null,
100
+ ): Promise<ProbeOutcome> {
101
+ const target = resolveSiteSshTarget(site, server, sshUser)
102
+ if (!target) return { ok: false, target: "(no IP)", error: "Site's server has no IP address." }
103
+
104
+ let proc: ReturnType<typeof Bun.spawn>
105
+ try {
106
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, target, buildRemoteScript(site.public_folder)], {
107
+ stdout: "pipe",
108
+ stderr: "pipe",
109
+ stdin: "ignore",
110
+ })
111
+ } catch (err) {
112
+ return { ok: false, target, error: `Failed to launch ssh: ${(err as Error).message}` }
113
+ }
114
+
115
+ const timeout = setTimeout(() => {
116
+ try {
117
+ proc.kill()
118
+ } catch {
119
+ /* already gone */
120
+ }
121
+ }, 15000)
122
+
123
+ const exitCode = await proc.exited
124
+ clearTimeout(timeout)
125
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
126
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
127
+
128
+ if (exitCode !== 0 || !stdout.includes("===END")) {
129
+ const reason = stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`
130
+ return { ok: false, target, error: reason }
131
+ }
132
+
133
+ return { ok: true, target, result: classify(parseSections(stdout)) }
134
+ }
135
+
136
+ // Parse the labelled `===KEY` sections into single trimmed string values.
137
+ function parseSections(out: string): Record<string, string> {
138
+ const sections: Record<string, string[]> = {}
139
+ let current = ""
140
+ for (const rawLine of out.split("\n")) {
141
+ const line = rawLine.replace(/\r$/, "")
142
+ const m = line.match(/^===([A-Z0-9]+)$/)
143
+ if (m) {
144
+ current = m[1]
145
+ sections[current] = []
146
+ } else if (current) {
147
+ sections[current].push(line)
148
+ }
149
+ }
150
+ const flat: Record<string, string> = {}
151
+ for (const [k, lines] of Object.entries(sections)) flat[k] = lines.join("\n").trim()
152
+ return flat
153
+ }
154
+
155
+ // Turn raw signals into a named stack. Order matters: name the distinctive apps
156
+ // (WHMCS, Bedrock, Laravel) before falling back to a generic WordPress version.
157
+ export function classify(sig: Record<string, string>): ProbeResult {
158
+ const wpVersion = sig.WPVERSION ? sig.WPVERSION.trim() || null : null
159
+
160
+ if (sig.WHMCSCONF === "yes" && sig.WHMCSVENDOR === "yes") {
161
+ return { kind: "whmcs", app: "WHMCS", version: null, label: "WHMCS" }
162
+ }
163
+ if (sig.BEDROCK === "yes" || sig.APPLICATION === "yes") {
164
+ return {
165
+ kind: "bedrock",
166
+ app: "Bedrock",
167
+ version: wpVersion,
168
+ label: wpVersion ? `Bedrock · WP ${wpVersion}` : "Bedrock",
169
+ }
170
+ }
171
+ if (sig.ARTISAN === "yes") {
172
+ return { kind: "laravel", app: "Laravel", version: null, label: "Laravel" }
173
+ }
174
+ if (wpVersion) {
175
+ return { kind: "wordpress", app: "WordPress", version: wpVersion, label: `WordPress ${wpVersion}` }
176
+ }
177
+ // No recognized server-side app: an index.html with no index.php is a static
178
+ // site. Stay conservative — anything ambiguous remains "Unknown".
179
+ if (sig.INDEXHTML === "yes" && sig.INDEXPHP !== "yes") {
180
+ return { kind: "static", app: "Static HTML", version: null, label: "Static HTML" }
181
+ }
182
+ return { kind: "unknown", app: "Unknown", version: null, label: "Unknown" }
183
+ }
@@ -0,0 +1,284 @@
1
+ // DNS provider access layer (Phase 2, read-only verification) — a small registry
2
+ // so adding a provider is one entry, not edits across the app.
3
+ //
4
+ // Two namespaces meet here:
5
+ // - host providerKey (from dns.ts): who answers DNS live — route53 | cloudflare
6
+ // | godaddy | <fallback domain> … (what the HOST column shows)
7
+ // - connection provider: who we can hold credentials for — see ConnProvider
8
+ // apiProviderFor() bridges them via each descriptor's `hostKeys`.
9
+ //
10
+ // "Verify" = list the zones a credential can actually see (and, where the API
11
+ // returns them for free, each zone's assigned nameservers). That data drives the
12
+ // ACCESS column: a zone is editable only when a connected account of the SAME
13
+ // provider that hosts it contains it — and, when nameservers are known, only when
14
+ // they match the live authoritative NS (so a stale/duplicate zone doesn't count).
15
+
16
+ import { awsGet } from "./awsSigV4.ts"
17
+
18
+ export type ConnProvider = "aws" | "cloudflare" | "godaddy"
19
+
20
+ // A stored credential for a provider. `creds` is a provider-specific bag (the
21
+ // descriptor's `fields` define its keys); secrets live in config.json (0600).
22
+ export interface Connection {
23
+ id: string
24
+ provider: ConnProvider
25
+ label: string
26
+ creds: Record<string, string>
27
+ env?: boolean // sourced from environment (read-only in the UI)
28
+ }
29
+
30
+ export interface VerifiedZone {
31
+ apex: string
32
+ account: string // account identifier/name as the provider reports it
33
+ nameservers: string[] // assigned NS (lowercased, sorted); [] when the API didn't return them
34
+ }
35
+
36
+ export interface VerifyResult {
37
+ ok: boolean
38
+ zones: VerifiedZone[]
39
+ accountLabel: string // a default connection label derived from the account(s) seen
40
+ error?: string
41
+ }
42
+
43
+ // A credential field rendered in the connect form (besides the always-present label).
44
+ export interface ProviderField {
45
+ name: string // key in Connection.creds
46
+ label: string
47
+ secret?: boolean // render masked (SecretInput)
48
+ optional?: boolean
49
+ placeholder?: string
50
+ }
51
+
52
+ export interface ProviderDescriptor {
53
+ key: ConnProvider
54
+ name: string // e.g. "AWS (Route 53)"
55
+ hostKeys: string[] // dns.ts providerKeys that map to this connection provider
56
+ fields: ProviderField[]
57
+ guidance: string // help line under the form
58
+ // Optional web handoff for the "no API access" fallback (e.g. GoDaddy, whose API
59
+ // is gated to large accounts). When set, an unconnected zone shows `↗ web` instead
60
+ // of `○ needs key`, and the connect overlay offers `w` to open `console`.
61
+ console?: string
62
+ consoleLabel?: string // button/hint text for the handoff (default "web console")
63
+ // Longer in-overlay note shown when `console` is set — explains the assumed
64
+ // access model + the handoff flow.
65
+ accessNote?: string
66
+ verify: (creds: Record<string, string>) => Promise<VerifyResult>
67
+ }
68
+
69
+ function normApex(name: string): string {
70
+ return name.trim().toLowerCase().replace(/\.$/, "")
71
+ }
72
+
73
+ // Normalize a nameserver set for comparison: lowercase, drop trailing dots, sort.
74
+ export function normNameservers(ns: string[] | undefined): string[] {
75
+ return (ns ?? []).map((s) => s.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean).sort()
76
+ }
77
+
78
+ export function nameserversMatch(a: string[], b: string[]): boolean {
79
+ if (a.length === 0 || b.length === 0) return false
80
+ if (a.length !== b.length) return false
81
+ return a.every((v, i) => v === b[i])
82
+ }
83
+
84
+ // ---- Cloudflare ------------------------------------------------------------
85
+
86
+ async function verifyCloudflare(creds: Record<string, string>): Promise<VerifyResult> {
87
+ const token = creds.token ?? ""
88
+ try {
89
+ const zones: VerifiedZone[] = []
90
+ const accounts = new Set<string>()
91
+ let page = 1
92
+ let totalPages = 1
93
+ do {
94
+ const res = await fetch(`https://api.cloudflare.com/client/v4/zones?per_page=50&page=${page}`, {
95
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
96
+ })
97
+ const json = (await res.json()) as {
98
+ success?: boolean
99
+ errors?: { message?: string }[]
100
+ result?: { name: string; name_servers?: string[]; account?: { name?: string } }[]
101
+ result_info?: { total_pages?: number }
102
+ }
103
+ if (!res.ok || !json.success) {
104
+ return { ok: false, zones: [], accountLabel: "", error: json?.errors?.[0]?.message || `HTTP ${res.status}` }
105
+ }
106
+ for (const z of json.result ?? []) {
107
+ zones.push({ apex: normApex(z.name), account: z.account?.name ?? "", nameservers: normNameservers(z.name_servers) })
108
+ if (z.account?.name) accounts.add(z.account.name)
109
+ }
110
+ totalPages = json.result_info?.total_pages ?? 1
111
+ page++
112
+ } while (page <= totalPages)
113
+ const accountLabel = accounts.size === 1 ? [...accounts][0] : accounts.size > 1 ? `${accounts.size} accounts` : "Cloudflare"
114
+ return { ok: true, zones, accountLabel }
115
+ } catch (err) {
116
+ return { ok: false, zones: [], accountLabel: "", error: (err as Error).message }
117
+ }
118
+ }
119
+
120
+ // ---- AWS Route 53 (hand-rolled SigV4) --------------------------------------
121
+
122
+ function awsErrorMessage(xml: string): string | null {
123
+ return xml.match(/<Message>([^<]+)<\/Message>/)?.[1] ?? null
124
+ }
125
+
126
+ async function verifyAws(creds: Record<string, string>): Promise<VerifyResult> {
127
+ const aws = { accessKeyId: creds.accessKeyId ?? "", secretAccessKey: creds.secretAccessKey ?? "", region: creds.region || undefined }
128
+ try {
129
+ const sts = await awsGet(aws, "sts", "sts.amazonaws.com", "/", { Action: "GetCallerIdentity", Version: "2011-06-15" })
130
+ if (sts.status !== 200) {
131
+ return { ok: false, zones: [], accountLabel: "", error: awsErrorMessage(sts.body) || `STS HTTP ${sts.status}` }
132
+ }
133
+ const accountId = sts.body.match(/<Account>([^<]+)<\/Account>/)?.[1] ?? ""
134
+
135
+ // Route 53 ListHostedZones (paginate). We deliberately do NOT fetch each
136
+ // zone's nameservers (GetHostedZone is one call per zone and Route 53 caps at
137
+ // ~5 req/s — 27s+ for a large account). AWS zones therefore use membership-
138
+ // based access (provider-scoped); nameservers stay [] → NS-match falls back.
139
+ const zones: VerifiedZone[] = []
140
+ let marker: string | undefined
141
+ do {
142
+ const query: Record<string, string> = { maxitems: "100" }
143
+ if (marker) query.marker = marker
144
+ const r = await awsGet(aws, "route53", "route53.amazonaws.com", "/2013-04-01/hostedzone", query)
145
+ if (r.status !== 200) {
146
+ return { ok: false, zones: [], accountLabel: "", error: awsErrorMessage(r.body) || `Route 53 HTTP ${r.status}` }
147
+ }
148
+ for (const block of r.body.split(/<HostedZone>/).slice(1)) {
149
+ const name = block.match(/<Name>([^<]+)<\/Name>/)?.[1]
150
+ if (name) zones.push({ apex: normApex(name), account: accountId, nameservers: [] })
151
+ }
152
+ marker = /<IsTruncated>true<\/IsTruncated>/.test(r.body)
153
+ ? r.body.match(/<NextMarker>([^<]+)<\/NextMarker>/)?.[1]
154
+ : undefined
155
+ } while (marker)
156
+
157
+ return { ok: true, zones, accountLabel: accountId || "AWS" }
158
+ } catch (err) {
159
+ return { ok: false, zones: [], accountLabel: "", error: (err as Error).message }
160
+ }
161
+ }
162
+
163
+ // ---- GoDaddy (header auth, no signing) -------------------------------------
164
+
165
+ async function verifyGodaddy(creds: Record<string, string>): Promise<VerifyResult> {
166
+ const key = creds.apiKey ?? ""
167
+ const secret = creds.apiSecret ?? ""
168
+ try {
169
+ const res = await fetch("https://api.godaddy.com/v1/domains?statuses=ACTIVE&limit=1000", {
170
+ headers: { Authorization: `sso-key ${key}:${secret}`, Accept: "application/json" },
171
+ })
172
+ if (!res.ok) {
173
+ // GoDaddy gates API access (≈20+ domains / reseller tiers); 403 means this
174
+ // account can't use the API — the zone stays a web-only (↗) handoff.
175
+ const body = (await res.json().catch(() => ({}))) as { message?: string }
176
+ const msg = res.status === 403 ? "This GoDaddy account has no API access (needs 20+ domains / reseller)." : body.message || `HTTP ${res.status}`
177
+ return { ok: false, zones: [], accountLabel: "", error: msg }
178
+ }
179
+ const list = (await res.json()) as { domain: string; nameServers?: string[] }[]
180
+ const zones: VerifiedZone[] = list.map((d) => ({
181
+ apex: normApex(d.domain),
182
+ account: "godaddy",
183
+ nameservers: normNameservers(d.nameServers),
184
+ }))
185
+ return { ok: true, zones, accountLabel: "GoDaddy" }
186
+ } catch (err) {
187
+ return { ok: false, zones: [], accountLabel: "", error: (err as Error).message }
188
+ }
189
+ }
190
+
191
+ // ---- Registry --------------------------------------------------------------
192
+
193
+ export const PROVIDER_REGISTRY: Record<ConnProvider, ProviderDescriptor> = {
194
+ aws: {
195
+ key: "aws",
196
+ name: "AWS (Route 53)",
197
+ hostKeys: ["route53"],
198
+ fields: [
199
+ { name: "accessKeyId", label: "Access Key ID", placeholder: "AKIA…" },
200
+ { name: "secretAccessKey", label: "Secret Access Key", secret: true, placeholder: "secret" },
201
+ { name: "region", label: "Region (optional, default us-east-1)", optional: true, placeholder: "us-east-1" },
202
+ ],
203
+ guidance: "IAM perms: route53 ListHostedZonesByName + ListResourceRecordSets to view; add ChangeResourceRecordSets + GetChange to edit TTLs.",
204
+ verify: verifyAws,
205
+ },
206
+ cloudflare: {
207
+ key: "cloudflare",
208
+ name: "Cloudflare",
209
+ hostKeys: ["cloudflare"],
210
+ fields: [{ name: "token", label: "API token — DNS:Read to view, DNS:Edit to change TTLs", secret: true, placeholder: "Cloudflare API token" }],
211
+ guidance: "Create one at dash.cloudflare.com → My Profile → API Tokens (Zone DNS:Edit to edit records).",
212
+ verify: verifyCloudflare,
213
+ },
214
+ godaddy: {
215
+ key: "godaddy",
216
+ name: "GoDaddy",
217
+ hostKeys: ["godaddy"],
218
+ fields: [
219
+ { name: "apiKey", label: "API Key", placeholder: "key" },
220
+ { name: "apiSecret", label: "API Secret", secret: true, placeholder: "secret" },
221
+ ],
222
+ guidance:
223
+ "At developer.godaddy.com → Create New API Key, choose Environment = Production (NOT OTE/test). GoDaddy's API is only available to larger accounts (≈20+ domains / reseller); without it, use w for the Clients hub.",
224
+ console: "https://hub.godaddy.com/clients?view=table",
225
+ consoleLabel: "Clients hub",
226
+ accessNote:
227
+ "SpinupTUI assumes you manage GoDaddy domains via Delegate Access from one main account. Press w to open your Clients hub (the domain is copied to your clipboard) → Login as the client → paste the domain → Exit access before checking the next.",
228
+ verify: verifyGodaddy,
229
+ },
230
+ }
231
+
232
+ export const ALL_PROVIDERS: ConnProvider[] = Object.keys(PROVIDER_REGISTRY) as ConnProvider[]
233
+
234
+ // Map a live host providerKey to the connection provider we can authenticate as.
235
+ export function apiProviderFor(hostKey: string): ConnProvider | null {
236
+ for (const d of Object.values(PROVIDER_REGISTRY)) if (d.hostKeys.includes(hostKey)) return d.key
237
+ return null
238
+ }
239
+
240
+ export function verifyConnection(conn: Connection): Promise<VerifyResult> {
241
+ return PROVIDER_REGISTRY[conn.provider].verify(conn.creds)
242
+ }
243
+
244
+ // Best-effort web-console deep links for hosts we can't (or don't yet) drive via
245
+ // API — the `↗ web only` handoff. API providers normally resolve to editable/
246
+ // needs-key before reaching here.
247
+ export const PROVIDER_CONSOLE: Record<string, string> = {
248
+ namecheap: "https://ap.www.namecheap.com/domains/list",
249
+ dnsme: "https://dnsmadeeasy.com",
250
+ digitalocean: "https://cloud.digitalocean.com/networking/domains",
251
+ google: "https://domains.google.com/registrar",
252
+ netsol: "https://www.networksolutions.com/my-account",
253
+ dreamhost: "https://panel.dreamhost.com",
254
+ azure: "https://portal.azure.com",
255
+ linode: "https://cloud.linode.com/domains",
256
+ vultr: "https://my.vultr.com/dns/",
257
+ }
258
+
259
+ // The web handoff (URL + label) for a host, if any: an API provider's console
260
+ // (e.g. GoDaddy's Clients hub), else a web-only host's console. null for hosts
261
+ // managed purely via API (Route 53 / Cloudflare) or unrecognized fallbacks.
262
+ export function consoleForHost(hostKey: string): { url: string; label: string } | null {
263
+ const prov = apiProviderFor(hostKey)
264
+ if (prov) {
265
+ const d = PROVIDER_REGISTRY[prov]
266
+ return d.console ? { url: d.console, label: d.consoleLabel ?? "web console" } : null
267
+ }
268
+ const url = PROVIDER_CONSOLE[hostKey]
269
+ return url ? { url, label: "web console" } : null
270
+ }
271
+
272
+ export type AccessState = "editable" | "needs-key" | "web" | "unknown"
273
+
274
+ // The assumed-normal access note for ANY `web`-state zone (GoDaddy, Namecheap,
275
+ // Network Solutions, any host with a console fallback and no API connection) —
276
+ // most registrars are managed the same way (delegate/reseller access from one
277
+ // main account), so this is a single global default rather than a per-provider
278
+ // one. A user-entered per-zone override (store.zoneAccessNotes) — a genuine
279
+ // exception, e.g. one client's domain a third party actually manages — shows
280
+ // instead, in amber, so it stands out.
281
+ export const DEFAULT_WEB_ACCESS_NOTE = "Delegate Access"
282
+
283
+ // NOTE: the access decision is computed in the store (accessForZone) — it has the
284
+ // connection data — and is PROVIDER-SCOPED + NS-aware. See store.tsx.
@@ -0,0 +1,105 @@
1
+ // Disk cache of verified provider zone sets, keyed by connection id.
2
+ //
3
+ // Holds only the RESULT of verification (zone names + account labels + an age) —
4
+ // NOT the credentials themselves (those live in config.json). Lets the ACCESS
5
+ // column populate at startup without re-hitting every provider; a visible age +
6
+ // a re-verify key keep it honest. Modeled on dnsCache.ts.
7
+ //
8
+ // Staleness: entries older than STALE_TTL_MS are dropped on load so they re-verify
9
+ // (see STALE_TTL_MS below) — the general-case fix for a zone registered after the
10
+ // last verify (DNS inventory ACCESS column, clone Plan pre-flight), complementing
11
+ // the vanity/clone resolveZoneConn re-verify-on-miss self-heal.
12
+
13
+ import { join } from "node:path"
14
+ import { existsSync, readFileSync } from "node:fs"
15
+ import { chmod, mkdir } from "node:fs/promises"
16
+ import { configDir } from "../config.ts"
17
+ import type { VerifiedZone } from "./providers.ts"
18
+
19
+ // v2: VerifiedZone gained `nameservers` + `account` (was accountId/accountName).
20
+ // Old entries are dropped on load so connections re-verify with the new shape.
21
+ const CACHE_VERSION = 2
22
+
23
+ // Backlog #3 (2026-06-27): entries older than this are dropped on load so they
24
+ // lazily RE-VERIFY (via the "no cache entry → verifyConnectionById" path in the
25
+ // views) instead of serving a stale zone list forever. This is what lets a zone
26
+ // registered since the last verify become visible without a manual bust — the
27
+ // general-case complement to the vanity/clone resolveZoneConn self-heal. We DROP
28
+ // (not eagerly re-verify) on purpose: the cache exists so startup doesn't hit every
29
+ // provider; freshness is reclaimed the next time a view actually needs the zone.
30
+ const STALE_TTL_MS = 24 * 60 * 60 * 1000 // 24h
31
+
32
+ export interface VerifiedConn {
33
+ ok: boolean
34
+ zones: VerifiedZone[]
35
+ accountLabel: string
36
+ error?: string
37
+ verifiedAt: number // epoch ms
38
+ }
39
+
40
+ interface CacheFile {
41
+ version: number
42
+ entries: Record<string, VerifiedConn> // key: connection id
43
+ }
44
+
45
+ export function providersCachePath(): string {
46
+ return join(configDir(), "providers-cache.json")
47
+ }
48
+
49
+ export class ProvidersCache {
50
+ private entries = new Map<string, VerifiedConn>()
51
+ private writeChain: Promise<void> = Promise.resolve()
52
+
53
+ load(): void {
54
+ try {
55
+ const path = providersCachePath()
56
+ if (!existsSync(path)) return
57
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
58
+ if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
59
+ const now = Date.now()
60
+ for (const [k, v] of Object.entries(parsed.entries)) {
61
+ if (!k || !v || typeof v.verifiedAt !== "number") continue
62
+ if (now - v.verifiedAt > STALE_TTL_MS) continue // stale → drop so it re-verifies
63
+ this.entries.set(k, v)
64
+ }
65
+ } catch {
66
+ /* ignore corrupt/unreadable cache — start empty */
67
+ }
68
+ }
69
+
70
+ get(id: string): VerifiedConn | undefined {
71
+ return this.entries.get(id)
72
+ }
73
+
74
+ snapshot(): Map<string, VerifiedConn> {
75
+ return new Map(this.entries)
76
+ }
77
+
78
+ async set(id: string, conn: VerifiedConn): Promise<void> {
79
+ this.entries.set(id, conn)
80
+ await this.persist()
81
+ }
82
+
83
+ async delete(id: string): Promise<void> {
84
+ if (!this.entries.delete(id)) return
85
+ await this.persist()
86
+ }
87
+
88
+ private persist(): Promise<void> {
89
+ this.writeChain = this.writeChain.then(() => this.writeFile()).catch(() => {})
90
+ return this.writeChain
91
+ }
92
+
93
+ private async writeFile(): Promise<void> {
94
+ await mkdir(configDir(), { recursive: true })
95
+ const file: CacheFile = { version: CACHE_VERSION, entries: {} }
96
+ for (const [k, entry] of this.entries) file.entries[k] = entry
97
+ const path = providersCachePath()
98
+ await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
99
+ try {
100
+ await chmod(path, 0o600)
101
+ } catch {
102
+ /* best-effort */
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,67 @@
1
+ // "What's new" notes for the version currently running, fetched once from
2
+ // GitHub's release for that exact tag — reuses the same API the update-checker
3
+ // (appUpdate.ts) already polls, so no new infrastructure (no spinuptui.com feed).
4
+ // Shown once via the ReleaseNotes overlay when store.tsx detects the running
5
+ // version hasn't been "seen" yet (config.lastSeenVersion).
6
+
7
+ import { REPO_SLUG } from "../version.ts"
8
+
9
+ const FETCH_TIMEOUT_MS = 5000
10
+
11
+ export interface ReleaseNotesInfo {
12
+ version: string
13
+ title: string
14
+ body: string // raw markdown from the GitHub release body
15
+ url: string
16
+ }
17
+
18
+ // Hit GitHub for one specific release's notes; null on any failure (offline, no
19
+ // release published yet for this tag, rate limit). Times out so it can't hang
20
+ // the launch. Unlike appUpdate.ts's "latest release" poll, this targets the
21
+ // EXACT running version's tag, so it's correct even if the user updates across
22
+ // several releases in one hop.
23
+ export async function fetchReleaseNotes(version: string): Promise<ReleaseNotesInfo | null> {
24
+ const ctrl = new AbortController()
25
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
26
+ try {
27
+ const res = await fetch(`https://api.github.com/repos/${REPO_SLUG}/releases/tags/v${version}`, {
28
+ headers: {
29
+ "User-Agent": "spinup-tui",
30
+ Accept: "application/vnd.github+json",
31
+ "X-GitHub-Api-Version": "2022-11-28",
32
+ },
33
+ signal: ctrl.signal,
34
+ })
35
+ if (!res.ok) return null
36
+ const json = (await res.json()) as { name?: string; body?: string; html_url?: string }
37
+ if (!json.body) return null
38
+ return {
39
+ version,
40
+ title: json.name ?? `v${version}`,
41
+ body: json.body,
42
+ url: json.html_url ?? `https://github.com/${REPO_SLUG}/releases/tag/v${version}`,
43
+ }
44
+ } catch {
45
+ return null
46
+ } finally {
47
+ clearTimeout(timer)
48
+ }
49
+ }
50
+
51
+ export type NoteLine = { kind: "heading" | "bullet" | "text" | "blank"; content: string }
52
+
53
+ // Strip GitHub-flavored markdown down to something readable as plain terminal
54
+ // text. Not a general markdown parser (no new dependency) — just enough for the
55
+ // shape of notes this project actually writes (see RELEASING.md's house style):
56
+ // an intro line, `##` section headers, `- ` bullets (often **bold**-led), a
57
+ // blank-line-separated Update/Full-changelog footer.
58
+ export function formatReleaseNotesBody(body: string): NoteLine[] {
59
+ const strip = (s: string) => s.replace(/\*\*/g, "").replace(/`/g, "")
60
+ return body.split("\n").map((raw): NoteLine => {
61
+ const line = raw.trimEnd()
62
+ if (line.trim() === "") return { kind: "blank", content: "" }
63
+ if (line.startsWith("## ")) return { kind: "heading", content: strip(line.slice(3)) }
64
+ if (line.startsWith("- ")) return { kind: "bullet", content: strip(line.slice(2)) }
65
+ return { kind: "text", content: strip(line) }
66
+ })
67
+ }