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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -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
+ }
@@ -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
+ }