spinuptui 0.28.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -472,6 +472,13 @@ export interface BedrockPullSpec {
472
472
  destDbPassword: string
473
473
  excludeUploads: boolean
474
474
  publicFolder?: string // a signal, not a fact (CLAUDE.md) — re-verified below, independently, on both ends
475
+ // The source site's own git deploy script. Run on the dest after the DB import
476
+ // when the source is Radicle: its front-end (Vite build → public/build) and
477
+ // Acorn caches are gitignored, so composer install alone leaves the theme
478
+ // unbuilt, and the owner's deploy script is their own definition of "build this
479
+ // site". It runs last because Radicle deploy scripts call wp-cli (acorn
480
+ // optimize, cache purge, rewrite flush), which needs the imported database.
481
+ deployScript?: string
475
482
  }
476
483
 
477
484
  export async function runBedrockPull(
@@ -515,9 +522,12 @@ export async function runBedrockPull(
515
522
  // source happened to detect — its structure comes from the git repo's own
516
523
  // content, which can legitimately diverge from the source's on-disk state.
517
524
  onProgress("detect", "start")
518
- const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"`, 30_000)
525
+ const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"; echo "RADICLEROOT:$RD"`, 30_000)
519
526
  const wpCore = (det.stdout.match(/^WPCORE:(.*)$/m)?.[1] ?? "").trim()
520
527
  const srcProjectRoot = (det.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
528
+ // Radicle requires roots/bedrock-autoloader, so it passes the Bedrock check
529
+ // above too — this is what tells the two apart.
530
+ const radicle = (det.stdout.match(/^RADICLEROOT:(.*)$/m)?.[1] ?? "").trim() !== ""
521
531
  if (!det.ok || !wpCore || !srcProjectRoot) {
522
532
  return fail("detect", `couldn't find a Bedrock project (composer.json + WordPress core) under ${root} on the source — is the public folder set correctly?`)
523
533
  }
@@ -577,20 +587,23 @@ export async function runBedrockPull(
577
587
  if (!composer.ok) return fail("build", (composer.stdout + composer.stderr).trim().split("\n").slice(-3).join(" ") || "composer install failed")
578
588
  onProgress("build", "ok")
579
589
 
580
- // 3. files — pull {web}/app/uploads (unless excluded), `web` being whatever the
581
- // detected public folder is actually named — which can differ in nesting
582
- // depth between source and dest (the repo's own content vs. how deep the
583
- // source's on-disk state happens to be). Archiving/extracting relative to
584
- // EACH side's own web dir (not `root`) with a fixed member name sidesteps
585
- // that — the two ends never need to agree on a shared relative path. Best-
586
- // effort: a site with no uploads dir yields an empty stream we simply skip.
590
+ // 3. files — pull the uploads dir under the public folder: Bedrock's
591
+ // app/uploads or Radicle's content/uploads, whichever the source has.
592
+ // `web` is whatever the detected public folder is actually named, and it
593
+ // can differ in nesting depth between source and dest (the repo's own
594
+ // content vs. how deep the source's on-disk state happens to be).
595
+ // Archiving/extracting relative to EACH side's own web dir (not `root`)
596
+ // keeps the member name (app/uploads or content/uploads) the same on both
597
+ // ends, so they never need to agree on a shared relative path. Best-effort:
598
+ // a site with no uploads dir yields an empty stream we simply skip.
587
599
  onProgress("files", "start")
588
600
  if (!spec.excludeUploads) {
589
601
  const stopUpPoll = pollTransferSize(dest, `${tmp}_up.tgz`, (b) => onTransfer?.("files", b))
602
+ const srcUploads = `cd ${shq(webDir)} && for d in app/uploads content/uploads; do if [ -d "$d" ]; then tar --warning=no-file-changed -czf - "$d"; rc=$?; [ $rc -le 1 ] && exit 0; exit $rc; fi; done; true`
590
603
  const up = await run(
591
604
  "files",
592
605
  dest,
593
- `set -e; timeout -k 5 3600 ${remote(`[ -d ${shq(`${webDir}/app/uploads`)} ] && tar -C ${shq(webDir)} --warning=no-file-changed -czf - app/uploads || true`)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(destWebDir)} -xzf ${tmp}_up.tgz && chown -R ${shq(du)}:${shq(du)} ${shq(`${destWebDir}/app/uploads`)}; fi; rm -f ${tmp}_up.tgz`,
606
+ `set -e; timeout -k 5 3600 ${remote(srcUploads)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(destWebDir)} -xzf ${tmp}_up.tgz && for d in app/uploads content/uploads; do [ -d ${shq(destWebDir)}/$d ] && chown -R ${shq(du)}:${shq(du)} ${shq(destWebDir)}/$d; done; true; fi; rm -f ${tmp}_up.tgz`,
594
607
  3_660_000, // outer must exceed the in-script `timeout 3600` so the inner reports a clean 124
595
608
  )
596
609
  stopUpPoll()
@@ -641,6 +654,23 @@ export async function runBedrockPull(
641
654
  if (!imp.ok) return fail("db", imp.stderr.trim() || "db pull/import failed")
642
655
  onProgress("db", "ok")
643
656
 
657
+ // 5b. deploy — Radicle only: run the source's own deploy script (see
658
+ // BedrockPullSpec.deployScript). Staged as a file owned by the site user
659
+ // (base64 in, so no quoting of the owner's script is ever needed) and run
660
+ // from the project root, exactly where SpinupWP's own deploys run it.
661
+ if (radicle && spec.deployScript?.trim()) {
662
+ onProgress("build", "start", "deploy script")
663
+ const b64 = Buffer.from(spec.deployScript.replace(/\r\n/g, "\n")).toString("base64")
664
+ const deploy = await run(
665
+ "build",
666
+ dest,
667
+ `set -e; echo ${shq(b64)} | base64 -d > ${tmp}_deploy.sh; chown ${shq(du)}:${shq(du)} ${tmp}_deploy.sh; chmod 600 ${tmp}_deploy.sh; cd ${shq(destProjectRoot)}; rc=0; sudo -u ${shq(du)} -H bash -lc ${shq(`cd ${shq(destProjectRoot)} && bash ${tmp}_deploy.sh`)} 2>&1 || rc=$?; rm -f ${tmp}_deploy.sh; exit $rc`,
668
+ 1_200_000,
669
+ )
670
+ if (!deploy.ok) return fail("build", `the site's deploy script failed: ${(deploy.stdout + deploy.stderr).trim().split("\n").slice(-3).join(" ") || `exit ${deploy.code}`}`)
671
+ onProgress("build", "ok")
672
+ }
673
+
644
674
  // 6. verify — wp-cli on the dest (HTTP --resolve is the caller's).
645
675
  onProgress("verify", "start")
646
676
  const ver = await run("verify", dest, `cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
@@ -652,7 +682,7 @@ export async function runBedrockPull(
652
682
  // 7. revoke — drop the pull key (by marker) on source, clean dest temp + staged dump.
653
683
  onProgress("revoke", "start")
654
684
  await run("revoke", source, `AK=${shq(home)}/.ssh/authorized_keys; [ -f "$AK" ] && sed -i ${shq(`/${MARKER}/d`)} "$AK" && chown ${shq(su)}:${shq(su)} "$AK"; rm -f ${shq(home)}/.clone_db.sql.gz`, 30_000).catch(() => {})
655
- await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql`, 30_000).catch(() => {})
685
+ await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql ${tmp}_deploy.sh`, 30_000).catch(() => {})
656
686
  onProgress("revoke", "ok")
657
687
  }
658
688
  }
@@ -667,11 +697,20 @@ export async function runBedrockPull(
667
697
  // just BEFORE a destination site (and its git clone) gets created for nothing.
668
698
  // Silent no-op for anything that isn't a detectable Bedrock install; that's
669
699
  // covered by the pull chain's own detection, not this pre-check's job.
670
- export async function preflightBedrockSource(source: SudoCtx, spec: { domain: string; publicFolder?: string }): Promise<{ ok: true } | { ok: false; error: string }> {
700
+ //
701
+ // For a Radicle source whose deploy script builds the front-end with npm/yarn/
702
+ // pnpm, it also checks the destination has Node: a stock SpinupWP server doesn't,
703
+ // and finding out after the create would leave a half-built site behind. We stop
704
+ // and say so rather than install it — the server's packages aren't ours to manage.
705
+ export async function preflightBedrockSource(
706
+ source: SudoCtx,
707
+ spec: { domain: string; publicFolder?: string; deployScript?: string },
708
+ dest?: SudoCtx,
709
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
671
710
  const root = `/sites/${spec.domain}/files`
672
711
  const expectedWebDir = webrootFor(root, spec.publicFolder)
673
712
  const expectedProjectRoot = expectedWebDir === root ? root : expectedWebDir.slice(0, expectedWebDir.lastIndexOf("/"))
674
- const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"`, 30_000)
713
+ const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"; echo "RADICLEROOT:$RD"`, 30_000)
675
714
  const detected = (r.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
676
715
  if (!r.ok || !detected) return { ok: true }
677
716
  if (detected !== expectedProjectRoot) {
@@ -680,9 +719,131 @@ export async function preflightBedrockSource(source: SudoCtx, spec: { domain: st
680
719
  error: `this site's actual files (Bedrock project at ${detected}) don't match its configured Public Folder (implies ${expectedProjectRoot}) — align them and retry`,
681
720
  }
682
721
  }
722
+ const radicle = (r.stdout.match(/^RADICLEROOT:(.*)$/m)?.[1] ?? "").trim() !== ""
723
+ // Radicle's front-end build lives only in the deploy script (see
724
+ // BedrockPullSpec.deployScript) — without one the clone would come up with no
725
+ // built assets and answer 500. SpinupWP can drop a script set through its API
726
+ // (one sent on create is gone a minute or two later), so this is reachable.
727
+ if (radicle && !spec.deployScript?.trim()) {
728
+ return {
729
+ ok: false,
730
+ error: "this Radicle site has no deploy script in SpinupWP, so there's nothing to build its front-end with — add one (Site → Git) and retry",
731
+ }
732
+ }
733
+ if (radicle && dest && /\b(npm|npx|yarn|pnpm|node)\b/.test(spec.deployScript ?? "")) {
734
+ const n = await exec(dest, `command -v node >/dev/null 2>&1 && echo HASNODE || echo NONODE`, 30_000)
735
+ if (n.stdout.includes("NONODE")) {
736
+ return {
737
+ ok: false,
738
+ error: `this Radicle site's deploy script builds its front-end with Node, and ${dest.server.name} doesn't have Node installed — install Node on it and retry`,
739
+ }
740
+ }
741
+ }
683
742
  return { ok: true }
684
743
  }
685
744
 
745
+ // ---- Crontab carry-over --------------------------------------------------------
746
+ //
747
+ // SpinupWP writes each site user's crontab itself: a PATH line and an
748
+ // `#Ansible: <domain>` block running WP cron. A new site gets its own copy at
749
+ // create, so those are never copied. Anything else in the source user's crontab
750
+ // was added by the site owner (a feed import, an Acorn/Laravel command, a report
751
+ // job), and without this step a clone silently stops running it. Lines are copied
752
+ // verbatim, commented-out ones included (a disabled job stays disabled), which is
753
+ // safe because a clone keeps the domain and site user, so every path matches.
754
+ // Idempotent: a line the dest already has is skipped, so a retry adds nothing.
755
+
756
+ export interface CrontabSyncResult {
757
+ ok: boolean
758
+ added: string[] // the lines appended to the dest crontab (empty = nothing to carry)
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
764
+ }
765
+
766
+ // The owner-added lines of a crontab: everything except SpinupWP's `#Ansible:`
767
+ // marker + the job line after it, and blank lines. Environment lines (PATH=…,
768
+ // MAILTO=…) are kept here; the caller drops the ones the dest already sets.
769
+ // Exported for the test harness.
770
+ export function ownerCrontabLines(crontab: string): string[] {
771
+ const out: string[] = []
772
+ const lines = crontab.split("\n").map((l) => l.replace(/\r$/, ""))
773
+ for (let i = 0; i < lines.length; i++) {
774
+ const line = lines[i]
775
+ if (/^#Ansible:/.test(line)) {
776
+ i++ // the managed job line that follows the marker
777
+ continue
778
+ }
779
+ if (line.trim() === "") continue
780
+ out.push(line)
781
+ }
782
+ return out
783
+ }
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
+
806
+ const envName = (line: string): string | null => line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? null
807
+
808
+ export async function syncCrontab(
809
+ source: SudoCtx,
810
+ dest: SudoCtx,
811
+ spec: { domain: string; sourceSiteUser: string; destSiteUser: string },
812
+ onExec?: CloneExecLog,
813
+ ): Promise<CrontabSyncResult> {
814
+ const read = async (ctx: SudoCtx, user: string) => {
815
+ const t0 = Date.now()
816
+ const script = `crontab -l -u ${shq(user)} 2>/dev/null || true`
817
+ const r = await exec(ctx, script, 30_000)
818
+ onExec?.({ domain: spec.domain, stage: "config", host: ctx.server.name, ok: r.ok, code: r.code, ms: Date.now() - t0, script, stdout: r.stdout, stderr: r.stderr })
819
+ return r
820
+ }
821
+ const src = await read(source, spec.sourceSiteUser)
822
+ if (!src.ok) return { ok: false, added: [], error: src.stderr.trim() || "couldn't read the source crontab" }
823
+ const wanted = ownerCrontabLines(src.stdout)
824
+ const sourceWpCron = wpCronInterval(src.stdout)
825
+
826
+ // Read even when there's nothing to append: the caller compares WP cron intervals.
827
+ const dst = await read(dest, spec.destSiteUser)
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 }
831
+ const have = new Set(dst.stdout.split("\n").map((l) => l.replace(/\r$/, "")))
832
+ const haveEnv = new Set([...have].map(envName).filter((n): n is string => n != null))
833
+ // An env line the dest already sets (SpinupWP's own PATH, typically) stays the dest's.
834
+ const added = wanted.filter((l) => !have.has(l) && !(envName(l) && haveEnv.has(envName(l)!)))
835
+ if (added.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
836
+
837
+ const block = ["", `# Carried over from ${source.server.name} by the SpinupTUI clone`, ...added].join("\n") + "\n"
838
+ const b64 = Buffer.from(block).toString("base64")
839
+ const script = `set -e; { crontab -l -u ${shq(spec.destSiteUser)} 2>/dev/null || true; echo ${shq(b64)} | base64 -d; } | crontab -u ${shq(spec.destSiteUser)} -`
840
+ const t0 = Date.now()
841
+ const w = await exec(dest, script, 30_000)
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 })
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 }
845
+ }
846
+
686
847
  // ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
687
848
  //
688
849
  // The same hardened transport as the Standard-WP files stage (per-site key,
@@ -31,3 +31,10 @@ export function serverProvidersSettingsUrl(accountSlug: string | null): string {
31
31
  export function siteSftpUrl(id: number, accountSlug: string | null): string {
32
32
  return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#sftp` : WEB_BASE
33
33
  }
34
+
35
+ // A site's Git settings (repo, branch, deploy script). The clone wizard links here
36
+ // when a dest site's deploy script needs re-entering: SpinupWP drops a script
37
+ // set through the API (see the create payload in store.tsx).
38
+ export function siteGitUrl(id: number, accountSlug: string | null): string {
39
+ return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#git` : WEB_BASE
40
+ }
package/src/lib/stack.ts CHANGED
@@ -80,10 +80,13 @@ export function cloneStackFor(site: Site, probeKind?: ProbeKind | null): "wp" |
80
80
  if (probeKind === "whmcs" || probeKind === "laravel" || probeKind === "static") return "files"
81
81
  return "bedrock"
82
82
  }
83
- // A "radicle" probe never falls into "wp" here since effectiveStack maps it
84
- // to "Radicle", not "Standard WP" — deliberate: the Standard-WP pull chain
85
- // assumes a flat webroot and would corrupt a Radicle clone. Real Radicle
86
- // clone-wizard support is a separate, not-yet-built phase (issue #38).
83
+ // A git-deployed Radicle site took the branch above: the Bedrock pull handles
84
+ // it (it detects Radicle itself, pulls content/uploads, and runs the site's
85
+ // deploy script to build the front-end). A "radicle" probe with no git repo
86
+ // never falls into "wp" here since effectiveStack maps it to "Radicle", not
87
+ // "Standard WP" — deliberate: the Standard-WP pull chain assumes a flat webroot
88
+ // and would corrupt a Radicle clone, and without the repo there's nothing to
89
+ // rebuild it from.
87
90
  return effectiveStack(site, probeKind) === "Standard WP" ? "wp" : "files"
88
91
  }
89
92
 
package/src/lib/wpCli.ts CHANGED
@@ -28,3 +28,55 @@ export function wpCliResolveScript(phpVersion?: string | null): string {
28
28
  const phpBin = phpVersion ? `php${phpVersion}` : "php"
29
29
  return [`WP=$(command -v wp 2>/dev/null || echo /usr/local/bin/wp)`, `PHP=$(command -v ${phpBin} 2>/dev/null || command -v php)`].join("\n")
30
30
  }
31
+
32
+ // Pull wp-cli's `--format=json` array out of a section of remote stdout that may
33
+ // also carry noise: under the CLI SAPI a plugin's PHP notices/deprecations print
34
+ // to STDOUT (2>/dev/null doesn't catch them), and with no newline of their own
35
+ // they can sit on the same line as the JSON — before it, or after it when they
36
+ // fire at shutdown (seen live: an Elementor Pro deprecation glued to the end of
37
+ // `plugin list` output). So: from every `[`, bracket-match to its closing `]`
38
+ // (skipping brackets inside JSON strings), and keep the longest slice that parses
39
+ // as an array of objects — which is what every wp-cli list is, and what notice
40
+ // text like "on line [3]" or "[array (" never is.
41
+ // Returns null when nothing parses, so callers can tell "wp-cli printed no list"
42
+ // (an error worth surfacing) from a genuinely empty `[]`.
43
+ export function extractJsonArray(lines: string[] | undefined): unknown[] | null {
44
+ const text = (lines ?? []).join("\n")
45
+ let best: unknown[] | null = null
46
+ let bestLen = -1
47
+ for (let start = text.indexOf("["); start !== -1; start = text.indexOf("[", start + 1)) {
48
+ const end = matchingBracket(text, start)
49
+ if (end === -1 || end - start <= bestLen) continue
50
+ try {
51
+ const parsed = JSON.parse(text.slice(start, end + 1))
52
+ if (Array.isArray(parsed) && parsed.every((o) => o !== null && typeof o === "object")) {
53
+ best = parsed
54
+ bestLen = end - start
55
+ }
56
+ } catch {
57
+ /* not JSON from here — try the next `[` */
58
+ }
59
+ }
60
+ return best
61
+ }
62
+
63
+ // Index of the `]` closing the `[` at `start`, or -1. Tracks JSON string state so
64
+ // a bracket inside a value (a plugin name, a URL) doesn't end the match early.
65
+ function matchingBracket(text: string, start: number): number {
66
+ let depth = 0
67
+ let inString = false
68
+ for (let i = start; i < text.length; i++) {
69
+ const ch = text[i]
70
+ if (inString) {
71
+ if (ch === "\\") i++
72
+ else if (ch === '"') inString = false
73
+ } else if (ch === '"') inString = true
74
+ else if (ch === "[" || ch === "{") depth++
75
+ else if (ch === "]" || ch === "}") {
76
+ depth--
77
+ if (depth === 0) return ch === "]" ? i : -1
78
+ if (depth < 0) return -1
79
+ }
80
+ }
81
+ return -1
82
+ }
package/src/lib/wpCore.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { Server, Site } from "../api/types.ts"
18
18
  import { SSH_OPTS, sshPort } from "./dbBackup.ts"
19
19
  import { detectWpDirScript } from "./serverClone.ts"
20
- import { wpCliResolveScript } from "./wpCli.ts"
20
+ import { extractJsonArray, wpCliResolveScript } from "./wpCli.ts"
21
21
  import { spawn } from "./spawn.ts"
22
22
 
23
23
  export interface WpCoreOffer {
@@ -177,24 +177,13 @@ export async function checkWpCore(server: Server, site: Site, sshUser: string |
177
177
  const current = lastVersionLine(s.VERSION)
178
178
  if (!current) return { ok: false, target: p.target, error: "wp-cli couldn't read the WordPress version on this site." }
179
179
 
180
- // Belt and braces with --skip-plugins: scan back for the line that parses as
181
- // the JSON array, ignoring any other noise (a mu-plugin's notices still load).
180
+ // Belt and braces with --skip-plugins: a mu-plugin's notices still load, so
181
+ // pull the JSON array out of any surrounding noise.
182
182
  // No match = "Success: WordPress is at the latest version." (plain text).
183
- let offers: WpCoreOffer[] = []
184
- for (const line of [...(s.UPDATES ?? [])].reverse()) {
185
- if (!line.trim().startsWith("[")) continue
186
- let parsed: unknown
187
- try {
188
- parsed = JSON.parse(line.trim())
189
- } catch {
190
- continue
191
- }
192
- if (!Array.isArray(parsed)) continue
193
- offers = parsed
194
- .filter((o) => o && typeof o.version === "string")
195
- .map((o) => ({ version: o.version as string, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
196
- break
197
- }
183
+ const parsed = extractJsonArray(s.UPDATES) ?? []
184
+ const offers: WpCoreOffer[] = parsed
185
+ .filter((o): o is { version: string; update_type?: unknown } => !!o && typeof (o as { version?: unknown }).version === "string")
186
+ .map((o) => ({ version: o.version, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
198
187
 
199
188
  const comp = (s.COMPOSER ?? []).join("\n").trim()
200
189
  const locked = comp.match(/^locked (.+)$/m)
@@ -11,7 +11,7 @@
11
11
  import type { Server, Site } from "../api/types.ts"
12
12
  import { SSH_OPTS, sshPort } from "./dbBackup.ts"
13
13
  import { detectWpDirScript } from "./serverClone.ts"
14
- import { wpCliResolveScript } from "./wpCli.ts"
14
+ import { extractJsonArray, wpCliResolveScript } from "./wpCli.ts"
15
15
  import { spawn } from "./spawn.ts"
16
16
 
17
17
  // One plugin or theme, mirroring `wp {plugin,theme} list` columns.
@@ -68,17 +68,11 @@ function splitSections(out: string): Record<string, string[]> {
68
68
  return sections
69
69
  }
70
70
 
71
- // wp --format=json emits a single-line JSON array; tolerate blank/garbage by
72
- // returning [] rather than throwing (a fresh site can legitimately have 0 themes).
73
- function parseJsonArray(lines: string[] | undefined): RawItem[] {
74
- const text = (lines ?? []).join("").trim()
75
- if (!text) return []
76
- try {
77
- const parsed = JSON.parse(text)
78
- return Array.isArray(parsed) ? parsed : []
79
- } catch {
80
- return []
81
- }
71
+ // First non-blank line of a section — the likeliest culprit when wp-cli's JSON is
72
+ // missing (a PHP fatal or notice printed in its place).
73
+ function firstNoise(lines: string[] | undefined): string {
74
+ const line = (lines ?? []).map((l) => l.trim()).find(Boolean) ?? ""
75
+ return line.length > 160 ? `${line.slice(0, 159)}…` : line
82
76
  }
83
77
 
84
78
  export async function fetchWpInventory(
@@ -147,8 +141,18 @@ export async function fetchWpInventory(
147
141
  return { ok: false, target, error: `No WordPress core found under ${root} — is this a WordPress site?` }
148
142
  }
149
143
  const wpDir = (s.WPDIR?.[0] || root).trim()
150
- const plugins = normalize(parseJsonArray(s.PLUGINS))
151
- const themes = normalize(parseJsonArray(s.THEMES))
144
+ // A plugin's PHP notices can print to stdout alongside the JSON (see
145
+ // extractJsonArray). No JSON at all means wp-cli failed; say so instead of
146
+ // showing an empty list that reads as "no plugins installed".
147
+ const rawPlugins = extractJsonArray(s.PLUGINS)
148
+ const rawThemes = extractJsonArray(s.THEMES)
149
+ if (!rawPlugins || !rawThemes) {
150
+ const which = !rawPlugins ? "plugin" : "theme"
151
+ const noise = firstNoise(!rawPlugins ? s.PLUGINS : s.THEMES)
152
+ return { ok: false, target, error: `wp-cli didn't return the ${which} list${noise ? `: ${noise}` : "."}` }
153
+ }
154
+ const plugins = normalize(rawPlugins as RawItem[])
155
+ const themes = normalize(rawThemes as RawItem[])
152
156
  return { ok: true, target, wpDir, inventory: { plugins, themes } }
153
157
  }
154
158
 
package/src/ui/Header.tsx CHANGED
@@ -6,6 +6,8 @@ import { timeAgo, truncate } from "../lib/format.ts"
6
6
  import { Spinner } from "./components.tsx"
7
7
  import { APP_NAME, APP_VERSION } from "../version.ts"
8
8
  import { isDevMode } from "../dev/devMode.ts"
9
+ import { RL_LOW_WATER, onRateLimitChange, rateLimitSnapshot, type RateLimitSnapshot } from "../api/client.ts"
10
+ import { useEffect, useState } from "react"
9
11
 
10
12
  const TABS: { route: Route; key: string; label: string }[] = [
11
13
  { route: "dashboard", key: "1", label: "Dashboard" },
@@ -26,6 +28,29 @@ const SUBTITLES: Record<Route, string> = {
26
28
  events: "Recent deploys, reboots, and operations across your account",
27
29
  }
28
30
 
31
+ // SpinupWP allows 60 API requests a minute. Show what's left of this minute so
32
+ // the wall is visible before it's hit: dim while there's plenty, amber past
33
+ // three-quarters spent, red once the client has started pacing requests (the
34
+ // point where the app slows down on purpose). Hidden until a response has
35
+ // reported the limit (and in Dev Mode, which never calls the API).
36
+ function ApiHeadroom() {
37
+ const [rl, setRl] = useState<RateLimitSnapshot | null>(rateLimitSnapshot)
38
+ useEffect(() => {
39
+ const refresh = () => setRl(rateLimitSnapshot())
40
+ const off = onRateLimitChange(refresh)
41
+ // The window rolls over with no request to announce it; re-read so a spent
42
+ // budget doesn't sit on screen after it has refilled.
43
+ const tick = setInterval(refresh, 5_000)
44
+ return () => {
45
+ off()
46
+ clearInterval(tick)
47
+ }
48
+ }, [])
49
+ if (!rl) return null
50
+ const color = rl.remaining <= RL_LOW_WATER ? theme.bad : rl.remaining <= rl.limit / 4 ? theme.warn : theme.textFaint
51
+ return <text content={`API ${rl.remaining}/${rl.limit} `} fg={color} style={{ flexShrink: 0 }} wrapMode="none" />
52
+ }
53
+
29
54
  export function Header() {
30
55
  const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus, profileLabel } = useStore()
31
56
  // Name the domain(s) when there are few enough to fit the header row; fall
@@ -114,6 +139,7 @@ export function Header() {
114
139
  )}
115
140
  {loading && <Spinner interval={100} />}
116
141
  <text content={` ${servers.length} servers · ${sites.length} sites `} fg={theme.textDim} style={{ flexShrink: 0 }} />
142
+ <ApiHeadroom />
117
143
  <text content={lastUpdated ? `updated ${timeAgo(lastUpdated.toISOString())}` : "loading…"} fg={theme.textFaint} style={{ flexShrink: 0 }} />
118
144
  </box>
119
145
  <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
package/src/ui/Help.tsx CHANGED
@@ -8,7 +8,7 @@ import { theme } from "../lib/theme.ts"
8
8
  import { APP_NAME, APP_VERSION, REPO_URL } from "../version.ts"
9
9
  import { Sparkle, Spinner } from "./components.tsx"
10
10
  import { useStore } from "./store.tsx"
11
- import { runSelfUpdate, installChannel, PACKAGE_UPDATE_CMD, type UpdateInfo, type SelfUpdateResult } from "../lib/appUpdate.ts"
11
+ import { runSelfUpdate, installChannel, updateCommand, type UpdateInfo, type SelfUpdateResult } from "../lib/appUpdate.ts"
12
12
 
13
13
  type Section = { title: string; keys: [string, string][] }
14
14
 
@@ -191,10 +191,10 @@ function AboutColumn({
191
191
  {selfUpdate !== "idle" && selfUpdate !== "running" ? (
192
192
  <text content={selfUpdate.message} fg={selfUpdate.ok ? theme.good : theme.bad} />
193
193
  ) : null}
194
- {installChannel() === "package" ? (
194
+ {installChannel() !== "git" ? (
195
195
  <>
196
196
  {line("Manual:")}
197
- {line(PACKAGE_UPDATE_CMD, theme.text)}
197
+ {line(updateCommand() ?? "", theme.text)}
198
198
  </>
199
199
  ) : (
200
200
  <>