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/probe.ts
ADDED
|
@@ -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
|
+
}
|