Back to Blog

Resurrecting a 15-Year-Old Platform with Cursor: Ten Days, 49 Commits, and What AI Was (and Wasn't) Good For

J Jacob Edmond Kerr · · 14 min read
Share:
Resurrecting a 15-Year-Old Platform with Cursor: Ten Days, 49 Commits, and What AI Was (and Wasn't) Good For

Part 8, the last post in the Spotlight Media Group series. The overview explains the method and its limits. This post is different from the rest: for the first time there is a real commit history to cite, because it covers the 2026 migration itself. Every commit subject quoted below is verbatim from the repository's log. I've left out hostnames, addresses, and credentials.

The first seven posts reconstructed what a fifteen-year-old platform does, all of it written by hand before AI coding tools existed. This one is about what it takes to make that platform run again somewhere new, and about the one place AI-assisted development entered the story. It's the post where the "vibe coding" claim can be checked, because it left evidence: 49 commits between September 28 and October 8, 2026, 41 of them co-authored with Cursor, working with Claude models.

Some context on that engagement, because it explains its scope. I stopped working on the platform day to day in 2022, and the owners couldn't fund a developer on an ongoing basis. What they could fund was a small, fixed-scope piece of work: move the site off its legacy Windows/IIS host onto Ubuntu and get it working one-to-one, on a machine they control, so that they can keep evolving it themselves with AI coding tools. The longer-term plan is to test it in that local staging environment, move it to a much smaller cloud server, and retire the expensive legacy host. Scope matters for everything below: this was a move, not a rewrite, and not a modernization.

I want to be careful with the framing. This is not a story about modernizing a platform. As I'll show, the platform is not modernized. It is runnable, reproducible, measured, and documented, which is a different and more honest accomplishment, and an essential first step.

The starting point

A production platform with no source-controlled history for most of its life, running on a hosted server that was about to change. The first job was to get the code out in a form a human (or a model) could reason about.

Step 1: a scripted, audited migration

The first commits are bulk imports (Migrate production website … (53883 files), … (21686 files), and a handful of smaller ones). They came from a migration script with a design I'd defend: an audit mode that inspected the remote tree and built a manifest before anything was copied, a sync mode that copied only approved files and made local commits, and a push mode. Every run wrote an include manifest and an exclude manifest with a reason for each excluded path, and the repo's migration_logs/ folder holds 83 of those manifests and run logs.

The script's filtering rules are where the story gets instructive. It excluded caches, backups, node_modules, and everything under S3-bound media (user uploads, tour images, photographer upload folders), since those don't belong in git. It excluded anything with a name like vendor, images, or photographers. And it carried this comment, which in hindsight is the most prophetic line in the file: directory-name rules "are intentionally specific … NOT generic words like 'images' or 'assets', so that theme icons/logos bundled with the app are NOT accidentally excluded."

The list then contained images and photographers anyway.

The bug class: name-based exclusion matches at any depth

The follow-up commits tell the story in their subjects:

  • Restore missing resources/views/vendor/ -- fixes completely broken local UI

  • Restore Moodle core files wrongly excluded by migration + wire local DB access

  • Fix .gitignore: anchor vendor/ rule, stop excluding real app content

  • Recover 570+ static assets + admin/photographers/ excluded by .gitignore

The root cause, spelled out in the last commit's message, is simple and general: an unanchored ignore pattern matches a directory of that name at any depth. A rule meant to skip the top-level photographers/ upload folder also swallowed admin/photographers/, an entire admin section's real source code that had never been committed. A rule for images/ swallowed about 700 UI icons referenced directly by templates. The fix wasn't clever: anchor the rules to a path, keep the specific upload-only rules, and recover each missing file by scanning every source file for the paths it references and downloading the missing ones from the live site, which recovered 564 files and identified 152 that were already dead references on production too.

I include this at length because it's the kind of failure that AI-assisted work is prone to and good at finding: a rule that is locally reasonable and globally wrong, discovered only when something visibly breaks. It's also a reminder that a clean migration is a hypothesis you test by running the result.

Step 2: make it run, and write down why it has to look like that

A migrated tree that doesn't run is an archive. The next ten commits build the runtime, and the architecture is dictated by facts about the code:

  • Three PHP versions plus Lucee, all load-bearing. The legacy tree uses functions removed in PHP 7 (hundreds of call sites), the Laravel portal's locked dependencies require PHP 8, Moodle wants 7.4, and about 600 ColdFusion files need a Lucee runtime. The design document doesn't assert that; it verifies it by execution. It runs the portal on 7.4 and shows the exact fatal error, runs the training app on both versions to show it doesn't need its own, and measures where the container image's weight goes. (All three PHP runtimes together are about 2% of it; Lucee plus its JRE is about 39%.)

  • A provisioning script and a Docker image that do the same thing, so a teammate's laptop, a fresh cloud machine, and a container converge on the same environment.

  • A data bundle. The environment needs real data to be meaningful, so the repo carries 487 compressed per-table exports pulled from the legacy backup host, including the full history of tours, orders, and users (roughly 126,000, 135,000, and 45,000 rows). One of those decisions I especially like: an old per-visit statistics table with about ten million rows is deliberately imported as an empty table, because the investigation showed that nothing in the code ever reads it, and the documentation explains exactly why.

The bugs: what "runs on a different OS" actually costs

The commit bodies read like a field guide to moving a 2010s Windows application onto Linux. Four of them, in my words:

  1. Case sensitivity. Production MySQL on Windows treats table names case-insensitively; MariaDB on Linux doesn't. A scan of every query in the tree found 24 tables referenced under 26 differently cased spellings. The fix was compatibility views under each wrong-case name (updatable, so writes work too), generated by a script rather than by editing hundreds of queries.

  2. A decimal in a LIMIT. The ColdFusion database driver sent a numeric bind parameter as 0.0, and the database's LIMIT grammar accepts only integers. The commit changed only the limit-bound parameters on nine real pages, not every numeric parameter in the tree, and was done with a byte-safe replacement so the files' original Windows line endings weren't rewritten into a thousand-line noise diff. That last detail is the kind of thing that separates a careful change from a plausible one.

  3. Infinite redirect behind a reverse proxy. Every legacy page had a "redirect to HTTPS if not secure" check. Behind a TLS-terminating proxy the app server never saw HTTPS, so each page redirected to itself forever, but only after logging in, because a different check intercepted logged-out requests first and masked the bug in earlier testing. The fix told the app server to trust the proxy's forwarded headers.

  4. A datasource nobody had ever configured. Admin login crashed with a message that the list of available datasources was empty. The cause was larger than the symptom: the legacy app depended on a one-time setup inside the ColdFusion engine that no automation had ever performed, anywhere. The fix drives the engine's admin API to register the datasources on every fresh install, so it's no longer a manual step.

Notice the pattern in each: a symptom, a root cause stated in the commit message, a fix scoped as narrowly as possible, and an end-to-end verification line ("verified end-to-end on both bare-metal and a fresh Docker container"). That's the commit-message discipline I'd want from any engineer, human or model, and a Cursor-assisted workflow made it cheap to keep.

Step 3: measure before you plan

Before any modernization plan, the repo got three audit documents, written from read-only inspection with every number labeled as measured. I'd point a skeptical hiring manager at these first, because they show judgment, not just speed:

  • Scale, honestly measured. About 7.6 million lines of PHP, 79% of it vendored third-party code (four complete WordPress installs, the AWS SDK, Zend, and others), about 1.58 million first-party legacy lines, and about 45,000 lines of Laravel. "A 7.6-million-line rewrite" and "a 1.5-million-line legacy codebase surrounded by a dependency-replacement program" are very different projects, and the audit is what tells them apart.

  • End-of-life everywhere. A table of every runtime and framework with its end-of-life date: the legacy site's PHP 5.6 (end of life at the end of 2018), the Node 12 sync tool (April 2022), Laravel 8, and WordPress installs from 2012 onward. The conclusion in the document is a good one: the risk is not any individual style problem; it's that nothing receives security patches.

  • Findings with an ordering. Secrets committed to the repository, 226 dependency advisories across three components (with a triage order that explicitly refuses to "clear the list" and instead ranks by reachability), and a high-severity remote-code-execution advisory in a Laravel dependency that the audit confirmed was reachable in the production affiliate portal. The plan's top item is a one-line version bump. Working through these findings is the next phase of the work: the platform now runs in a local staging environment, which is exactly what lets the owners (with AI coding tools, and with this audit as the to-do list) fix them safely, one at a time, before anything goes live on the new server.

The action plan that follows is the part I'm proudest of, because it resists the obvious move. It's six phases, ordered by risk of harm if deferred, not by interest: rotate secrets (an afternoon), stop the bleeding (a week or two), build the safety net, then upgrade the modern apps, and only then touch the legacy code, as a strangler-fig by URL prefix, "delete before you port", picking the first target from 30 days of access-log data, not from taste. Its stated biggest risk: doing the upgrade phase before the safety-net phase. "Modernizing 1.5M lines of untested PHP 5.6 is how outages become permanent."

And the docs correct themselves. The design document includes a blockquote correcting the README's earlier justification for the portal's PHP version: the conclusion was right, the reasoning was not, with the actual reason (locked Symfony 6 components) and how it was verified. That's the same verify-then-correct habit that ran through the Prestige series.

What the AI was good at

From this work, specifically:

  • Breadth reading with discipline. Scanning 178,000 files for every query's table spelling, every ereg() call, every referenced-but-missing asset, and every hardcoded production hostname, then producing counts and a fix plan. This is the highest-value use: turn "I think there are some" into "there are 24 tables under 26 spellings."

  • The debug loop on environment problems. Container build, boot, request a page, read the error, find the root cause, patch the script, rebuild. Each cycle is mechanical and the root causes (a proxy header, an unregistered datasource) are exactly the kind of thing a model finds by reading config and logs.

  • Documentation as a byproduct. The README's quick-starts, the known-issues list, and the design rationale were written while the knowledge was fresh, in the same commits that fixed the problems.

  • Narrowly scoped, verified changes. Fixing only the limit-bound parameters, preserving line endings, running a before/after check on the affected pages.

What it was not good at, and where a human had to decide

  • Anything involving access and risk. Whether to open a remote database to the environment, which backup host to pull from, whether to import ten million rows of an abandoned table: those were decisions with a person's name on them.

  • Secrets. The most valuable thing an assistant can do with credentials is find them and stop. The audit found live credentials in source; the right sequence is to rotate first, then scrub history (scrubbing first just hides a secret that still works), and nobody should paste the real values into a model.

  • Knowing what the business needs. The decision to delay deleting the four end-of-life WordPress installs until someone confirms whether they're reachable is a business and risk call. The model can enumerate; it can't own.

  • Truth about the old system's intent. The code can tell you what it does. Why the video pipeline probes three volumes in order, or why a particular customer has a hardcoded domain, lives in someone's memory. That's why this series says so plainly wherever the code is silent and my recollection is the source of record.

What this didn't do

I want to close the loop on the honesty I started with. After ten days:

  • The legacy site still runs on PHP 5.6, which is end-of-life.

  • There is no CI and the production Laravel app has no automated tests.

  • The WordPress installs are still end-of-life.

  • Credentials were found in source; the audit's recommendation is to rotate them first and scrub history second, and I won't describe that as done until it is.

What exists now is a platform you can run in one command, whose structure is explained with evidence, whose risks are ranked, and whose next steps are ordered. For anyone who has inherited a system like this, that's the real unlock: you cannot modernize what you cannot run, measure, and explain.

The through-line of the series

Across eight posts, the same few ideas kept showing up in different clothes:

  1. Keep the original, treat derivatives as a cache (photos, video, listings).

  2. The database is the queue, and flags need leases (zip builds, scheduling, the process watcher).

  3. Every integration has a half-life (MLS boards, LinkedIn, Facebook, Pinterest, Google+).

  4. Measure, don't assume (the line counts, the table spellings, the audit).

  5. An unexercised safety mechanism is a comment, not a safeguard (the lock nobody checked, the exception class that doesn't exist).

  6. AI is a force multiplier for the reading and the loop, and not a substitute for ownership of the decisions.

If you're hiring for a team that works on large, long-lived systems, such as ML-powered ad platforms or distributed systems with a decade of history, that last point is the one I'd most want to talk about, and I'm glad to walk through any of this live.


Evidence appendix

  • 49 commits, 2026-09-28 to 2026-10-08; 41 with a Cursor co-author trailer; verbatim subjects quoted above: git log on the repository.

  • Migration script, include/exclude manifests, 83 migration logs, filtering rules and the comment about generic directory names: scripts/migrate_website.sh, migration_logs/.

  • Unanchored ignore-rule recovery (564 assets recovered, 152 dead references): commit Recover 570+ static assets + admin/photographers/ excluded by .gitignore.

  • Case-sensitivity compatibility views and the LIMIT decimal bug: commit Fix legacy table-name case mismatches + LIMIT clause decimal bug; deploy/db/create-legacy-case-compat-views.sql.

  • Proxy-header redirect loop and the unconfigured datasources: commits Fix ERR_TOO_MANY_REDIRECTS on admin pages after login (Lucee CGI.https) and Fix admin login 500: configure missing Lucee 'spotlight'/'postproduction' datasources; deploy/lucee-trust-reverse-proxy.sh, deploy/lucee-configure-datasources.sh.

  • Three PHP versions, image-size breakdown, verified-by-execution reasoning, self-correction: deploy/docker/Design_Reasoning.md.

  • Phases, advisories, EOL table, scale numbers: deploy/docker/Action_Plan.md, deploy/docker/Project_Status.md.

  • Environment docs and data bundle: deploy/README.md, deploy/db/dumps/.

Share:

Comments

No comments yet — be the first to share your thoughts.

Leave a comment

Your comment will be reviewed before it appears publicly.

Never published — only used if we need to reach you.

More from the blog