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