spinuptui 0.23.0 → 0.24.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 CHANGED
@@ -11,6 +11,19 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.24.0] - 2026-07-15
15
+
16
+ ### Added
17
+ - **`p` (Pull prod. DB) is now discoverable even when `localSync` is off.**
18
+ Previously the Site Control legend hid the entry entirely until `localSync`
19
+ was enabled in config — the only one of `p`'s three preconditions
20
+ (WordPress, linked copy, localSync) that hid the entry instead of flashing
21
+ a message on press. Now `p` always shows for WordPress sites; pressing it
22
+ with `localSync` off opens an in-app "Enable local sync?" confirm instead.
23
+ Confirming just saves the setting — you press `p` again afterward to
24
+ actually pull, so the destructive action itself stays behind two
25
+ deliberate presses, same as before.
26
+
14
27
  ## [0.23.0] - 2026-07-15
15
28
 
16
29
  ### Added
@@ -1226,7 +1239,8 @@ Initial tagged release.
1226
1239
  ### Notes
1227
1240
  - Read-only release: works with a SpinupWP **Read Only** API token.
1228
1241
 
1229
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.23.0...HEAD
1242
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.24.0...HEAD
1243
+ [0.24.0]: https://github.com/mwender/spinupwp-tui/compare/v0.23.0...v0.24.0
1230
1244
  [0.23.0]: https://github.com/mwender/spinupwp-tui/compare/v0.22.3...v0.23.0
1231
1245
  [0.22.3]: https://github.com/mwender/spinupwp-tui/compare/v0.22.2...v0.22.3
1232
1246
  [0.22.2]: https://github.com/mwender/spinupwp-tui/compare/v0.22.1...v0.22.2
package/README.md CHANGED
@@ -220,6 +220,10 @@ These can be set in `config.json` or via an environment variable:
220
220
  and stack probes (see "Server health" below).
221
221
  - **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
222
222
  local** DB sync (`p`); off by default because it overwrites your local database.
223
+ You don't need to set this by hand: pressing `p` on a WordPress site while
224
+ it's off opens an in-app "Enable local sync?" prompt that flips and saves it
225
+ for you — press `p` again afterward to actually pull. Setting it directly is
226
+ still supported for scripted/first-run setups.
223
227
  **Prefer `"localSync": true` in `config.json`** — it's read from a fixed path
224
228
  (`~/.config/spinupwp-tui/config.json`) so it applies wherever you launch `spinuptui`
225
229
  from. The environment variable works too, but note a `.env` is only loaded when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ui/App.tsx CHANGED
@@ -23,6 +23,7 @@ import { GrantKey } from "./views/GrantKey.tsx"
23
23
  import { SudoConnect } from "./views/SudoConnect.tsx"
24
24
  import { DbBackup } from "./views/DbBackup.tsx"
25
25
  import { DbSync } from "./views/DbSync.tsx"
26
+ import { EnableLocalSync } from "./views/EnableLocalSync.tsx"
26
27
  import { MediaFallback } from "./views/MediaFallback.tsx"
27
28
  import { ServerActions } from "./views/ServerActions.tsx"
28
29
  import { NewServer } from "./views/NewServer.tsx"
@@ -67,6 +68,7 @@ export function App() {
67
68
  store.sudoConnectServer !== null ||
68
69
  store.dbBackupSite !== null ||
69
70
  store.dbSyncSite !== null ||
71
+ store.enableLocalSyncSite !== null ||
70
72
  store.mediaFallbackSite !== null ||
71
73
  store.serverActionsServer !== null ||
72
74
  store.newServerOpen ||
@@ -143,6 +145,9 @@ export function App() {
143
145
  // The DB-sync overlay owns the keyboard while open.
144
146
  if (store.dbSyncSite) return
145
147
 
148
+ // The "enable local sync?" confirm overlay owns the keyboard while open.
149
+ if (store.enableLocalSyncSite) return
150
+
146
151
  // The media-fallback overlay owns the keyboard while open.
147
152
  if (store.mediaFallbackSite) return
148
153
 
@@ -244,6 +249,7 @@ export function App() {
244
249
  {store.sudoConnectServer && <SudoConnect />}
245
250
  {store.dbBackupSite && <DbBackup />}
246
251
  {store.dbSyncSite && <DbSync />}
252
+ {store.enableLocalSyncSite && <EnableLocalSync />}
247
253
  {store.mediaFallbackSite && <MediaFallback />}
248
254
  {store.serverActionsServer && <ServerActions />}
249
255
  {store.newServerOpen && <NewServer />}
@@ -276,11 +276,11 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
276
276
  // Site Control groups — see ServerControl above. Recomputes stack/vanity
277
277
  // itself since it's not a child of SiteDetail; both derivations are cheap.
278
278
  export function SiteControl({ site, serverName, layout }: { site: Site; serverName: string; layout?: "list" | "flow" }) {
279
- const { probes, localSync } = useStore()
279
+ const { probes } = useStore()
280
280
  const stack = effectiveStack(site, probes.get(site.id)?.result.kind)
281
281
  const isWordPress = stack !== "Non-WP"
282
282
  const isVanity = isVanityPair(site.domain, serverName)
283
- return <ControlPanel heading="Site Control" groups={siteGroups(isWordPress, localSync, isVanity)} layout={layout} />
283
+ return <ControlPanel heading="Site Control" groups={siteGroups(isWordPress, isVanity)} layout={layout} />
284
284
  }
285
285
 
286
286
  // DNS zone-host lines for a site's domains, populated on demand (key `n`). Each
@@ -434,7 +434,14 @@ export function ControlPanel({ heading, groups, layout = "list" }: { heading: st
434
434
  // own server's name, per `isVanityPair`) adds the one-off page-refresh action —
435
435
  // vanity pages seeded before this app existed need a way to pick up the current
436
436
  // bundled HTML.
437
- export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: boolean): ActionGroup[] {
437
+ //
438
+ // `p` (Pull prod. DB) is always shown for WordPress sites, even when the
439
+ // localSync config flag is off — pressing it then opens a confirm overlay to
440
+ // enable the flag, rather than hiding the entry entirely. That matches how
441
+ // the OTHER two gates on this same action already behave (is_wordpress and
442
+ // "not linked" both flash on press instead of hiding the entry); localSync
443
+ // used to be the odd one out.
444
+ export function siteGroups(isWordpress: boolean, isVanity: boolean): ActionGroup[] {
438
445
  const remote: [string, string][] = [
439
446
  ["s", "SSH"],
440
447
  ["K", "Grant SSH key"],
@@ -443,7 +450,7 @@ export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: b
443
450
  ]
444
451
  if (isWordpress) {
445
452
  remote.push(["d", "↓ DB backup"])
446
- if (localSync) remote.push(["p", "Pull prod. DB"])
453
+ remote.push(["p", "Pull prod. DB"])
447
454
  remote.push(["e", "Plugins & themes"])
448
455
  }
449
456
  const local: [string, string][] = [
package/src/ui/store.tsx CHANGED
@@ -728,6 +728,12 @@ interface StoreValue extends DataState {
728
728
  sshUser: string | null
729
729
  // Opt-in for the destructive production→local DB sync (`p`); default off.
730
730
  localSync: boolean
731
+ // The site `p` was pressed on while localSync was off — drives the "enable
732
+ // local sync?" confirm overlay. null when the overlay is closed.
733
+ enableLocalSyncSite: Site | null
734
+ setEnableLocalSyncSite: (s: Site | null) => void
735
+ // Persists localSync=true to config.json and flips the reactive flag above.
736
+ enableLocalSync: () => void
731
737
  // SpinupWP account slug (from env/config) for building web deep links.
732
738
  accountSlug: string | null
733
739
  sitesForServer: (serverId: number) => Site[]
@@ -997,6 +1003,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
997
1003
  const [dbBackups, setDbBackups] = useState<Map<number, DbBackupProgress>>(new Map())
998
1004
  const [dbSyncSite, setDbSyncSite] = useState<Site | null>(null)
999
1005
  const [dbSyncs, setDbSyncs] = useState<Map<number, DbSyncProgress>>(new Map())
1006
+ const [localSync, setLocalSyncState] = useState<boolean>(() => cfgRef.current.localSync)
1007
+ const [enableLocalSyncSite, setEnableLocalSyncSite] = useState<Site | null>(null)
1000
1008
  const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
1001
1009
  const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
1002
1010
  const [discoverOpen, setDiscoverOpen] = useState(false)
@@ -1728,6 +1736,12 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1728
1736
  })
1729
1737
  }, [])
1730
1738
 
1739
+ const enableLocalSync = useCallback(() => {
1740
+ cfgRef.current.localSync = true
1741
+ void saveConfig({ localSync: true })
1742
+ setLocalSyncState(true)
1743
+ }, [])
1744
+
1731
1745
  const openLocalTerminal = useCallback(
1732
1746
  (siteId: number) => {
1733
1747
  const link = localLinks.get(siteId)
@@ -4326,7 +4340,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4326
4340
  rebootInfoErrors,
4327
4341
  loadRebootInfo,
4328
4342
  sshUser: cfgRef.current.sshUser,
4329
- localSync: cfgRef.current.localSync,
4343
+ localSync,
4344
+ enableLocalSyncSite,
4345
+ setEnableLocalSyncSite,
4346
+ enableLocalSync,
4330
4347
  accountSlug: cfgRef.current.accountSlug,
4331
4348
  sitesForServer,
4332
4349
  serverById,
@@ -23,7 +23,7 @@ type Focus = "servers" | "sites"
23
23
 
24
24
  export function Browser({ rows }: { rows: number }) {
25
25
  const store = useStore()
26
- const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite } = store
26
+ const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
27
27
 
28
28
  const [serverIndex, setServerIndex] = useState(0)
29
29
  const [siteIndex, setSiteIndex] = useState(0)
@@ -203,11 +203,14 @@ export function Browser({ rows }: { rows: number }) {
203
203
  // Pull production → local (opt-in, overwrites the local DB): needs a
204
204
  // WordPress site, localSync enabled, and a linked local copy. Same
205
205
  // gate as Search's `p` (Search.tsx) so the Site Control legend's
206
- // "Pull prod. DB" label is accurate here too.
206
+ // "Pull prod. DB" label is accurate here too. A localSync-off site
207
+ // opens a confirm overlay to enable it (rather than a dead-end flash)
208
+ // since it's now always shown in the legend, not hidden until enabled.
207
209
  const s = focus === "sites" ? sites[siteIndex] : undefined
208
210
  if (!s) return
209
211
  if (!localSync) {
210
- setFlash('Local sync is off — set "localSync": true in config to enable')
212
+ setEnableLocalSyncSite(s)
213
+ return
211
214
  } else if (!s.is_wordpress) {
212
215
  setFlash("Sync needs WordPress (wp-cli) — this isn't a WP site")
213
216
  } else if (!localLinks.has(s.id)) {
@@ -0,0 +1,88 @@
1
+ // "Enable local sync?" confirm overlay — opened when `p` (Pull prod. DB) is
2
+ // pressed on a site while the localSync config flag is off. localSync is a
3
+ // deliberate opt-in (pulling production overwrites the local database), so
4
+ // confirming here only flips the flag and saves it — it does NOT chain into
5
+ // the pull itself. The user presses `p` again afterward to actually run it,
6
+ // which still has its own confirm-before-firing step. Keeps the destructive
7
+ // action behind two deliberate presses, same safety margin as before, while
8
+ // making the feature discoverable instead of a silent config-file-only switch.
9
+
10
+ import { useState } from "react"
11
+ import { useKeyboard } from "@opentui/react"
12
+ import { theme } from "../../lib/theme.ts"
13
+ import { truncate } from "../../lib/format.ts"
14
+ import { Panel, Centered } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+
18
+ type Phase = "confirm" | "done"
19
+
20
+ export function EnableLocalSync() {
21
+ const { enableLocalSyncSite: site, setEnableLocalSyncSite, enableLocalSync } = useStore()
22
+ const [phase, setPhase] = useState<Phase>("confirm")
23
+
24
+ const close = () => setEnableLocalSyncSite(null)
25
+
26
+ useKeyboard((key) => {
27
+ const name = key.name ?? ""
28
+ if (phase === "confirm") {
29
+ if (name === "y") {
30
+ enableLocalSync()
31
+ setPhase("done")
32
+ return
33
+ }
34
+ if (name === "escape" || name === "q") return close()
35
+ return
36
+ }
37
+ // "done" — any key closes.
38
+ return close()
39
+ })
40
+
41
+ if (!site) return null
42
+
43
+ return (
44
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
45
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
46
+ <text content="↓ Local sync " fg={theme.brand} style={{ flexShrink: 0 }} />
47
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
48
+ </box>
49
+
50
+ <Centered>{renderBody()}</Centered>
51
+
52
+ <StatusBar hints={phase === "confirm" ? [{ key: "y", label: "enable" }, { key: "esc", label: "cancel" }] : [{ key: "any", label: "close" }]} showGlobal={false} />
53
+ </box>
54
+ )
55
+
56
+ function renderBody() {
57
+ if (phase === "confirm") {
58
+ return (
59
+ <Panel title=" Enable local sync? " active>
60
+ <box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
61
+ <text content="Local sync is off — it's opt-in because pulling production" fg={theme.text} wrapMode="none" />
62
+ <text content="overwrites your LOCAL database." fg={theme.text} wrapMode="none" />
63
+ <box style={{ height: 1 }} />
64
+ <text content="Enabling it just flips the setting — it won't pull anything" fg={theme.textDim} wrapMode="none" />
65
+ <text content="yet. Press p again afterward to actually pull production." fg={theme.textDim} wrapMode="none" />
66
+ <box style={{ height: 1 }} />
67
+ <text content="Press y to enable · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
68
+ </box>
69
+ </Panel>
70
+ )
71
+ }
72
+
73
+ return (
74
+ <Panel title=" Done " active>
75
+ <box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
76
+ <box style={{ flexDirection: "row" }}>
77
+ <text content="✓ " fg={theme.good} />
78
+ <text content="Local sync enabled" fg={theme.text} wrapMode="none" />
79
+ </box>
80
+ <box style={{ height: 1 }} />
81
+ <text content="Press p again to pull production into your local copy." fg={theme.textDim} wrapMode="none" />
82
+ <box style={{ height: 1 }} />
83
+ <text content="Press any key to close" fg={theme.textFaint} />
84
+ </box>
85
+ </Panel>
86
+ )
87
+ }
88
+ }
@@ -35,7 +35,7 @@ function score(haystack: string, q: string): number | null {
35
35
 
36
36
  export function Search({ rows }: { rows: number }) {
37
37
  const store = useStore()
38
- const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe } = store
38
+ const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite } = store
39
39
  const [query, setQuery] = useState("")
40
40
  const [selected, setSelected] = useState(0)
41
41
  // "query" = typing/filtering (input focused); "actions" = input blurred so the
@@ -179,9 +179,11 @@ export function Search({ rows }: { rows: number }) {
179
179
  return
180
180
  case "p":
181
181
  // Pull production → local: opt-in (it overwrites the local DB), and needs
182
- // a WordPress site + a local link.
182
+ // a WordPress site + a local link. A localSync-off site opens a confirm
183
+ // overlay to enable it (rather than a dead-end flash) since it's now
184
+ // always shown in the legend, not hidden until enabled.
183
185
  if (current?.kind === "site") {
184
- if (!localSync) flashMsg('Local sync is off — set "localSync": true in config to enable')
186
+ if (!localSync) setEnableLocalSyncSite(current.site)
185
187
  else if (!current.site.is_wordpress) flashMsg("Sync needs WordPress (wp-cli) — this isn't a WP site")
186
188
  else if (!localLinks.has(current.site.id)) flashMsg("Not linked — press L to link a local copy")
187
189
  else setDbSyncSite(current.site)
@@ -379,7 +381,6 @@ export function Search({ rows }: { rows: number }) {
379
381
  <ActionsCard
380
382
  result={current}
381
383
  serverName={current.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current.server.name}
382
- localSync={localSync}
383
384
  />
384
385
  ) : current.kind === "server" ? (
385
386
  <ServerDetail server={current.server} siteCount={store.sitesForServer(current.server.id).length} />
@@ -394,12 +395,12 @@ export function Search({ rows }: { rows: number }) {
394
395
  )
395
396
  }
396
397
 
397
- function ActionsCard({ result, serverName, localSync }: { result: Result; serverName: string; localSync: boolean }) {
398
+ function ActionsCard({ result, serverName }: { result: Result; serverName: string }) {
398
399
  const isSite = result.kind === "site"
399
400
  const name = isSite ? result.site.domain : result.server.name
400
401
  const status = isSite ? result.site.status : result.server.connection_status
401
402
  // Server results reuse the SAME Server Control suite as the Browser detail pane.
402
- const groups = isSite ? siteGroups(result.site.is_wordpress, localSync, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
403
+ const groups = isSite ? siteGroups(result.site.is_wordpress, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
403
404
  return (
404
405
  <box style={{ flexDirection: "column" }}>
405
406
  <box style={{ flexDirection: "row" }}>