spinuptui 1.1.0 → 1.1.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,16 @@ version; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.1.1] - 2026-10-01
15
+
16
+ ### Changed
17
+ - **`spinuptui ssh-exec` allows `wp user list`, `wp user get` and `wp user list-caps`.** The read-only guard used to refuse everything under `wp user`, reads included, which got in the way of checking when an account was registered. Creating, deleting and changing users is still denied.
18
+
19
+ ### Fixed
20
+ - **`spinuptui ssh-exec` no longer lets Redis writes through.** The read-only guard had no patterns for Redis, so `redis-cli CONFIG SET …`, `FLUSHALL`, `FLUSHDB`, `BGSAVE` and plain `SET`/`DEL` all ran as if they were diagnostics. `redis-cli` is now allowed only with a known read — `ping`, `info`, `dbsize`, `lastsave`, `config get`, `memory doctor|stats|usage`, `latency latest|history`, `slowlog get|len`, `client list|info` and a few key-inspection reads — and anything else is denied, including unrecognised options and a bare `redis-cli` fed from stdin. The denial message names the verb or option that was refused.
21
+ - **`spinuptui ssh-exec` no longer lets unlisted WP-CLI writes through.** The guard denied a dozen named WP-CLI writes and allowed every other command, so `wp transient delete`, `wp search-replace`, `wp eval`, `wp plugin uninstall`, `wp cron event run` and more ran as diagnostics. `wp` is now allowed only with a known read (`plugin list`, `option get`, `core version`, `cache type`, `cron event list`, `db size` and the like), and the `--exec`, `--require`, `--ssh` and `--http` flags are refused on any command. Two reads that used to pass are now denied: `wp config get` and `wp config list`, because they print the database password into the audit log.
22
+ - **The update command now actually updates.** The About panel (`?`) now suggests `bun add -g spinuptui@latest --no-cache`. Without `--no-cache`, Bun can answer `@latest` from its cached copy of npm's package list, which right after a release can still name the previous version, so the command reported success and left you where you were.
23
+
14
24
  ## [1.1.0] - 2026-09-30
15
25
 
16
26
  ### Added
@@ -1469,7 +1479,8 @@ Initial tagged release.
1469
1479
  ### Notes
1470
1480
  - Read-only release: works with a SpinupWP **Read Only** API token.
1471
1481
 
1472
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...HEAD
1482
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...HEAD
1483
+ [1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
1473
1484
  [1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
1474
1485
  [1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
1475
1486
  [0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
package/README.md CHANGED
@@ -165,10 +165,14 @@ to the version in the header (and in the `?` About panel).
165
165
 
166
166
  - **Homebrew:** `brew upgrade spinuptui` (the About panel shows the same
167
167
  command).
168
- - **Package install:** `bun add -g spinuptui@latest` (the About panel shows
169
- the same command). Don't use `bun update -g` — while spinuptui is pre-1.0,
170
- it records a caret range (e.g. `^0.22.2`) that excludes every later minor
171
- release, so `update -g` reports success but silently doesn't update.
168
+ - **Package install:** `bun add -g spinuptui@latest --no-cache` (the About
169
+ panel shows the same command). `--no-cache` matters: without it Bun can
170
+ answer `@latest` from its cached copy of the registry, which right after a
171
+ release may still name the previous version — the command reports success
172
+ and installs nothing new. Don't use `bun update -g` either — while spinuptui
173
+ was pre-1.0, it recorded a caret range (e.g. `^0.22.2`) that excludes every
174
+ later minor release, so `update -g` reports success but silently doesn't
175
+ update.
172
176
  - **Source checkout:** press **`u`** in the About panel to update in place
173
177
  (`git pull --ff-only`; refuses if you have uncommitted changes, and never
174
178
  merges/rebases). It can't hot-reload the already-running process, so it tells
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.1.0",
3
+ "version": "1.1.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": {
@@ -162,7 +162,14 @@ export function updateCommand(): string | null {
162
162
  // hits the same wall until the following minor bump. Verified live against a
163
163
  // real stale install (0.22.2 -> 0.24.0): `update -g` exited 0 without updating,
164
164
  // `add -g spinuptui@latest` updated correctly.
165
- export const PACKAGE_UPDATE_CMD = "bun add -g spinuptui@latest"
165
+ //
166
+ // `--no-cache` because Bun answers `@latest` from its own cached copy of the
167
+ // registry metadata when it has one. Right after a release that copy can still
168
+ // name the previous version, so the add "succeeds" without contacting npm and
169
+ // installs nothing new — seen live on 1.1.0's release day: two runs installed
170
+ // 1.0.0 while the registry already served 1.1.0; the same command with
171
+ // `--no-cache` installed 1.1.0 at once.
172
+ export const PACKAGE_UPDATE_CMD = "bun add -g spinuptui@latest --no-cache"
166
173
 
167
174
  // The real checkout directory, resolved through Bun's module system rather
168
175
  // than process.cwd() — `spinuptui` is typically a global symlink invoked from
@@ -8,13 +8,6 @@ const DANGEROUS_PATTERNS: RegExp[] = [
8
8
  /\b(rm|mv|chmod|chown|reboot|shutdown|kill|truncate)\b/i,
9
9
  /systemctl\s+(restart|stop|reload)\b/i,
10
10
  /service\s+\S+\s+restart\b/i,
11
- /wp\s+plugin\s+(activate|deactivate|install|update|delete)\b/i,
12
- /wp\s+theme\s+(activate|delete|install)\b/i,
13
- /wp\s+db\s+(query|import|reset)\b/i,
14
- /wp\s+option\s+(update|delete|add)\b/i,
15
- /wp\s+user\b/i,
16
- /wp\s+core\s+(update|install)\b/i,
17
- /wp\s+(rewrite\s+flush|cache\s+flush)\b/i,
18
11
  /crontab\s+-[re]\b/i,
19
12
  /sed\s+-i\b/i,
20
13
  /\b(DROP\s+TABLE|INSERT\s+INTO|DELETE\s+FROM)\b/i,
@@ -32,12 +25,131 @@ function hasUnsafeRedirect(cmd: string): boolean {
32
25
  return />/.test(stripped)
33
26
  }
34
27
 
28
+ // redis-cli has a verb grammar a deny-list can't enumerate (CONFIG SET, FLUSHALL,
29
+ // CLIENT PAUSE, plain SET/DEL/EXPIRE, …), and generic patterns like /\bset\b/
30
+ // would collide with awk in ordinary log analysis. So it is allowlisted instead:
31
+ // every redis-cli in the command must name a known read, and anything else is
32
+ // denied — an unknown verb, an unknown flag (--pipe, --eval, --rdb, --cluster and
33
+ // --lru-test all write), or no verb at all (bare redis-cli takes commands on stdin).
34
+ // Deliberately left out though read-only: KEYS and MONITOR (both can stall a busy
35
+ // instance) and GET (dumps cached values into the log).
36
+ const REDIS_READ_VERBS = new Set([
37
+ "ping", "info", "dbsize", "lastsave", "time", "role",
38
+ "scan", "type", "ttl", "pttl", "exists", "strlen",
39
+ ])
40
+ const REDIS_READ_SUBVERBS: Record<string, Set<string>> = {
41
+ config: new Set(["get"]),
42
+ memory: new Set(["doctor", "stats", "usage", "malloc-stats"]),
43
+ latency: new Set(["latest", "history", "doctor"]),
44
+ slowlog: new Set(["get", "len"]),
45
+ client: new Set(["list", "info"]),
46
+ }
47
+ const REDIS_VALUE_FLAGS = new Set(["-h", "-p", "-s", "-a", "-n", "-u", "--user", "--pass"])
48
+ const REDIS_BARE_FLAGS = new Set(["--no-auth-warning", "--raw", "--no-raw", "--tls", "-c", "-e", "-2", "-3"])
49
+
50
+ // Shared by the per-tool allowlists below. A segment is what follows the tool
51
+ // name up to the next shell operator.
52
+ const SEGMENT = "([^|;&()`<>\\n]*)"
53
+ // Drop a quote pair around a token, and a stray closing quote left by an
54
+ // enclosing `ssh host '…'` / `bash -c "…"`.
55
+ const clean = (t: string) => t.replace(/^(['"])(.*)\1$/, "$2").replace(/['"]+$/, "").toLowerCase()
56
+ // A token that opens a quote it doesn't close (`-a 'pw ping' flushall`) would
57
+ // shift which later token we read as the verb.
58
+ const hasOpenQuote = (t: string) => /['"]/.test(t) && !/^[^'"]*(['"])[^'"]*\1$/.test(t)
59
+
60
+ // Returns why the command is unsafe, or null. Looks at every `redis-cli` in the
61
+ // string, wherever it appears — including inside `bash -c '…'`, `sudo`, `xargs`,
62
+ // or a grep pattern — so a mention that isn't an invocation (`which redis-cli`)
63
+ // is denied too; that is the price of not having to recognise command position.
64
+ function unsafeRedisCli(cmd: string): string | null {
65
+ for (const m of cmd.matchAll(new RegExp(`\\bredis-cli\\b${SEGMENT}`, "g"))) {
66
+ const tokens = (m[1] ?? "").trim().split(/\s+/).filter(Boolean)
67
+ if (tokens.length === 1 && /^(-v|--version)$/.test(clean(tokens[0]!))) continue
68
+ let i = 0
69
+ for (; i < tokens.length; i++) {
70
+ const flag = clean(tokens[i]!)
71
+ if (!flag.startsWith("-")) break
72
+ if (REDIS_BARE_FLAGS.has(flag)) continue
73
+ if (!REDIS_VALUE_FLAGS.has(flag)) return `redis-cli option \`${flag}\` is not on the read-only allowlist`
74
+ if (hasOpenQuote(tokens[++i] ?? "")) return "redis-cli option value has quoting this guard can't read safely"
75
+ }
76
+ const verb = clean(tokens[i] ?? "")
77
+ if (!verb) return "redis-cli with no command reads commands from stdin"
78
+ if (REDIS_READ_VERBS.has(verb)) continue
79
+ const sub = clean(tokens[i + 1] ?? "")
80
+ if (REDIS_READ_SUBVERBS[verb]?.has(sub)) continue
81
+ return `redis-cli \`${[verb, sub].filter(Boolean).join(" ")}\` is not on the read-only allowlist`
82
+ }
83
+ return null
84
+ }
85
+
86
+ // WP-CLI gets the same treatment: the deny patterns this replaces listed a dozen
87
+ // writes and missed the rest (`transient delete`, `search-replace`, `eval`, …),
88
+ // while blanket-denying reads like `wp user list`. Each entry is a full command
89
+ // path. Left out on purpose: `db query`/`eval`/`shell` (arbitrary SQL or PHP),
90
+ // `db export` (writes a file), `config get|list` (prints the DB password) and
91
+ // `cron test` (spawns a cron run).
92
+ const WP_READ_COMMANDS = new Set([
93
+ "core version", "core is-installed", "core verify-checksums", "core check-update",
94
+ "plugin list", "plugin get", "plugin status", "plugin is-active", "plugin is-installed", "plugin path", "plugin verify-checksums",
95
+ "theme list", "theme get", "theme status", "theme is-active", "theme is-installed", "theme path",
96
+ "option get", "option list", "transient get", "transient list", "cache type",
97
+ "user list", "user get", "user list-caps", "role list", "cap list",
98
+ "post list", "post get", "site list", "rewrite list",
99
+ "cron event list", "cron schedule list",
100
+ "db size", "db tables", "db prefix", "db check",
101
+ "config path", "config has", "maintenance-mode status", "maintenance-mode is-active",
102
+ "cli version", "cli info", "cli check-update",
103
+ "doctor list", "doctor check", "profile stage", "profile hook",
104
+ ])
105
+ // Global flags that run code or send the command somewhere else. Per-command
106
+ // flags (--format, --field, --status, …) are too many to allowlist.
107
+ const WP_UNSAFE_FLAGS = /(^|\s)['"]?--(exec|require|ssh|http)\b/
108
+
109
+ // Unlike redis-cli, `wp` is only treated as an invocation when it stands alone
110
+ // and is followed by a word — a bare `wp` just prints help, and this keeps paths
111
+ // (`wp-content`, Bedrock's `web/wp`) from tripping it.
112
+ function unsafeWpCli(cmd: string): string | null {
113
+ // `wp 2>/dev/null plugin delete x` must not end the segment at the redirect.
114
+ const stripped = cmd.replace(/\d?>{1,2}\s*(\/dev\/null\b|&\d+)/g, "")
115
+ for (const m of stripped.matchAll(new RegExp(`(?<![\\w.$-])wp(?:-cli\\.phar)?(?=\\s)${SEGMENT}(.?)`, "gs"))) {
116
+ const segment = m[1] ?? ""
117
+ if (WP_UNSAFE_FLAGS.test(segment)) return "wp --exec/--require/--ssh/--http can run code or target another host"
118
+ const tokens = segment.trim().split(/\s+/).filter(Boolean)
119
+ let i = 0
120
+ for (; i < tokens.length && tokens[i]!.replace(/^['"]/, "").startsWith("-"); i++) {
121
+ if (hasOpenQuote(tokens[i]!)) return "wp option value has quoting this guard can't read safely"
122
+ }
123
+ const words: string[] = []
124
+ for (; i < tokens.length && words.length < 3; i++) {
125
+ const word = clean(tokens[i]!)
126
+ if (word.startsWith("-")) break
127
+ if (word) words.push(word)
128
+ }
129
+ if (words.length === 0) {
130
+ // `wp </dev/stdin plugin delete` — the command sits past a redirect we stopped at.
131
+ if (m[2] === "<" || m[2] === ">") return "wp followed by a redirect hides its command from this guard"
132
+ continue
133
+ }
134
+ if ([3, 2].some((n) => words.length >= n && WP_READ_COMMANDS.has(words.slice(0, n).join(" ")))) continue
135
+ return `wp \`${words.slice(0, 2).join(" ")}\` is not on the read-only allowlist`
136
+ }
137
+ return null
138
+ }
139
+
35
140
  export interface SshCommandVerdict {
36
141
  decision: "allow" | "deny"
37
142
  reason: string
38
143
  }
39
144
 
40
145
  export function classifySshCommand(cmd: string): SshCommandVerdict {
146
+ const unsafeTool = unsafeRedisCli(cmd) ?? unsafeWpCli(cmd)
147
+ if (unsafeTool) {
148
+ return {
149
+ decision: "deny",
150
+ reason: `${unsafeTool} — spinuptui ssh-exec only runs read-only diagnostic commands.`,
151
+ }
152
+ }
41
153
  if (DANGEROUS_PATTERNS.some((re) => re.test(cmd)) || hasUnsafeRedirect(cmd)) {
42
154
  return {
43
155
  decision: "deny",
package/src/ui/Help.tsx CHANGED
@@ -129,6 +129,24 @@ function SectionBlock({ section }: { section: Section }) {
129
129
  )
130
130
  }
131
131
 
132
+ // Greedy word wrap for a shell command, so a line never breaks inside a flag.
133
+ // Continuation lines are indented two columns by the caller, hence `width - 2`.
134
+ function wrapAtSpaces(text: string, width: number): string[] {
135
+ const lines: string[] = []
136
+ let cur = ""
137
+ for (const word of text.split(" ")) {
138
+ const limit = lines.length === 0 ? width : width - 2
139
+ if (cur && cur.length + 1 + word.length > limit) {
140
+ lines.push(cur)
141
+ cur = word
142
+ } else {
143
+ cur = cur ? `${cur} ${word}` : word
144
+ }
145
+ }
146
+ if (cur) lines.push(cur)
147
+ return lines
148
+ }
149
+
132
150
  function ShortcutColumn({ sections, width }: { sections: Section[]; width: number }) {
133
151
  return (
134
152
  <box style={{ flexDirection: "column", width, flexShrink: 0 }}>
@@ -194,7 +212,11 @@ function AboutColumn({
194
212
  {installChannel() !== "git" ? (
195
213
  <>
196
214
  {line("Manual:")}
197
- {line(updateCommand() ?? "", theme.text)}
215
+ {/* Broken at spaces by hand: the package command is wider than the
216
+ About column, and OpenTUI's own wrap splits "--no-cache" at its hyphen. */}
217
+ {wrapAtSpaces(updateCommand() ?? "", width).map((l, i) => (
218
+ <text key={i} content={i === 0 ? l : ` ${l}`} fg={theme.text} wrapMode="none" />
219
+ ))}
198
220
  </>
199
221
  ) : (
200
222
  <>