spinuptui 1.2.0-alpha.1 → 1.2.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 +4 -11
- package/README.md +2 -8
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/config.ts +0 -23
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +17 -16
- package/src/lib/appUpdate.ts +7 -33
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/ui/App.tsx +7 -11
- package/src/ui/Details.tsx +1 -1
- package/src/ui/Explain.tsx +2 -15
- package/src/ui/Header.tsx +0 -2
- package/src/ui/Help.tsx +3 -2
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +7 -7
- package/src/ui/views/Browser.tsx +9 -1
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +4 -1
- package/src/ui/views/ServerActions.tsx +102 -7
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +5 -1
- package/src/lib/appUpdate.test.ts +0 -26
- package/src/lib/beacon.test.ts +0 -140
- package/src/lib/beacon.ts +0 -349
- package/src/lib/beaconSetup.test.ts +0 -65
- package/src/lib/beaconSetup.ts +0 -143
- package/src/lib/cliBeacon.ts +0 -290
- package/src/lib/prompt.ts +0 -47
- package/src/modules/beacon.ts +0 -51
- package/src/modules/contract.ts +0 -61
- package/src/modules/index.ts +0 -34
- package/src/ui/views/BeaconSetup.tsx +0 -268
- package/src/ui/views/Modules.tsx +0 -196
package/CHANGELOG.md
CHANGED
|
@@ -11,17 +11,10 @@ version; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
-
## [1.2.0
|
|
15
|
-
|
|
16
|
-
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.1.2. The stable 1.2.0 will roll these notes up with the next modules.
|
|
14
|
+
## [1.2.0] - 2026-10-21
|
|
17
15
|
|
|
18
16
|
### Added
|
|
19
|
-
- **
|
|
20
|
-
- **A Modules tab (`6`).** Everything SpinupTUI can keep doing after you close it, with its status on this machine and set up from the same place: select Beacon and press `⏎` for the guided Telegram setup, or `t` for a test page. Modules still on the way (Scheduler, Sentinel, Samurai) are listed as coming soon.
|
|
21
|
-
- **`spinuptui modules`** lists SpinupTUI's modules (the features that keep working after the TUI is closed) and their status. Beacon is the first; the module contract is in [docs/2026-10-16_beacon-and-module-contract-spec.md](docs/2026-10-16_beacon-and-module-contract-spec.md).
|
|
22
|
-
|
|
23
|
-
### Fixed
|
|
24
|
-
- **The update hint understands pre-releases.** Someone running a pre-release (say `1.2.0-alpha.1`) is now told when the matching stable release ships; before, the two counted as the same version.
|
|
17
|
+
- **Take a server's sites offline, and bring them back.** For a vulnerability you can't patch yet: `spinuptui offline <server>… --yes` (or **Take all sites offline** in the `a` menu) disables every site on the server through SpinupWP, so nginx stops answering for them and no request reaches PHP. Files, databases and certificates are untouched. `spinuptui online <server>… --yes` re-enables exactly the sites it took down, never one disabled for another reason, and a re-run finishes anything that failed. Without `--yes` both print the plan. One site at a time works too: `O` on a site, or `--site <domain>` on either command; the confirmation says how many other sites on its server stay up, since PHP is shared. Uptime Kuma monitors for those sites sit in a maintenance window until they're back, and offline servers are flagged `⏻ off` in the Servers list and on the Overview. See [docs/taking-sites-offline.md](docs/taking-sites-offline.md).
|
|
25
18
|
|
|
26
19
|
## [1.1.2] - 2026-10-15
|
|
27
20
|
|
|
@@ -1497,8 +1490,8 @@ Initial tagged release.
|
|
|
1497
1490
|
### Notes
|
|
1498
1491
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1499
1492
|
|
|
1500
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0
|
|
1501
|
-
[1.2.0
|
|
1493
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...HEAD
|
|
1494
|
+
[1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
|
|
1502
1495
|
[1.1.2]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...v1.1.2
|
|
1503
1496
|
[1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
|
|
1504
1497
|
[1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
|
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
- **Purge cache** (`P`) — clear page + object cache together.
|
|
61
61
|
- **Server actions** (`a`) — reboot or restart a service, with the real reason a
|
|
62
62
|
reboot is pending. → [docs/server-actions.md](docs/server-actions.md)
|
|
63
|
+
- **Take sites offline** (`a` for a whole server, `O` for one site, or `spinuptui offline`) — for a vulnerability you can't patch yet: nginx stops answering for those sites, so no request reaches PHP through them. Bringing them back re-enables exactly those sites, and Uptime Kuma stays quiet meanwhile. → [docs/taking-sites-offline.md](docs/taking-sites-offline.md)
|
|
63
64
|
- **Create & connect a server** (`c` / `V`) — provision a server, then wire it up
|
|
64
65
|
end-to-end (DNS, a vanity site, HTTPS, SSH-key handoff).
|
|
65
66
|
→ [docs/creating-connecting-servers.md](docs/creating-connecting-servers.md)
|
|
@@ -76,11 +77,6 @@
|
|
|
76
77
|
register/recalibrate/remove it — plus a cache/outage doctor and alert
|
|
77
78
|
wiring. → [docs/site-monitoring.md](docs/site-monitoring.md) ·
|
|
78
79
|
[docs/uptime-kuma.md](docs/uptime-kuma.md)
|
|
79
|
-
- **Modules** (`6`, pre-release: `bun add -g spinuptui@next --no-cache`) — the parts of SpinupTUI that keep working after you close it,
|
|
80
|
-
each set up from the same screen. First up, **Beacon**: `spinuptui beacon send`
|
|
81
|
-
pages you on Telegram from any cron job, watcher or agent, with retries, long
|
|
82
|
-
messages split instead of dropped, and a send log. A guided setup creates the
|
|
83
|
-
bot connection for you. → [docs/beacon.md](docs/beacon.md)
|
|
84
80
|
- **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
|
|
85
81
|
resolves a domain to a live SSH target with a live connectivity probe;
|
|
86
82
|
`spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
|
|
@@ -338,7 +334,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
338
334
|
|
|
339
335
|
| Key | Action |
|
|
340
336
|
| --- | --- |
|
|
341
|
-
| `1`…`
|
|
337
|
+
| `1`…`5` | Switch tabs: Dashboard · Servers · Stacks · Search · Events |
|
|
342
338
|
| `↑`/`↓` or `j`/`k` | Move selection |
|
|
343
339
|
| `Enter` / `→` | Drill in (server → its sites) |
|
|
344
340
|
| `←` / `Esc` | Go back / collapse |
|
|
@@ -374,8 +370,6 @@ These can be set in `config.json` or via an environment variable:
|
|
|
374
370
|
| `w` | Open the selected server/site in the SpinupWP web app |
|
|
375
371
|
| `/` | Jump to global search |
|
|
376
372
|
| `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
|
|
377
|
-
| `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup (Modules tab) |
|
|
378
|
-
| `t` | Send a test page through Beacon (Modules tab, once Beacon is set up) |
|
|
379
373
|
| `r` | Refresh data from the API |
|
|
380
374
|
| `i` | Explain the current screen (what each pane and key does) |
|
|
381
375
|
| `?` | Toggle the help overlay |
|
package/package.json
CHANGED
package/src/api/client.ts
CHANGED
|
@@ -109,6 +109,8 @@ export interface SpinupWPClientLike {
|
|
|
109
109
|
upgradeSitePhp(siteId: number, phpVersion: string): Promise<{ event_id: number }>
|
|
110
110
|
rebootServer(serverId: number): Promise<{ event_id: number }>
|
|
111
111
|
restartService(serverId: number, service: ServerService): Promise<{ event_id: number }>
|
|
112
|
+
disableSite(siteId: number): Promise<{ event_id: number }>
|
|
113
|
+
enableSite(siteId: number): Promise<{ event_id: number }>
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
const MAX_PAGES = 100 // hard safety cap when auto-paginating
|
|
@@ -482,4 +484,17 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
482
484
|
restartService(serverId: number, service: ServerService): Promise<{ event_id: number }> {
|
|
483
485
|
return this.mutate<{ event_id: number }>(`/servers/${serverId}/services/${service}/restart`, "POST")
|
|
484
486
|
}
|
|
487
|
+
|
|
488
|
+
// Take a site offline without touching its files, database or certificate.
|
|
489
|
+
// Verified on a test box: nginx drops the site's server block, so port 80
|
|
490
|
+
// returns an empty reply and 443 fails the TLS handshake — no request reaches
|
|
491
|
+
// PHP. The site's PHP-FPM pool and cron stay in place. Async → event_id.
|
|
492
|
+
disableSite(siteId: number): Promise<{ event_id: number }> {
|
|
493
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/disable`, "POST")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Undo disableSite: the site serves traffic again exactly as before.
|
|
497
|
+
enableSite(siteId: number): Promise<{ event_id: number }> {
|
|
498
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/enable`, "POST")
|
|
499
|
+
}
|
|
485
500
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -120,6 +120,9 @@ export interface Site {
|
|
|
120
120
|
} | null
|
|
121
121
|
basic_auth?: { enabled: boolean; username?: string | null } | null
|
|
122
122
|
subdomain?: { enabled: boolean; url: string | null } | null
|
|
123
|
+
// Set while the site is disabled (POST /sites/{id}/disable): nginx stops
|
|
124
|
+
// serving every domain on it, so no request reaches PHP. Null when live.
|
|
125
|
+
disabled_at?: string | null
|
|
123
126
|
created_at: string
|
|
124
127
|
status: string
|
|
125
128
|
}
|
package/src/config.ts
CHANGED
|
@@ -103,27 +103,6 @@ export interface AppConfig {
|
|
|
103
103
|
// running version with no notes shown — nothing meaningful to announce). A
|
|
104
104
|
// mismatch against the running version is what triggers the overlay.
|
|
105
105
|
lastSeenVersion: string | null
|
|
106
|
-
// Beacon's notification channels (see lib/beacon.ts and
|
|
107
|
-
// docs/2026-10-16_beacon-and-module-contract-spec.md). Machine-level like the
|
|
108
|
-
// Kuma login: one operator's pager serves every account. Holds bot tokens, so
|
|
109
|
-
// it relies on the file's 600 mode. The env channel (SPINUPTUI_BEACON_*) is
|
|
110
|
-
// resolved in lib/beacon.ts, not here, so this is only what's stored.
|
|
111
|
-
beacon: BeaconStored | null
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export interface BeaconTelegramChannel {
|
|
115
|
-
type: "telegram"
|
|
116
|
-
botToken: string
|
|
117
|
-
chatId: string
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// A union so later transports (Slack, ntfy, a client-facing Basecamp channel)
|
|
121
|
-
// are additive.
|
|
122
|
-
export type BeaconChannel = BeaconTelegramChannel
|
|
123
|
-
|
|
124
|
-
export interface BeaconStored {
|
|
125
|
-
defaultChannel?: string
|
|
126
|
-
channels?: Record<string, BeaconChannel>
|
|
127
106
|
}
|
|
128
107
|
|
|
129
108
|
export interface ServerProviderRef {
|
|
@@ -223,7 +202,6 @@ export interface StoredConfig {
|
|
|
223
202
|
uptimeKuma?: UptimeKumaConn
|
|
224
203
|
kumaMonitors?: Record<string, KumaMonitorRef>
|
|
225
204
|
lastSeenVersion?: string
|
|
226
|
-
beacon?: BeaconStored
|
|
227
205
|
}
|
|
228
206
|
|
|
229
207
|
// ---- Accounts (profiles) -------------------------------------------------------
|
|
@@ -509,7 +487,6 @@ export function loadConfig(): AppConfig {
|
|
|
509
487
|
})(),
|
|
510
488
|
kumaMonitors: stored.kumaMonitors ?? {},
|
|
511
489
|
lastSeenVersion: stored.lastSeenVersion?.trim() || null,
|
|
512
|
-
beacon: stored.beacon ?? null,
|
|
513
490
|
}
|
|
514
491
|
}
|
|
515
492
|
|
package/src/dev/mockClient.ts
CHANGED
|
@@ -45,6 +45,16 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
45
45
|
let nextEventId = nextIdAfter(events, 9100)
|
|
46
46
|
const eventPolls = new Map<number, number>()
|
|
47
47
|
|
|
48
|
+
function advance(e: Event): void {
|
|
49
|
+
if (e.status !== "running") return
|
|
50
|
+
const polls = (eventPolls.get(e.id) ?? 0) + 1
|
|
51
|
+
eventPolls.set(e.id, polls)
|
|
52
|
+
if (polls >= EVENT_POLLS_BEFORE_DONE) {
|
|
53
|
+
e.status = "deployed"
|
|
54
|
+
e.finished_at = new Date().toISOString()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
function pushEvent(name: string, serverId: number | null, output: string): number {
|
|
49
59
|
const id = nextEventId++
|
|
50
60
|
const now = new Date().toISOString()
|
|
@@ -230,20 +240,19 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
230
240
|
return { event_id }
|
|
231
241
|
},
|
|
232
242
|
|
|
243
|
+
// Reading an event (singly or in the list) counts as a poll, so callers
|
|
244
|
+
// that batch-check via listEvents see events finish too.
|
|
233
245
|
async listEvents(maxPages) {
|
|
234
246
|
const limit = (maxPages ?? 3) * 100
|
|
235
|
-
|
|
247
|
+
const page = events.slice(0, limit)
|
|
248
|
+
for (const e of page) advance(e)
|
|
249
|
+
return page.map((e) => ({ ...e }))
|
|
236
250
|
},
|
|
237
251
|
|
|
238
252
|
async getEvent(id) {
|
|
239
253
|
const found = events.find((e) => e.id === id)
|
|
240
254
|
if (!found) throw new Error(`dev mode: no such event ${id}`)
|
|
241
|
-
|
|
242
|
-
eventPolls.set(id, polls)
|
|
243
|
-
if (polls >= EVENT_POLLS_BEFORE_DONE && found.status === "running") {
|
|
244
|
-
found.status = "deployed"
|
|
245
|
-
found.finished_at = new Date().toISOString()
|
|
246
|
-
}
|
|
255
|
+
advance(found)
|
|
247
256
|
return { ...found }
|
|
248
257
|
},
|
|
249
258
|
|
|
@@ -266,5 +275,19 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
266
275
|
const event_id = pushEvent(`server.service.${service}.restarted`, serverId, `${service} restarted on ${server?.name ?? serverId}`)
|
|
267
276
|
return { event_id }
|
|
268
277
|
},
|
|
278
|
+
|
|
279
|
+
async disableSite(siteId) {
|
|
280
|
+
const site = sites.find((s) => s.id === siteId)
|
|
281
|
+
if (site) site.disabled_at = new Date().toISOString()
|
|
282
|
+
const event_id = pushEvent("site.disabled", site?.server_id ?? null, `Disabling ${site?.domain ?? siteId}`)
|
|
283
|
+
return { event_id }
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async enableSite(siteId) {
|
|
287
|
+
const site = sites.find((s) => s.id === siteId)
|
|
288
|
+
if (site) site.disabled_at = null
|
|
289
|
+
const event_id = pushEvent("site.enabled", site?.server_id ?? null, `Enabling ${site?.domain ?? siteId}`)
|
|
290
|
+
return { event_id }
|
|
291
|
+
},
|
|
269
292
|
}
|
|
270
293
|
}
|
package/src/index.tsx
CHANGED
|
@@ -23,7 +23,7 @@ import { resolveIncidents } from "./lib/cliIncidents.ts"
|
|
|
23
23
|
import { resolveSiblings } from "./lib/cliSiblings.ts"
|
|
24
24
|
import { execSshCommand } from "./lib/sshExec.ts"
|
|
25
25
|
import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
|
|
26
|
-
import {
|
|
26
|
+
import { runOffline, runOnline } from "./lib/cliOffline.ts"
|
|
27
27
|
|
|
28
28
|
const args = process.argv.slice(2)
|
|
29
29
|
const positionals = args.filter((a) => !a.startsWith("-"))
|
|
@@ -90,17 +90,18 @@ Usage:
|
|
|
90
90
|
Import production's database into the linked local copy,
|
|
91
91
|
rewriting URLs. OVERWRITES your local database: needs
|
|
92
92
|
both --yes and "localSync": true in the config.
|
|
93
|
-
spinuptui
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
spinuptui
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
spinuptui
|
|
103
|
-
|
|
93
|
+
spinuptui offline List servers with sites offline (--json)
|
|
94
|
+
spinuptui offline <server>… | --all --yes
|
|
95
|
+
Take every site on those servers offline: nginx stops
|
|
96
|
+
answering, so no request reaches PHP. Files, databases
|
|
97
|
+
and certificates are untouched. Without --yes, prints
|
|
98
|
+
the plan and changes nothing.
|
|
99
|
+
spinuptui offline --site <domain> [--server <name>] --yes
|
|
100
|
+
Just that site. Its server's other sites (and PHP) stay
|
|
101
|
+
up, so this protects the site, not the server.
|
|
102
|
+
spinuptui online <server>… | --all | --site <domain> --yes
|
|
103
|
+
Bring back exactly the sites \`offline\` took down.
|
|
104
|
+
Servers are matched by exact name, IP or id.
|
|
104
105
|
spinuptui --version Print the version
|
|
105
106
|
spinuptui --help Show this help
|
|
106
107
|
|
|
@@ -284,12 +285,12 @@ if (command === "pull") {
|
|
|
284
285
|
process.exit(result.ok ? 0 : 1)
|
|
285
286
|
}
|
|
286
287
|
|
|
287
|
-
if (command === "
|
|
288
|
-
process.exit(await
|
|
288
|
+
if (command === "offline") {
|
|
289
|
+
process.exit(await runOffline(args.slice(args.indexOf("offline") + 1)))
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
if (command === "
|
|
292
|
-
process.exit(await
|
|
292
|
+
if (command === "online") {
|
|
293
|
+
process.exit(await runOnline(args.slice(args.indexOf("online") + 1)))
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
// `spinuptui login` forces the onboarding wizard even when a token already exists,
|
package/src/lib/appUpdate.ts
CHANGED
|
@@ -35,47 +35,21 @@ function cachePath(): string {
|
|
|
35
35
|
return join(configDir(), "update-check.json")
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Parse "v1.2.3" / "1.2.3"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// SemVer pre-release precedence: dot-separated identifiers compared left to
|
|
46
|
-
// right, numeric ones numerically and below alphanumeric ones; a shorter set of
|
|
47
|
-
// otherwise-equal identifiers sorts first.
|
|
48
|
-
function comparePre(a: string, b: string): number {
|
|
49
|
-
const x = a.split(".")
|
|
50
|
-
const y = b.split(".")
|
|
51
|
-
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
52
|
-
if (x[i] === undefined) return -1
|
|
53
|
-
if (y[i] === undefined) return 1
|
|
54
|
-
const nx = /^\d+$/.test(x[i]!)
|
|
55
|
-
const ny = /^\d+$/.test(y[i]!)
|
|
56
|
-
if (nx && ny) {
|
|
57
|
-
const d = Number(x[i]) - Number(y[i])
|
|
58
|
-
if (d) return d
|
|
59
|
-
} else if (nx !== ny) return nx ? -1 : 1
|
|
60
|
-
else if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1
|
|
61
|
-
}
|
|
62
|
-
return 0
|
|
38
|
+
// Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when it doesn't look like a version.
|
|
39
|
+
function parseVersion(s: string): [number, number, number] | null {
|
|
40
|
+
const m = s.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
41
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
|
63
42
|
}
|
|
64
43
|
|
|
65
44
|
// Strictly-newer comparison (latest > current). Unparseable → false (never nag).
|
|
66
|
-
|
|
67
|
-
// when 1.2.0 ships.
|
|
68
|
-
export function isNewer(latest: string, current: string): boolean {
|
|
45
|
+
function isNewer(latest: string, current: string): boolean {
|
|
69
46
|
const a = parseVersion(latest)
|
|
70
47
|
const b = parseVersion(current)
|
|
71
48
|
if (!a || !b) return false
|
|
72
49
|
for (let i = 0; i < 3; i++) {
|
|
73
|
-
if (a
|
|
50
|
+
if (a[i] !== b[i]) return a[i] > b[i]
|
|
74
51
|
}
|
|
75
|
-
|
|
76
|
-
if (a.pre === null) return true
|
|
77
|
-
if (b.pre === null) return false
|
|
78
|
-
return comparePre(a.pre, b.pre) > 0
|
|
52
|
+
return false
|
|
79
53
|
}
|
|
80
54
|
|
|
81
55
|
function readCache(): CacheFile | null {
|