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
package/src/config.ts ADDED
@@ -0,0 +1,309 @@
1
+ // Configuration resolution + persistence.
2
+ //
3
+ // Token resolution order (first match wins):
4
+ // 1. SPINUPWP_ACCESS_TOKEN environment variable (Bun auto-loads ./.env)
5
+ // 2. ~/.config/spinupwp-tui/config.json (written by the onboarding wizard)
6
+ //
7
+ // This lets the original author keep a project-local .env while letting other
8
+ // users configure the tool globally after a `bun install -g`.
9
+
10
+ import { homedir } from "node:os"
11
+ import { join } from "node:path"
12
+ import { mkdir, chmod } from "node:fs/promises"
13
+ import { existsSync, readFileSync } from "node:fs"
14
+ import type { LocalLink } from "./lib/local.ts"
15
+ import { ALL_PROVIDERS, type Connection, type ConnProvider } from "./lib/providers.ts"
16
+
17
+ export const DEFAULT_BASE_URL = "https://api.spinupwp.app/v1"
18
+
19
+ export interface AppConfig {
20
+ token: string
21
+ baseUrl: string
22
+ tokenSource: "env" | "file" | "none"
23
+ // Optional override for the SSH user used by the server health view. When unset,
24
+ // the health view derives the user from a site on the server (its `site_user`).
25
+ sshUser: string | null
26
+ // SpinupWP account/team slug (e.g. "wenmark-digital-solutions"), used to build
27
+ // deep links into the SpinupWP web app. The API doesn't expose it, so it's
28
+ // configured. When unset, deep links fall back to the dashboard root.
29
+ accountSlug: string | null
30
+ // macOS terminal app to open for local working copies (e.g. "iTerm", "Warp").
31
+ // When unset, inferred from $TERM_PROGRAM, falling back to Terminal.
32
+ terminalApp: string | null
33
+ // Opt-in for the production→local DB sync (`p`). Off by default because it
34
+ // OVERWRITES the local database and assumes a working local WP dev environment
35
+ // (WP-CLI + a local DB). The read-only DB backup (`d`) needs neither and stays
36
+ // available without this. Set "localSync": true in config to enable.
37
+ localSync: boolean
38
+ // Directories to (eventually) scan for local working copies. Reserved for the
39
+ // Phase 2 auto-discovery pass; unused by Phase 1's manual linking.
40
+ localRoots: string[]
41
+ // Local working-copy links, keyed by SpinupWP site id (as a string, since
42
+ // JSON object keys are strings). See lib/local.ts for the link shape.
43
+ localSites: Record<string, LocalLink>
44
+ // DNS provider connections (Phase 2 access detection), keyed by provider, merged
45
+ // from the stored config and the environment. Env-sourced connections carry
46
+ // `env: true` and are read-only in the UI. Secrets live here (file is chmod 600).
47
+ providerConnections: Record<ConnProvider, Connection[]>
48
+ // SpinupWP server-provider connections, keyed by provider name
49
+ // (digitalocean | vultr | linode | hetzner). The API exposes no endpoint to
50
+ // list these, so — like accountSlug — the id is configured. Required to create
51
+ // a server on that provider (POST /servers needs server_provider[id]). Find the
52
+ // id in SpinupWP → Account Settings → Server Providers.
53
+ serverProviders: Record<string, ServerProviderRef>
54
+ // In-flight resumable jobs, keyed by job id. Hydrated at startup so a build
55
+ // (e.g. a server provision) keeps its tracker across a quit/relaunch.
56
+ jobs: Record<string, StoredJob>
57
+ // Per-server sudo user for privileged writes-over-SSH (e.g. dropping Spinup's
58
+ // machine key into a site user's authorized_keys), keyed by server id (string,
59
+ // since JSON object keys are strings). Only the username is stored — the sudo
60
+ // PASSWORD is held in-memory for the session and (opt-in, macOS) the Keychain,
61
+ // never config.json. `keychain: true` flags that a password is saved in Keychain.
62
+ sudoUsers: Record<string, { user: string; keychain?: boolean }>
63
+ // The public keys (by key body, the base64 second field) the user last chose to
64
+ // grant. Pre-selected in the grant-key picker so they don't re-pick every time.
65
+ preferredGrantKeys: string[]
66
+ // Keys Spinup has granted, keyed by site id (string) → key bodies. Drives the
67
+ // "has Spinup's key" row badge and informs revoke. Optimistic (a record of what
68
+ // Spinup wrote, not a live probe); revoke removes entries.
69
+ grantedKeys: Record<string, string[]>
70
+ // Per-zone access-note OVERRIDES only, keyed by zone apex. A provider's
71
+ // `defaultAccessNote` (e.g. GoDaddy's "Delegate Access") is the assumed normal
72
+ // case and is never stored here — only the exceptions (e.g. "Integracon", a
73
+ // third-party IT contact) are, so this stays empty for the common case.
74
+ zoneAccessNotes: Record<string, string>
75
+ // Uptime Kuma connection (optional). Creds live here like provider creds (file
76
+ // is chmod 600); `jwt` is captured after the first successful login so later
77
+ // sessions use loginByToken (survives 2FA accounts, skips re-sending the
78
+ // password). Env overrides: SPINUP_KUMA_URL / _USERNAME / _PASSWORD.
79
+ uptimeKuma: UptimeKumaConn | null
80
+ // Kuma monitors Spinup has registered, keyed by site domain. `pushToken` is the
81
+ // token baked into the server-side cron; ids let later features pause/verify.
82
+ kumaMonitors: Record<string, KumaMonitorRef>
83
+ // Per-vanity-site health keys, keyed by domain — the `key` the seeded page's
84
+ // ?format=json mode requires (baked in at seed time). Kept here so re-seeds
85
+ // reuse the same key (monitor URLs stay stable) and so monitor-registration
86
+ // features can build the JSON URL later. Letters/digits only.
87
+ vanityHealthKeys: Record<string, string>
88
+ // The last app version the user actually saw the Release Notes overlay for
89
+ // (or, on a fresh install / pre-this-feature upgrade, silently seeded to the
90
+ // running version with no notes shown — nothing meaningful to announce). A
91
+ // mismatch against the running version is what triggers the overlay.
92
+ lastSeenVersion: string | null
93
+ }
94
+
95
+ export interface ServerProviderRef {
96
+ id: number
97
+ databaseProviderId?: number
98
+ }
99
+
100
+ export interface UptimeKumaConn {
101
+ url: string
102
+ username: string
103
+ password: string
104
+ jwt?: string
105
+ env?: boolean // creds came from the environment — read-only in the UI
106
+ }
107
+
108
+ // CONVENTION: every numeric field ending in `Id` is a Kuma monitor id belonging
109
+ // to this site, and the alert wiring (`n` in the monitoring overlay) attaches/
110
+ // detaches notification providers across ALL of them by that naming rule — a
111
+ // new monitor kind only needs a `fooId?: number` field here to be included.
112
+ // Don't add a numeric `*Id` field that isn't a monitor id.
113
+ export interface KumaMonitorRef {
114
+ healthId?: number // HTTP monitor on /?healthz
115
+ pushId?: number // push monitor fed by the server-side load cron
116
+ pushToken?: string
117
+ redisId?: number // "{server} redis" push monitor fed by the cron's redis-cli ping (vanity/server domains)
118
+ redisToken?: string
119
+ fingerprintId?: number // keyword monitor asserting the front page serves its own template
120
+ // What fingerprint calibration derived (lib/siteFingerprint.ts) — shown in the
121
+ // overlay and kept so a recalibration can explain what it's replacing.
122
+ // `interval` is the check window in seconds.
123
+ fingerprint?: { keyword: string; kind: string; detail: string; interval: number; derivedAt: string }
124
+ }
125
+
126
+ // A long-running, fire-and-forget job persisted across restarts so the app can
127
+ // resume tracking it (see docs/2026-06-24_clone-to-server-spec.md "Resumable
128
+ // jobs"). `eventId` is the resume key — the SpinupWP event we re-attach a poller
129
+ // to on startup. `inputs` is the kind-specific payload needed to continue/retry
130
+ // (e.g. { hostname } for a server create). Only in-flight jobs are persisted;
131
+ // they're removed once terminal.
132
+ export interface StoredJob {
133
+ id: string
134
+ kind: string
135
+ status: string
136
+ step?: string
137
+ failedStep?: string
138
+ error?: string
139
+ startedAt: number
140
+ eventId?: number
141
+ inputs?: unknown
142
+ }
143
+
144
+ // Stored connection (no `provider`/`env` discriminators — added on load).
145
+ export interface StoredConnection {
146
+ id: string
147
+ label: string
148
+ creds: Record<string, string>
149
+ }
150
+ export type StoredProviders = Partial<Record<ConnProvider, StoredConnection[]>>
151
+
152
+ // Read a connection's creds, migrating the pre-registry shape (credentials stored
153
+ // as inline fields) to the `creds` bag. The migrated bag is rewritten to disk on
154
+ // the next saveConfig (add/remove a connection).
155
+ function migrateCreds(provider: ConnProvider, c: Record<string, unknown>): Record<string, string> {
156
+ if (c.creds && typeof c.creds === "object") return c.creds as Record<string, string>
157
+ if (provider === "aws") {
158
+ return {
159
+ accessKeyId: String(c.accessKeyId ?? ""),
160
+ secretAccessKey: String(c.secretAccessKey ?? ""),
161
+ region: String(c.region ?? ""),
162
+ }
163
+ }
164
+ if (provider === "cloudflare") return { token: String(c.token ?? "") }
165
+ if (provider === "godaddy") return { apiKey: String(c.apiKey ?? ""), apiSecret: String(c.apiSecret ?? "") }
166
+ return {}
167
+ }
168
+
169
+ export interface StoredConfig {
170
+ token?: string
171
+ baseUrl?: string
172
+ sshUser?: string
173
+ accountSlug?: string
174
+ terminalApp?: string
175
+ localRoots?: string[]
176
+ localSites?: Record<string, LocalLink>
177
+ localSync?: boolean
178
+ providers?: StoredProviders
179
+ serverProviders?: Record<string, ServerProviderRef>
180
+ jobs?: Record<string, StoredJob>
181
+ sudoUsers?: Record<string, { user: string; keychain?: boolean }>
182
+ preferredGrantKeys?: string[]
183
+ grantedKeys?: Record<string, string[]>
184
+ zoneAccessNotes?: Record<string, string>
185
+ vanityHealthKeys?: Record<string, string>
186
+ uptimeKuma?: UptimeKumaConn
187
+ kumaMonitors?: Record<string, KumaMonitorRef>
188
+ lastSeenVersion?: string
189
+ }
190
+
191
+ export function configDir(): string {
192
+ const xdg = process.env.XDG_CONFIG_HOME
193
+ return xdg ? join(xdg, "spinupwp-tui") : join(homedir(), ".config", "spinupwp-tui")
194
+ }
195
+
196
+ export function configPath(): string {
197
+ return join(configDir(), "config.json")
198
+ }
199
+
200
+ // Where Spinup's dedicated machine keypair (spinup-tui[.pub]) lives. Generated
201
+ // lazily on first privileged use (see lib/ssh.ts ensureSpinupKey).
202
+ export function keysDir(): string {
203
+ return join(configDir(), "keys")
204
+ }
205
+
206
+ function readStoredConfig(): StoredConfig {
207
+ try {
208
+ const path = configPath()
209
+ if (!existsSync(path)) return {}
210
+ return JSON.parse(readFileSync(path, "utf8")) as StoredConfig
211
+ } catch {
212
+ return {}
213
+ }
214
+ }
215
+
216
+ // Resolve the active config from env + stored file. Never throws.
217
+ export function loadConfig(): AppConfig {
218
+ const stored = readStoredConfig()
219
+ const envToken = process.env.SPINUPWP_ACCESS_TOKEN?.trim()
220
+ const fileToken = stored.token?.trim()
221
+
222
+ const token = envToken || fileToken || ""
223
+ const tokenSource: AppConfig["tokenSource"] = envToken ? "env" : fileToken ? "file" : "none"
224
+
225
+ // Provider connections: stored ones first (per provider), with env-derived ones
226
+ // prepended as read-only "env" connections (consistent with the env token above).
227
+ const sp = stored.providers ?? {}
228
+ const providerConnections = {} as Record<ConnProvider, Connection[]>
229
+ for (const provider of ALL_PROVIDERS) {
230
+ providerConnections[provider] = (sp[provider] ?? []).map((c) => ({
231
+ id: c.id,
232
+ provider,
233
+ label: c.label,
234
+ creds: migrateCreds(provider, c as unknown as Record<string, unknown>),
235
+ }))
236
+ }
237
+ const cfEnv = process.env.CLOUDFLARE_API_TOKEN?.trim()
238
+ if (cfEnv) providerConnections.cloudflare.unshift({ id: "cloudflare-env", provider: "cloudflare", label: "env", creds: { token: cfEnv }, env: true })
239
+ const awsKey = process.env.AWS_ACCESS_KEY_ID?.trim()
240
+ const awsSecret = process.env.AWS_SECRET_ACCESS_KEY?.trim()
241
+ if (awsKey && awsSecret) {
242
+ providerConnections.aws.unshift({
243
+ id: "aws-env",
244
+ provider: "aws",
245
+ label: "env",
246
+ creds: { accessKeyId: awsKey, secretAccessKey: awsSecret, region: process.env.AWS_REGION?.trim() || "" },
247
+ env: true,
248
+ })
249
+ }
250
+ const gdKey = process.env.GODADDY_API_KEY?.trim()
251
+ const gdSecret = process.env.GODADDY_API_SECRET?.trim()
252
+ if (gdKey && gdSecret) {
253
+ providerConnections.godaddy.unshift({ id: "godaddy-env", provider: "godaddy", label: "env", creds: { apiKey: gdKey, apiSecret: gdSecret }, env: true })
254
+ }
255
+
256
+ return {
257
+ token,
258
+ baseUrl: (stored.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""),
259
+ tokenSource,
260
+ sshUser: process.env.SPINUPWP_SSH_USER?.trim() || stored.sshUser?.trim() || null,
261
+ accountSlug: process.env.SPINUPWP_ACCOUNT_SLUG?.trim() || stored.accountSlug?.trim() || null,
262
+ terminalApp: process.env.SPINUPWP_TERMINAL_APP?.trim() || stored.terminalApp?.trim() || null,
263
+ localSync: ((): boolean => {
264
+ // Env overrides the file when present; otherwise the stored flag (default off).
265
+ const env = process.env.SPINUPWP_LOCAL_SYNC?.trim()
266
+ if (env != null && env !== "") return /^(1|true|yes|on)$/i.test(env)
267
+ return stored.localSync === true
268
+ })(),
269
+ localRoots: stored.localRoots ?? [],
270
+ localSites: stored.localSites ?? {},
271
+ providerConnections,
272
+ serverProviders: stored.serverProviders ?? {},
273
+ jobs: stored.jobs ?? {},
274
+ sudoUsers: stored.sudoUsers ?? {},
275
+ preferredGrantKeys: stored.preferredGrantKeys ?? [],
276
+ grantedKeys: stored.grantedKeys ?? {},
277
+ zoneAccessNotes: stored.zoneAccessNotes ?? {},
278
+ vanityHealthKeys: stored.vanityHealthKeys ?? {},
279
+ uptimeKuma: ((): UptimeKumaConn | null => {
280
+ const url = process.env.SPINUP_KUMA_URL?.trim()
281
+ const username = process.env.SPINUP_KUMA_USERNAME?.trim()
282
+ const password = process.env.SPINUP_KUMA_PASSWORD?.trim()
283
+ if (url && username && password) return { url: url.replace(/\/+$/, ""), username, password, env: true }
284
+ return stored.uptimeKuma ?? null
285
+ })(),
286
+ kumaMonitors: stored.kumaMonitors ?? {},
287
+ lastSeenVersion: stored.lastSeenVersion?.trim() || null,
288
+ }
289
+ }
290
+
291
+ export function hasToken(): boolean {
292
+ return loadConfig().token.length > 0
293
+ }
294
+
295
+ // Persist a token (and optional base URL) to the config file. Used by onboarding.
296
+ export async function saveConfig(partial: StoredConfig): Promise<void> {
297
+ const dir = configDir()
298
+ await mkdir(dir, { recursive: true })
299
+ const current = readStoredConfig()
300
+ const next: StoredConfig = { ...current, ...partial }
301
+ const path = configPath()
302
+ await Bun.write(path, JSON.stringify(next, null, 2) + "\n")
303
+ // The file holds an API token — restrict it to the owner.
304
+ try {
305
+ await chmod(path, 0o600)
306
+ } catch {
307
+ // Best-effort (e.g. on filesystems without POSIX perms).
308
+ }
309
+ }
@@ -0,0 +1,7 @@
1
+ // Dev Mode: run Spinup entirely against an in-memory fixture fleet instead of the
2
+ // real SpinupWP API — no token, no network, nothing that can touch a live account.
3
+ // For demos, screenshots, and UI work. See src/dev/fixtures.ts + mockClient.ts.
4
+
5
+ export function isDevMode(): boolean {
6
+ return /^(1|true|yes|on)$/i.test(process.env.SPINUP_DEV_MODE?.trim() ?? "")
7
+ }
@@ -0,0 +1,238 @@
1
+ // Example fleet for Dev Mode (SPINUP_DEV_MODE=1) — a small, deliberately varied set
2
+ // of servers/sites/events so every view has something interesting to show: an EOL
3
+ // Ubuntu server, EOL PHP versions, a Bedrock site, a non-WP app, an empty server, a
4
+ // disabled-HTTPS site, and a mix of disk usage / update counts. All domains are
5
+ // example.com/.org/.net — never a real client's.
6
+ //
7
+ // freshFixtures() returns a deep clone so MockSpinupWPClient can mutate its own copy
8
+ // freely without leaking state across StoreProvider remounts.
9
+
10
+ import type { Event, Server, Site } from "../api/types.ts"
11
+
12
+ const GB = 1024 * 1024 * 1024
13
+
14
+ const SERVERS: Server[] = [
15
+ {
16
+ id: 101,
17
+ name: "web1.example.com",
18
+ provider_name: "digitalocean",
19
+ ubuntu_version: "24.04",
20
+ ip_address: "203.0.113.11",
21
+ ssh_port: 22,
22
+ timezone: "UTC",
23
+ region: "nyc3",
24
+ size: "s-2vcpu-4gb",
25
+ disk_space: { total: 80 * GB, available: 61 * GB, used: 19 * GB, updated_at: "2026-07-01T08:00:00Z" },
26
+ database: null,
27
+ connection_status: "connected",
28
+ reboot_required: false,
29
+ upgrade_required: false,
30
+ created_at: "2024-11-02T10:15:00Z",
31
+ status: "active",
32
+ },
33
+ {
34
+ id: 102,
35
+ name: "web2.example.com",
36
+ provider_name: "vultr",
37
+ ubuntu_version: "22.04",
38
+ ip_address: "203.0.113.12",
39
+ ssh_port: 22,
40
+ timezone: "UTC",
41
+ region: "ewr",
42
+ size: "vc2-2c-4gb",
43
+ disk_space: { total: 60 * GB, available: 55 * GB, used: 5 * GB, updated_at: "2026-07-01T08:00:00Z" },
44
+ database: null,
45
+ connection_status: "connected",
46
+ reboot_required: true,
47
+ upgrade_required: false,
48
+ created_at: "2026-06-20T14:00:00Z",
49
+ status: "active",
50
+ },
51
+ {
52
+ id: 103,
53
+ name: "web3.example.org",
54
+ provider_name: "hetzner",
55
+ ubuntu_version: "20.04",
56
+ ip_address: "203.0.113.13",
57
+ ssh_port: 22,
58
+ timezone: "UTC",
59
+ region: "nbg1",
60
+ size: "cx31",
61
+ disk_space: { total: 40 * GB, available: 7 * GB, used: 33 * GB, updated_at: "2026-07-01T08:00:00Z" },
62
+ database: null,
63
+ connection_status: "connected",
64
+ reboot_required: false,
65
+ upgrade_required: true,
66
+ created_at: "2022-03-14T09:30:00Z",
67
+ status: "active",
68
+ },
69
+ {
70
+ id: 104,
71
+ name: "web9.example.org",
72
+ provider_name: "linode",
73
+ ubuntu_version: "24.04",
74
+ ip_address: "203.0.113.19",
75
+ ssh_port: 22,
76
+ timezone: "UTC",
77
+ region: "us-east",
78
+ size: "g6-standard-2",
79
+ disk_space: { total: 80 * GB, available: 68 * GB, used: 12 * GB, updated_at: "2026-07-01T08:00:00Z" },
80
+ database: null,
81
+ connection_status: "connected",
82
+ reboot_required: false,
83
+ upgrade_required: false,
84
+ created_at: "2025-01-08T16:45:00Z",
85
+ status: "active",
86
+ },
87
+ ]
88
+
89
+ const SITES: Site[] = [
90
+ {
91
+ id: 1001,
92
+ server_id: 101,
93
+ domain: "example.com",
94
+ additional_domains: [{ id: 1, domain: "www.example.com", created_at: "2024-11-02T10:20:00Z" }],
95
+ site_user: "example",
96
+ php_version: "8.3",
97
+ public_folder: "/",
98
+ is_wordpress: true,
99
+ page_cache: { enabled: true },
100
+ https: { enabled: true },
101
+ database: { id: 1, user_id: 1, table_prefix: "wp_" },
102
+ backups: { files: true, database: true, retention_period: 14, next_run_time: "2026-07-03T03:00:00Z" },
103
+ wp_core_update: false,
104
+ wp_theme_updates: 1,
105
+ wp_plugin_updates: 3,
106
+ created_at: "2024-11-02T10:20:00Z",
107
+ status: "active",
108
+ },
109
+ {
110
+ id: 1002,
111
+ server_id: 101,
112
+ domain: "shop.example.com",
113
+ site_user: "shop",
114
+ php_version: "8.3",
115
+ public_folder: "/web/",
116
+ is_wordpress: true,
117
+ page_cache: { enabled: true },
118
+ https: { enabled: true },
119
+ database: { id: 2, user_id: 2, table_prefix: "wp_" },
120
+ backups: { files: true, database: true, retention_period: 14, next_run_time: "2026-07-03T03:00:00Z" },
121
+ git: {
122
+ repo: "git@github.com:example/shop-bedrock.git",
123
+ branch: "main",
124
+ deploy_script: "composer install -o --no-dev",
125
+ push_enabled: true,
126
+ deployment_url: null,
127
+ },
128
+ wp_core_update: false,
129
+ wp_theme_updates: 0,
130
+ wp_plugin_updates: 0,
131
+ created_at: "2025-02-18T13:00:00Z",
132
+ status: "active",
133
+ },
134
+ {
135
+ id: 1003,
136
+ server_id: 101,
137
+ domain: "blog.example.com",
138
+ site_user: "blog",
139
+ php_version: "8.1",
140
+ public_folder: "/",
141
+ is_wordpress: true,
142
+ page_cache: { enabled: false },
143
+ https: { enabled: true },
144
+ database: { id: 3, user_id: 3, table_prefix: "wp_" },
145
+ backups: { files: true, database: true, retention_period: 7, next_run_time: "2026-07-03T03:00:00Z" },
146
+ wp_core_update: true,
147
+ wp_theme_updates: 2,
148
+ wp_plugin_updates: 5,
149
+ created_at: "2023-08-05T11:00:00Z",
150
+ status: "active",
151
+ },
152
+ {
153
+ id: 1004,
154
+ server_id: 103,
155
+ domain: "app.example.org",
156
+ site_user: "app",
157
+ php_version: "8.2",
158
+ public_folder: "/public/",
159
+ is_wordpress: false,
160
+ https: { enabled: true },
161
+ created_at: "2022-04-01T09:00:00Z",
162
+ status: "active",
163
+ },
164
+ {
165
+ id: 1005,
166
+ server_id: 104,
167
+ domain: "web9.example.org",
168
+ site_user: "web9",
169
+ php_version: "8.3",
170
+ public_folder: "/",
171
+ is_wordpress: false,
172
+ https: { enabled: true },
173
+ created_at: "2025-01-08T17:00:00Z",
174
+ status: "active",
175
+ },
176
+ {
177
+ id: 1006,
178
+ server_id: 103,
179
+ domain: "docs.example.org",
180
+ site_user: "docs",
181
+ php_version: "7.4",
182
+ public_folder: "/",
183
+ is_wordpress: true,
184
+ page_cache: { enabled: false },
185
+ https: { enabled: false },
186
+ database: { id: 4, user_id: 4, table_prefix: "wp_" },
187
+ backups: { files: false, database: false },
188
+ wp_core_update: true,
189
+ wp_theme_updates: 0,
190
+ wp_plugin_updates: 8,
191
+ created_at: "2022-06-11T15:20:00Z",
192
+ status: "active",
193
+ },
194
+ ]
195
+
196
+ const EVENTS: Event[] = [
197
+ {
198
+ id: 9001,
199
+ initiated_by: "dev-mode",
200
+ server_id: 101,
201
+ name: "site.php_version.updated",
202
+ status: "deployed",
203
+ output: "PHP updated to 8.3 for shop.example.com",
204
+ created_at: "2026-06-30T09:00:00Z",
205
+ started_at: "2026-06-30T09:00:05Z",
206
+ finished_at: "2026-06-30T09:00:42Z",
207
+ },
208
+ {
209
+ id: 9002,
210
+ initiated_by: "dev-mode",
211
+ server_id: 101,
212
+ name: "site.cache.purged",
213
+ status: "deployed",
214
+ output: "Page cache and object cache purged for example.com",
215
+ created_at: "2026-06-29T16:12:00Z",
216
+ started_at: "2026-06-29T16:12:01Z",
217
+ finished_at: "2026-06-29T16:12:09Z",
218
+ },
219
+ {
220
+ id: 9003,
221
+ initiated_by: "dev-mode",
222
+ server_id: 103,
223
+ name: "server.rebooted",
224
+ status: "deployed",
225
+ output: "Server rebooted successfully",
226
+ created_at: "2026-06-27T04:00:00Z",
227
+ started_at: "2026-06-27T04:00:02Z",
228
+ finished_at: "2026-06-27T04:01:15Z",
229
+ },
230
+ ]
231
+
232
+ function clone<T>(value: T): T {
233
+ return JSON.parse(JSON.stringify(value)) as T
234
+ }
235
+
236
+ export function freshFixtures(): { servers: Server[]; sites: Site[]; events: Event[] } {
237
+ return { servers: clone(SERVERS), sites: clone(SITES), events: clone(EVENTS) }
238
+ }