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
|
@@ -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(/</g, "<")
|
|
137
|
+
.replace(/>/g, ">")
|
|
138
|
+
.replace(/"/g, '"')
|
|
139
|
+
.replace(/'/g, "'")
|
|
140
|
+
.replace(/'/g, "'")
|
|
141
|
+
.replace(/&/g, "&")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function xmlEscape(s: string): string {
|
|
145
|
+
return s
|
|
146
|
+
.replace(/&/g, "&")
|
|
147
|
+
.replace(/</g, "<")
|
|
148
|
+
.replace(/>/g, ">")
|
|
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
|
+
}
|
package/src/lib/jobs.ts
ADDED
|
@@ -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
|
+
}
|