spinuptui 1.2.0 → 1.2.1

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,12 @@ version; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.1] - 2026-10-27
15
+
16
+ ### Fixed
17
+ - **WordPress versions no longer go stale.** The version shown for a site was read once, when the site was first identified, and never again, so sites updated outside SpinupTUI (auto-updates, wp-admin, deploys) kept showing the old version, and `r` didn't help. Versions older than a day are now re-checked in the background when the site list loads, one site at a time per server, stopping at a server's first failure so unattended SSH can't trip fail2ban. A reading that couldn't be refreshed shows its age in the Stacks list, and the Details pane always says when it was taken.
18
+ - **`w` opens the site or server you picked, not the SpinupWP dashboard.** Deep links need the account slug, which SpinupWP's API doesn't provide and first-run setup never asked for, so unless you had added `accountSlug` to `config.json` by hand, `w` quietly fell back to the dashboard. Now `w` asks for the slug the first time it needs it (pasting any dashboard URL works), saves it to the current account and opens the right page. First-run setup also asks for it, as an optional step after the token.
19
+
14
20
  ## [1.2.0] - 2026-10-21
15
21
 
16
22
  ### Added
@@ -1490,7 +1496,8 @@ Initial tagged release.
1490
1496
  ### Notes
1491
1497
  - Read-only release: works with a SpinupWP **Read Only** API token.
1492
1498
 
1493
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...HEAD
1499
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.1...HEAD
1500
+ [1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
1494
1501
  [1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
1495
1502
  [1.1.2]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...v1.1.2
1496
1503
  [1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
package/README.md CHANGED
@@ -305,8 +305,9 @@ These can be set in `config.json` or via an environment variable:
305
305
  - **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
306
306
  (the first path segment in a SpinupWP URL, e.g. `acme-agency` in
307
307
  `https://spinupwp.app/acme-agency/servers/12345`). The API
308
- doesn't expose it, so set it to enable the `w` deep links into the web app.
309
- Without it, `w` opens the SpinupWP dashboard root.
308
+ doesn't expose it, so it's needed for the `w` deep links into the web app.
309
+ You don't have to set it by hand: first-run setup asks for it (optional), and
310
+ `w` asks the first time it's needed, then saves it to the current account.
310
311
  - **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
311
312
  and stack probes (see "Server health" below).
312
313
  - **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseAccountSlug, siteWebUrl } from "./spinupweb.ts"
3
+
4
+ describe("parseAccountSlug", () => {
5
+ test("a bare slug", () => {
6
+ expect(parseAccountSlug(" acme-agency ")).toBe("acme-agency")
7
+ })
8
+ test("a whole dashboard URL", () => {
9
+ expect(parseAccountSlug("https://spinupwp.app/acme-agency/sites/123#git")).toBe("acme-agency")
10
+ expect(parseAccountSlug("spinupwp.app/acme-agency")).toBe("acme-agency")
11
+ expect(parseAccountSlug("https://SpinupWP.app/acme-agency/?tab=servers")).toBe("acme-agency")
12
+ })
13
+ test("anything that isn't a slug", () => {
14
+ expect(parseAccountSlug("")).toBeNull()
15
+ expect(parseAccountSlug("https://spinupwp.app/")).toBeNull()
16
+ expect(parseAccountSlug("acme agency")).toBeNull()
17
+ expect(parseAccountSlug("../etc")).toBeNull()
18
+ })
19
+ test("feeds the deep link", () => {
20
+ expect(siteWebUrl(42, parseAccountSlug("https://spinupwp.app/acme-agency/sites"))).toBe("https://spinupwp.app/acme-agency/sites/42")
21
+ })
22
+ })
@@ -12,6 +12,17 @@
12
12
 
13
13
  const WEB_BASE = "https://spinupwp.app"
14
14
 
15
+ // Read an account slug from what a user types: the slug itself, or any dashboard
16
+ // URL pasted whole ("https://spinupwp.app/acme-agency/sites/123" → "acme-agency").
17
+ // Null when it doesn't look like a slug.
18
+ export function parseAccountSlug(input: string): string | null {
19
+ let s = input.trim()
20
+ const at = s.toLowerCase().indexOf("spinupwp.app/")
21
+ if (at >= 0) s = s.slice(at + "spinupwp.app/".length)
22
+ s = s.replace(/^\/+/, "").split(/[/?#]/)[0] ?? ""
23
+ return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(s) ? s : null
24
+ }
25
+
15
26
  export function serverWebUrl(id: number, accountSlug: string | null): string {
16
27
  return accountSlug ? `${WEB_BASE}/${accountSlug}/servers/${id}` : WEB_BASE
17
28
  }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { isRecheckDue, isVersionOld, VERSION_MAX_AGE_MS, type CachedProbe } from "./stackCache.ts"
3
+
4
+ const NOW = 1_800_000_000_000
5
+
6
+ function entry(version: string | null, ageMs: number): CachedProbe {
7
+ return {
8
+ result: { kind: version ? "wordpress" : "whmcs", app: "x", version, label: "x" },
9
+ detectedAt: NOW - ageMs,
10
+ signature: "true:",
11
+ }
12
+ }
13
+
14
+ describe("isVersionOld", () => {
15
+ test("a fresh version reading isn't old", () => {
16
+ expect(isVersionOld(entry("7.1.3", 60_000), NOW)).toBe(false)
17
+ })
18
+ test("a version reading past the max age is old", () => {
19
+ expect(isVersionOld(entry("7.0", VERSION_MAX_AGE_MS + 1), NOW)).toBe(true)
20
+ })
21
+ test("an identity without a version never goes old", () => {
22
+ expect(isVersionOld(entry(null, 365 * VERSION_MAX_AGE_MS), NOW)).toBe(false)
23
+ })
24
+ test("no entry isn't old", () => {
25
+ expect(isVersionOld(undefined, NOW)).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe("isRecheckDue", () => {
30
+ const old = entry("7.0", 3 * VERSION_MAX_AGE_MS)
31
+ test("an old version with no attempt yet is due", () => {
32
+ expect(isRecheckDue(old, NOW)).toBe(true)
33
+ })
34
+ test("an attempt inside the window (even a failed one) holds it off", () => {
35
+ expect(isRecheckDue({ ...old, recheckedAt: NOW - 60_000 }, NOW)).toBe(false)
36
+ })
37
+ test("an attempt from a previous window doesn't", () => {
38
+ expect(isRecheckDue({ ...old, recheckedAt: NOW - VERSION_MAX_AGE_MS - 1 }, NOW)).toBe(true)
39
+ })
40
+ test("a fresh reading is never due", () => {
41
+ expect(isRecheckDue(entry("7.1.3", 60_000), NOW)).toBe(false)
42
+ })
43
+ })
@@ -5,7 +5,8 @@
5
5
  // - set() is called after a successful `d` probe; it upserts the entry and
6
6
  // writes the whole file back immediately (write-through), so a probe
7
7
  // survives even if the TUI is killed with q / Ctrl+C.
8
- // Probing itself is always on-demand and per-site — this cache exists to avoid
8
+ // Probing is on-demand and per-site, with one exception: a WP version older than
9
+ // VERSION_MAX_AGE_MS is re-probed in the background. This cache exists to avoid
9
10
  // repeating the slow SSH round-trips across launches, not to save RAM. It's a
10
11
  // deliberately small, reusable pattern for other expensive derived data later.
11
12
 
@@ -21,6 +22,10 @@ export interface CachedProbe {
21
22
  result: ProbeResult
22
23
  detectedAt: number // epoch ms
23
24
  signature: string // invalidation key (see siteSignature)
25
+ // Last automatic re-check of an old version (epoch ms), kept even when it
26
+ // failed, so the once-per-window limit holds across launches. A successful
27
+ // probe replaces the whole entry, which clears it.
28
+ recheckedAt?: number
24
29
  }
25
30
 
26
31
  interface CacheFile {
@@ -32,6 +37,25 @@ export function stackCachePath(dir = profileDataDir()): string {
32
37
  return join(dir, "stack-cache.json")
33
38
  }
34
39
 
40
+ // A WordPress version is a fact about the server that changes without us: auto-
41
+ // updates, wp-admin and deploys all move it, and none of them touch the site's
42
+ // shape below. So a reading that carries a version is re-checked once it's older
43
+ // than this (in the background, when the site list loads), and its age is shown.
44
+ export const VERSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
45
+
46
+ // True when the entry carries a WP version read longer ago than the max age.
47
+ // Identities without a version (WHMCS, Laravel, static) don't drift this way.
48
+ export function isVersionOld(entry: CachedProbe | undefined, now = Date.now()): boolean {
49
+ return entry?.result.version != null && now - entry.detectedAt > VERSION_MAX_AGE_MS
50
+ }
51
+
52
+ // True when an old version is due an automatic re-check: none was attempted in
53
+ // the current window. Unattended SSH that fails must not repeat on every launch.
54
+ export function isRecheckDue(entry: CachedProbe | undefined, now = Date.now()): boolean {
55
+ if (!entry || !isVersionOld(entry, now)) return false
56
+ return entry.recheckedAt == null || now - entry.recheckedAt > VERSION_MAX_AGE_MS
57
+ }
58
+
35
59
  // A cached probe is considered stale when the site's relevant shape changes.
36
60
  // Keying on is_wordpress + public_folder catches the cases that would alter the
37
61
  // probe's target paths or expected result.
@@ -83,6 +107,16 @@ export class StackCache {
83
107
  await this.persist()
84
108
  }
85
109
 
110
+ // Record an automatic re-check attempt on existing entries, in one write.
111
+ async noteRecheck(siteIds: number[]): Promise<void> {
112
+ const now = Date.now()
113
+ for (const id of siteIds) {
114
+ const e = this.entries.get(id)
115
+ if (e) this.entries.set(id, { ...e, recheckedAt: now })
116
+ }
117
+ await this.persist()
118
+ }
119
+
86
120
  // A copy of all entries (for rendering aggregates without exposing the map).
87
121
  snapshot(): Map<number, CachedProbe> {
88
122
  return new Map(this.entries)
package/src/ui/App.tsx CHANGED
@@ -19,6 +19,7 @@ import { SiteUpdate } from "./views/SiteUpdate.tsx"
19
19
  import { BulkWpUpdate } from "./views/BulkWpUpdate.tsx"
20
20
  import { HttpsToggle } from "./views/HttpsToggle.tsx"
21
21
  import { PurgeCache } from "./views/PurgeCache.tsx"
22
+ import { AccountSlugPrompt } from "./views/AccountSlugPrompt.tsx"
22
23
  import { SiteOffline } from "./views/SiteOffline.tsx"
23
24
  import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
24
25
  import { GrantKey } from "./views/GrantKey.tsx"
@@ -89,7 +90,8 @@ export function App() {
89
90
  store.dnsInventoryServer !== null ||
90
91
  store.connectZoneTarget !== null ||
91
92
  store.dnsRecordsTarget !== null ||
92
- store.kumaSite !== null
93
+ store.kumaSite !== null ||
94
+ store.slugPrompt !== null
93
95
  useEffect(() => {
94
96
  store.setOverlayOpen(overlayActive)
95
97
  }, [overlayActive, store])
@@ -127,6 +129,9 @@ export function App() {
127
129
  // The Accounts overlay owns the keyboard while open.
128
130
  if (showAccounts) return
129
131
 
132
+ // The account-slug prompt (from `w`) owns the keyboard while open.
133
+ if (store.slugPrompt) return
134
+
130
135
  // The release-notes overlay owns the keyboard while open (any key dismisses).
131
136
  if (store.releaseNotesInfo) return
132
137
 
@@ -290,6 +295,7 @@ export function App() {
290
295
  {store.dnsRecordsTarget && <DnsRecords />}
291
296
  {store.kumaSite && <KumaSite />}
292
297
  {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
298
+ {store.slugPrompt && <AccountSlugPrompt />}
293
299
  <ToastStack />
294
300
  </box>
295
301
  )
@@ -180,7 +180,13 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
180
180
  <Field label="Stack" value={stack} valueColor={stackColor(stack)} />
181
181
  <Field
182
182
  label="Detected"
183
- value={probing ? "identifying…" : probe ? probe.result.label + (isProbeStale(site) ? " (stale)" : "") : "not identified · d"}
183
+ value={
184
+ probing
185
+ ? "identifying…"
186
+ : probe
187
+ ? `${probe.result.label}${isProbeStale(site) ? " (stale)" : ""} · ${timeAgo(new Date(probe.detectedAt).toISOString())}`
188
+ : "not identified · d"
189
+ }
184
190
  valueColor={probing ? theme.textDim : probe ? probeKindColor(probe.result.kind) : theme.textFaint}
185
191
  />
186
192
  <Field label="Type" value={isWordPress ? "WordPress" : "Generic"} valueColor={isWordPress ? theme.brand : theme.textDim} />
@@ -2,20 +2,25 @@
2
2
  //
3
3
  // Shown when no token is found in the environment or config file. Collects an
4
4
  // API token, validates it against the live API, persists it to ~/.config, then
5
- // hands control back to the app.
5
+ // asks (optionally) for the account slug that `w` deep links need — the API
6
+ // can't tell us — and hands control back to the app.
6
7
 
7
8
  import { useState } from "react"
8
9
  import { theme } from "../lib/theme.ts"
9
10
  import { Spinner, SecretInput } from "./components.tsx"
10
11
  import { SpinupWPClient } from "../api/client.ts"
11
12
  import { DEFAULT_BASE_URL, saveConfig, configPath } from "../config.ts"
13
+ import { parseAccountSlug } from "../lib/spinupweb.ts"
12
14
 
13
- type Phase = "input" | "validating" | "error"
15
+ type Phase = "input" | "validating" | "error" | "slug"
16
+
17
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
14
18
 
15
19
  export function Onboarding({ onComplete }: { onComplete: () => void }) {
16
20
  const [value, setValue] = useState("")
17
21
  const [phase, setPhase] = useState<Phase>("input")
18
22
  const [error, setError] = useState<string | null>(null)
23
+ const [slug, setSlug] = useState("")
19
24
 
20
25
  async function submit(token: string) {
21
26
  const trimmed = token.trim()
@@ -26,13 +31,76 @@ export function Onboarding({ onComplete }: { onComplete: () => void }) {
26
31
  const result = await client.validateToken()
27
32
  if (result.ok) {
28
33
  await saveConfig({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
29
- onComplete()
34
+ setPhase("slug")
30
35
  } else {
31
36
  setError(result.reason)
32
37
  setPhase("error")
33
38
  }
34
39
  }
35
40
 
41
+ // Optional: empty skips (an existing slug, e.g. on `spinuptui login`, is kept).
42
+ async function submitSlug() {
43
+ if (!slug.trim()) return onComplete()
44
+ const parsed = parseAccountSlug(slug)
45
+ if (!parsed) {
46
+ setError("That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL.")
47
+ return
48
+ }
49
+ await saveConfig({ accountSlug: parsed })
50
+ onComplete()
51
+ }
52
+
53
+ if (phase === "slug") {
54
+ return (
55
+ <box
56
+ style={{
57
+ width: "100%",
58
+ height: "100%",
59
+ flexDirection: "column",
60
+ justifyContent: "center",
61
+ alignItems: "center",
62
+ backgroundColor: theme.bg,
63
+ }}
64
+ >
65
+ <ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
66
+ <box style={{ height: 1 }} />
67
+ <box
68
+ title=" Almost done "
69
+ titleColor={theme.brand}
70
+ border
71
+ borderColor={theme.border}
72
+ style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
73
+ >
74
+ <text content="✓ Token works." fg={theme.good} />
75
+ <box style={{ height: 1 }} />
76
+ <text content="One more (optional): your SpinupWP account slug, the part" fg={theme.text} wrapMode="none" />
77
+ <text content="after spinupwp.app/ in your dashboard's address. With it, w" fg={theme.text} wrapMode="none" />
78
+ <text content="opens a site or server straight in SpinupWP." fg={theme.text} wrapMode="none" />
79
+ <text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
80
+ <box style={{ height: 1 }} />
81
+ <text content="Account slug" fg={theme.accent} />
82
+ <input
83
+ focused
84
+ value={slug}
85
+ placeholder="acme-agency"
86
+ onInput={(v: string) => {
87
+ setSlug(v)
88
+ setError(null)
89
+ }}
90
+ onSubmit={() => void submitSlug()}
91
+ style={INPUT_STYLE}
92
+ />
93
+ <box style={{ height: 1 }} />
94
+ {error ? (
95
+ <text content={"✕ " + error} fg={theme.bad} wrapMode="none" />
96
+ ) : (
97
+ <text content="Press Enter to finish. Leave it empty to skip; w will ask later." fg={theme.textFaint} wrapMode="none" />
98
+ )}
99
+ </box>
100
+ </box>
101
+ )
102
+ }
103
+
36
104
  return (
37
105
  <box
38
106
  style={{
package/src/ui/store.tsx CHANGED
@@ -51,7 +51,7 @@ import { carrySiteSettings, type SettingsCarryResult } from "../lib/cloneSetting
51
51
  import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
52
52
  import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
53
53
  import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
54
- import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
54
+ import { StackCache, siteSignature, isRecheckDue, type CachedProbe } from "../lib/stackCache.ts"
55
55
  import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
56
56
  import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
57
57
  import { wpCoreBusy } from "./wpCoreJobs.tsx"
@@ -819,6 +819,14 @@ interface StoreValue extends DataState {
819
819
  enableLocalSync: () => void
820
820
  // SpinupWP account slug (from env/config) for building web deep links.
821
821
  accountSlug: string | null
822
+ // Open a page in the SpinupWP web app. Deep links need the account slug, which
823
+ // the API doesn't expose; with none saved this asks for it (slugPrompt) and
824
+ // opens the page once it's given, rather than landing on the dashboard root.
825
+ // Returns true when the page opened straight away.
826
+ openInSpinupWP: (build: (slug: string) => string) => boolean
827
+ slugPrompt: { build: (slug: string) => string } | null
828
+ setSlugPrompt: (p: { build: (slug: string) => string } | null) => void
829
+ saveAccountSlug: (slug: string) => void
822
830
  // The account (profile) this store is mounted for — see config.ts "Accounts".
823
831
  // Switching remounts the whole store, so these never change under a view.
824
832
  profileId: string
@@ -1032,6 +1040,24 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1032
1040
  })
1033
1041
  }, [])
1034
1042
  const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
1043
+ const [accountSlug, setAccountSlug] = useState(cfgRef.current.accountSlug)
1044
+ const [slugPrompt, setSlugPrompt] = useState<{ build: (slug: string) => string } | null>(null)
1045
+ const saveAccountSlug = useCallback((slug: string) => {
1046
+ cfgRef.current.accountSlug = slug
1047
+ setAccountSlug(slug)
1048
+ void saveProfileConfig({ accountSlug: slug })
1049
+ }, [])
1050
+ const openInSpinupWP = useCallback(
1051
+ (build: (slug: string) => string) => {
1052
+ if (accountSlug) {
1053
+ openUrl(build(accountSlug))
1054
+ return true
1055
+ }
1056
+ setSlugPrompt({ build })
1057
+ return false
1058
+ },
1059
+ [accountSlug],
1060
+ )
1035
1061
  const [route, setRoute] = useState<Route>("dashboard")
1036
1062
  const [inputMode, setInputMode] = useState(false)
1037
1063
  const [overlayOpen, setOverlayOpen] = useState(false)
@@ -1250,7 +1276,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1250
1276
  }, [])
1251
1277
 
1252
1278
  const probeOne = useCallback(
1253
- async (site: Site) => {
1279
+ async (site: Site): Promise<boolean> => {
1254
1280
  const cache = cacheRef.current!
1255
1281
  setProbingIds((prev) => new Set(prev).add(site.id))
1256
1282
  setProbeErrors((prev) => {
@@ -1272,6 +1298,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1272
1298
  next.delete(site.id)
1273
1299
  return next
1274
1300
  })
1301
+ return outcome.ok
1275
1302
  },
1276
1303
  [servers],
1277
1304
  )
@@ -1305,6 +1332,41 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1305
1332
 
1306
1333
  const isProbeStale = useCallback((site: Site) => cacheRef.current!.isStale(site.id, siteSignature(site)), [])
1307
1334
 
1335
+ // Re-check WordPress versions that have gone old, whenever the site list loads
1336
+ // (launch, and every `r`). Only sites already probed successfully: an unprobed
1337
+ // site's stack is the user's call (`d`/`D`), and it may not be reachable at all.
1338
+ // Each site gets at most one automatic attempt per max-age window, recorded in
1339
+ // the cache so it holds across launches: a site whose probe now fails keeps its
1340
+ // old reading (shown with its age) instead of being retried on every launch or
1341
+ // refresh. Dev Mode's servers aren't real.
1342
+ //
1343
+ // Unattended SSH must not trip fail2ban, which bans the source IP after a few
1344
+ // failed logins on one server. So each server's sites go one at a time, and the
1345
+ // first failure on a server ends that server's run for this window: at most one
1346
+ // failed login per server per day. Servers run in parallel, a few at a time.
1347
+ useEffect(() => {
1348
+ if (isDevMode() || servers.length === 0) return
1349
+ const cache = cacheRef.current!
1350
+ const now = Date.now()
1351
+ const due = sites.filter((s) => isRecheckDue(cache.get(s.id), now))
1352
+ if (due.length === 0) return
1353
+ // Marked up front, all of them: a reload mid-run must not queue these again,
1354
+ // and a server's sites after its first failure are skipped for the window too.
1355
+ void cache.noteRecheck(due.map((s) => s.id))
1356
+ const byServer = new Map<number, Site[]>()
1357
+ for (const s of due) byServer.set(s.server_id, [...(byServer.get(s.server_id) ?? []), s])
1358
+ const queues = [...byServer.values()]
1359
+ let cursor = 0
1360
+ const worker = async () => {
1361
+ while (cursor < queues.length) {
1362
+ for (const site of queues[cursor++]) if (!(await probeOne(site))) break
1363
+ }
1364
+ }
1365
+ for (let i = 0; i < Math.min(3, queues.length); i++) void worker()
1366
+ // probeOne changes identity with `servers`, which reloads together with
1367
+ // `sites` — this should only re-run when the site list itself is reloaded.
1368
+ }, [sites, servers])
1369
+
1308
1370
  const isPhpEol = useCallback((version: string | null | undefined) => isPhpEolWith(version, phpEolDates), [phpEolDates])
1309
1371
  const offeredPhpVersions = useCallback(
1310
1372
  (current?: string | null) => offeredPhpVersionsWith(phpEolDates, current),
@@ -4575,7 +4637,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4575
4637
  enableLocalSyncSite,
4576
4638
  setEnableLocalSyncSite,
4577
4639
  enableLocalSync,
4578
- accountSlug: cfgRef.current.accountSlug,
4640
+ accountSlug,
4641
+ openInSpinupWP,
4642
+ slugPrompt,
4643
+ setSlugPrompt,
4644
+ saveAccountSlug,
4579
4645
  profileId: cfgRef.current.profileId,
4580
4646
  profileLabel,
4581
4647
  setProfileLabel,
@@ -0,0 +1,102 @@
1
+ // Ask for the SpinupWP account slug the first time `w` needs it.
2
+ //
3
+ // Deep links into the web app are account-scoped (spinupwp.app/{slug}/sites/{id})
4
+ // and the API doesn't expose the slug, so without one `w` could only open the
5
+ // dashboard root. Instead of that, `w` opens this prompt: the user pastes the
6
+ // slug (or any dashboard URL), it's saved to the current account, and the page
7
+ // they asked for opens. Every later `w` goes straight there.
8
+
9
+ import { useEffect, useState } from "react"
10
+ import { useKeyboard } from "@opentui/react"
11
+ import { theme } from "../../lib/theme.ts"
12
+ import { openUrl } from "../../lib/open.ts"
13
+ import { parseAccountSlug } from "../../lib/spinupweb.ts"
14
+ import { Panel, Centered } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+ import { toast } from "../toast.ts"
18
+
19
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
20
+
21
+ export function AccountSlugPrompt() {
22
+ const { slugPrompt, setSlugPrompt, saveAccountSlug, setInputMode } = useStore()
23
+ const [value, setValue] = useState("")
24
+ const [error, setError] = useState<string | null>(null)
25
+
26
+ // The input owns the keyboard while this is open.
27
+ useEffect(() => {
28
+ setInputMode(true)
29
+ return () => setInputMode(false)
30
+ }, [setInputMode])
31
+
32
+ const close = () => setSlugPrompt(null)
33
+
34
+ const submit = () => {
35
+ if (!slugPrompt) return
36
+ const slug = parseAccountSlug(value)
37
+ if (!slug) {
38
+ setError(value.trim() ? "That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL." : "Paste your account slug, or Esc to cancel.")
39
+ return
40
+ }
41
+ saveAccountSlug(slug)
42
+ openUrl(slugPrompt.build(slug))
43
+ toast.success(`Saved “${slug}” — opening in SpinupWP…`)
44
+ close()
45
+ }
46
+
47
+ // Esc reaches us even with the input focused (it doesn't consume it).
48
+ useKeyboard((key) => {
49
+ if (key.name === "escape") close()
50
+ })
51
+
52
+ if (!slugPrompt) return null
53
+
54
+ return (
55
+ <box
56
+ style={{
57
+ position: "absolute",
58
+ top: 0,
59
+ left: 0,
60
+ width: "100%",
61
+ height: "100%",
62
+ flexDirection: "column",
63
+ backgroundColor: theme.bg,
64
+ zIndex: 300,
65
+ }}
66
+ >
67
+ <Centered>
68
+ <Panel title=" Open in SpinupWP " active>
69
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
70
+ <text content="To open the right page, SpinupTUI needs your SpinupWP account slug:" fg={theme.text} wrapMode="none" />
71
+ <text content="the part after spinupwp.app/ in your dashboard's address." fg={theme.text} wrapMode="none" />
72
+ <box style={{ height: 1 }} />
73
+ <text content="SpinupWP's API doesn't provide it, so this asks once and saves it." fg={theme.textDim} wrapMode="none" />
74
+ <text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
75
+ <box style={{ height: 1 }} />
76
+ <text content="Account slug" fg={theme.accent} />
77
+ <input
78
+ focused
79
+ value={value}
80
+ placeholder="acme-agency"
81
+ onInput={(v: string) => {
82
+ setValue(v)
83
+ setError(null)
84
+ }}
85
+ onSubmit={submit}
86
+ style={INPUT_STYLE}
87
+ />
88
+ <box style={{ height: 1 }} />
89
+ {error ? <text content={"✕ " + error} fg={theme.bad} wrapMode="none" /> : <text content="Saved to this account in config.json." fg={theme.textFaint} wrapMode="none" />}
90
+ </box>
91
+ </Panel>
92
+ </Centered>
93
+ <StatusBar
94
+ hints={[
95
+ { key: "⏎", label: "save & open" },
96
+ { key: "esc", label: "cancel" },
97
+ ]}
98
+ showGlobal={false}
99
+ />
100
+ </box>
101
+ )
102
+ }
@@ -24,7 +24,7 @@ type Focus = "servers" | "sites"
24
24
 
25
25
  export function Browser({ rows }: { rows: number }) {
26
26
  const store = useStore()
27
- const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, 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
+ const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, openInSpinupWP, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, 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
28
28
 
29
29
  const [serverIndex, setServerIndex] = useState(0)
30
30
  const [siteIndex, setSiteIndex] = useState(0)
@@ -283,11 +283,11 @@ export function Browser({ rows }: { rows: number }) {
283
283
  // Open the focused server/site in the SpinupWP web app — useful for
284
284
  // actions the API can't do (e.g. running a pending server upgrade).
285
285
  if (focus === "sites" && sites[siteIndex]) {
286
- openUrl(siteWebUrl(sites[siteIndex].id, accountSlug))
287
- setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
286
+ const id = sites[siteIndex].id
287
+ if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
288
288
  } else if (server) {
289
- openUrl(serverWebUrl(server.id, accountSlug))
290
- setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
289
+ const id = server.id
290
+ if (openInSpinupWP((slug) => serverWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
291
291
  }
292
292
  setTimeout(() => setFlash(null), 2000)
293
293
  return
@@ -36,7 +36,7 @@ function score(haystack: string, q: string): number | null {
36
36
 
37
37
  export function Search({ rows }: { rows: number }) {
38
38
  const store = useStore()
39
- const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
39
+ const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, openInSpinupWP, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
40
40
  const [query, setQuery] = useState("")
41
41
  const [selected, setSelected] = useState(0)
42
42
  // "query" = typing/filtering (input focused); "actions" = input blurred so the
@@ -101,9 +101,8 @@ export function Search({ rows }: { rows: number }) {
101
101
  openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
102
102
  flashMsg(`Opening ${s.domain}…`)
103
103
  }
104
- const openInSpinup = (url: string) => {
105
- openUrl(url)
106
- flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
104
+ const openInSpinup = (build: (slug: string) => string) => {
105
+ if (openInSpinupWP(build)) flashMsg("Opening in SpinupWP…")
107
106
  }
108
107
 
109
108
  useKeyboard((key) => {
@@ -141,8 +140,8 @@ export function Search({ rows }: { rows: number }) {
141
140
  if (current?.kind === "site") openSite(current.site)
142
141
  return
143
142
  case "w":
144
- if (current?.kind === "site") openInSpinup(siteWebUrl(current.site.id, accountSlug))
145
- else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id, accountSlug))
143
+ if (current?.kind === "site") openInSpinup((slug) => siteWebUrl(current.site.id, slug))
144
+ else if (current?.kind === "server") openInSpinup((slug) => serverWebUrl(current.server.id, slug))
146
145
  return
147
146
  case "u":
148
147
  if (current?.kind === "site") setPhpUpgradeSite(current.site)
@@ -19,7 +19,6 @@ import { Panel, Spinner, Centered } from "../components.tsx"
19
19
  import { StatusBar } from "../StatusBar.tsx"
20
20
  import { useStore } from "../store.tsx"
21
21
  import { moveSelection } from "../List.tsx"
22
- import { openUrl } from "../../lib/open.ts"
23
22
  import { serverWebUrl } from "../../lib/spinupweb.ts"
24
23
  import { toast } from "../toast.ts"
25
24
  import type { RebootInfo } from "../../lib/ssh.ts"
@@ -67,7 +66,7 @@ export function ServerActions() {
67
66
  rebootInfoLoading,
68
67
  rebootInfoErrors,
69
68
  loadRebootInfo,
70
- accountSlug,
69
+ openInSpinupWP,
71
70
  refresh,
72
71
  } = store
73
72
 
@@ -161,8 +160,11 @@ export function ServerActions() {
161
160
  return
162
161
  case "w":
163
162
  if (upgradeRequired && server) {
164
- openUrl(serverWebUrl(server.id, accountSlug))
165
- toast.success(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
163
+ const id = server.id
164
+ if (openInSpinupWP((slug) => serverWebUrl(id, slug))) toast.success("Opening in SpinupWP…")
165
+ // No slug yet: the prompt takes over — close this overlay so its
166
+ // keys don't fire underneath.
167
+ else close()
166
168
  }
167
169
  return
168
170
  }
@@ -16,7 +16,8 @@
16
16
  import { useEffect, useMemo, useRef, useState } from "react"
17
17
  import { useKeyboard } from "@opentui/react"
18
18
  import { theme, statusColor, statusDot } from "../../lib/theme.ts"
19
- import { bar, truncate } from "../../lib/format.ts"
19
+ import { bar, timeAgo, truncate } from "../../lib/format.ts"
20
+ import { isVersionOld } from "../../lib/stackCache.ts"
20
21
  import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
21
22
  import { phpSortKey } from "../../lib/phpEol.ts"
22
23
  import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
@@ -75,7 +76,7 @@ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
75
76
  export function Stacks({ rows }: { rows: number }) {
76
77
  const store = useStore()
77
78
  const wpCoreJobs = useWpCoreJobs()
78
- const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
79
+ const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, openInSpinupWP, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
79
80
  store
80
81
 
81
82
  const [groupIndex, setGroupIndex] = useState(0)
@@ -218,8 +219,8 @@ export function Stacks({ rows }: { rows: number }) {
218
219
  case "w":
219
220
  // Open the selected site in the SpinupWP web app.
220
221
  if (focus === "sites" && groupSites[siteIndex]) {
221
- openUrl(siteWebUrl(groupSites[siteIndex].id, accountSlug))
222
- flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
222
+ const id = groupSites[siteIndex].id
223
+ if (openInSpinupWP((slug) => siteWebUrl(id, slug))) flashMsg("Opening in SpinupWP…")
223
224
  }
224
225
  return
225
226
  case "d":
@@ -421,11 +422,23 @@ export function Stacks({ rows }: { rows: number }) {
421
422
  ) : vanity ? (
422
423
  <text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
423
424
  ) : cached ? (
424
- <text
425
- content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
426
- fg={probeKindColor(cached.result.kind, selected)}
427
- wrapMode="none"
428
- />
425
+ <>
426
+ <text
427
+ content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
428
+ fg={probeKindColor(cached.result.kind, selected)}
429
+ wrapMode="none"
430
+ />
431
+ {/* A version past its re-check age (the background re-probe
432
+ failed or hasn't run) says how old it is, so it can't
433
+ pass for current. */}
434
+ {isVersionOld(cached) && (
435
+ <text
436
+ content={" " + timeAgo(new Date(cached.detectedAt).toISOString()).replace(/ ago$/, "")}
437
+ fg={selected ? theme.text : theme.warn}
438
+ wrapMode="none"
439
+ />
440
+ )}
441
+ </>
429
442
  ) : errored ? (
430
443
  <text content="probe failed" fg={selected ? theme.text : theme.bad} wrapMode="none" />
431
444
  ) : (