spinuptui 0.19.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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
package/src/lib/local.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Local working-copy linking — Phase 1 (link + view, no mutation).
|
|
2
|
+
//
|
|
3
|
+
// Bridges SpinupWP's remote site inventory to the user's LOCAL dev copies so
|
|
4
|
+
// the TUI can later help with continued management (the Bedrock composer-update
|
|
5
|
+
// → push → auto-deploy loop). This module owns the link *shape* and the
|
|
6
|
+
// read-only validation of a configured link; it never mutates anything.
|
|
7
|
+
//
|
|
8
|
+
// A link is keyed by SpinupWP site id (stable across domain renames) and stores
|
|
9
|
+
// the local `path` plus an explicitly-captured `localUrl` (we don't guess it —
|
|
10
|
+
// the URL is recorded as entered, per the locked spec). `localUrl` is host-tool
|
|
11
|
+
// agnostic on purpose: it's whatever serves the local copy — Valet, Cove,
|
|
12
|
+
// LocalWP, Herd, DDEV, `artisan serve`, etc. We never assume a specific tool.
|
|
13
|
+
|
|
14
|
+
import { homedir } from "node:os"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
17
|
+
|
|
18
|
+
export interface LocalLink {
|
|
19
|
+
// Recorded for display / sanity-checking against the live site domain.
|
|
20
|
+
domain: string
|
|
21
|
+
// Local filesystem path to the working copy. Stored as entered (may use `~`);
|
|
22
|
+
// expanded only at point of use so the config stays readable/portable.
|
|
23
|
+
path: string
|
|
24
|
+
// The URL where the local copy is served (e.g. https://example.test). Stored
|
|
25
|
+
// explicitly, not derived, and tool-agnostic. May be empty.
|
|
26
|
+
localUrl: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Normalize a stored link, tolerating older configs that used `valetUrl` before
|
|
30
|
+
// the field was generalized to `localUrl`. Reads either key; emits `localUrl`.
|
|
31
|
+
export function normalizeLink(raw: LocalLink & { valetUrl?: string }): LocalLink {
|
|
32
|
+
return { domain: raw.domain, path: raw.path, localUrl: raw.localUrl ?? raw.valetUrl ?? "" }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Resolution of a link against the filesystem, computed on demand (cheap — one
|
|
36
|
+
// focused site at a time). `kind` mirrors the stack buckets used elsewhere.
|
|
37
|
+
export type LocalKind = "bedrock" | "wp" | "unknown"
|
|
38
|
+
export interface LocalState {
|
|
39
|
+
exists: boolean
|
|
40
|
+
kind: LocalKind
|
|
41
|
+
// Short human label for the detail field, e.g. "Bedrock" / "WordPress".
|
|
42
|
+
label: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Expand a leading `~` to the user's home directory. Leaves other paths as-is.
|
|
46
|
+
export function expandPath(path: string): string {
|
|
47
|
+
if (path === "~") return homedir()
|
|
48
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2))
|
|
49
|
+
return path
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Files that signal "a project root lives here" — used to locate the working
|
|
53
|
+
// copy and to read its git/.env. Deliberately marker-based (not folder-name
|
|
54
|
+
// based) so layouts like `app/`, `public/`, `web/` all work without hardcoding
|
|
55
|
+
// any one developer's convention.
|
|
56
|
+
function hasProjectMarkers(dir: string): boolean {
|
|
57
|
+
return (
|
|
58
|
+
existsSync(join(dir, "composer.json")) ||
|
|
59
|
+
existsSync(join(dir, ".git")) ||
|
|
60
|
+
existsSync(join(dir, ".env")) ||
|
|
61
|
+
existsSync(join(dir, "wp-config.php")) ||
|
|
62
|
+
existsSync(join(dir, "config", "application.php"))
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve the actual project root within a linked folder: the folder itself if
|
|
67
|
+
// it holds markers, else the first immediate subdirectory that does (so a
|
|
68
|
+
// domain folder containing the app in `app/`, `public/`, etc. resolves without
|
|
69
|
+
// us naming the convention). Read-only; never throws.
|
|
70
|
+
export function findProjectRoot(dir: string): string {
|
|
71
|
+
if (hasProjectMarkers(dir)) return dir
|
|
72
|
+
try {
|
|
73
|
+
for (const child of readdirSync(dir)) {
|
|
74
|
+
if (child.startsWith(".")) continue
|
|
75
|
+
const c = join(dir, child)
|
|
76
|
+
if (hasProjectMarkers(c)) return c
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// unreadable — fall through to the folder itself
|
|
80
|
+
}
|
|
81
|
+
return dir
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Classify a local working copy by reading a few marker files (read-only):
|
|
85
|
+
// Bedrock = composer.json requiring roots/bedrock, or config/application.php
|
|
86
|
+
// WordPress = wp-config.php at the root (or a public/ webroot)
|
|
87
|
+
// Anything that exists but matches neither is "unknown".
|
|
88
|
+
function classify(dir: string): LocalKind {
|
|
89
|
+
const composer = join(dir, "composer.json")
|
|
90
|
+
if (existsSync(composer)) {
|
|
91
|
+
try {
|
|
92
|
+
if (readFileSync(composer, "utf8").includes("roots/bedrock")) return "bedrock"
|
|
93
|
+
} catch {
|
|
94
|
+
// unreadable composer.json — fall through to other markers
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (existsSync(join(dir, "config", "application.php"))) return "bedrock"
|
|
98
|
+
if (existsSync(join(dir, "wp-config.php")) || existsSync(join(dir, "public", "wp-config.php"))) return "wp"
|
|
99
|
+
return "unknown"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const KIND_LABEL: Record<LocalKind, string> = {
|
|
103
|
+
bedrock: "Bedrock",
|
|
104
|
+
wp: "WordPress",
|
|
105
|
+
unknown: "unrecognized",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate a configured link against the local filesystem. Never throws. The
|
|
109
|
+
// stored path is the project *folder*; we classify at its detected root so a
|
|
110
|
+
// nested layout (app/, public/, …) still reports the right stack.
|
|
111
|
+
export function resolveLocalLink(link: LocalLink): LocalState {
|
|
112
|
+
const dir = expandPath(link.path)
|
|
113
|
+
if (!existsSync(dir)) return { exists: false, kind: "unknown", label: "missing" }
|
|
114
|
+
const kind = classify(findProjectRoot(dir))
|
|
115
|
+
return { exists: true, kind, label: KIND_LABEL[kind] }
|
|
116
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Production media fallback for a linked local site.
|
|
2
|
+
//
|
|
3
|
+
// After a `p` prod→local DB sync the local DB is current but media files aren't
|
|
4
|
+
// pulled down, so browsing locally shows broken images. This drops a small,
|
|
5
|
+
// self-contained mu-plugin into the LOCAL WordPress copy that rewrites any
|
|
6
|
+
// uploads URL whose file is MISSING locally to the production origin — so images
|
|
7
|
+
// resolve without syncing the (often huge) media library.
|
|
8
|
+
//
|
|
9
|
+
// The feature is LOCAL-ONLY and read-only on production (it just hotlinks the
|
|
10
|
+
// site's own production images). Its on/off state is the file's presence — no
|
|
11
|
+
// config flag — so enabling/disabling is just writing/removing the plugin. The
|
|
12
|
+
// plugin self-configures from `wp_get_upload_dir()` (correct for Standard WP,
|
|
13
|
+
// Bedrock, and multisite) and self-disables when running on the production
|
|
14
|
+
// domain, so it's inert if ever deployed.
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import type { Site } from "../api/types.ts"
|
|
19
|
+
import { expandPath, findProjectRoot, type LocalLink } from "./local.ts"
|
|
20
|
+
|
|
21
|
+
const PLUGIN_FILE = "spinup-media-fallback.php"
|
|
22
|
+
// Identifies a plugin WE generated, so we never touch a user's own file.
|
|
23
|
+
const MARKER = "spinup:media-fallback"
|
|
24
|
+
// Bumped whenever the generated PHP changes; an installed file older than this
|
|
25
|
+
// surfaces an "update available" prompt in the overlay. A file carrying our
|
|
26
|
+
// marker but no version tag predates versioning and counts as v1.
|
|
27
|
+
const PLUGIN_VERSION = 2
|
|
28
|
+
|
|
29
|
+
export interface MediaFallbackPlan {
|
|
30
|
+
localRoot: string // where the local WordPress install lives
|
|
31
|
+
muDir: string // the mu-plugins dir (created on enable)
|
|
32
|
+
pluginPath: string // muDir/spinup-media-fallback.php
|
|
33
|
+
prodOrigin: string // https://prod-domain — the fallback source
|
|
34
|
+
stack: "bedrock" | "standard"
|
|
35
|
+
enabled: boolean // our plugin currently present
|
|
36
|
+
version: number // the version this build would write
|
|
37
|
+
installedVersion: number | null // version on disk, or null when not installed
|
|
38
|
+
updateAvailable: boolean // installed but older than `version`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type MediaFallbackResult = { ok: true; plan: MediaFallbackPlan } | { ok: false; error: string }
|
|
42
|
+
|
|
43
|
+
// Bedrock keeps wp-content under web/app; Standard WP under wp-content. Detect by
|
|
44
|
+
// the on-disk content dir so the plugin lands where WordPress autoloads mu-plugins.
|
|
45
|
+
function contentMuDir(localRoot: string): { dir: string; stack: "bedrock" | "standard" } {
|
|
46
|
+
if (existsSync(join(localRoot, "web", "app"))) return { dir: join(localRoot, "web", "app", "mu-plugins"), stack: "bedrock" }
|
|
47
|
+
return { dir: join(localRoot, "wp-content", "mu-plugins"), stack: "standard" }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// True only when the file exists AND carries our marker (never report or remove a
|
|
51
|
+
// user's own same-named plugin).
|
|
52
|
+
function isOurs(pluginPath: string): boolean {
|
|
53
|
+
return installedVersion(pluginPath) !== null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Version of an installed plugin: the tagged number, 1 for a marked-but-untagged
|
|
57
|
+
// file (predates versioning), or null when the file is absent / not ours.
|
|
58
|
+
function installedVersion(pluginPath: string): number | null {
|
|
59
|
+
try {
|
|
60
|
+
const txt = readFileSync(pluginPath, "utf8")
|
|
61
|
+
if (!txt.includes(MARKER)) return null
|
|
62
|
+
const m = txt.match(/spinup:media-fallback v(\d+)/)
|
|
63
|
+
return m ? parseInt(m[1], 10) : 1
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function planMediaFallback(site: Site, link: LocalLink | undefined): MediaFallbackResult {
|
|
70
|
+
if (!link) return { ok: false, error: "Not linked — press L to link a local copy first." }
|
|
71
|
+
const dir = expandPath(link.path)
|
|
72
|
+
if (!existsSync(dir)) return { ok: false, error: "Local path is missing — press L to fix the link." }
|
|
73
|
+
|
|
74
|
+
const localRoot = findProjectRoot(dir)
|
|
75
|
+
const { dir: muDir, stack } = contentMuDir(localRoot)
|
|
76
|
+
const pluginPath = join(muDir, PLUGIN_FILE)
|
|
77
|
+
const prodOrigin = `${site.https?.enabled ? "https" : "http"}://${site.domain}`
|
|
78
|
+
const iv = installedVersion(pluginPath)
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
plan: {
|
|
82
|
+
localRoot,
|
|
83
|
+
muDir,
|
|
84
|
+
pluginPath,
|
|
85
|
+
prodOrigin,
|
|
86
|
+
stack,
|
|
87
|
+
enabled: iv !== null,
|
|
88
|
+
version: PLUGIN_VERSION,
|
|
89
|
+
installedVersion: iv,
|
|
90
|
+
updateAvailable: iv !== null && iv < PLUGIN_VERSION,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Write (or refresh) the mu-plugin. Idempotent; safe to call repeatedly.
|
|
96
|
+
export function enableMediaFallback(plan: MediaFallbackPlan): void {
|
|
97
|
+
mkdirSync(plan.muDir, { recursive: true })
|
|
98
|
+
writeFileSync(plan.pluginPath, pluginSource(plan.prodOrigin))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Remove the mu-plugin — but only if it's the one we generated.
|
|
102
|
+
export function disableMediaFallback(plan: MediaFallbackPlan): void {
|
|
103
|
+
if (isOurs(plan.pluginPath)) unlinkSync(plan.pluginPath)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The generated mu-plugin. String.raw keeps PHP backslashes (regex) intact; the
|
|
107
|
+
// only interpolation is the production origin. The marker comment must survive.
|
|
108
|
+
function pluginSource(origin: string): string {
|
|
109
|
+
return String.raw`<?php
|
|
110
|
+
/**
|
|
111
|
+
* Plugin Name: Spinup Media Fallback (local dev)
|
|
112
|
+
* Description: Serve missing local uploads from production so images resolve without syncing the whole media library. Managed by spinup — safe to delete.
|
|
113
|
+
*
|
|
114
|
+
* ${MARKER} v${PLUGIN_VERSION}
|
|
115
|
+
*
|
|
116
|
+
* Generated by the spinup TUI for local development. When an uploaded file is
|
|
117
|
+
* not present locally, its URL is rewritten to the production origin below.
|
|
118
|
+
* Self-disables when the site runs on its production domain, so it is inert if
|
|
119
|
+
* ever deployed. Define SPINUP_MEDIA_FALLBACK_DISABLE truthy to force it off.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
if (!defined('ABSPATH')) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!defined('SPINUP_MEDIA_FALLBACK_ORIGIN')) {
|
|
127
|
+
define('SPINUP_MEDIA_FALLBACK_ORIGIN', '${origin}');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Active only on a non-production host (computed once, lazily — after options are
|
|
132
|
+
* loaded). Prevents borrowing from ourselves and makes an accidental deploy inert.
|
|
133
|
+
*/
|
|
134
|
+
function spinup_mf_active() {
|
|
135
|
+
static $active = null;
|
|
136
|
+
if ($active !== null) {
|
|
137
|
+
return $active;
|
|
138
|
+
}
|
|
139
|
+
$active = false;
|
|
140
|
+
if (defined('SPINUP_MEDIA_FALLBACK_DISABLE') && SPINUP_MEDIA_FALLBACK_DISABLE) {
|
|
141
|
+
return $active;
|
|
142
|
+
}
|
|
143
|
+
$origin = SPINUP_MEDIA_FALLBACK_ORIGIN;
|
|
144
|
+
$prod_host = $origin ? parse_url($origin, PHP_URL_HOST) : '';
|
|
145
|
+
$home_host = parse_url(home_url(), PHP_URL_HOST);
|
|
146
|
+
if (!$prod_host || !$home_host) {
|
|
147
|
+
return $active;
|
|
148
|
+
}
|
|
149
|
+
$suffix = '.' . $prod_host;
|
|
150
|
+
// On the production domain (or a clone using it)? Then do nothing.
|
|
151
|
+
if ($home_host === $prod_host || substr($home_host, -strlen($suffix)) === $suffix) {
|
|
152
|
+
return $active;
|
|
153
|
+
}
|
|
154
|
+
$active = true;
|
|
155
|
+
return $active;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* [local base URL, local base dir, production base URL] for uploads. Cached.
|
|
160
|
+
*/
|
|
161
|
+
function spinup_mf_base() {
|
|
162
|
+
static $cache = null;
|
|
163
|
+
if ($cache !== null) {
|
|
164
|
+
return $cache;
|
|
165
|
+
}
|
|
166
|
+
$u = wp_get_upload_dir();
|
|
167
|
+
$base_url = rtrim($u['baseurl'], '/');
|
|
168
|
+
$base_dir = rtrim($u['basedir'], '/');
|
|
169
|
+
$path = parse_url($base_url, PHP_URL_PATH);
|
|
170
|
+
$prod_base = rtrim(SPINUP_MEDIA_FALLBACK_ORIGIN, '/') . $path;
|
|
171
|
+
$cache = array($base_url, $base_dir, $prod_base);
|
|
172
|
+
return $cache;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Is the uploads-relative file (e.g. /2024/05/x-300x200.jpg) present locally?
|
|
177
|
+
*/
|
|
178
|
+
function spinup_mf_local_has($rel) {
|
|
179
|
+
$base = spinup_mf_base();
|
|
180
|
+
$rel_path = strtok($rel, '?'); // drop any ?ver= cache buster
|
|
181
|
+
return @file_exists($base[1] . $rel_path);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Rewrite a full local uploads URL to production when the file is missing.
|
|
186
|
+
*/
|
|
187
|
+
function spinup_mf_map_url($url) {
|
|
188
|
+
if (!is_string($url) || $url === '' || !spinup_mf_active()) {
|
|
189
|
+
return $url;
|
|
190
|
+
}
|
|
191
|
+
$base = spinup_mf_base();
|
|
192
|
+
if (strpos($url, $base[0]) !== 0) {
|
|
193
|
+
return $url;
|
|
194
|
+
}
|
|
195
|
+
$rel = substr($url, strlen($base[0]));
|
|
196
|
+
if (spinup_mf_local_has($rel)) {
|
|
197
|
+
return $url;
|
|
198
|
+
}
|
|
199
|
+
return $base[2] . $rel;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Core-generated URLs (blocks, themes, and the admin media library).
|
|
203
|
+
add_filter('wp_get_attachment_url', 'spinup_mf_map_url', 99);
|
|
204
|
+
add_filter('wp_get_attachment_image_src', function ($image) {
|
|
205
|
+
if (is_array($image) && isset($image[0])) {
|
|
206
|
+
$image[0] = spinup_mf_map_url($image[0]);
|
|
207
|
+
}
|
|
208
|
+
return $image;
|
|
209
|
+
}, 99);
|
|
210
|
+
add_filter('wp_calculate_image_srcset', function ($sources) {
|
|
211
|
+
if (is_array($sources)) {
|
|
212
|
+
foreach ($sources as $k => $s) {
|
|
213
|
+
if (isset($s['url'])) {
|
|
214
|
+
$sources[$k]['url'] = spinup_mf_map_url($s['url']);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return $sources;
|
|
219
|
+
}, 99);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Would a local-origin request for $path 404? Decide from the real document root
|
|
223
|
+
* (what the web server itself would resolve), so this works for ANY path — the
|
|
224
|
+
* current uploads dir, a legacy /wp-content/uploads after a Bedrock conversion,
|
|
225
|
+
* or a CDN-offloaded path. Returns the production URL to use, or null to keep the
|
|
226
|
+
* local one. Deferring to production by path means production's own routing
|
|
227
|
+
* (redirects, S3/CDN) resolves whatever we hand it.
|
|
228
|
+
*/
|
|
229
|
+
function spinup_mf_map_path($path) {
|
|
230
|
+
$root = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim($_SERVER['DOCUMENT_ROOT'], '/') : '';
|
|
231
|
+
if ($root !== '' && @file_exists($root . $path)) {
|
|
232
|
+
return null; // present locally — leave it
|
|
233
|
+
}
|
|
234
|
+
return rtrim(SPINUP_MEDIA_FALLBACK_ORIGIN, '/') . $path;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Front-end catch-all: rewrite any local-origin media URL in the final HTML whose
|
|
238
|
+
// file is missing locally — covers hardcoded URLs and page builders (e.g.
|
|
239
|
+
// Elementor inline background-image CSS) that bypass the attachment filters, plus
|
|
240
|
+
// JSON-escaped URLs (\/) that Elementor embeds for galleries/lightboxes.
|
|
241
|
+
add_action('template_redirect', function () {
|
|
242
|
+
if (!spinup_mf_active()) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (is_admin() || is_feed()
|
|
246
|
+
|| (defined('DOING_AJAX') && DOING_AJAX)
|
|
247
|
+
|| (defined('REST_REQUEST') && REST_REQUEST)
|
|
248
|
+
|| (defined('WP_CLI') && WP_CLI)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
ob_start('spinup_mf_filter_html');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
function spinup_mf_filter_html($html) {
|
|
255
|
+
if (!is_string($html) || $html === '' || !spinup_mf_active()) {
|
|
256
|
+
return $html;
|
|
257
|
+
}
|
|
258
|
+
$home_host = parse_url(home_url(), PHP_URL_HOST);
|
|
259
|
+
if (!$home_host) {
|
|
260
|
+
return $html;
|
|
261
|
+
}
|
|
262
|
+
$host = preg_quote($home_host, '#');
|
|
263
|
+
// Media-ish extensions worth a production fallback (not js/css — those are
|
|
264
|
+
// local code, and the file-exists check would leave them alone anyway).
|
|
265
|
+
$ext = 'jpe?g|png|gif|webp|avif|svg|bmp|ico|tiff?|heic|pdf|mp4|m4v|mov|webm|ogv|mp3|wav|ogg|m4a|woff2?|ttf|otf|eot';
|
|
266
|
+
// A slash that may be JSON-escaped (\/), so one pass matches plain, protocol-
|
|
267
|
+
// relative, and escaped URLs alike.
|
|
268
|
+
$sl = '(?:\\\\?/)';
|
|
269
|
+
$seg = '[^\s"\'()<>\\\\/]';
|
|
270
|
+
$re = '#(?:https?:)?' . $sl . $sl . $host . '(?:' . $sl . $seg . '+)+\.(?:' . $ext . ')(?:\?' . $seg . '*)?#i';
|
|
271
|
+
return preg_replace_callback($re, function ($m) use ($host) {
|
|
272
|
+
$url = $m[0];
|
|
273
|
+
$escaped = strpos($url, '\\/') !== false;
|
|
274
|
+
$norm = $escaped ? str_replace('\\/', '/', $url) : $url;
|
|
275
|
+
$rest = preg_replace('#^(?:https?:)?//' . $host . '#i', '', $norm); // /path(?query)
|
|
276
|
+
$qpos = strpos($rest, '?');
|
|
277
|
+
$path = $qpos === false ? $rest : substr($rest, 0, $qpos);
|
|
278
|
+
$query = $qpos === false ? '' : substr($rest, $qpos);
|
|
279
|
+
$prod = spinup_mf_map_path($path);
|
|
280
|
+
if ($prod === null) {
|
|
281
|
+
return $m[0];
|
|
282
|
+
}
|
|
283
|
+
$out = $prod . $query;
|
|
284
|
+
return $escaped ? str_replace('/', '\\/', $out) : $out;
|
|
285
|
+
}, $html);
|
|
286
|
+
}
|
|
287
|
+
`
|
|
288
|
+
}
|
package/src/lib/open.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Open a URL in the user's default browser, cross-platform. Best-effort; never throws.
|
|
2
|
+
|
|
3
|
+
export function openUrl(url: string): void {
|
|
4
|
+
try {
|
|
5
|
+
const platform = process.platform
|
|
6
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"
|
|
7
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url]
|
|
8
|
+
Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
9
|
+
} catch {
|
|
10
|
+
// ignore — opening a browser is a convenience, not critical
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Copy text to the system clipboard, cross-platform. Best-effort; never throws.
|
|
15
|
+
// Used by the GoDaddy handoff so the domain is ready to paste after switching
|
|
16
|
+
// into a delegated client account.
|
|
17
|
+
export function copyToClipboard(text: string): void {
|
|
18
|
+
try {
|
|
19
|
+
const platform = process.platform
|
|
20
|
+
const cmd = platform === "darwin" ? ["pbcopy"] : platform === "win32" ? ["clip"] : ["xclip", "-selection", "clipboard"]
|
|
21
|
+
const proc = Bun.spawn(cmd, { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
|
22
|
+
proc.stdin.write(text)
|
|
23
|
+
void proc.stdin.end()
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore — clipboard is a convenience, not critical
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// macOS has no registered "default terminal" role (unlike the default browser),
|
|
30
|
+
// so we can't ask LaunchServices which terminal to use. The best proxy is
|
|
31
|
+
// $TERM_PROGRAM — the terminal that launched this process sets it — which works
|
|
32
|
+
// well because the app is normally started via the `spinuptui` command from the
|
|
33
|
+
// user's terminal of choice. This maps those values to the app name `open -a`
|
|
34
|
+
// expects. Unknown/unset → Terminal.
|
|
35
|
+
const TERM_PROGRAM_APP: Record<string, string> = {
|
|
36
|
+
"iTerm.app": "iTerm",
|
|
37
|
+
Apple_Terminal: "Terminal",
|
|
38
|
+
WarpTerminal: "Warp",
|
|
39
|
+
ghostty: "Ghostty",
|
|
40
|
+
WezTerm: "WezTerm",
|
|
41
|
+
Hyper: "Hyper",
|
|
42
|
+
Tabby: "Tabby",
|
|
43
|
+
vscode: "Visual Studio Code",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve which macOS terminal app to open. An explicit config override wins;
|
|
47
|
+
// otherwise infer from $TERM_PROGRAM; otherwise fall back to Terminal.
|
|
48
|
+
export function resolveTerminalApp(override?: string | null): string {
|
|
49
|
+
const o = override?.trim()
|
|
50
|
+
if (o) return o
|
|
51
|
+
const tp = process.env.TERM_PROGRAM?.trim()
|
|
52
|
+
return (tp && TERM_PROGRAM_APP[tp]) || "Terminal"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The ssh command line for a site shell (adds -p when the port isn't the default).
|
|
56
|
+
export function sshCommand(user: string, host: string, port?: number | null): string {
|
|
57
|
+
return port && port !== 22 ? `ssh -p ${port} ${user}@${host}` : `ssh ${user}@${host}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Open a new terminal window already running `ssh user@host`. Best-effort; never
|
|
61
|
+
// throws. macOS scripts the user's terminal (iTerm or, as a reliable fallback for
|
|
62
|
+
// any other, Terminal); Linux/Windows do their best.
|
|
63
|
+
export function openSshSession(user: string, host: string, port?: number | null, terminalApp?: string | null): void {
|
|
64
|
+
const cmd = sshCommand(user, host, port)
|
|
65
|
+
try {
|
|
66
|
+
const platform = process.platform
|
|
67
|
+
if (platform === "darwin") {
|
|
68
|
+
const esc = cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
69
|
+
const app = resolveTerminalApp(terminalApp)
|
|
70
|
+
const args =
|
|
71
|
+
app === "iTerm"
|
|
72
|
+
? ["-e", `tell application "iTerm"\ncreate window with default profile command "${esc}"\nactivate\nend tell`]
|
|
73
|
+
: ["-e", `tell application "Terminal" to do script "${esc}"`, "-e", `tell application "Terminal" to activate`]
|
|
74
|
+
Bun.spawn(["osascript", ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
75
|
+
} else if (platform === "win32") {
|
|
76
|
+
Bun.spawn(["cmd", "/c", "start", "cmd", "/k", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
77
|
+
} else {
|
|
78
|
+
Bun.spawn(["x-terminal-emulator", "-e", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore — opening a shell is a convenience, not critical
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Open a terminal window at the given directory (where the user runs editor /
|
|
86
|
+
// composer / git themselves). Best-effort; never throws. macOS opens the user's
|
|
87
|
+
// terminal (see resolveTerminalApp); Linux tries common emulators; Windows opens
|
|
88
|
+
// cmd in the directory.
|
|
89
|
+
export function openTerminalAt(dir: string, terminalApp?: string | null): void {
|
|
90
|
+
try {
|
|
91
|
+
const platform = process.platform
|
|
92
|
+
if (platform === "darwin") {
|
|
93
|
+
Bun.spawn(["open", "-a", resolveTerminalApp(terminalApp), dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
94
|
+
} else if (platform === "win32") {
|
|
95
|
+
Bun.spawn(["cmd", "/c", "start", "cmd", "/k", `cd /d ${dir}`], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
96
|
+
} else {
|
|
97
|
+
// Try a few common terminal emulators; the first that exists wins.
|
|
98
|
+
Bun.spawn(["x-terminal-emulator", "--working-directory", dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore — opening a terminal is a convenience, not critical
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// PHP end-of-life data, computed from real EOL *dates* vs the current date —
|
|
2
|
+
// not a hard-coded "version X and below" cut, which would silently rot as the
|
|
3
|
+
// calendar advances. A version is EOL once its security-support date is past.
|
|
4
|
+
//
|
|
5
|
+
// Hybrid sourcing:
|
|
6
|
+
// 1. An embedded table of php.net's published dates is the offline default,
|
|
7
|
+
// so flagging is correct with no network.
|
|
8
|
+
// 2. At startup we refresh from endoflife.date (cached to disk), which keeps
|
|
9
|
+
// the data current and adds versions released after this build.
|
|
10
|
+
//
|
|
11
|
+
// A version with no known date is NOT flagged — we don't guess.
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { mkdir } from "node:fs/promises"
|
|
16
|
+
import { configDir } from "../config.ts"
|
|
17
|
+
|
|
18
|
+
// Map of "major.minor" → security-support EOL date (ISO). Source: php.net
|
|
19
|
+
// supported-versions schedule. Refreshed at runtime from endoflife.date.
|
|
20
|
+
export const EMBEDDED_PHP_EOL: Record<string, string> = {
|
|
21
|
+
"7.2": "2020-11-30",
|
|
22
|
+
"7.3": "2021-12-06",
|
|
23
|
+
"7.4": "2022-11-28",
|
|
24
|
+
"8.0": "2023-11-26",
|
|
25
|
+
"8.1": "2025-12-31",
|
|
26
|
+
"8.2": "2026-12-31",
|
|
27
|
+
"8.3": "2027-12-31",
|
|
28
|
+
"8.4": "2028-12-31",
|
|
29
|
+
"8.5": "2029-12-31",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Refresh from the network at most this often.
|
|
33
|
+
const REFRESH_AFTER_MS = 7 * 24 * 60 * 60 * 1000
|
|
34
|
+
const FETCH_TIMEOUT_MS = 6000
|
|
35
|
+
const ENDPOINT = "https://endoflife.date/api/php.json"
|
|
36
|
+
|
|
37
|
+
export type PhpEolDates = Record<string, string>
|
|
38
|
+
|
|
39
|
+
interface CacheFile {
|
|
40
|
+
fetchedAt: number
|
|
41
|
+
dates: PhpEolDates
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function phpEolCachePath(): string {
|
|
45
|
+
return join(configDir(), "php-eol.json")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Normalize "8.2.13" / "8.2" / "8" → "8.2" (the key php.net schedules by).
|
|
49
|
+
function majorMinor(version: string): string | null {
|
|
50
|
+
const parts = version.split(".")
|
|
51
|
+
const maj = Number.parseInt(parts[0], 10)
|
|
52
|
+
if (!Number.isFinite(maj)) return null
|
|
53
|
+
const min = Number.parseInt(parts[1] ?? "0", 10)
|
|
54
|
+
return `${maj}.${Number.isFinite(min) ? min : 0}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// True when `version`'s known EOL date is before `asOf`. Unknown → not flagged.
|
|
58
|
+
export function isPhpEol(version: string | null | undefined, dates: PhpEolDates, asOf: Date = new Date()): boolean {
|
|
59
|
+
if (!version) return false
|
|
60
|
+
const key = majorMinor(version)
|
|
61
|
+
if (!key) return false
|
|
62
|
+
const eol = dates[key]
|
|
63
|
+
if (!eol) return false
|
|
64
|
+
const t = Date.parse(eol)
|
|
65
|
+
return Number.isFinite(t) && t < asOf.getTime()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The PHP versions to offer in the upgrade picker. Derived dynamically from the
|
|
69
|
+
// (network-refreshed) EOL schedule so it tracks new releases (8.5, 8.6, …) on its
|
|
70
|
+
// own rather than rotting in a hard-coded list. We keep the modern major (8.x),
|
|
71
|
+
// drop ancient 7.x, and always include the site's current version even if the
|
|
72
|
+
// schedule no longer lists it. Not filtered to "installed on the server" —
|
|
73
|
+
// SpinupWP installs a version on demand the first time a site is assigned it.
|
|
74
|
+
export function offeredPhpVersions(dates: PhpEolDates, current?: string | null): string[] {
|
|
75
|
+
const set = new Set<string>()
|
|
76
|
+
for (const key of Object.keys(dates)) {
|
|
77
|
+
const maj = Number.parseInt(key.split(".")[0] ?? "", 10)
|
|
78
|
+
if (maj >= 8) set.add(key)
|
|
79
|
+
}
|
|
80
|
+
const cur = current ? majorMinor(current) : null
|
|
81
|
+
if (cur) set.add(cur)
|
|
82
|
+
return [...set].sort((a, b) => phpSortKey(a) - phpSortKey(b))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Numeric sort key for "8.10" > "8.2" correctness (major*100 + minor).
|
|
86
|
+
export function phpSortKey(version: string | null | undefined): number {
|
|
87
|
+
if (!version) return -1
|
|
88
|
+
const [maj, min] = version.split(".").map((n) => Number.parseInt(n, 10))
|
|
89
|
+
if (!Number.isFinite(maj)) return -1
|
|
90
|
+
return maj * 100 + (Number.isFinite(min) ? min : 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadCache(): CacheFile | null {
|
|
94
|
+
try {
|
|
95
|
+
const path = phpEolCachePath()
|
|
96
|
+
if (!existsSync(path)) return null
|
|
97
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
98
|
+
if (!parsed?.dates || typeof parsed.fetchedAt !== "number") return null
|
|
99
|
+
return parsed
|
|
100
|
+
} catch {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The dates to use right now: embedded defaults overlaid with cached fetch data.
|
|
106
|
+
export function resolvePhpEolDates(): PhpEolDates {
|
|
107
|
+
return { ...EMBEDDED_PHP_EOL, ...(loadCache()?.dates ?? {}) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cacheIsFresh(): boolean {
|
|
111
|
+
const c = loadCache()
|
|
112
|
+
return c != null && Date.now() - c.fetchedAt < REFRESH_AFTER_MS
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fetch the live PHP schedule from endoflife.date. Returns null on any failure
|
|
116
|
+
// (offline, timeout, bad shape) so callers fall back to embedded/cached data.
|
|
117
|
+
async function fetchPhpEolDates(): Promise<PhpEolDates | null> {
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(ENDPOINT, {
|
|
120
|
+
headers: { Accept: "application/json" },
|
|
121
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
122
|
+
})
|
|
123
|
+
if (!res.ok) return null
|
|
124
|
+
const cycles = (await res.json()) as { cycle?: string; eol?: string | boolean }[]
|
|
125
|
+
if (!Array.isArray(cycles)) return null
|
|
126
|
+
const dates: PhpEolDates = {}
|
|
127
|
+
for (const c of cycles) {
|
|
128
|
+
if (typeof c.cycle === "string" && typeof c.eol === "string") dates[c.cycle] = c.eol
|
|
129
|
+
}
|
|
130
|
+
return Object.keys(dates).length > 0 ? dates : null
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Refresh the cache if stale; returns the freshly merged dates when it updated,
|
|
137
|
+
// or null when nothing changed (cache still fresh, or the fetch failed). Never
|
|
138
|
+
// throws — PHP-EOL data is best-effort decoration.
|
|
139
|
+
export async function refreshPhpEolDates(): Promise<PhpEolDates | null> {
|
|
140
|
+
if (cacheIsFresh()) return null
|
|
141
|
+
const fetched = await fetchPhpEolDates()
|
|
142
|
+
if (!fetched) return null
|
|
143
|
+
try {
|
|
144
|
+
await mkdir(configDir(), { recursive: true })
|
|
145
|
+
const file: CacheFile = { fetchedAt: Date.now(), dates: fetched }
|
|
146
|
+
await Bun.write(phpEolCachePath(), JSON.stringify(file, null, 2) + "\n")
|
|
147
|
+
} catch {
|
|
148
|
+
/* best-effort cache write */
|
|
149
|
+
}
|
|
150
|
+
return { ...EMBEDDED_PHP_EOL, ...fetched }
|
|
151
|
+
}
|