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,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
+ }
@@ -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
+ }