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 +12 -1
- package/README.md +8 -4
- package/package.json +1 -1
- package/src/lib/appUpdate.ts +8 -1
- package/src/lib/sshExecPolicy.ts +119 -7
- package/src/ui/Help.tsx +23 -1
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.
|
|
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
|
|
169
|
-
the same command).
|
|
170
|
-
|
|
171
|
-
release
|
|
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
package/src/lib/appUpdate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/lib/sshExecPolicy.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
<>
|