spinuptui 1.0.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/src/lib/cliSsh.ts CHANGED
@@ -169,15 +169,17 @@ export async function resolveSshTargetInfo(
169
169
  }
170
170
  }
171
171
 
172
- export async function resolveSshAccess(
173
- domain: string,
174
- client: SpinupWPClientLike,
175
- cfg: AppConfig,
176
- opts?: { server?: string | null },
177
- ): Promise<SshAccessResult> {
178
- const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
179
- if (!resolution.ok) return resolution.result
180
- const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
172
+ // One BatchMode `ssh … true` against an already-resolved target, classified.
173
+ // Shared by `ssh` (one domain) and `siblings --probe` (every site on a server)
174
+ // so both read a refused key, an unreachable box and anything else the same way.
175
+ export type SshProbeStatus = "ok" | "permission_denied" | "connection_failed" | "ssh_error"
176
+
177
+ const PROBE_TIMEOUT_MS = 15_000
178
+
179
+ export async function probeSshTarget(
180
+ target: string,
181
+ port: number | null,
182
+ ): Promise<{ status: SshProbeStatus; message?: string }> {
181
183
  const portOpt = port ? ["-p", String(port)] : []
182
184
 
183
185
  let proc: ReturnType<typeof Bun.spawn>
@@ -188,46 +190,58 @@ export async function resolveSshAccess(
188
190
  stdin: "ignore",
189
191
  })
190
192
  } catch (err) {
191
- return { ok: false, domain, reason: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
193
+ return { status: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
192
194
  }
193
195
 
196
+ let timedOut = false
194
197
  const timeout = setTimeout(() => {
198
+ timedOut = true
195
199
  try {
196
200
  proc.kill()
197
201
  } catch {
198
202
  /* already gone */
199
203
  }
200
- }, 15000)
204
+ }, PROBE_TIMEOUT_MS)
201
205
 
202
206
  const exitCode = await proc.exited
203
207
  clearTimeout(timeout)
204
- const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
208
+ if (exitCode === 0) return { status: "ok" }
205
209
 
206
- if (exitCode === 0) {
207
- return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
210
+ // Killed by our own timer: ssh never got to say why, so say it here rather
211
+ // than reporting the kill signal's exit code as an unexplained ssh error.
212
+ if (timedOut) {
213
+ return { status: "connection_failed", message: `ssh did not finish within ${PROBE_TIMEOUT_MS / 1000}s.` }
208
214
  }
209
215
 
210
- if (/permission denied/i.test(stderr)) {
211
- return {
212
- ok: false,
213
- domain,
214
- reason: "permission_denied",
215
- message: stderr.trim().split("\n").slice(-2).join(" ") || "Permission denied.",
216
- remedy: GRANT_KEY_REMEDY,
217
- }
218
- }
216
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
217
+ const tail = stderr.trim().split("\n").slice(-2).join(" ")
218
+
219
+ if (/permission denied/i.test(stderr)) return { status: "permission_denied", message: tail || "Permission denied." }
219
220
  if (/(connection timed out|operation timed out|connection refused|could not resolve)/i.test(stderr)) {
220
- return {
221
- ok: false,
222
- domain,
223
- reason: "connection_failed",
224
- message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
225
- }
221
+ return { status: "connection_failed", message: tail || `ssh exited with code ${exitCode}` }
222
+ }
223
+ return { status: "ssh_error", message: tail || `ssh exited with code ${exitCode}` }
224
+ }
225
+
226
+ export async function resolveSshAccess(
227
+ domain: string,
228
+ client: SpinupWPClientLike,
229
+ cfg: AppConfig,
230
+ opts?: { server?: string | null },
231
+ ): Promise<SshAccessResult> {
232
+ const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
233
+ if (!resolution.ok) return resolution.result
234
+ const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
235
+
236
+ const probe = await probeSshTarget(target, port)
237
+ if (probe.status === "ok") {
238
+ return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
226
239
  }
227
240
  return {
228
241
  ok: false,
229
242
  domain,
230
- reason: "ssh_error",
231
- message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
243
+ reason: probe.status,
244
+ message: probe.message ?? "ssh failed.",
245
+ ...(probe.status === "permission_denied" ? { remedy: GRANT_KEY_REMEDY } : {}),
232
246
  }
233
247
  }
@@ -0,0 +1,180 @@
1
+ // Carry a site's SpinupWP settings onto its clone: page cache (on/off, duration,
2
+ // exclusions), the nginx security toggles, path redirects, and the server-level WP
3
+ // cron interval. The create payload can only say "page cache on", so without this
4
+ // a clone comes out with SpinupWP's defaults for everything else. Each setting is
5
+ // compared first and only a difference is written, so a retry re-runs it safely.
6
+ //
7
+ // Not carried here: basic auth (the password can't be read from the API — the
8
+ // result flags it instead) and backups (stored per DOMAIN in the bucket, so a clone
9
+ // backing up before DNS cutover would write over production's files; they're
10
+ // applied at cutover).
11
+
12
+ import type { SpinupWPClientLike } from "../api/client.ts"
13
+ import { WP_CRON_INTERVALS, type NginxSettings, type PageCachePayload, type PageCacheSettings, type PathRedirect, type Site } from "../api/types.ts"
14
+ import { pollEvent } from "./cloneDomains.ts"
15
+
16
+ export interface SettingsCarryResult {
17
+ carried: string[] // short labels for what was written ("page cache", "3 redirects", …)
18
+ failed: { what: string; error: string }[]
19
+ basicAuthMissing: boolean // the source is password-protected, the clone isn't
20
+ }
21
+
22
+ type Log = (entry: Record<string, unknown>) => void
23
+
24
+ // Page-cache exclusion lists read back as one pipe-joined regex but are written one
25
+ // entry per line. Split on top-level pipes only, so an alternation inside a group
26
+ // or class — "(a|b)", "[|]" — stays one entry. Exported for the test harness.
27
+ export function splitCacheList(joined: string): string[] {
28
+ const out: string[] = []
29
+ let depth = 0
30
+ let cur = ""
31
+ for (let i = 0; i < joined.length; i++) {
32
+ const ch = joined[i]!
33
+ if (ch === "\\") {
34
+ cur += ch + (joined[i + 1] ?? "")
35
+ i++
36
+ continue
37
+ }
38
+ if (ch === "(" || ch === "[" || ch === "{") depth++
39
+ else if ((ch === ")" || ch === "]" || ch === "}") && depth > 0) depth--
40
+ if (ch === "|" && depth === 0) {
41
+ out.push(cur)
42
+ cur = ""
43
+ continue
44
+ }
45
+ cur += ch
46
+ }
47
+ out.push(cur)
48
+ return out.filter((e) => e !== "")
49
+ }
50
+
51
+ const CACHE_LISTS = ["path_exclusions", "cookie_exclusions", "ignored_query_params"] as const
52
+
53
+ // What to send so the dest's page cache matches the source's: only the fields that
54
+ // differ, and never a list the source leaves unset (null reads as "SpinupWP's
55
+ // default", which the dest already has).
56
+ function pageCacheDiff(src: PageCacheSettings, dst: PageCacheSettings | undefined): PageCachePayload {
57
+ const out: PageCachePayload = {}
58
+ if (src.duration != null && src.duration_unit != null && (src.duration !== dst?.duration || src.duration_unit !== dst?.duration_unit)) {
59
+ out.duration = src.duration
60
+ out.duration_unit = src.duration_unit
61
+ }
62
+ for (const k of CACHE_LISTS) {
63
+ const v = src[k]
64
+ if (v != null && v !== dst?.[k]) out[k] = splitCacheList(v).join("\n")
65
+ }
66
+ return out
67
+ }
68
+
69
+ // Wait for each event and turn the outcome into an error string (undefined = done).
70
+ async function settle(client: SpinupWPClientLike, eventIds: number[]): Promise<string | undefined> {
71
+ for (const id of eventIds) {
72
+ const r = await pollEvent(client, id)
73
+ if (r.outcome === "failed") return `event ${id} failed on SpinupWP`
74
+ if (r.outcome === "timeout") return `gave up waiting for event ${id}${r.lastStatus ? ` (still "${r.lastStatus}")` : ""} — retry the site to re-check`
75
+ }
76
+ return undefined
77
+ }
78
+
79
+ const redirectKey = (r: Pick<PathRedirect, "from" | "to">) => `${r.from}\u0000${r.to}`
80
+
81
+ export async function carrySiteSettings(
82
+ client: SpinupWPClientLike,
83
+ sourceSiteId: number,
84
+ destSiteId: number,
85
+ wpCron: { source?: number | null; dest?: number | null },
86
+ log?: Log,
87
+ ): Promise<SettingsCarryResult> {
88
+ const result: SettingsCarryResult = { carried: [], failed: [], basicAuthMissing: false }
89
+ let src: Site
90
+ let dst: Site
91
+ try {
92
+ ;[src, dst] = await Promise.all([client.getSite(sourceSiteId), client.getSite(destSiteId)])
93
+ } catch (err) {
94
+ result.failed.push({ what: "settings", error: `couldn't read the sites: ${(err as Error).message}` })
95
+ return result
96
+ }
97
+
98
+ // Each step records its own outcome; one failing never stops the rest.
99
+ const step = async (what: string, run: () => Promise<number[] | null>) => {
100
+ try {
101
+ const events = await run()
102
+ if (events == null) return // nothing to change
103
+ const error = await settle(client, events)
104
+ if (error) {
105
+ result.failed.push({ what, error })
106
+ log?.({ event: "settings-carry", what, ok: false, error })
107
+ } else {
108
+ result.carried.push(what)
109
+ log?.({ event: "settings-carry", what, ok: true })
110
+ }
111
+ } catch (err) {
112
+ const error = (err as Error).message
113
+ result.failed.push({ what, error })
114
+ log?.({ event: "settings-carry", what, ok: false, error })
115
+ }
116
+ }
117
+
118
+ // Page cache
119
+ const sc = src.page_cache
120
+ if (sc) {
121
+ await step("page cache", async () => {
122
+ if (!sc.enabled) return dst.page_cache?.enabled ? [(await client.disablePageCache(destSiteId)).event_id] : null
123
+ const diff = pageCacheDiff(sc, dst.page_cache)
124
+ if (!dst.page_cache?.enabled) return [(await client.enablePageCache(destSiteId, diff)).event_id]
125
+ return Object.keys(diff).length > 0 ? [(await client.updatePageCache(destSiteId, diff)).event_id] : null
126
+ })
127
+ }
128
+
129
+ // Nginx security toggles (public_folder is set at create and handled by the pull)
130
+ const sn = src.nginx ?? {}
131
+ const nginx: NginxSettings = {}
132
+ for (const k of ["uploads_directory_protected", "xmlrpc_protected", "subdirectory_rewrite_in_place"] as const) {
133
+ if (sn[k] != null && sn[k] !== dst.nginx?.[k]) nginx[k] = sn[k]
134
+ }
135
+ if (Object.keys(nginx).length > 0) {
136
+ await step("security settings", async () => (await client.updateNginx(destSiteId, nginx)).event_ids)
137
+ }
138
+
139
+ // Path redirects: add the source's that the dest lacks. The dest's own extras
140
+ // are left alone (a clone shouldn't delete anything).
141
+ let srcRedirects: PathRedirect[] = []
142
+ try {
143
+ srcRedirects = await client.listPathRedirects(sourceSiteId)
144
+ } catch (err) {
145
+ result.failed.push({ what: "redirects", error: `couldn't list the source's redirects: ${(err as Error).message}` })
146
+ }
147
+ if (srcRedirects.length > 0) {
148
+ let have = new Set<string>()
149
+ try {
150
+ have = new Set((await client.listPathRedirects(destSiteId)).map(redirectKey))
151
+ } catch {
152
+ /* add them all; a duplicate is rejected per redirect and reported */
153
+ }
154
+ const missing = srcRedirects.filter((r) => !have.has(redirectKey(r)))
155
+ if (missing.length > 0) {
156
+ await step(`${missing.length} redirect${missing.length === 1 ? "" : "s"}`, async () => {
157
+ const events: number[] = []
158
+ for (const r of missing) events.push((await client.addPathRedirect(destSiteId, { from: r.from, to: r.to, type: r.type })).event_id)
159
+ return events
160
+ })
161
+ }
162
+ }
163
+
164
+ // WP cron: both ends' intervals come from their crontabs (undefined = unread,
165
+ // null = no managed job recognized). Only an interval is ever carried: a source
166
+ // with no recognizable job might have WP cron off, or a line shape we don't
167
+ // parse, and switching the clone's off on a guess is worse than leaving it.
168
+ const want = wpCron.source
169
+ const have = wpCron.dest
170
+ if (want != null && have !== undefined && want !== have) {
171
+ if (!(WP_CRON_INTERVALS as readonly number[]).includes(want)) {
172
+ result.failed.push({ what: "WP cron", error: `the source runs WP cron every ${want} min, which SpinupWP's API doesn't offer` })
173
+ } else {
174
+ await step(`WP cron every ${want} min`, async () => [(have == null ? await client.enableWpCron(destSiteId, want) : await client.updateWpCron(destSiteId, want)).event_id])
175
+ }
176
+ }
177
+
178
+ result.basicAuthMissing = !!src.basic_auth?.enabled && !dst.basic_auth?.enabled
179
+ return result
180
+ }
@@ -757,6 +757,10 @@ export interface CrontabSyncResult {
757
757
  ok: boolean
758
758
  added: string[] // the lines appended to the dest crontab (empty = nothing to carry)
759
759
  error?: string
760
+ // SpinupWP's server-level WP cron on each end, in minutes (see wpCronInterval):
761
+ // null = no managed WP cron job, undefined = that crontab wasn't read.
762
+ sourceWpCron?: number | null
763
+ destWpCron?: number | null
760
764
  }
761
765
 
762
766
  // The owner-added lines of a crontab: everything except SpinupWP's `#Ansible:`
@@ -778,6 +782,27 @@ export function ownerCrontabLines(crontab: string): string[] {
778
782
  return out
779
783
  }
780
784
 
785
+ // The interval, in minutes, of SpinupWP's managed WP cron job — the line after the
786
+ // `#Ansible:` marker. The API doesn't report it, so the crontab is the only place
787
+ // to read it. SpinupWP staggers the start minute per site, so every-5 is written
788
+ // "4,9,14,…,59 * * * *": the gap between the first two minutes is the interval, a
789
+ // single minute is hourly, "*" every minute. null = no managed WP cron job (or one
790
+ // in a shape we don't recognize). Exported for the test harness.
791
+ export function wpCronInterval(crontab: string): number | null {
792
+ const lines = crontab.split("\n").map((l) => l.replace(/\r$/, ""))
793
+ const at = lines.findIndex((l) => /^#Ansible:/.test(l))
794
+ const job = at >= 0 ? lines[at + 1] : undefined
795
+ if (!job || !/wp cron event run/.test(job)) return null
796
+ const [minute, hour] = job.trim().split(/\s+/)
797
+ if (hour !== "*" || !minute) return null
798
+ if (minute === "*") return 1
799
+ const step = minute.match(/^\*\/(\d+)$/)
800
+ if (step) return Number(step[1])
801
+ const mins = minute.split(",").map(Number)
802
+ if (mins.some((m) => !Number.isInteger(m))) return null
803
+ return mins.length === 1 ? 60 : mins[1]! - mins[0]!
804
+ }
805
+
781
806
  const envName = (line: string): string | null => line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? null
782
807
 
783
808
  export async function syncCrontab(
@@ -796,15 +821,18 @@ export async function syncCrontab(
796
821
  const src = await read(source, spec.sourceSiteUser)
797
822
  if (!src.ok) return { ok: false, added: [], error: src.stderr.trim() || "couldn't read the source crontab" }
798
823
  const wanted = ownerCrontabLines(src.stdout)
799
- if (wanted.length === 0) return { ok: true, added: [] }
824
+ const sourceWpCron = wpCronInterval(src.stdout)
800
825
 
826
+ // Read even when there's nothing to append: the caller compares WP cron intervals.
801
827
  const dst = await read(dest, spec.destSiteUser)
802
- if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab" }
828
+ if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab", sourceWpCron }
829
+ const destWpCron = wpCronInterval(dst.stdout)
830
+ if (wanted.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
803
831
  const have = new Set(dst.stdout.split("\n").map((l) => l.replace(/\r$/, "")))
804
832
  const haveEnv = new Set([...have].map(envName).filter((n): n is string => n != null))
805
833
  // An env line the dest already sets (SpinupWP's own PATH, typically) stays the dest's.
806
834
  const added = wanted.filter((l) => !have.has(l) && !(envName(l) && haveEnv.has(envName(l)!)))
807
- if (added.length === 0) return { ok: true, added: [] }
835
+ if (added.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
808
836
 
809
837
  const block = ["", `# Carried over from ${source.server.name} by the SpinupTUI clone`, ...added].join("\n") + "\n"
810
838
  const b64 = Buffer.from(block).toString("base64")
@@ -812,8 +840,8 @@ export async function syncCrontab(
812
840
  const t0 = Date.now()
813
841
  const w = await exec(dest, script, 30_000)
814
842
  onExec?.({ domain: spec.domain, stage: "config", host: dest.server.name, ok: w.ok, code: w.code, ms: Date.now() - t0, script, stdout: w.stdout, stderr: w.stderr })
815
- if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab" }
816
- return { ok: true, added }
843
+ if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab", sourceWpCron, destWpCron }
844
+ return { ok: true, added, sourceWpCron, destWpCron }
817
845
  }
818
846
 
819
847
  // ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
@@ -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
  <>