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,214 @@
1
+ // In-memory stand-in for SpinupWPClient, used when SPINUP_DEV_MODE is on (see
2
+ // devMode.ts). Backed by the fixture fleet in fixtures.ts; writes mutate this
3
+ // module's own copy of the data and never touch the network. Event polling
4
+ // simulates a couple of "running" ticks before settling "deployed" so the same
5
+ // spinners/toasts a real write shows are visible in a demo.
6
+ //
7
+ // Every write here always succeeds — Dev Mode is for showing the UI's happy path,
8
+ // not for testing SpinupWP's error handling (the real client covers that).
9
+
10
+ import type {
11
+ CreateServerPayload,
12
+ CreateSitePayload,
13
+ Event,
14
+ ProviderMetadata,
15
+ Server,
16
+ Site,
17
+ } from "../api/types.ts"
18
+ import type { ServerService, SpinupWPClientLike } from "../api/client.ts"
19
+ import { freshFixtures } from "./fixtures.ts"
20
+
21
+ const EVENT_POLLS_BEFORE_DONE = 2 // ~2 poll intervals of visible "in progress" state
22
+
23
+ function nextIdAfter(items: { id: number }[], floor: number): number {
24
+ return Math.max(floor, ...items.map((i) => i.id)) + 1
25
+ }
26
+
27
+ function fakeProviderMetadata(): ProviderMetadata {
28
+ return {
29
+ regions: {
30
+ "North America": [
31
+ { slug: "nyc3", name: "New York 3", available: true, continent: "North America", sizes: ["s-1vcpu-2gb", "s-2vcpu-4gb"] },
32
+ ],
33
+ },
34
+ sizes: [
35
+ { slug: "s-1vcpu-2gb", type: "standard", memory: 2048, vcpus: 1, disk: 50, priceMonthly: 12, available: true },
36
+ { slug: "s-2vcpu-4gb", type: "standard", memory: 4096, vcpus: 2, disk: 80, priceMonthly: 24, available: true },
37
+ ],
38
+ }
39
+ }
40
+
41
+ export function createMockClient(): SpinupWPClientLike {
42
+ const { servers, sites, events } = freshFixtures()
43
+ let nextServerId = nextIdAfter(servers, 200)
44
+ let nextSiteId = nextIdAfter(sites, 2000)
45
+ let nextEventId = nextIdAfter(events, 9100)
46
+ const eventPolls = new Map<number, number>()
47
+
48
+ function pushEvent(name: string, serverId: number | null, output: string): number {
49
+ const id = nextEventId++
50
+ const now = new Date().toISOString()
51
+ events.unshift({ id, initiated_by: "dev-mode", server_id: serverId, name, status: "running", output, created_at: now, started_at: now, finished_at: null })
52
+ return id
53
+ }
54
+
55
+ return {
56
+ async validateToken() {
57
+ return { ok: true }
58
+ },
59
+
60
+ async listServers() {
61
+ return servers.map((s) => ({ ...s }))
62
+ },
63
+
64
+ async getServer(id) {
65
+ const found = servers.find((s) => s.id === id)
66
+ if (!found) throw new Error(`dev mode: no such server ${id}`)
67
+ return { ...found }
68
+ },
69
+
70
+ async providerMetadata() {
71
+ return fakeProviderMetadata()
72
+ },
73
+
74
+ async createServer(payload: CreateServerPayload) {
75
+ const id = nextServerId++
76
+ const server: Server = {
77
+ id,
78
+ name: payload.hostname,
79
+ provider_name: payload.server_provider.name ?? "digitalocean",
80
+ ubuntu_version: "24.04",
81
+ ip_address: `203.0.113.${(id % 200) + 20}`,
82
+ ssh_port: 22,
83
+ timezone: payload.timezone ?? "UTC",
84
+ region: payload.server_provider.region,
85
+ size: payload.server_provider.size,
86
+ disk_space: { total: 80 * 1024 * 1024 * 1024, available: 78 * 1024 * 1024 * 1024, used: 2 * 1024 * 1024 * 1024, updated_at: new Date().toISOString() },
87
+ database: null,
88
+ connection_status: "connected",
89
+ reboot_required: false,
90
+ upgrade_required: false,
91
+ created_at: new Date().toISOString(),
92
+ status: "active",
93
+ }
94
+ servers.push(server)
95
+ const event_id = pushEvent("server.provisioned", id, `Server ${payload.hostname} provisioned`)
96
+ return { event_id }
97
+ },
98
+
99
+ async listSites(serverId) {
100
+ const rows = serverId ? sites.filter((s) => s.server_id === serverId) : sites
101
+ return rows.map((s) => ({ ...s }))
102
+ },
103
+
104
+ async getSite(id) {
105
+ const found = sites.find((s) => s.id === id)
106
+ if (!found) throw new Error(`dev mode: no such site ${id}`)
107
+ return { ...found }
108
+ },
109
+
110
+ async createSite(payload: CreateSitePayload) {
111
+ const id = nextSiteId++
112
+ const site: Site = {
113
+ id,
114
+ server_id: payload.server_id,
115
+ domain: payload.domain,
116
+ site_user: payload.site_user,
117
+ php_version: payload.php_version ?? "8.3",
118
+ public_folder: payload.public_folder ?? "/",
119
+ is_wordpress: payload.installation_method !== "blank",
120
+ page_cache: { enabled: payload.page_cache?.enabled ?? false },
121
+ https: { enabled: false },
122
+ database: payload.database ? { id, user_id: id, table_prefix: payload.database.table_prefix ?? "wp_" } : null,
123
+ git: payload.git ? { repo: payload.git.repo, branch: payload.git.branch ?? "main", deploy_script: payload.deploy_script ?? null, push_enabled: payload.git.push_to_deploy ?? false, deployment_url: null } : null,
124
+ wp_core_update: false,
125
+ wp_theme_updates: 0,
126
+ wp_plugin_updates: 0,
127
+ created_at: new Date().toISOString(),
128
+ status: "active",
129
+ }
130
+ sites.push(site)
131
+ const event_id = pushEvent("site.created", payload.server_id, `Site ${payload.domain} created`)
132
+ return { event_id }
133
+ },
134
+
135
+ async enableHttps(siteId) {
136
+ const site = sites.find((s) => s.id === siteId)
137
+ if (site) site.https = { enabled: true }
138
+ const event_id = pushEvent("site.https.enabled", site?.server_id ?? null, `HTTPS enabled for ${site?.domain ?? siteId}`)
139
+ return { event_id }
140
+ },
141
+
142
+ async disableHttps(siteId) {
143
+ const site = sites.find((s) => s.id === siteId)
144
+ if (site) site.https = { enabled: false }
145
+ const event_id = pushEvent("site.https.disabled", site?.server_id ?? null, `HTTPS disabled for ${site?.domain ?? siteId}`)
146
+ return { event_id }
147
+ },
148
+
149
+ async purgePageCache(siteId) {
150
+ const site = sites.find((s) => s.id === siteId)
151
+ const event_id = pushEvent("site.cache.purged", site?.server_id ?? null, `Page cache purged for ${site?.domain ?? siteId}`)
152
+ return { event_id }
153
+ },
154
+
155
+ async purgeObjectCache(siteId) {
156
+ const site = sites.find((s) => s.id === siteId)
157
+ const event_id = pushEvent("site.object_cache.purged", site?.server_id ?? null, `Object cache purged for ${site?.domain ?? siteId}`)
158
+ return { event_id }
159
+ },
160
+
161
+ async listSiteDomains(siteId) {
162
+ const site = sites.find((s) => s.id === siteId)
163
+ return (site?.additional_domains ?? []).map((d) => ({ ...d }))
164
+ },
165
+
166
+ async addSiteDomain(siteId, payload) {
167
+ const site = sites.find((s) => s.id === siteId)
168
+ if (site) {
169
+ site.additional_domains = site.additional_domains ?? []
170
+ const domId = 9000 + sites.reduce((n, x) => n + (x.additional_domains?.length ?? 0), 0)
171
+ site.additional_domains.push({ id: domId, domain: payload.domain, redirect: { enabled: payload.redirect?.enabled ?? false, type: payload.redirect?.type ?? 301, destination: payload.redirect?.destination ?? site.domain }, created_at: new Date().toISOString() })
172
+ }
173
+ const event_id = pushEvent("site.domains.updated", site?.server_id ?? null, `Updating domains for ${site?.domain ?? siteId}`)
174
+ return { event_id }
175
+ },
176
+
177
+ async listEvents(maxPages) {
178
+ const limit = (maxPages ?? 3) * 100
179
+ return events.slice(0, limit).map((e) => ({ ...e }))
180
+ },
181
+
182
+ async getEvent(id) {
183
+ const found = events.find((e) => e.id === id)
184
+ if (!found) throw new Error(`dev mode: no such event ${id}`)
185
+ const polls = (eventPolls.get(id) ?? 0) + 1
186
+ eventPolls.set(id, polls)
187
+ if (polls >= EVENT_POLLS_BEFORE_DONE && found.status === "running") {
188
+ found.status = "deployed"
189
+ found.finished_at = new Date().toISOString()
190
+ }
191
+ return { ...found }
192
+ },
193
+
194
+ async upgradeSitePhp(siteId, phpVersion) {
195
+ const site = sites.find((s) => s.id === siteId)
196
+ if (site) site.php_version = phpVersion
197
+ const event_id = pushEvent("site.php_version.updated", site?.server_id ?? null, `PHP updated to ${phpVersion} for ${site?.domain ?? siteId}`)
198
+ return { event_id }
199
+ },
200
+
201
+ async rebootServer(serverId) {
202
+ const server = servers.find((s) => s.id === serverId)
203
+ if (server) server.reboot_required = false
204
+ const event_id = pushEvent("server.rebooted", serverId, `Server ${server?.name ?? serverId} rebooted`)
205
+ return { event_id }
206
+ },
207
+
208
+ async restartService(serverId, service: ServerService) {
209
+ const server = servers.find((s) => s.id === serverId)
210
+ const event_id = pushEvent(`server.service.${service}.restarted`, serverId, `${service} restarted on ${server?.name ?? serverId}`)
211
+ return { event_id }
212
+ },
213
+ }
214
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bun
2
+ // Entry point. Handles a few non-interactive CLI subcommands, then boots the
3
+ // OpenTUI renderer and mounts either the first-run onboarding wizard (no token
4
+ // yet) or the main app wrapped in the data store.
5
+
6
+ import { useState } from "react"
7
+ import { createCliRenderer } from "@opentui/core"
8
+ import { createRoot } from "@opentui/react"
9
+ import pkg from "../package.json" with { type: "json" }
10
+ import { hasToken, configPath, loadConfig } from "./config.ts"
11
+ import { StoreProvider } from "./ui/store.tsx"
12
+ import { App } from "./ui/App.tsx"
13
+ import { Onboarding } from "./ui/Onboarding.tsx"
14
+ import { isDevMode } from "./dev/devMode.ts"
15
+
16
+ const args = process.argv.slice(2)
17
+ const command = args.find((a) => !a.startsWith("-"))
18
+
19
+ if (args.includes("-h") || args.includes("--help") || command === "help") {
20
+ const cfg = loadConfig()
21
+ console.log(`SpinupTUI v${pkg.version} — terminal dashboard for your SpinupWP account
22
+
23
+ Usage:
24
+ spinuptui Launch the dashboard
25
+ spinuptui login Set or update your saved API token
26
+ spinuptui where Print the config file path and token source
27
+ spinuptui --version Print the version
28
+ spinuptui --help Show this help
29
+
30
+ Token resolution: SPINUPWP_ACCESS_TOKEN (env / .env) first, then the config
31
+ file. Run \`spinuptui login\` once to save a token so \`spinuptui\` works from
32
+ anywhere.
33
+
34
+ Config file: ${configPath()}
35
+ Token source: ${cfg.tokenSource}`)
36
+ process.exit(0)
37
+ }
38
+
39
+ if (args.includes("-v") || args.includes("--version") || command === "version") {
40
+ console.log(pkg.version)
41
+ process.exit(0)
42
+ }
43
+
44
+ if (command === "where") {
45
+ const cfg = loadConfig()
46
+ console.log(`config: ${configPath()}`)
47
+ console.log(`token source: ${cfg.tokenSource}`)
48
+ process.exit(0)
49
+ }
50
+
51
+ // `spinuptui login` forces the onboarding wizard even when a token already exists,
52
+ // so the token can be (re)saved to the config file for global use.
53
+ const forceLogin = command === "login"
54
+
55
+ function Root() {
56
+ const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
57
+ if (!configured) {
58
+ return <Onboarding onComplete={() => setConfigured(true)} />
59
+ }
60
+ return (
61
+ <StoreProvider>
62
+ <App />
63
+ </StoreProvider>
64
+ )
65
+ }
66
+
67
+ const renderer = await createCliRenderer()
68
+ createRoot(renderer).render(<Root />)
@@ -0,0 +1,206 @@
1
+ // "Update available" check: compare the running version to the latest GitHub
2
+ // release, so a `✨ vX.Y.Z` hint can nudge users — either to update in-app (`u`
3
+ // in the Help/About overlay, see runSelfUpdate below) or with a manual `git pull`.
4
+ //
5
+ // Polling model: checked once per launch, backed by a disk cache with a 6h TTL —
6
+ // so frequent open/close cycles reuse the cached result instead of hitting GitHub
7
+ // each time (releases are infrequent; the unauthenticated API allows 60 req/hr).
8
+ // Never throws and degrades gracefully offline.
9
+
10
+ import { join, sep } from "node:path"
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
12
+ import { configDir } from "../config.ts"
13
+ import { REPO_SLUG } from "../version.ts"
14
+
15
+ const CACHE_VERSION = 1
16
+ const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
17
+ const FETCH_TIMEOUT_MS = 5000
18
+
19
+ export interface UpdateInfo {
20
+ current: string // running version
21
+ latest: string // latest released version (no leading "v")
22
+ url: string // release page
23
+ updateAvailable: boolean
24
+ }
25
+
26
+ interface CacheFile {
27
+ version: number
28
+ checkedAt: number // epoch ms
29
+ latest: string | null
30
+ url: string | null
31
+ }
32
+
33
+ function cachePath(): string {
34
+ return join(configDir(), "update-check.json")
35
+ }
36
+
37
+ // Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when it doesn't look like a version.
38
+ function parseVersion(s: string): [number, number, number] | null {
39
+ const m = s.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)/)
40
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
41
+ }
42
+
43
+ // Strictly-newer comparison (latest > current). Unparseable → false (never nag).
44
+ function isNewer(latest: string, current: string): boolean {
45
+ const a = parseVersion(latest)
46
+ const b = parseVersion(current)
47
+ if (!a || !b) return false
48
+ for (let i = 0; i < 3; i++) {
49
+ if (a[i] !== b[i]) return a[i] > b[i]
50
+ }
51
+ return false
52
+ }
53
+
54
+ function readCache(): CacheFile | null {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(cachePath(), "utf8")) as CacheFile
57
+ return parsed?.version === CACHE_VERSION ? parsed : null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ function writeCache(latest: string | null, url: string | null): void {
64
+ try {
65
+ mkdirSync(configDir(), { recursive: true })
66
+ const file: CacheFile = { version: CACHE_VERSION, checkedAt: Date.now(), latest, url }
67
+ writeFileSync(cachePath(), JSON.stringify(file))
68
+ } catch {
69
+ /* best-effort; a failed cache write just means we re-check next launch */
70
+ }
71
+ }
72
+
73
+ function deriveInfo(current: string, latest: string | null, url: string | null): UpdateInfo | null {
74
+ if (!latest) return null
75
+ return { current, latest, url: url ?? `https://github.com/${REPO_SLUG}/releases`, updateAvailable: isNewer(latest, current) }
76
+ }
77
+
78
+ // Synchronous read of whatever we last cached — used to show a hint instantly on
79
+ // launch without waiting on the network. Returns null when nothing is cached.
80
+ export function cachedUpdateInfo(current: string): UpdateInfo | null {
81
+ const c = readCache()
82
+ return c ? deriveInfo(current, c.latest, c.url) : null
83
+ }
84
+
85
+ // Hit GitHub for the latest release; null on any failure (offline, rate limit, no
86
+ // releases yet). Times out so it can't hang the launch.
87
+ async function fetchLatestRelease(): Promise<{ latest: string; url: string } | null> {
88
+ const ctrl = new AbortController()
89
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
90
+ try {
91
+ const res = await fetch(`https://api.github.com/repos/${REPO_SLUG}/releases/latest`, {
92
+ headers: {
93
+ "User-Agent": "spinup-tui",
94
+ Accept: "application/vnd.github+json",
95
+ "X-GitHub-Api-Version": "2022-11-28",
96
+ },
97
+ signal: ctrl.signal,
98
+ })
99
+ if (!res.ok) return null
100
+ const json = (await res.json()) as { tag_name?: string; html_url?: string }
101
+ const tag = json.tag_name?.replace(/^v/i, "")
102
+ if (!tag) return null
103
+ return { latest: tag, url: json.html_url ?? `https://github.com/${REPO_SLUG}/releases` }
104
+ } catch {
105
+ return null
106
+ } finally {
107
+ clearTimeout(timer)
108
+ }
109
+ }
110
+
111
+ // The launch-time check: returns the freshest info we can, hitting GitHub only
112
+ // when the cache is stale (older than the TTL). Falls back to a stale cache if
113
+ // the network fails. Returns null only when we have nothing at all.
114
+ export async function refreshUpdateInfo(current: string): Promise<UpdateInfo | null> {
115
+ const cache = readCache()
116
+ if (cache && Date.now() - cache.checkedAt < TTL_MS) {
117
+ return deriveInfo(current, cache.latest, cache.url)
118
+ }
119
+ const fetched = await fetchLatestRelease()
120
+ if (fetched) {
121
+ writeCache(fetched.latest, fetched.url)
122
+ return deriveInfo(current, fetched.latest, fetched.url)
123
+ }
124
+ // Network failed — keep whatever we had so the hint doesn't flicker off.
125
+ return cache ? deriveInfo(current, cache.latest, cache.url) : null
126
+ }
127
+
128
+ // ---- In-app updater (`u` in the Help/About overlay) ------------------------
129
+
130
+ // How this copy of the app was installed, which decides the update path shown
131
+ // in Help/About: a package-manager install (bun/npm global) lives under a
132
+ // node_modules dir and updates via `bun update -g spinuptui`; anything else is
133
+ // treated as a git checkout and updates via `git pull` (runSelfUpdate).
134
+ export type InstallChannel = "git" | "package"
135
+
136
+ export function installChannel(): InstallChannel {
137
+ return import.meta.dir.includes(`${sep}node_modules${sep}`) ? "package" : "git"
138
+ }
139
+
140
+ // The one-liner Help shows for a package install (also used by runSelfUpdate's
141
+ // "not a checkout" message so every path names the same command).
142
+ export const PACKAGE_UPDATE_CMD = "bun update -g spinuptui"
143
+
144
+ // The real checkout directory, resolved through Bun's module system rather
145
+ // than process.cwd() — `spinuptui` is typically a global symlink invoked from
146
+ // wherever the user happens to be, so cwd is meaningless here. import.meta.dir
147
+ // follows the symlink to this file's REAL location (verified: it does not
148
+ // resolve to the symlink's own path or the invoking shell's cwd).
149
+ function checkoutRoot(): string {
150
+ return join(import.meta.dir, "..", "..")
151
+ }
152
+
153
+ async function git(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
154
+ const proc = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
155
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
156
+ return { code, stdout: stdout.trim(), stderr: stderr.trim() }
157
+ }
158
+
159
+ export interface SelfUpdateResult {
160
+ ok: boolean
161
+ message: string
162
+ // package.json / bun.lock changed — the running code on disk now needs
163
+ // `bun install` before it'll actually run.
164
+ needsInstall: boolean
165
+ }
166
+
167
+ // `git pull --ff-only` in the checkout, with the safety rails a self-updater
168
+ // needs: refuse on a dirty tree (this is the user's own dev checkout, not an
169
+ // opaque install dir — never silently overwrite their uncommitted work), and
170
+ // never attempt a merge/rebase that could produce conflicts (--ff-only). Can't
171
+ // hot-reload the already-running process — the result always says "on disk",
172
+ // never implies the update is live without a restart.
173
+ // `cwd` defaults to the real checkout; overridable so this is testable against
174
+ // a disposable sandbox without ever touching the real repo's git state.
175
+ export async function runSelfUpdate(cwd: string = checkoutRoot()): Promise<SelfUpdateResult> {
176
+ try {
177
+ const isRepo = await git(cwd, ["rev-parse", "--is-inside-work-tree"])
178
+ if (isRepo.code !== 0 || isRepo.stdout !== "true") {
179
+ return { ok: false, message: `This install isn't a git checkout — update with \`${PACKAGE_UPDATE_CMD}\`.`, needsInstall: false }
180
+ }
181
+ const status = await git(cwd, ["status", "--porcelain"])
182
+ if (status.stdout.length > 0) {
183
+ return { ok: false, message: "Your checkout has uncommitted changes — commit or stash them, then try again.", needsInstall: false }
184
+ }
185
+ const before = await git(cwd, ["rev-parse", "HEAD"])
186
+ const pull = await git(cwd, ["pull", "--ff-only"])
187
+ if (pull.code !== 0) {
188
+ return { ok: false, message: pull.stderr || pull.stdout || "git pull failed.", needsInstall: false }
189
+ }
190
+ const after = await git(cwd, ["rev-parse", "HEAD"])
191
+ if (before.stdout && before.stdout === after.stdout) {
192
+ return { ok: true, message: "Already up to date.", needsInstall: false }
193
+ }
194
+ const diff = await git(cwd, ["diff", "--name-only", before.stdout, after.stdout])
195
+ const needsInstall = /(^|\/)(package\.json|bun\.lock)$/m.test(diff.stdout)
196
+ return {
197
+ ok: true,
198
+ needsInstall,
199
+ message: needsInstall
200
+ ? "Updated on disk — run `bun install`, then press q and restart spinuptui."
201
+ : "Updated on disk — press q and restart spinuptui.",
202
+ }
203
+ } catch (err) {
204
+ return { ok: false, message: `Update failed: ${(err as Error).message}`, needsInstall: false }
205
+ }
206
+ }
@@ -0,0 +1,133 @@
1
+ // Hand-rolled AWS Signature Version 4 — no `aws` CLI, no SDK. We only issue a
2
+ // handful of calls: read-only GETs (STS GetCallerIdentity, Route 53
3
+ // ListHostedZonesByName / ListResourceRecordSets) and one write POST (Route 53
4
+ // ChangeResourceRecordSets, for editing a record's TTL). The full SigV4 algorithm
5
+ // for a signed request — empty body for GETs, hashed XML body for the POST — is
6
+ // all we need, which keeps the app dependency-light (node:crypto + fetch only).
7
+ //
8
+ // Reference: docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
9
+ // signGetV4 is verified against AWS's published "get-vanilla" test vector.
10
+
11
+ import { createHash, createHmac } from "node:crypto"
12
+
13
+ export interface AwsCreds {
14
+ accessKeyId: string
15
+ secretAccessKey: string
16
+ region?: string // defaults to us-east-1 (Route 53 + STS are global, signed in us-east-1)
17
+ sessionToken?: string
18
+ }
19
+
20
+ function sha256hex(data: string): string {
21
+ return createHash("sha256").update(data, "utf8").digest("hex")
22
+ }
23
+ function hmac(key: string | Buffer, data: string): Buffer {
24
+ return createHmac("sha256", key).update(data, "utf8").digest()
25
+ }
26
+
27
+ // AWS URI-encoding (RFC 3986): encodeURIComponent plus the four chars it leaves.
28
+ function enc(s: string): string {
29
+ return encodeURIComponent(s).replace(/[!*'()]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase())
30
+ }
31
+
32
+ export interface SignOptions {
33
+ creds: AwsCreds
34
+ service: string
35
+ host: string
36
+ method?: string // defaults to GET
37
+ path: string // already in canonical form (we only use simple ASCII paths)
38
+ query?: Record<string, string>
39
+ body?: string // request body (empty string for GET)
40
+ contentType?: string // sent (unsigned) when a body is present
41
+ amzDate?: string // override for testing; defaults to now in ISO8601 basic (YYYYMMDDTHHMMSSZ)
42
+ }
43
+
44
+ // Produce the headers + URL for a signed request. Pure (no I/O) so it can be unit
45
+ // tested against AWS's reference vectors. Only host + x-amz-date (+ the session
46
+ // token when present) are signed; Content-Type, when sent, rides along unsigned —
47
+ // SigV4 permits unsigned headers, and this keeps the signed set (and thus the GET
48
+ // path) identical to the verified reference implementation.
49
+ export function signV4(opts: SignOptions): { url: string; headers: Record<string, string> } {
50
+ const { creds, service, host, path } = opts
51
+ const method = opts.method ?? "GET"
52
+ const body = opts.body ?? ""
53
+ const region = creds.region || "us-east-1"
54
+ const query = opts.query ?? {}
55
+ const amzDate = opts.amzDate ?? new Date().toISOString().replace(/[:-]|\.\d{3}/g, "")
56
+ const dateStamp = amzDate.slice(0, 8)
57
+
58
+ const canonicalQuery = Object.keys(query)
59
+ .sort()
60
+ .map((k) => `${enc(k)}=${enc(query[k])}`)
61
+ .join("&")
62
+
63
+ // Signed headers (lowercase names, sorted). host + x-amz-date, plus the session
64
+ // token header when present.
65
+ const hdrs: Record<string, string> = { host, "x-amz-date": amzDate }
66
+ if (creds.sessionToken) hdrs["x-amz-security-token"] = creds.sessionToken
67
+ const names = Object.keys(hdrs).sort()
68
+ const canonicalHeaders = names.map((n) => `${n}:${hdrs[n].trim()}\n`).join("")
69
+ const signedHeaders = names.join(";")
70
+
71
+ const payloadHash = sha256hex(body)
72
+ const canonicalRequest = [method, path, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n")
73
+
74
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`
75
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n")
76
+
77
+ const kDate = hmac("AWS4" + creds.secretAccessKey, dateStamp)
78
+ const kRegion = hmac(kDate, region)
79
+ const kService = hmac(kRegion, service)
80
+ const kSigning = hmac(kService, "aws4_request")
81
+ const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex")
82
+
83
+ const authorization = `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
84
+
85
+ // host is implied by the URL (fetch sets it); send the rest.
86
+ const headers: Record<string, string> = { "x-amz-date": amzDate, Authorization: authorization }
87
+ if (creds.sessionToken) headers["x-amz-security-token"] = creds.sessionToken
88
+ if (body && opts.contentType) headers["Content-Type"] = opts.contentType
89
+
90
+ const url = `https://${host}${path}${canonicalQuery ? "?" + canonicalQuery : ""}`
91
+ return { url, headers }
92
+ }
93
+
94
+ // Back-compat name for the GET-only signer (verified against AWS's reference
95
+ // vector). Delegates to signV4 — the signed set is unchanged, so its output is
96
+ // byte-identical to the original implementation.
97
+ export function signGetV4(opts: {
98
+ creds: AwsCreds
99
+ service: string
100
+ host: string
101
+ path: string
102
+ query?: Record<string, string>
103
+ amzDate?: string
104
+ }): { url: string; headers: Record<string, string> } {
105
+ return signV4({ ...opts, method: "GET" })
106
+ }
107
+
108
+ // Sign and perform a GET. Returns the HTTP status + raw body text.
109
+ export async function awsGet(
110
+ creds: AwsCreds,
111
+ service: string,
112
+ host: string,
113
+ path: string,
114
+ query: Record<string, string> = {},
115
+ ): Promise<{ status: number; body: string }> {
116
+ const { url, headers } = signV4({ creds, service, host, path, query, method: "GET" })
117
+ const res = await fetch(url, { method: "GET", headers })
118
+ return { status: res.status, body: await res.text() }
119
+ }
120
+
121
+ // Sign and perform a POST with a body. Returns the HTTP status + raw body text.
122
+ export async function awsPost(
123
+ creds: AwsCreds,
124
+ service: string,
125
+ host: string,
126
+ path: string,
127
+ body: string,
128
+ contentType = "application/xml",
129
+ ): Promise<{ status: number; body: string }> {
130
+ const { url, headers } = signV4({ creds, service, host, path, body, contentType, method: "POST" })
131
+ const res = await fetch(url, { method: "POST", headers, body })
132
+ return { status: res.status, body: await res.text() }
133
+ }