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/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
|
+
}
|