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,411 @@
1
+ // DNS record access layer (Phase 3) — the read + first-write slice for editing a
2
+ // site's hosting records directly at the host, since SpinupWP's API doesn't manage
3
+ // DNS records. Mirrors the providers.ts registry shape: one descriptor per editable
4
+ // provider, so the overlay and store stay provider-agnostic.
5
+ //
6
+ // This is a MIGRATION record manager, NOT a zone editor. We never enumerate a zone:
7
+ // reads are scoped to ONE named record (`getRecord`), so by construction the module
8
+ // can't see — let alone touch — MX/TXT/DKIM/other records. That untouchability is
9
+ // the product's safety guarantee (move the site without knocking out email), and it
10
+ // holds host-agnostically: Route 53 scopes via StartRecordName/Type, Cloudflare via
11
+ // the `?name=&type=` filter.
12
+ //
13
+ // Two distinct writes, two distinct editability questions: `editable` gates the
14
+ // TTL editor (well-defined only when the record HAS a real TTL — not a Route 53
15
+ // alias, not a routing-policy set, not a Cloudflare proxied record, whose TTL is
16
+ // forced to automatic); `valueEditable` gates the DNS-cutover origin-IP repoint
17
+ // (defaults to `editable` when unset — Cloudflare sets it explicitly, since a
18
+ // proxied record's VALUE stays freely PATCHable even though its TTL doesn't). A
19
+ // non-editable record surfaces with a short reason.
20
+ //
21
+ // Two providers are wired: AWS Route 53 (hand-rolled SigV4; the write is an async
22
+ // ChangeResourceRecordSets we poll to INSYNC) and Cloudflare (REST; the PATCH is
23
+ // synchronous). GoDaddy stays a web handoff — its API is gated, like in Phase 2.
24
+
25
+ import { awsGet, awsPost, type AwsCreds } from "./awsSigV4.ts"
26
+ import type { ConnProvider } from "./providers.ts"
27
+
28
+ export interface DnsRecord {
29
+ key: string // stable row key (Route 53: name|type; Cloudflare: record id)
30
+ recordId?: string // provider record id (Cloudflare PATCH target)
31
+ name: string // record name (trailing dot stripped)
32
+ type: string // A, AAAA, CNAME, MX, TXT, …
33
+ ttl: number | null // current TTL in seconds; null when the record carries no TTL
34
+ values: string[] // record value(s) — shown, and echoed back on a Route 53 upsert
35
+ editable: boolean // can we change THIS record's TTL?
36
+ // Can we repoint THIS record's value (the DNS-cutover write)? Defaults to
37
+ // `editable` when unset (see module comment) — only set explicitly when it
38
+ // differs, e.g. Cloudflare's proxied records.
39
+ valueEditable?: boolean
40
+ reason?: string // when not editable, the short why (alias / proxied / policy)
41
+ }
42
+
43
+ export interface RecordResult {
44
+ ok: boolean
45
+ zoneId: string // provider zone id (needed again for the write); "" on failure
46
+ record?: DnsRecord // the single requested record
47
+ // The zone's declared apex NS records — fuel for the edit-time NS-match gate
48
+ // (compare to fresh live NS). Omitted by providers whose access is already
49
+ // NS-matched at connect time (Cloudflare), so the gate simply doesn't apply.
50
+ apexNs?: string[]
51
+ error?: string
52
+ }
53
+
54
+ export interface ChangeResult {
55
+ ok: boolean
56
+ // Route 53 applies the change asynchronously; pollId is its change id (poll to
57
+ // INSYNC). Absent (Cloudflare) means the change already took effect.
58
+ pollId?: string
59
+ error?: string
60
+ }
61
+
62
+ export type ChangeStatus = "pending" | "done" | "failed"
63
+
64
+ export interface RecordProvider {
65
+ // Read ONE named record (and return the provider zone id for the follow-up write,
66
+ // plus the apex NS for the edit-time gate). Scoped on purpose — we never list the
67
+ // whole zone.
68
+ getRecord(creds: Record<string, string>, apex: string, name: string, type: string): Promise<RecordResult>
69
+ // Change one record's TTL. `zoneId` comes from getRecord; `record` carries the
70
+ // values to preserve (Route 53 needs the full set on an upsert).
71
+ setTtl(creds: Record<string, string>, zoneId: string, record: DnsRecord, ttl: number): Promise<ChangeResult>
72
+ // Create (or replace) a single record name → value. Used by the vanity-site flow
73
+ // to add the server hostname's A record. Resolves the zone itself (no prior
74
+ // getRecord). Optional — providers without it fall back to a web handoff.
75
+ createRecord?(creds: Record<string, string>, apex: string, name: string, type: string, value: string, ttl: number): Promise<ChangeResult>
76
+ // Repoint one EXISTING record to a new value (the DNS cutover write). `zoneId` +
77
+ // `record` come from getRecord; the record's current TTL/type are preserved.
78
+ // Optional — providers without it fall back to a manual handoff.
79
+ setValue?(creds: Record<string, string>, zoneId: string, record: DnsRecord, value: string): Promise<ChangeResult>
80
+ // Poll an async change to completion. Only providers that return a pollId set this.
81
+ pollChange?(creds: Record<string, string>, pollId: string): Promise<ChangeStatus>
82
+ }
83
+
84
+ // ---- TTL presets + helpers -------------------------------------------------
85
+
86
+ // Common TTLs offered in the picker. The record's current value is always shown
87
+ // too, so any existing TTL is selectable even when it's not on this list.
88
+ export const TTL_PRESETS: { ttl: number; label: string }[] = [
89
+ { ttl: 300, label: "5 minutes" },
90
+ { ttl: 1800, label: "30 minutes" },
91
+ { ttl: 3600, label: "1 hour" },
92
+ { ttl: 14400, label: "4 hours" },
93
+ { ttl: 43200, label: "12 hours" },
94
+ { ttl: 86400, label: "1 day" },
95
+ ]
96
+
97
+ // Sane bounds for the TTL editor. Cloudflare rejects sub-60s TTLs (other than its
98
+ // "automatic" sentinel, which the proxied/auto guard already excludes); Route 53
99
+ // allows lower, but a 1-week ceiling keeps the custom input from fat-fingering a
100
+ // value far outside what anyone wants here.
101
+ const TTL_MAX = 604800 // 7 days
102
+ const TTL_MIN: Record<ConnProvider, number> = { aws: 1, cloudflare: 60, godaddy: 60 }
103
+
104
+ // Validate a custom TTL for a provider. Returns an error string, or null when ok.
105
+ export function validateTtl(provider: ConnProvider, ttl: number): string | null {
106
+ if (!Number.isInteger(ttl)) return "TTL must be a whole number of seconds."
107
+ const min = TTL_MIN[provider]
108
+ if (ttl < min) return `TTL must be at least ${min}s for this provider.`
109
+ if (ttl > TTL_MAX) return `TTL can't exceed ${TTL_MAX}s (7 days).`
110
+ return null
111
+ }
112
+
113
+ // Humanize a TTL for display (e.g. 3600 → "1h", 5400 → "1h30m").
114
+ export function formatTtl(ttl: number | null): string {
115
+ if (ttl == null) return "—"
116
+ if (ttl === 0) return "0"
117
+ const d = Math.floor(ttl / 86400)
118
+ const h = Math.floor((ttl % 86400) / 3600)
119
+ const m = Math.floor((ttl % 3600) / 60)
120
+ const s = ttl % 60
121
+ const parts: string[] = []
122
+ if (d) parts.push(`${d}d`)
123
+ if (h) parts.push(`${h}h`)
124
+ if (m) parts.push(`${m}m`)
125
+ if (s) parts.push(`${s}s`)
126
+ return parts.join("") || "0"
127
+ }
128
+
129
+ function stripDot(name: string): string {
130
+ return name.replace(/\.$/, "")
131
+ }
132
+
133
+ // Decode the small set of XML entities Route 53 emits in values/names.
134
+ function xmlUnescape(s: string): string {
135
+ return s
136
+ .replace(/&lt;/g, "<")
137
+ .replace(/&gt;/g, ">")
138
+ .replace(/&quot;/g, '"')
139
+ .replace(/&#39;/g, "'")
140
+ .replace(/&apos;/g, "'")
141
+ .replace(/&amp;/g, "&")
142
+ }
143
+
144
+ function xmlEscape(s: string): string {
145
+ return s
146
+ .replace(/&/g, "&amp;")
147
+ .replace(/</g, "&lt;")
148
+ .replace(/>/g, "&gt;")
149
+ }
150
+
151
+ // ---- AWS Route 53 ----------------------------------------------------------
152
+
153
+ const R53_HOST = "route53.amazonaws.com"
154
+ const R53_BASE = "/2013-04-01"
155
+
156
+ function awsCreds(creds: Record<string, string>): AwsCreds {
157
+ return { accessKeyId: creds.accessKeyId ?? "", secretAccessKey: creds.secretAccessKey ?? "", region: creds.region || undefined }
158
+ }
159
+
160
+ function awsErrorMessage(xml: string): string | null {
161
+ return xml.match(/<Message>([^<]+)<\/Message>/)?.[1] ?? null
162
+ }
163
+
164
+ // Find the PUBLIC hosted-zone id for an apex (there can be a private zone of the
165
+ // same name; we only edit the public one). Returns the bare id, e.g. "Z123ABC".
166
+ async function awsZoneId(creds: AwsCreds, apex: string): Promise<{ id: string; error?: string }> {
167
+ const r = await awsGet(creds, "route53", R53_HOST, `${R53_BASE}/hostedzonesbyname`, { dnsname: apex, maxitems: "10" })
168
+ if (r.status !== 200) return { id: "", error: awsErrorMessage(r.body) || `Route 53 HTTP ${r.status}` }
169
+ const want = stripDot(apex).toLowerCase()
170
+ for (const block of r.body.split(/<HostedZone>/).slice(1)) {
171
+ const name = block.match(/<Name>([^<]+)<\/Name>/)?.[1]
172
+ if (!name || stripDot(name).toLowerCase() !== want) continue
173
+ if (/<PrivateZone>true<\/PrivateZone>/.test(block)) continue // skip private zones
174
+ const id = block.match(/<Id>([^<]+)<\/Id>/)?.[1]
175
+ if (id) return { id: id.replace(/^\/hostedzone\//, "") }
176
+ }
177
+ return { id: "", error: `No public Route 53 hosted zone for ${want}.` }
178
+ }
179
+
180
+ function parseRoute53Records(xml: string): DnsRecord[] {
181
+ const out: DnsRecord[] = []
182
+ for (const block of xml.split(/<ResourceRecordSet>/).slice(1)) {
183
+ const body = block.split("</ResourceRecordSet>")[0]
184
+ const name = body.match(/<Name>([^<]*)<\/Name>/)?.[1]
185
+ const type = body.match(/<Type>([^<]*)<\/Type>/)?.[1]
186
+ if (!name || !type) continue
187
+ const ttlRaw = body.match(/<TTL>(\d+)<\/TTL>/)?.[1]
188
+ const isAlias = /<AliasTarget>/.test(body)
189
+ const setId = body.match(/<SetIdentifier>([^<]*)<\/SetIdentifier>/)?.[1]
190
+ const values = [...body.matchAll(/<Value>([\s\S]*?)<\/Value>/g)].map((m) => xmlUnescape(m[1]))
191
+ let editable = true
192
+ let reason: string | undefined
193
+ if (isAlias) {
194
+ editable = false
195
+ reason = "alias"
196
+ } else if (setId != null) {
197
+ editable = false
198
+ reason = "routing policy"
199
+ } else if (ttlRaw == null) {
200
+ editable = false
201
+ reason = "no TTL"
202
+ }
203
+ const display = stripDot(xmlUnescape(name))
204
+ out.push({
205
+ key: `${display}|${type}|${setId ?? ""}`,
206
+ name: display,
207
+ type,
208
+ ttl: ttlRaw != null ? Number(ttlRaw) : null,
209
+ values: isAlias ? ["(alias)"] : values,
210
+ editable,
211
+ reason,
212
+ })
213
+ }
214
+ return out
215
+ }
216
+
217
+ // Scoped read: ListResourceRecordSets starting at a name+type returns records AT or
218
+ // AFTER that name, so we ask for a few and pick the exact match (never the zone).
219
+ async function r53RecordAt(creds: AwsCreds, zoneId: string, name: string, type: string): Promise<{ ok: boolean; records: DnsRecord[]; error?: string }> {
220
+ const r = await awsGet(creds, "route53", R53_HOST, `${R53_BASE}/hostedzone/${zoneId}/rrset`, { name, type, maxitems: "5" })
221
+ if (r.status !== 200) return { ok: false, records: [], error: awsErrorMessage(r.body) || `Route 53 HTTP ${r.status}` }
222
+ return { ok: true, records: parseRoute53Records(r.body) }
223
+ }
224
+
225
+ const route53: RecordProvider = {
226
+ async getRecord(rawCreds, apex, name, type) {
227
+ const creds = awsCreds(rawCreds)
228
+ const zone = await awsZoneId(creds, apex)
229
+ if (!zone.id) return { ok: false, zoneId: "", error: zone.error }
230
+ const want = stripDot(name).toLowerCase()
231
+ const got = await r53RecordAt(creds, zone.id, name, type)
232
+ if (!got.ok) return { ok: false, zoneId: "", error: got.error }
233
+ const record = got.records.find((r) => r.name.toLowerCase() === want && r.type === type)
234
+ if (!record) return { ok: false, zoneId: "", error: `No ${type} record for ${want} in this zone.` }
235
+ // A second scoped read for the apex NS (the edit-time NS-match gate compares it
236
+ // to the fresh live NS). Failure here just disables the gate, never the edit.
237
+ const apexLc = stripDot(apex).toLowerCase()
238
+ const nsGot = await r53RecordAt(creds, zone.id, apex, "NS")
239
+ const apexNs = nsGot.records.find((r) => r.type === "NS" && r.name.toLowerCase() === apexLc)?.values ?? []
240
+ return { ok: true, zoneId: zone.id, record, apexNs }
241
+ },
242
+
243
+ async setTtl(rawCreds, zoneId, record, ttl) {
244
+ const creds = awsCreds(rawCreds)
245
+ // UPSERT replaces the whole record set, so echo the existing values back with
246
+ // the new TTL — anything omitted would be dropped.
247
+ const valuesXml = record.values.map((v) => `<ResourceRecord><Value>${xmlEscape(v)}</Value></ResourceRecord>`).join("")
248
+ const body =
249
+ `<?xml version="1.0" encoding="UTF-8"?>` +
250
+ `<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">` +
251
+ `<ChangeBatch><Comment>spinup: set TTL to ${ttl}</Comment><Changes><Change>` +
252
+ `<Action>UPSERT</Action><ResourceRecordSet>` +
253
+ `<Name>${xmlEscape(record.name)}</Name><Type>${record.type}</Type><TTL>${ttl}</TTL>` +
254
+ `<ResourceRecords>${valuesXml}</ResourceRecords>` +
255
+ `</ResourceRecordSet></Change></Changes></ChangeBatch>` +
256
+ `</ChangeResourceRecordSetsRequest>`
257
+ const r = await awsPost(creds, "route53", R53_HOST, `${R53_BASE}/hostedzone/${zoneId}/rrset/`, body)
258
+ if (r.status !== 200) return { ok: false, error: awsErrorMessage(r.body) || `Route 53 HTTP ${r.status}` }
259
+ const changeId = r.body.match(/<Id>([^<]+)<\/Id>/)?.[1]?.replace(/^\/change\//, "")
260
+ return { ok: true, pollId: changeId }
261
+ },
262
+
263
+ async createRecord(rawCreds, apex, name, type, value, ttl) {
264
+ const creds = awsCreds(rawCreds)
265
+ const zone = await awsZoneId(creds, apex)
266
+ if (!zone.id) return { ok: false, error: zone.error }
267
+ // UPSERT creates the record, or replaces it if the exact name+type already
268
+ // exists (the vanity host is purpose-built, so a collision means re-point it
269
+ // at this server — what we want). The confirm overlay shows the exact write.
270
+ const body =
271
+ `<?xml version="1.0" encoding="UTF-8"?>` +
272
+ `<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">` +
273
+ `<ChangeBatch><Comment>spinup: create ${type} ${stripDot(name)}</Comment><Changes><Change>` +
274
+ `<Action>UPSERT</Action><ResourceRecordSet>` +
275
+ `<Name>${xmlEscape(stripDot(name))}</Name><Type>${type}</Type><TTL>${ttl}</TTL>` +
276
+ `<ResourceRecords><ResourceRecord><Value>${xmlEscape(value)}</Value></ResourceRecord></ResourceRecords>` +
277
+ `</ResourceRecordSet></Change></Changes></ChangeBatch>` +
278
+ `</ChangeResourceRecordSetsRequest>`
279
+ const r = await awsPost(creds, "route53", R53_HOST, `${R53_BASE}/hostedzone/${zone.id}/rrset/`, body)
280
+ if (r.status !== 200) return { ok: false, error: awsErrorMessage(r.body) || `Route 53 HTTP ${r.status}` }
281
+ const changeId = r.body.match(/<Id>([^<]+)<\/Id>/)?.[1]?.replace(/^\/change\//, "")
282
+ return { ok: true, pollId: changeId }
283
+ },
284
+
285
+ async setValue(rawCreds, zoneId, record, value) {
286
+ const creds = awsCreds(rawCreds)
287
+ // UPSERT the record to the new single value, preserving its TTL (A records carry
288
+ // one; fall back to 300 if somehow absent). Type/name unchanged → a clean repoint.
289
+ const ttl = record.ttl ?? 300
290
+ const body =
291
+ `<?xml version="1.0" encoding="UTF-8"?>` +
292
+ `<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">` +
293
+ `<ChangeBatch><Comment>spinup: repoint ${stripDot(record.name)} to ${value}</Comment><Changes><Change>` +
294
+ `<Action>UPSERT</Action><ResourceRecordSet>` +
295
+ `<Name>${xmlEscape(record.name)}</Name><Type>${record.type}</Type><TTL>${ttl}</TTL>` +
296
+ `<ResourceRecords><ResourceRecord><Value>${xmlEscape(value)}</Value></ResourceRecord></ResourceRecords>` +
297
+ `</ResourceRecordSet></Change></Changes></ChangeBatch>` +
298
+ `</ChangeResourceRecordSetsRequest>`
299
+ const r = await awsPost(creds, "route53", R53_HOST, `${R53_BASE}/hostedzone/${zoneId}/rrset/`, body)
300
+ if (r.status !== 200) return { ok: false, error: awsErrorMessage(r.body) || `Route 53 HTTP ${r.status}` }
301
+ const changeId = r.body.match(/<Id>([^<]+)<\/Id>/)?.[1]?.replace(/^\/change\//, "")
302
+ return { ok: true, pollId: changeId }
303
+ },
304
+
305
+ async pollChange(rawCreds, pollId) {
306
+ const creds = awsCreds(rawCreds)
307
+ const r = await awsGet(creds, "route53", R53_HOST, `${R53_BASE}/change/${pollId}`)
308
+ if (r.status !== 200) return "failed"
309
+ return /<Status>INSYNC<\/Status>/.test(r.body) ? "done" : "pending"
310
+ },
311
+ }
312
+
313
+ // ---- Cloudflare ------------------------------------------------------------
314
+
315
+ const CF_API = "https://api.cloudflare.com/client/v4"
316
+
317
+ function cfHeaders(token: string): Record<string, string> {
318
+ return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
319
+ }
320
+
321
+ function cfError(json: { errors?: { message?: string }[] } | undefined, status: number): string {
322
+ return json?.errors?.[0]?.message || `HTTP ${status}`
323
+ }
324
+
325
+ async function cfZoneId(token: string, apex: string): Promise<{ id: string; error?: string }> {
326
+ const res = await fetch(`${CF_API}/zones?name=${encodeURIComponent(apex)}`, { headers: cfHeaders(token) })
327
+ const json = (await res.json().catch(() => undefined)) as { success?: boolean; result?: { id: string }[]; errors?: { message?: string }[] } | undefined
328
+ if (!res.ok || !json?.success) return { id: "", error: cfError(json, res.status) }
329
+ const id = json.result?.[0]?.id
330
+ return id ? { id } : { id: "", error: `No Cloudflare zone for ${apex}.` }
331
+ }
332
+
333
+ const cloudflare: RecordProvider = {
334
+ async getRecord(rawCreds, apex, name, type) {
335
+ const token = rawCreds.token ?? ""
336
+ const zone = await cfZoneId(token, apex)
337
+ if (!zone.id) return { ok: false, zoneId: "", error: zone.error }
338
+ // Scoped read: filter to the one record by name+type (never the whole zone).
339
+ const url = `${CF_API}/zones/${zone.id}/dns_records?name=${encodeURIComponent(stripDot(name))}&type=${encodeURIComponent(type)}`
340
+ const res = await fetch(url, { headers: cfHeaders(token) })
341
+ const json = (await res.json().catch(() => undefined)) as
342
+ | { success?: boolean; errors?: { message?: string }[]; result?: { id: string; type: string; name: string; content: string; ttl: number; proxied?: boolean }[] }
343
+ | undefined
344
+ if (!res.ok || !json?.success) return { ok: false, zoneId: "", error: cfError(json, res.status) }
345
+ const r = json.result?.[0]
346
+ if (!r) return { ok: false, zoneId: "", error: `No ${type} record for ${stripDot(name)} in this zone.` }
347
+ const proxied = r.proxied === true
348
+ const record: DnsRecord = {
349
+ key: r.id,
350
+ recordId: r.id,
351
+ name: stripDot(r.name),
352
+ type: r.type,
353
+ ttl: r.ttl === 1 ? null : r.ttl, // 1 = Cloudflare "automatic"
354
+ values: [r.content],
355
+ editable: !proxied,
356
+ // The origin-IP PATCH (setValue) only ever touches `content`, leaving
357
+ // `proxied` untouched — so it works identically whether proxied or not,
358
+ // even though the TTL editor above stays blocked for a proxied record.
359
+ valueEditable: true,
360
+ reason: proxied ? "proxied" : undefined,
361
+ }
362
+ // apexNs omitted — Cloudflare access is already NS-matched at connect time.
363
+ return { ok: true, zoneId: zone.id, record }
364
+ },
365
+
366
+ async setTtl(rawCreds, zoneId, record, ttl) {
367
+ const token = rawCreds.token ?? ""
368
+ if (!record.recordId) return { ok: false, error: "Missing Cloudflare record id." }
369
+ const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${record.recordId}`, {
370
+ method: "PATCH",
371
+ headers: cfHeaders(token),
372
+ body: JSON.stringify({ ttl }),
373
+ })
374
+ const json = (await res.json().catch(() => undefined)) as { success?: boolean; errors?: { message?: string }[] } | undefined
375
+ if (!res.ok || !json?.success) return { ok: false, error: cfError(json, res.status) }
376
+ return { ok: true } // Cloudflare applies synchronously
377
+ },
378
+
379
+ async setValue(rawCreds, zoneId, record, value) {
380
+ const token = rawCreds.token ?? ""
381
+ if (!record.recordId) return { ok: false, error: "Missing Cloudflare record id." }
382
+ // PATCH only the content — name/type/ttl/proxied stay as configured.
383
+ const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${record.recordId}`, {
384
+ method: "PATCH",
385
+ headers: cfHeaders(token),
386
+ body: JSON.stringify({ content: value }),
387
+ })
388
+ const json = (await res.json().catch(() => undefined)) as { success?: boolean; errors?: { message?: string }[] } | undefined
389
+ if (!res.ok || !json?.success) return { ok: false, error: cfError(json, res.status) }
390
+ return { ok: true } // Cloudflare applies synchronously
391
+ },
392
+ }
393
+
394
+ // ---- Registry --------------------------------------------------------------
395
+
396
+ const RECORD_PROVIDERS: Partial<Record<ConnProvider, RecordProvider>> = {
397
+ aws: route53,
398
+ cloudflare,
399
+ }
400
+
401
+ // The record provider for a connection provider, or null when records aren't
402
+ // API-editable for it (e.g. GoDaddy → web handoff).
403
+ export function recordProviderFor(provider: ConnProvider): RecordProvider | null {
404
+ return RECORD_PROVIDERS[provider] ?? null
405
+ }
406
+
407
+ // The TTL the picker should show as "current" for a record whose live TTL is null
408
+ // (Cloudflare auto): default the custom field to a sensible 1h starting point.
409
+ export function defaultTtlFor(record: DnsRecord): number {
410
+ return record.ttl ?? 3600
411
+ }
@@ -0,0 +1,106 @@
1
+ // Pure formatting helpers — bytes, dates, relative time, truncation, bars.
2
+
3
+ export function formatBytes(bytes: number | null | undefined, decimals = 1): string {
4
+ if (bytes == null || isNaN(bytes)) return "—"
5
+ if (bytes === 0) return "0 B"
6
+ const k = 1000 // SpinupWP reports disk space in decimal (GB), so use base-1000
7
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
8
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
9
+ const value = bytes / Math.pow(k, i)
10
+ return `${value.toFixed(i === 0 ? 0 : decimals)} ${sizes[i]}`
11
+ }
12
+
13
+ // Percentage of disk used (0–100). Returns 0 when unknown.
14
+ export function diskUsedPct(used?: number | null, total?: number | null): number {
15
+ if (!total || total <= 0 || used == null) return 0
16
+ return Math.min(100, Math.max(0, (used / total) * 100))
17
+ }
18
+
19
+ // A unicode progress bar, e.g. ████████░░░░ for a given fraction.
20
+ export function bar(fraction: number, width = 12): string {
21
+ const clamped = Math.min(1, Math.max(0, fraction))
22
+ const filled = Math.round(clamped * width)
23
+ return "█".repeat(filled) + "░".repeat(width - filled)
24
+ }
25
+
26
+ export function formatDate(iso: string | null | undefined): string {
27
+ if (!iso) return "—"
28
+ const d = new Date(iso)
29
+ if (isNaN(d.getTime())) return "—"
30
+ return d.toLocaleString(undefined, {
31
+ year: "numeric",
32
+ month: "short",
33
+ day: "numeric",
34
+ hour: "2-digit",
35
+ minute: "2-digit",
36
+ })
37
+ }
38
+
39
+ // Human relative time, e.g. "3m ago", "2h ago", "5d ago".
40
+ export function timeAgo(iso: string | null | undefined): string {
41
+ if (!iso) return "—"
42
+ const d = new Date(iso)
43
+ if (isNaN(d.getTime())) return "—"
44
+ const secs = Math.floor((Date.now() - d.getTime()) / 1000)
45
+ if (secs < 0) return "soon"
46
+ if (secs < 60) return `${secs}s ago`
47
+ const mins = Math.floor(secs / 60)
48
+ if (mins < 60) return `${mins}m ago`
49
+ const hours = Math.floor(mins / 60)
50
+ if (hours < 24) return `${hours}h ago`
51
+ const days = Math.floor(hours / 24)
52
+ if (days < 30) return `${days}d ago`
53
+ const months = Math.floor(days / 30)
54
+ if (months < 12) return `${months}mo ago`
55
+ return `${Math.floor(months / 12)}y ago`
56
+ }
57
+
58
+ // Compact uptime, e.g. "36d 5h", "5h 12m", "3m".
59
+ export function formatUptime(secs: number | null | undefined): string {
60
+ if (!secs || secs < 0) return "—"
61
+ const d = Math.floor(secs / 86400)
62
+ const h = Math.floor((secs % 86400) / 3600)
63
+ const m = Math.floor((secs % 3600) / 60)
64
+ if (d > 0) return `${d}d ${h}h`
65
+ if (h > 0) return `${h}h ${m}m`
66
+ return `${m}m`
67
+ }
68
+
69
+ const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
70
+
71
+ // Render a series (0..max) as a unicode sparkline of the most recent `width` points.
72
+ export function sparkline(values: number[], width = 30, max = 100): string {
73
+ if (values.length === 0) return ""
74
+ const recent = values.slice(-width)
75
+ return recent
76
+ .map((v) => {
77
+ const frac = Math.min(1, Math.max(0, v / max))
78
+ return SPARK_CHARS[Math.min(SPARK_CHARS.length - 1, Math.round(frac * (SPARK_CHARS.length - 1)))]
79
+ })
80
+ .join("")
81
+ }
82
+
83
+ // Truncate the MIDDLE, keeping the head and tail — for long paths/filenames where
84
+ // both ends matter (e.g. ".../app/sql/site_2026-06-22.sql.gz").
85
+ export function middleTruncate(s: string, max: number): string {
86
+ if (s.length <= max) return s
87
+ if (max <= 1) return s.slice(0, Math.max(0, max))
88
+ const keep = max - 1 // room for the ellipsis
89
+ const head = Math.ceil(keep / 2)
90
+ const tail = Math.floor(keep / 2)
91
+ return s.slice(0, head) + "…" + s.slice(s.length - tail)
92
+ }
93
+
94
+ export function truncate(s: string | null | undefined, max: number): string {
95
+ if (!s) return ""
96
+ if (s.length <= max) return s
97
+ if (max <= 1) return s.slice(0, max)
98
+ return s.slice(0, max - 1) + "…"
99
+ }
100
+
101
+ // Right-pad a string to a fixed visible width (for column alignment).
102
+ export function padEndVis(s: string, width: number): string {
103
+ const len = s.length
104
+ if (len >= width) return s
105
+ return s + " ".repeat(width - len)
106
+ }
Binary file
@@ -0,0 +1,37 @@
1
+ // Local git drift for a linked working copy — Phase 4b.
2
+ //
3
+ // Network-free on purpose (no `git fetch`): we only report what's knowable
4
+ // instantly from the local repo — uncommitted changes (dirty) and commits not
5
+ // yet pushed to the upstream (ahead). Together these answer "do I have local
6
+ // work that isn't deployed yet?". "Behind remote" is intentionally omitted (it
7
+ // needs a network fetch + possibly SSH auth, against the app's no-slow-ops rule).
8
+
9
+ import { expandPath, findProjectRoot } from "./local.ts"
10
+
11
+ export interface Drift {
12
+ dirty: boolean // uncommitted working-tree changes
13
+ ahead: number // commits ahead of the upstream (0 if none / no upstream)
14
+ }
15
+
16
+ async function git(cwd: string, args: string[]): Promise<{ ok: boolean; out: string }> {
17
+ try {
18
+ const proc = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
19
+ const out = await new Response(proc.stdout).text()
20
+ const code = await proc.exited
21
+ return { ok: code === 0, out: out.trim() }
22
+ } catch {
23
+ return { ok: false, out: "" }
24
+ }
25
+ }
26
+
27
+ // Compute drift for a link's stored path. Resolves the actual repo root (which
28
+ // may be nested, e.g. app/). Returns null when the path isn't a git repo.
29
+ export async function gitDrift(linkPath: string): Promise<Drift | null> {
30
+ const dir = findProjectRoot(expandPath(linkPath))
31
+ const inside = await git(dir, ["rev-parse", "--is-inside-work-tree"])
32
+ if (!inside.ok || inside.out !== "true") return null
33
+ const status = await git(dir, ["status", "--porcelain"])
34
+ const dirty = status.out.length > 0
35
+ const ahead = await git(dir, ["rev-list", "--count", "@{u}..HEAD"])
36
+ return { dirty, ahead: ahead.ok ? parseInt(ahead.out, 10) || 0 : 0 }
37
+ }
@@ -0,0 +1,32 @@
1
+ // Resumable-job persistence — the shared mechanism described in
2
+ // docs/2026-06-24_clone-to-server-spec.md ("Resumable jobs"). Long-running,
3
+ // fire-and-forget jobs (server create today; clone/dbSync/phpUpgrade next) mirror
4
+ // their state to config.json so a quit/relaunch can re-attach a poller via the
5
+ // stored `eventId` instead of forgetting the work.
6
+ //
7
+ // Only in-flight jobs are persisted; they're removed once terminal. Writes are
8
+ // deliberately rare (once when the job's event id is known, once on completion) —
9
+ // intermediate status is re-fetched on resume, so it needn't be stored.
10
+
11
+ import { loadConfig, saveConfig, type StoredJob } from "../config.ts"
12
+
13
+ export type { StoredJob }
14
+
15
+ // Upsert a job into the persisted set (read-modify-write; saveConfig merges).
16
+ export async function saveJob(job: StoredJob): Promise<void> {
17
+ const jobs = { ...loadConfig().jobs, [job.id]: job }
18
+ await saveConfig({ jobs })
19
+ }
20
+
21
+ // Remove a job from the persisted set. No-op when it isn't present.
22
+ export async function removeJob(id: string): Promise<void> {
23
+ const jobs = { ...loadConfig().jobs }
24
+ if (!(id in jobs)) return
25
+ delete jobs[id]
26
+ await saveConfig({ jobs })
27
+ }
28
+
29
+ // A persisted job is in-flight unless it has settled.
30
+ export function isJobInFlight(job: StoredJob | undefined): boolean {
31
+ return job != null && job.status !== "done" && job.status !== "failed"
32
+ }
@@ -0,0 +1,49 @@
1
+ // macOS Keychain storage for sudo passwords (opt-in), via the built-in `security`
2
+ // CLI — zero dependencies, service-scoped to one item per server. The password lives
3
+ // ONLY in the Keychain + the in-memory session ref; it never touches config.json.
4
+ //
5
+ // Off macOS this no-ops (keychainAvailable() === false) and sudo stays in-memory per
6
+ // session, exactly as before. NOTE: `security add-generic-password` takes the password
7
+ // as an argv value (-w), so it's briefly visible to `ps` on the local machine during
8
+ // the write — acceptable for an opt-in convenience on a personal box; documented here.
9
+
10
+ const SERVICE = "spinup-sudo"
11
+
12
+ export function keychainAvailable(): boolean {
13
+ return process.platform === "darwin"
14
+ }
15
+
16
+ function account(serverId: number): string {
17
+ return `server-${serverId}`
18
+ }
19
+
20
+ async function security(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
21
+ try {
22
+ const proc = Bun.spawn(["security", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
23
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
24
+ return { ok: code === 0, stdout, stderr }
25
+ } catch (err) {
26
+ return { ok: false, stdout: "", stderr: (err as Error).message }
27
+ }
28
+ }
29
+
30
+ // Store (or update) a server's sudo password. Returns false off macOS / on failure.
31
+ export async function setSudoPassword(serverId: number, password: string): Promise<boolean> {
32
+ if (!keychainAvailable()) return false
33
+ const r = await security(["add-generic-password", "-U", "-s", SERVICE, "-a", account(serverId), "-w", password])
34
+ return r.ok
35
+ }
36
+
37
+ // Retrieve a server's sudo password, or null if absent / denied / off macOS. The first
38
+ // read of an item may surface macOS's own "allow access" prompt (then Always Allow).
39
+ export async function getSudoPassword(serverId: number): Promise<string | null> {
40
+ if (!keychainAvailable()) return null
41
+ const r = await security(["find-generic-password", "-s", SERVICE, "-a", account(serverId), "-w"])
42
+ return r.ok ? r.stdout.replace(/\n+$/, "") : null
43
+ }
44
+
45
+ // Remove a server's stored sudo password (best-effort).
46
+ export async function deleteSudoPassword(serverId: number): Promise<void> {
47
+ if (!keychainAvailable()) return
48
+ await security(["delete-generic-password", "-s", SERVICE, "-a", account(serverId)])
49
+ }