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/bin/spinuptui.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Published-package entry shim. The app must run under Bun — OpenTUI loads
|
|
3
|
+
// native code via Bun's FFI — so when a Node-based global install ignores the
|
|
4
|
+
// shebang, fail with instructions instead of exploding inside the renderer.
|
|
5
|
+
if (!process.versions?.bun) {
|
|
6
|
+
console.error(
|
|
7
|
+
"SpinupTUI runs on Bun (its terminal renderer uses Bun-native FFI).\n" +
|
|
8
|
+
"Install Bun: https://bun.sh\n" +
|
|
9
|
+
"Then reinstall with: bun install -g spinuptui",
|
|
10
|
+
)
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
await import("../src/index.tsx")
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spinuptui",
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"spinuptui": "bin/spinuptui.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/mwender/spinupwp-tui.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/mwender/spinupwp-tui#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/mwender/spinupwp-tui/issues"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "bun run src/index.tsx",
|
|
26
|
+
"start": "bun run src/index.tsx",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"link-global": "bun link",
|
|
29
|
+
"unlink-global": "bun unlink",
|
|
30
|
+
"build:binary": "bun build src/index.tsx --compile --outfile spinuptui"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"spinupwp",
|
|
34
|
+
"tui",
|
|
35
|
+
"terminal-ui",
|
|
36
|
+
"opentui",
|
|
37
|
+
"wordpress",
|
|
38
|
+
"devops",
|
|
39
|
+
"dashboard"
|
|
40
|
+
],
|
|
41
|
+
"author": "Michael Wender",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@opentui-ui/toast": "^0.0.5",
|
|
45
|
+
"@opentui/core": "^0.4.1",
|
|
46
|
+
"@opentui/react": "^0.4.1",
|
|
47
|
+
"react": "^19.0.0",
|
|
48
|
+
"socket.io-client": "^4.8.3"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/bun": "^1.3.14",
|
|
52
|
+
"@types/react": "^19.0.0",
|
|
53
|
+
"typescript": "^5.6.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// Thin typed wrapper around the SpinupWP REST API using fetch + Bearer auth.
|
|
2
|
+
// Reads (GET) work with any token. The handful of write methods (e.g.
|
|
3
|
+
// upgradeSitePhp) need a Read/Write-scoped token; since the API exposes no
|
|
4
|
+
// token-scope endpoint, a write that comes back 403 is treated as "token is
|
|
5
|
+
// read-only" (see mutate()).
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ApiList,
|
|
9
|
+
ApiSingle,
|
|
10
|
+
Server,
|
|
11
|
+
Site,
|
|
12
|
+
Event,
|
|
13
|
+
ProviderMetadata,
|
|
14
|
+
CreateServerPayload,
|
|
15
|
+
CreateSitePayload,
|
|
16
|
+
AdditionalDomain,
|
|
17
|
+
AddDomainPayload,
|
|
18
|
+
} from "./types.ts"
|
|
19
|
+
|
|
20
|
+
// The restartable services SpinupWP exposes (POST /servers/{id}/services/{svc}/restart).
|
|
21
|
+
export type ServerService = "nginx" | "php" | "mysql" | "redis"
|
|
22
|
+
|
|
23
|
+
export class ApiError extends Error {
|
|
24
|
+
status: number
|
|
25
|
+
body?: string
|
|
26
|
+
constructor(message: string, status: number, body?: string) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = "ApiError"
|
|
29
|
+
this.status = status
|
|
30
|
+
this.body = body
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ClientOptions {
|
|
35
|
+
token: string
|
|
36
|
+
baseUrl: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The public surface the store (and Onboarding) actually calls. Extracted so a fake
|
|
40
|
+
// implementation (see src/dev/mockClient.ts, used by SPINUP_DEV_MODE) can stand in for
|
|
41
|
+
// SpinupWPClient wherever it's consumed, without either side depending on the other.
|
|
42
|
+
export interface SpinupWPClientLike {
|
|
43
|
+
validateToken(): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
44
|
+
listServers(): Promise<Server[]>
|
|
45
|
+
getServer(id: number): Promise<Server>
|
|
46
|
+
providerMetadata(provider: string): Promise<ProviderMetadata>
|
|
47
|
+
createServer(payload: CreateServerPayload): Promise<{ event_id: number }>
|
|
48
|
+
listSites(serverId?: number): Promise<Site[]>
|
|
49
|
+
getSite(id: number): Promise<Site>
|
|
50
|
+
createSite(payload: CreateSitePayload): Promise<{ event_id: number }>
|
|
51
|
+
enableHttps(siteId: number): Promise<{ event_id: number }>
|
|
52
|
+
disableHttps(siteId: number): Promise<{ event_id: number } | undefined>
|
|
53
|
+
purgePageCache(siteId: number): Promise<{ event_id: number }>
|
|
54
|
+
purgeObjectCache(siteId: number): Promise<{ event_id: number }>
|
|
55
|
+
listSiteDomains(siteId: number): Promise<AdditionalDomain[]>
|
|
56
|
+
addSiteDomain(siteId: number, payload: AddDomainPayload): Promise<{ event_id: number }>
|
|
57
|
+
listEvents(maxPages?: number): Promise<Event[]>
|
|
58
|
+
getEvent(id: number): Promise<Event>
|
|
59
|
+
upgradeSitePhp(siteId: number, phpVersion: string): Promise<{ event_id: number }>
|
|
60
|
+
rebootServer(serverId: number): Promise<{ event_id: number }>
|
|
61
|
+
restartService(serverId: number, service: ServerService): Promise<{ event_id: number }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MAX_PAGES = 100 // hard safety cap when auto-paginating
|
|
65
|
+
|
|
66
|
+
// ---- Rate-limit awareness -------------------------------------------------
|
|
67
|
+
// SpinupWP sends X-RateLimit-Remaining on every response (Laravel throttle,
|
|
68
|
+
// 1-minute windows). All traffic funnels through fetchWithRateLimit below:
|
|
69
|
+
// PROACTIVE pacing — when the window is nearly spent, spread the remaining
|
|
70
|
+
// requests across what's left of it instead of slamming into the wall — plus
|
|
71
|
+
// REACTIVE Retry-After backoff on 429 (safe to retry: a 429 was rejected before
|
|
72
|
+
// any processing). Module-level so every client instance shares one budget.
|
|
73
|
+
// Born from the 2026-07-02 prod clone retry, where three concurrent site workers'
|
|
74
|
+
// event polling burned the window and made SUCCESSFUL operations look failed.
|
|
75
|
+
let rlRemaining = Infinity
|
|
76
|
+
let rlResetAt = 0
|
|
77
|
+
const RL_LOW_WATER = 8 // start pacing when fewer than this many requests remain
|
|
78
|
+
const RL_MAX_RETRIES = 3
|
|
79
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
80
|
+
|
|
81
|
+
function rlUpdate(headers: Headers): void {
|
|
82
|
+
const rem = Number(headers.get("x-ratelimit-remaining"))
|
|
83
|
+
if (Number.isFinite(rem)) rlRemaining = rem
|
|
84
|
+
// No reset header outside 429s — assume the standard 1-minute window rolls over.
|
|
85
|
+
if (rlResetAt < Date.now()) rlResetAt = Date.now() + 60_000
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function rlGate(): Promise<void> {
|
|
89
|
+
if (rlRemaining > RL_LOW_WATER) return
|
|
90
|
+
const windowLeft = Math.max(0, rlResetAt - Date.now())
|
|
91
|
+
const delay = rlRemaining <= 1 ? windowLeft : Math.ceil(windowLeft / Math.max(1, rlRemaining))
|
|
92
|
+
if (delay > 0) await sleep(delay + Math.random() * 300)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class SpinupWPClient implements SpinupWPClientLike {
|
|
96
|
+
private token: string
|
|
97
|
+
private baseUrl: string
|
|
98
|
+
|
|
99
|
+
constructor(opts: ClientOptions) {
|
|
100
|
+
this.token = opts.token
|
|
101
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Gate → fetch → learn from headers; on 429 wait out Retry-After (default 20s)
|
|
105
|
+
// and retry a few times before surfacing it. Network errors become ApiError(0).
|
|
106
|
+
private async fetchWithRateLimit(url: string, init: RequestInit): Promise<Response> {
|
|
107
|
+
for (let attempt = 0; ; attempt++) {
|
|
108
|
+
await rlGate()
|
|
109
|
+
let res: Response
|
|
110
|
+
try {
|
|
111
|
+
res = await fetch(url, init)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new ApiError(`Network error reaching the SpinupWP API: ${(err as Error).message}`, 0)
|
|
114
|
+
}
|
|
115
|
+
rlUpdate(res.headers)
|
|
116
|
+
if (res.status === 429 && attempt < RL_MAX_RETRIES) {
|
|
117
|
+
const ra = Number(res.headers.get("retry-after"))
|
|
118
|
+
const waitMs = (Number.isFinite(ra) && ra > 0 ? ra : 20) * 1000
|
|
119
|
+
rlRemaining = 0
|
|
120
|
+
rlResetAt = Date.now() + waitMs
|
|
121
|
+
await sleep(waitMs + Math.random() * 500)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
return res
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
|
129
|
+
const url = new URL(this.baseUrl + path)
|
|
130
|
+
if (params) {
|
|
131
|
+
for (const [k, v] of Object.entries(params)) {
|
|
132
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, String(v))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const res = await this.fetchWithRateLimit(url.toString(), {
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: `Bearer ${this.token}`,
|
|
139
|
+
Accept: "application/json",
|
|
140
|
+
"User-Agent": "spinupwp-tui",
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
if (res.status === 401) {
|
|
145
|
+
throw new ApiError("Unauthorized — your access token was rejected (401).", 401)
|
|
146
|
+
}
|
|
147
|
+
if (res.status === 403) {
|
|
148
|
+
throw new ApiError("Forbidden — this token lacks permission for that resource (403).", 403)
|
|
149
|
+
}
|
|
150
|
+
if (res.status === 429) {
|
|
151
|
+
throw new ApiError("Rate limited by the SpinupWP API (429). Try again shortly.", 429)
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const body = await res.text().catch(() => "")
|
|
155
|
+
throw new ApiError(`SpinupWP API error (HTTP ${res.status}).`, res.status, body)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (await res.json()) as T
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Write request (POST/PUT/PATCH/DELETE) with a JSON body. A 403 here almost
|
|
162
|
+
// always means the token lacks Read/Write scope — there's no scope endpoint to
|
|
163
|
+
// check up front, so we detect it by attempting the write and translating the
|
|
164
|
+
// 403 into an actionable message. All other statuses mirror request().
|
|
165
|
+
private async mutate<T>(path: string, method: string, body?: unknown): Promise<T> {
|
|
166
|
+
const url = this.baseUrl + path
|
|
167
|
+
const res = await this.fetchWithRateLimit(url, {
|
|
168
|
+
method,
|
|
169
|
+
headers: {
|
|
170
|
+
Authorization: `Bearer ${this.token}`,
|
|
171
|
+
Accept: "application/json",
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"User-Agent": "spinupwp-tui",
|
|
174
|
+
},
|
|
175
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
if (res.status === 401) {
|
|
179
|
+
throw new ApiError("Unauthorized — your access token was rejected (401).", 401)
|
|
180
|
+
}
|
|
181
|
+
if (res.status === 403) {
|
|
182
|
+
throw new ApiError(
|
|
183
|
+
"Your token is read-only — this action needs a Read/Write token. Run `spinuptui login` to set one.",
|
|
184
|
+
403,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
if (res.status === 429) {
|
|
188
|
+
throw new ApiError("Rate limited by the SpinupWP API (429). Try again shortly.", 429)
|
|
189
|
+
}
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
const errBody = await res.text().catch(() => "")
|
|
192
|
+
throw new ApiError(`SpinupWP API error (HTTP ${res.status}).`, res.status, errBody)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Some write endpoints (e.g. PHP upgrade) return a bare body, others 204.
|
|
196
|
+
if (res.status === 204) return undefined as T
|
|
197
|
+
return (await res.json().catch(() => undefined)) as T
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Fetch a single page of a list resource.
|
|
201
|
+
private listPage<T>(path: string, page: number, params?: Record<string, string | number | undefined>) {
|
|
202
|
+
return this.request<ApiList<T>>(path, { page, limit: 100, ...params })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Follow pagination until exhausted, aggregating all items.
|
|
206
|
+
private async listAll<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T[]> {
|
|
207
|
+
const all: T[] = []
|
|
208
|
+
let page = 1
|
|
209
|
+
while (page <= MAX_PAGES) {
|
|
210
|
+
const res = await this.listPage<T>(path, page, params)
|
|
211
|
+
all.push(...res.data)
|
|
212
|
+
if (!res.pagination?.next) break
|
|
213
|
+
page += 1
|
|
214
|
+
}
|
|
215
|
+
return all
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- Token validation -------------------------------------------------
|
|
219
|
+
|
|
220
|
+
// Lightweight call used by onboarding to verify a token works.
|
|
221
|
+
async validateToken(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
222
|
+
try {
|
|
223
|
+
await this.request<ApiList<Server>>("/servers", { limit: 1 })
|
|
224
|
+
return { ok: true }
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err instanceof ApiError) return { ok: false, reason: err.message }
|
|
227
|
+
return { ok: false, reason: (err as Error).message }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- Servers ----------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
listServers(): Promise<Server[]> {
|
|
234
|
+
return this.listAll<Server>("/servers")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async getServer(id: number): Promise<Server> {
|
|
238
|
+
const res = await this.request<ApiSingle<Server>>(`/servers/${id}`)
|
|
239
|
+
return res.data
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The catalog of sizes + regions (with pricing) a provider offers. Provider is
|
|
243
|
+
// a key the API recognizes: digitalocean | vultr | linode | hetzner. The
|
|
244
|
+
// endpoint may or may not be data-wrapped, so normalize defensively.
|
|
245
|
+
async providerMetadata(provider: string): Promise<ProviderMetadata> {
|
|
246
|
+
const raw = await this.request<Record<string, unknown>>(`/providers/${provider}/metadata`)
|
|
247
|
+
const data = raw && typeof raw === "object" && "data" in raw ? (raw.data as Record<string, unknown>) : raw
|
|
248
|
+
return data as unknown as ProviderMetadata
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Provision a managed server. Async on SpinupWP's side: returns an event_id to
|
|
252
|
+
// poll via getEvent() (provisioning averages ~10 min). Needs a Read/Write token.
|
|
253
|
+
createServer(payload: CreateServerPayload): Promise<{ event_id: number }> {
|
|
254
|
+
return this.mutate<{ event_id: number }>("/servers", "POST", payload)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- Sites ------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
listSites(serverId?: number): Promise<Site[]> {
|
|
260
|
+
return this.listAll<Site>("/sites", serverId ? { server_id: serverId } : undefined)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getSite(id: number): Promise<Site> {
|
|
264
|
+
const res = await this.request<ApiSingle<Site>>(`/sites/${id}`)
|
|
265
|
+
return res.data
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Create a site. Async on SpinupWP's side: returns an event_id to poll via
|
|
269
|
+
// getEvent(). Needs a Read/Write token. HTTPS is enabled separately — see
|
|
270
|
+
// enableHttps().
|
|
271
|
+
createSite(payload: CreateSitePayload): Promise<{ event_id: number }> {
|
|
272
|
+
return this.mutate<{ event_id: number }>("/sites", "POST", payload)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Additional domains on a site (the extra hostnames nginx answers for). The
|
|
276
|
+
// clone wizard re-creates the source's set on the dest — a fresh site only
|
|
277
|
+
// serves its primary domain otherwise.
|
|
278
|
+
async listSiteDomains(siteId: number): Promise<AdditionalDomain[]> {
|
|
279
|
+
return this.listAll<AdditionalDomain>(`/sites/${siteId}/domains`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Add an additional domain to a site. Async (nginx reconfig) → returns an
|
|
283
|
+
// event_id to poll. Needs a Read/Write token.
|
|
284
|
+
addSiteDomain(siteId: number, payload: AddDomainPayload): Promise<{ event_id: number }> {
|
|
285
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/domains`, "POST", payload)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Enable HTTPS on a site. `type: "webroot"` requests a Let's Encrypt cert (the
|
|
289
|
+
// domain must already resolve to the server for LE to issue, so this runs after
|
|
290
|
+
// the DNS A record has propagated). Async → returns an event_id to poll. Needs a
|
|
291
|
+
// Read/Write token.
|
|
292
|
+
enableHttps(siteId: number): Promise<{ event_id: number }> {
|
|
293
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/https`, "POST", { type: "webroot" })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Disable HTTPS on a site (removes the certificate). May return an event_id to
|
|
297
|
+
// poll, or settle synchronously (204 → undefined) — the store handles both.
|
|
298
|
+
// Needs a Read/Write token.
|
|
299
|
+
disableHttps(siteId: number): Promise<{ event_id: number } | undefined> {
|
|
300
|
+
return this.mutate<{ event_id: number } | undefined>(`/sites/${siteId}/https`, "DELETE")
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Purge a site's page cache / WordPress object cache. There's no enable/disable
|
|
304
|
+
// for either on an existing site (only at site-creation time) — purge is the
|
|
305
|
+
// only available write. Async → returns an event_id to poll. Needs a Read/Write
|
|
306
|
+
// token.
|
|
307
|
+
purgePageCache(siteId: number): Promise<{ event_id: number }> {
|
|
308
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache/purge`, "POST")
|
|
309
|
+
}
|
|
310
|
+
purgeObjectCache(siteId: number): Promise<{ event_id: number }> {
|
|
311
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/object-cache/purge`, "POST")
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- Events -----------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
// Events can be numerous; cap to the most recent few pages for the feed.
|
|
317
|
+
async listEvents(maxPages = 3): Promise<Event[]> {
|
|
318
|
+
const all: Event[] = []
|
|
319
|
+
let page = 1
|
|
320
|
+
while (page <= maxPages) {
|
|
321
|
+
const res = await this.listPage<Event>("/events", page)
|
|
322
|
+
all.push(...res.data)
|
|
323
|
+
if (!res.pagination?.next) break
|
|
324
|
+
page += 1
|
|
325
|
+
}
|
|
326
|
+
return all
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Fetch a single event by id — used to track an async write to completion.
|
|
330
|
+
async getEvent(id: number): Promise<Event> {
|
|
331
|
+
const res = await this.request<ApiSingle<Event>>(`/events/${id}`)
|
|
332
|
+
return res.data
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- Writes -----------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
// Change a site's PHP version. Async on SpinupWP's side: returns an event_id
|
|
338
|
+
// to poll via getEvent(). SpinupWP installs the version on the server first if
|
|
339
|
+
// it isn't already present. Needs a Read/Write token (see mutate()).
|
|
340
|
+
upgradeSitePhp(siteId: number, phpVersion: string): Promise<{ event_id: number }> {
|
|
341
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/php`, "PUT", { php_version: phpVersion })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Reboot a server. Async → returns an event_id to poll via getEvent().
|
|
345
|
+
rebootServer(serverId: number): Promise<{ event_id: number }> {
|
|
346
|
+
return this.mutate<{ event_id: number }>(`/servers/${serverId}/reboot`, "POST")
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Restart a single service on a server (nginx / php / mysql / redis). Async →
|
|
350
|
+
// returns an event_id to poll via getEvent().
|
|
351
|
+
restartService(serverId: number, service: ServerService): Promise<{ event_id: number }> {
|
|
352
|
+
return this.mutate<{ event_id: number }>(`/servers/${serverId}/services/${service}/restart`, "POST")
|
|
353
|
+
}
|
|
354
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Type definitions for the SpinupWP REST API (v1).
|
|
2
|
+
// Kept intentionally permissive — the API may add fields over time, and we only
|
|
3
|
+
// depend on the subset we render. See https://api.spinupwp.com/ for the full spec.
|
|
4
|
+
|
|
5
|
+
export interface Pagination {
|
|
6
|
+
previous: string | null
|
|
7
|
+
next: string | null
|
|
8
|
+
per_page: number
|
|
9
|
+
count: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ApiList<T> {
|
|
13
|
+
data: T[]
|
|
14
|
+
pagination: Pagination
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ApiSingle<T> {
|
|
18
|
+
data: T
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiskSpace {
|
|
22
|
+
total: number
|
|
23
|
+
available: number
|
|
24
|
+
used: number
|
|
25
|
+
updated_at: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ServerDatabase {
|
|
29
|
+
server: string | null
|
|
30
|
+
host: string | null
|
|
31
|
+
port: number | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ConnectionStatus = "connected" | "disconnected" | "connecting" | string
|
|
35
|
+
|
|
36
|
+
export interface Server {
|
|
37
|
+
id: number
|
|
38
|
+
name: string
|
|
39
|
+
provider_name: string | null
|
|
40
|
+
ubuntu_version: string | null
|
|
41
|
+
ip_address: string | null
|
|
42
|
+
ssh_port: number | null
|
|
43
|
+
timezone: string | null
|
|
44
|
+
region: string | null
|
|
45
|
+
size: string | null
|
|
46
|
+
disk_space: DiskSpace | null
|
|
47
|
+
database: ServerDatabase | null
|
|
48
|
+
ssh_publickey?: string | null
|
|
49
|
+
git_publickey?: string | null
|
|
50
|
+
connection_status: ConnectionStatus
|
|
51
|
+
reboot_required: boolean
|
|
52
|
+
upgrade_required: boolean
|
|
53
|
+
install_notes?: string | null
|
|
54
|
+
created_at: string
|
|
55
|
+
status: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AdditionalDomain {
|
|
59
|
+
id: number
|
|
60
|
+
domain: string
|
|
61
|
+
redirect?: {
|
|
62
|
+
enabled: boolean
|
|
63
|
+
type: number
|
|
64
|
+
destination: string
|
|
65
|
+
}
|
|
66
|
+
created_at: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface Site {
|
|
70
|
+
id: number
|
|
71
|
+
server_id: number
|
|
72
|
+
domain: string
|
|
73
|
+
additional_domains?: AdditionalDomain[]
|
|
74
|
+
site_user: string | null
|
|
75
|
+
php_version: string | null
|
|
76
|
+
public_folder: string | null
|
|
77
|
+
is_wordpress: boolean
|
|
78
|
+
page_cache?: { enabled: boolean }
|
|
79
|
+
https?: { enabled: boolean }
|
|
80
|
+
nginx?: Record<string, unknown>
|
|
81
|
+
database?: {
|
|
82
|
+
id: number | null
|
|
83
|
+
user_id: number | null
|
|
84
|
+
table_prefix: string | null
|
|
85
|
+
} | null
|
|
86
|
+
backups?: {
|
|
87
|
+
files: boolean
|
|
88
|
+
database: boolean
|
|
89
|
+
retention_period?: number | null
|
|
90
|
+
next_run_time?: string | null
|
|
91
|
+
storage_provider?: { id: number; region: string; bucket: string } | null
|
|
92
|
+
} | null
|
|
93
|
+
wp_core_update?: boolean
|
|
94
|
+
wp_theme_updates?: number
|
|
95
|
+
wp_plugin_updates?: number
|
|
96
|
+
git?: {
|
|
97
|
+
repo: string | null
|
|
98
|
+
branch: string | null
|
|
99
|
+
deploy_script?: string | null
|
|
100
|
+
push_enabled?: boolean
|
|
101
|
+
deployment_url?: string | null
|
|
102
|
+
} | null
|
|
103
|
+
basic_auth?: { enabled: boolean; username?: string | null } | null
|
|
104
|
+
subdomain?: { enabled: boolean; url: string | null } | null
|
|
105
|
+
created_at: string
|
|
106
|
+
status: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Server-provider metadata (GET /providers/{provider}/metadata) — the catalog of
|
|
110
|
+
// sizes and regions a provider offers, including pricing. Used to show "match
|
|
111
|
+
// source" specs + a monthly cost before creating a server.
|
|
112
|
+
export interface ProviderSize {
|
|
113
|
+
slug: string
|
|
114
|
+
type?: string
|
|
115
|
+
memory: number // MB
|
|
116
|
+
vcpus: number
|
|
117
|
+
disk: number // GB
|
|
118
|
+
transfer?: number
|
|
119
|
+
priceMonthly: number
|
|
120
|
+
backupPriceMonthly?: number
|
|
121
|
+
available?: boolean
|
|
122
|
+
processor?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ProviderRegion {
|
|
126
|
+
slug: string
|
|
127
|
+
name: string
|
|
128
|
+
available?: boolean
|
|
129
|
+
continent?: string
|
|
130
|
+
sizes: string[] // size slugs offered in this region
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ProviderMetadata {
|
|
134
|
+
regions: Record<string, ProviderRegion[]> // grouped by continent name
|
|
135
|
+
sizes: ProviderSize[]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Request body for POST /servers (provision a managed server). The API uses
|
|
139
|
+
// bracketed form keys (server_provider[id], …) which map to these nested objects.
|
|
140
|
+
export interface CreateServerPayload {
|
|
141
|
+
server_provider: {
|
|
142
|
+
id?: number // an existing provider connection (from SpinupWP Account Settings)
|
|
143
|
+
name?: string // or provider name + token to use an unsaved provider
|
|
144
|
+
api_token?: string
|
|
145
|
+
region: string
|
|
146
|
+
size: string
|
|
147
|
+
enable_backups?: boolean
|
|
148
|
+
}
|
|
149
|
+
hostname: string
|
|
150
|
+
timezone?: string
|
|
151
|
+
database?: { root_password?: string }
|
|
152
|
+
database_provider?: { id: number }
|
|
153
|
+
post_provision_script?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// POST /sites. HTTPS is NOT a creation field — it's enabled afterward via
|
|
157
|
+
// POST /sites/{id}/https (see SpinupWPClient.enableHttps). For a vanity/placeholder
|
|
158
|
+
// site we use installation_method "blank" (empty docroot we drop an index.php into).
|
|
159
|
+
// POST /sites/{id}/domains — add an additional domain (async, returns event_id).
|
|
160
|
+
// redirect.type: 301/302/307/308 (defaults 301); redirect.destination defaults to
|
|
161
|
+
// the site's primary domain.
|
|
162
|
+
export interface AddDomainPayload {
|
|
163
|
+
domain: string
|
|
164
|
+
redirect?: { enabled?: boolean; type?: number; destination?: string }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface CreateSitePayload {
|
|
168
|
+
server_id: number
|
|
169
|
+
domain: string
|
|
170
|
+
site_user: string
|
|
171
|
+
installation_method: "wp" | "wp_subdirectory" | "wp_subdomain" | "git" | "blank"
|
|
172
|
+
php_version?: string // defaults to 8.3 server-side
|
|
173
|
+
public_folder?: string // defaults to "/"
|
|
174
|
+
database?: { name?: string; username?: string; password?: string; table_prefix?: string }
|
|
175
|
+
page_cache?: { enabled?: boolean }
|
|
176
|
+
// installation_method "git" (Bedrock): SpinupWP clones `git.repo` at create time.
|
|
177
|
+
// We always send a UNIQUE per-site keypair via `deploy_key_enabled` + `deploy_key`
|
|
178
|
+
// (SpinupWP installs it as the site's git identity) — the server-wide git_publickey
|
|
179
|
+
// can only be attached to ONE GitHub repo account-wide, so it can never cover a
|
|
180
|
+
// second repo on the same server (verified 2026-07-06). The public half must be a
|
|
181
|
+
// read-only deploy key on the repo before create or the clone fails. `deploy_script`
|
|
182
|
+
// is TOP-LEVEL (a nested git.deploy_script is silently ignored); the write key is
|
|
183
|
+
// `push_to_deploy` (the read shape echoes it back as git.push_enabled). See the
|
|
184
|
+
// site-creation findings doc.
|
|
185
|
+
deploy_script?: string
|
|
186
|
+
git?: {
|
|
187
|
+
repo: string
|
|
188
|
+
branch?: string
|
|
189
|
+
push_to_deploy?: boolean
|
|
190
|
+
always_run_deploy_script?: boolean
|
|
191
|
+
deploy_key_enabled?: boolean
|
|
192
|
+
deploy_key?: { privatekey: string; publickey: string }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface Event {
|
|
197
|
+
id: number
|
|
198
|
+
initiated_by: string | null
|
|
199
|
+
server_id: number | null
|
|
200
|
+
name: string
|
|
201
|
+
status: string
|
|
202
|
+
output: string | null
|
|
203
|
+
created_at: string
|
|
204
|
+
started_at: string | null
|
|
205
|
+
finished_at: string | null
|
|
206
|
+
}
|