472fd9cb8815fc5018c1fe2713ae1c293822cbbf
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
472fd9cb88 |
publish promotes development's content, then builds
The admin edits development and the build renders production, so nothing written in the panel could reach the live site. Build was only half of publishing, and the missing half was invisible: it succeeded, against the content somebody last promoted, and a successful build of yesterday's site reads as a broken renderer. Publish copies the site's content tables across and then builds. Two things it will not copy: `users`, because production's logins are not development's and nobody should be locked out of the live site to publish a photograph; and `contact_submissions`, the legacy inbox, because development's is empty and production's is not — copying it would destroy real messages in order to publish nothing. The table list is written out rather than derived. A table added later should be considered rather than swept along, and an empty development table quietly overwriting production-only data is discovered far too late to fix. Order is load-bearing in two places. Production is dumped before anything is destroyed, and the publish refuses to start if that dump fails. Development is read BEFORE production is cleared, so a failure to read leaves production untouched rather than empty for a reason that had nothing to do with it. `TRUNCATE ... RESTART IDENTITY`, so the sequences the copy sets are not merely ahead of the rows it inserts — otherwise the collision arrives on the client's next save, hours later, somewhere else. `backupAfterPublish` has been sitting in backups.ts without a caller since the day "On publish" became a backup schedule. This is the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9fd5f1b3ab |
gallery items get a thumbnail and a caption
The thumbnail comes from the DERIVED ladder, which is the point of the whole pipeline: most of these are HEIC and a browser cannot display the original at all. 320 is the smallest rung and still sharp at this size. Served by this plugin from disk rather than by the site. The panel talks to the plugin and the catch-all forwards everything to the site's /api/admin, which is a different route — and more importantly, looking at a gallery should not require the development server to be running. Editing the site is not the same as reading it. Not under /_config, because that prefix is owner-only and a thumbnail is not a credential: a member who can open the section can see the pictures in it, which is what the section is. Registered before the catch-all, or the catch-all would forward it to fofos. Path traversal is refused by resolving first and comparing against the directory plus a separator — a bare startsWith accepts `derived-old` for a root of `derived`. An item whose conversion has not run has no rung, and the thumbnail hides rather than showing a broken-image glyph, which reads as corrupt rather than pending. Captions save on blur. A caption is a sentence somebody is in the middle of, and a request per keystroke would be wasteful and a race with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5cfe571db0 |
the expansion route was doubling the folder into every path
`listOriginals` already includes the prefix in what it yields — it builds `prefix/name` as it descends — so prefixing again produced Home_Page_Gallery/Home_Page_Gallery/…, a real-looking path to nothing. Found by calling the route rather than reading it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a610f93189 |
Create Gallery on a folder means the files inside it
It stored the folder. What somebody right-clicks is a directory; what a gallery holds is pictures — obvious the moment you look at the result, and not before. Expanded by the server, because that side already knows which extensions are media and how to walk the tree. It is the same `listOriginals` the sync uses, so a gallery cannot end up holding a file the pipeline would never convert. Recursive, deliberately: a folder of folders is still "these pictures", and refusing to descend would silently miss most of what was pointed at. An expansion that returns nothing creates nothing. Falling back to the folder itself is exactly the bug being fixed, and doing it quietly is worse than doing nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
063d2c4a88 |
watch originals, because a move is not an event
You should not have had to press Sync. Nine files moved into a folder and nothing noticed. Two gaps, and neither would have caught it. The platform's event bus emits `files:uploaded` and `archive:extracted` — things Officer's own file browser DID — so a move emits nothing at all, and neither would an rsync, a drop over SMB, or anything done in a terminal. And no plugin subscribes to hooks anyway. That is not a bug in the bus: it reports actions, and this needs to know about a directory. So a filesystem watcher, the same shape as the mailbox one already here. The debounce is twenty seconds and long on purpose. A move of nine files is dozens of events and an upload of two hundred is thousands, while a sync walks the whole tree — so waiting for quiet means one run after the dust settles rather than one per file. Being slightly late costs nothing, because the sync skips everything already current; being eager costs a full walk per event. It cannot feed itself: only originals/ is watched and the sync writes only to derived/. Worth stating, because the opposite arrangement is a loop that looks like a busy machine and nothing else. A run already in flight is left alone rather than queued — it walks the tree when it starts, and whatever lands after that is what the next quiet stretch is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f0d29c8000 |
the site sections open, because there is finally something behind them
`ready` was hardcoded false for as long as the sections proxied to an address nobody could set — the connection form was removed and nothing replaced it, so nine doors led to the same "not connected" message. Both halves exist now and neither is typed by a person: the development server runs on a port this plugin chose, holding a FOFOS_API_TOKEN this plugin generated and handed it at start. So the credentials are DERIVED rather than stored. Asking somebody to copy a value out of one part of this plugin into another was never a setting, it was a chore. Development and not production, and that is not a preference. Production is a directory of files behind nginx — no API, no database, nothing listening — so editing against it is impossible rather than merely wrong. Sections read and write dev; Build is what publishes. An edit is visible immediately and public only when somebody decides it should be. The gate asks whether the server ANSWERS, not whether pm2 says online. `bun --hot` survives a crash at startup: the process stays up, pm2 reports online, and nothing is listening. Opening nine sections onto that would make one failed boot look like nine broken screens. And when /status itself cannot be reached, `configured` stays true while `ready` goes false. Telling somebody they have set nothing up is alarming and wrong; opening nine sections onto a server we could not confirm is answering is nine screens that each fail separately. Cannot-tell keeps them shut and lets the next poll open them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27f93d6e14 |
production is a static build behind nginx, not a process
The site's own build script says it outright: "The public site is not served by Bun in production — it is a directory of files behind nginx. Output: dist/." So production has no port, nothing to restart, and nothing that can crash at three in the morning. It is built or stale, and served or not served. Showing it as a pm2 process was showing something that does not exist, and its Start button could only ever have failed. It now has Build, Serve and Stop, and the two states are reported separately — a build can succeed while nothing serves it, and a container can serve a directory built weeks ago. They fail independently and are fixed differently. The container exists because nginx could not see the files. NPM mounts only its own /data and /etc/letsencrypt, and there is no other nginx on the box. The alternatives were putting site files inside NPM's own volume, or editing NPM's compose and restarting it — which takes every site on this machine down for a moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with NO published port. NPM reaches it by name, and nothing about the site is exposed except through the proxy. Recreated rather than restarted, for the same reason pm2 is: a container keeps the mounts it was created with, so a root location changed here would be saved, displayed, and still serving the old directory. Domains follows. Production's forward target is a container NAME, not the docker gateway that reaches a host process — and matching what already points at it uses the full target rather than the port, since every other site behind this NPM on port 80 would otherwise be listed as fofos's. The compiler found the dead branches this left in startServer once production could no longer reach it. Removed rather than left to read as though the case were still possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2e56260a88 |
a Servers section: development and production, both under pm2
pm2 for BOTH, development included. A `bun dev` started in a terminal dies with the terminal, and nobody can tell whether it is running without finding the window it was started in. Under pm2 the two are the same kind of thing — a named process with a status, an uptime and a log, startable from here and inspectable from anywhere. The cost is that development stops printing to a console somebody is watching, which is what `pm2 logs fofos-dev` is for; the process names are in the panel so nobody has to guess them. This plugin owns exactly two names, `fofos-dev` and `fofos-prod`, and every command is built from that pair and from nothing a caller supplies. No request can stop, restart or delete another process on the machine. That matters more than usual here: Officer's own processes share this pm2 daemon, and a plugin able to name any of them would be a way to take the host down through a web form. Start is really a replace — delete, then start. Not `restart`: it fails on a process that has never existed, and on one that has it reuses the environment it was started with, so a port or database changed here would be saved, displayed, and not in effect. That is an hour of somebody's debugging. The environment is assembled here because this plugin is what knows the ports and the database names, and it is handed to pm2 through the invoking command's own environment — pm2 has no flag for it; `--env` names a block in an ecosystem file. The site reads .env files first and the environment last, so this fills what is absent rather than overriding what somebody wrote down. Ports have no default. It is the one setting where a plausible guess is worst: it binds, the site appears to work, and it is quietly answering on a port another service wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af31b57cdf |
the databases say what is in them, and the block folds away when it is done
Two databases with identical structure look identical until you count what is in them — one is the live site and one is a copy. So a row now reads "18 tables · 155 rows" with the time anything was last written, rather than only reporting that a schema exists. Counted with query_to_xml over information_schema, which is exact. reltuples from the planner would be free and is an ESTIMATE: it reads as zero on a table nobody has analysed, which is precisely the freshly restored database this is meant to reassure somebody about. That is not a hypothetical — it is why pg_stat_user_tables reported an empty database earlier today that had 155 rows in it. And the block collapses with a check once everything is in order, on the same terms as Root location: both databases existing with a schema, and backups configured. Not backups having RUN — a site set up an hour ago has taken none, and calling that incomplete is telling somebody to fix what is not broken. Seeded from /summary so the first paint is already right, then kept live by what the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone while unsettled, which is the bug that stopped Root location folding at all. BackupsRow reports its bit from an EFFECT, not during render: calling the parent's setter mid-render is an update to a different component, which React refuses outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fb234ede5d |
the contact inbox is a mailbox, and it is copied the moment it changes
Messages are the one thing that happens between publishes. A backup triggered by publishing would never contain them, and a RESTORE would destroy every message received since the dump being restored — turning a routine recovery into losing the only copy of something a stranger sent, with nothing about the operation looking like it had done that. So they live in their own SQLite file, in the shape Officer's email plugin already uses. Not a schema that fits a contact form: this IS mail — a from, a subject, a date and a body wearing a form — and storing it as a mailbox means the mail client can open it, and anybody's agent already knows how to read it without being told. The form's own fields become labelled lines in text_body, where a reader and a grep both find them; inventing columns would be the moment it stopped being a mailbox. This side watches the file and copies it within seconds of a write, to the local backup directory and to the machine off this one. The alternative is a window: a message at ten past nine and a disk that fails at twenty past is a message nobody sees, while the sender believes they contacted you. Two details are the whole reason this is not a `cp`. The WAL is checkpointed first — the site writes in WAL mode, so a new message lives in messages.db-wal and not yet in messages.db, and copying the main file alone yields a database that is intact, readable, and missing exactly the message that triggered the copy. And the DIRECTORY is watched rather than the file, because SQLite replaces files during a checkpoint and a watch on a replaced inode goes quiet for good — a watcher that silently stops is worse than none, since the panel would go on implying the inbox is protected. The copy is current, not history; the ordinary backup runs take a timestamped one alongside the dumps, so a restored site can have the inbox that matches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
89c9dd6f63 |
restore: pick a dump, pick a database, say yes
A pg_dump carries CREATE TABLE, so it cannot be layered onto a database that already has the schema — it fails on every object. The only honest restore empties the target first, and that is not a detail to bury behind a button. So choosing a destination and confirming are two separate clicks, and the second names what it is about to destroy. `confirm` is required by the function, not merely by the panel, so no code path can reach a restore by accident. The file is matched against what listDumps actually found in the backup directory rather than taken as given. There is no path to traverse out of and no way to point psql at a file this module did not write: an unknown name is refused before anything is opened, rather than sanitised and hoped about. Emptied with DROP SCHEMA public CASCADE through psql, not by dropping the DATABASE — that would need a connection to another one, fail while anything is attached, and lose whatever was granted on it. Replacing the schema does the same job to the part that matters. psql rather than pg_restore, because these are plain-SQL dumps, which is what pg_dump writes without -F c. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5fc710f072 |
backups go to another machine, and old ones are pruned
Three things. The local directory defaults to <officerdev root>/dockers/postgres/dumps, derived from OFFICER_ROOT rather than written down — the same reasoning as rootPath: a path fixed at install freezes the layout of the machine it was installed on, and a restore elsewhere then points somewhere that is not there while looking deliberately chosen. The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the machine it came from is a second copy on one disk, lost to the same failure that would make anybody want it. So local is staging and the copy off the box is the backup, and a schedule with nowhere to send it does not count as configured. Testable, in two steps reported separately, because they fail for different reasons and the fixes differ: the connection is keys and networking, the directory is permissions on the far side. It tests what is in the FORM rather than what is stored — testing a saved destination while somebody looks at a different one is worse than not testing. Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs, so a key that has lost access must FAIL rather than block forever on a prompt nothing will answer. It is also why no credential is stored: there is none to store and none to leak. The remote path is the one value here a shell interprets — `ssh host mkdir -p <path>` runs through it — so shell characters are refused rather than escaped. Refusing is checkable by reading it; escaping is checkable by being right about every shell. And rotation keeps the newest N per database at both ends. Per database, because two sharing one budget means a busy one evicts the other's history. Sorted by NAME rather than mtime: the timestamp is in the filename and is sortable, while mtime stops meaning "when this was taken" the moment a file is copied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b398121b7 |
backups: a schedule, a location, and a timer that survives a restart
Restore goes. What it would read from was never decided, and a disabled button with a tooltip explaining it is not built is worse than no button. In its place, backups. `cron` was already a dependency and used nowhere, so the schedule is a real cron expression — a backup wants to happen at an HOUR, and an interval counted from whenever somebody pressed save drifts across the clock until it runs mid-afternoon. Cron alone is not enough, and this is the part worth writing down: it does not run a job it was down for. A daily backup at three on a machine that restarts overnight is skipped silently — the schedule is still there, the next fire is still in the future, and a month later there is one file. So `lastBackupAt` is in the database, and both the startup and a fifteen-minute tick compare it against the schedule's own period, computed from two successive fires. A backup that should already have happened happens now. The job handle is module-level and stopped before a new one is made. Two saves leaving two jobs is a bug that surfaces as backups arriving in pairs, weeks later, with nothing connecting it to a settings form. Back up now exists because a scheduled job nobody has ever run by hand is a guess — it is how you learn the location is writable and pg_dump can reach the databases without waiting until three to find out it cannot. The list underneath is a readdir of the location rather than a record of what should be there: a schedule that has been failing quietly looks exactly like a working one until you count the files. pg_dump per database, not pg_dumpall — a cluster dump would also carry Officer's own, which is not this plugin's to copy around. And a failed run still records the timestamp, or one undumpable database would have the catch-up tick retrying every fifteen minutes for the rest of the week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0971764fca |
once both databases are there, the form becomes two rows of actions
A text box invites an edit nobody is here to make. Named and created, the two things somebody wants are per-database actions, so the inputs and the button go and each database becomes a label, a table count, and what can be done to it. Push schema runs the WEBSITE's own `db:push`. Not drizzle-kit driven from here with a config this plugin assembles — the schema, the drizzle version and the push command all belong to that repository, and reimplementing any of them would be a second definition of the site's database going stale on its own schedule. This supplies the connection and runs their script. The connection goes in the environment, which works because src/env-file.ts consults .env files first and process.env last. So an ambient value is used only where no file in the checkout sets that key, and this cannot quietly override a connection somebody wrote down. Their name guard still applies and should: env.ts refuses to connect unless the database name matches what FOFOS_ENV expects. A plugin that worked around that would be defeating the one check standing between development and live data. "Schema pushed" is a COUNT of tables in public, not a comparison against the site's expected tables — that list would be a second copy of the site's schema, stale the first time it gains a table, and the question being asked is only whether anything is in there yet. Restore is present and disabled, gated on the schema being pushed. It is not wired: which backup it reads from is not decided, and guessing at the source of a restore is not a thing to do with a client's data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
30c52e6449 |
one button, and it appears when there is something to press
Two buttons was the wrong model, and it failed in the worst way: Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not render at all — and the hint explaining why was behind the same condition. Two filled fields, one Save, and nothing saying what came next. Naming and creating are not separate decisions from the outside. You type two names because you want two databases. So one button that records them and creates whatever is missing, labelled with whichever of those it is about to do, and disabled only when there is genuinely nothing to press. The tick beside each field now asks pg_database about the text in the BOX rather than the text last saved. That is the question somebody typing a name has, and answering it about a different name was worse than silence. The PUT that only stored names goes with the second button. Nothing called it, and a second way to write the same two columns is a second thing that can drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a1fe543dff |
two database names, and a button that creates what is missing
Production and development, stored as NAMES rather than URLs. The site runs on the same Postgres Officer is already connected to — that is the whole reason a name is enough — and a URL here would be a second copy of a host, a port and a credential that already exist in the platform's configuration, free to drift from it and free to point at another machine. Naming and creating are separate, and were worth two buttons. A database that already exists still needs its name recorded, and making Create the only way to save would leave nobody able to point the site at something already set up. The tick beside each field comes from pg_database, not from what was typed — the useful question is whether the thing exists, and that came apart from "somebody saved a name" the moment the two actions split. CREATE DATABASE cannot be parameterised, so the name goes into the statement as text and the identifier check is the entire security boundary. It is therefore strict rather than accommodating — lowercase, leading letter or underscore, nothing else — and it is re-run immediately before the statement is built, because the gap between storing a name and using it is exactly where a check that happened earlier stops being a check. Postgres folds unquoted identifiers to lowercase, so accepting `Fofos` would create `fofos` and then report a name nobody could find; refusing is kinder than silently changing it. Officer's own database and the Postgres templates are refused outright. Pointing the site at Officer's database would not read as a configuration mistake — it would read as the platform corrupting itself. Nothing here drops a database, and nothing should: `fofos_website` and `fofos_old` are one keystroke apart and one of them holds the client's site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e621bc761d |
the block knows it is settled before it paints, so it stops flickering
It could not. Each part of Root location fetches its own status, so the block had no way to know whether it was finished until its children had mounted and reported — meaning it painted open and then folded away, on every load. That is not a rendering problem to tune: the information genuinely was not there yet. So one request answers the three bits first, and nothing renders until both it and the path have arrived. The children still own their detail and still report changes upward while the block is open; the summary only seeds the opening state. Two sources for one fact with a clean division — the summary is what is true on ARRIVAL, the callbacks are what changes while somebody is looking. `settledNow` picks between them: the summary while nothing has reported, the live value once anything has. Using the live value throughout would open the block for exactly the gap between the summary landing and the children answering, which is the flicker with extra steps. The summary walks the media tree, so it is deliberately not polled, and it skips the walk entirely when there is no media folder to walk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f30d40446a |
a ledger of runs, and what is still outstanding
Two things the panel could not answer. A run's state lives in a Map, so a restart erased it — and with it any record that a file FAILED. That is the gap the manifests structurally cannot cover: a derived folder records the size, mtime and variants it was built from, so provenance is already precise, but a file that did not build has no folder and leaves no trace. Once the watcher is running, a failure at three in the morning would never be discovered. So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than a table because db/schema.ts is emphatic that the connection is the only thing this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside derived/ rather than inside it, so wiping derived does not erase the history of what happened before, which is the comparison it exists for. Written in a finally, so a run that died still records that it did. And the line above the button now says what is TRUE rather than what a run once did: reconcile() walks originals, reads each manifest, and reports current, missing and changed. It uses upToDate — the same predicate the sync uses to skip a file — because two definitions of "current" is how a panel comes to say everything is in step while the next run rebuilds half of it. Missing and changed are separated because only the second means somebody edited something. The ledger is not polled. It reads the whole tree, so it loads on arrival and again when a run stops — the only two moments its answer can change. Sync also takes an optional folder now, so a subtree can be re-synced on its own rather than the whole library. The scope is recorded on each run, because "3 built" means different things for one folder and for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e87e19ee46 |
derived/ is built from originals/, and you can watch it happen
The mapping: derived/ mirrors originals/ directory for directory, and where originals holds a FILE, derived holds a DIRECTORY of the same name — extension included — with the renditions inside it. Keeping the extension is what makes it total: a.jpg and a.png are different originals and must not share a folder. The inverse is pure string work, so the filesystem is the record and there is no second copy of the truth. Images get a WebP ladder at 320/640/960/1280/1920/2560 plus full.webp, and nothing is ever upscaled — a rung wider than the original would be a soft file that srcset then confidently prefers over the sharp one. full.webp always exists because for a HEIC it is the only browser-readable copy of the picture there is. Video gets av-club's web-1080 and web-720 presets by name, plus a poster. Images are named by width because srcset descriptors are widths; video by height because that is av-club's vocabulary and nobody misreads 1080.mp4. The ladder is fixed here and the SITE chooses from it. This never reads website/src/media/slots.ts — a plugin that knows a website's layout is a plugin for one website, and that boundary is the difference between a template and something fofos-shaped. Each derived folder carries a manifest naming the size and mtime it was built from, so the sync is idempotent and resumable: a run that died halfway is finished by running it again. That is what makes an in-memory job acceptable rather than merely convenient. It builds into <name>.partial and renames, so an interruption can never leave a folder that looks finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c38d269faf |
Media moves to the foot, and each section asks its own question
TWO REPORTS, AND THE SECOND ONE WAS A DESIGN MISTAKE RATHER THAN A SETTING.
Media is pinned now, beside Administration. it sat at the end of the scrolling list,
which is the bottom of the LIST and not the bottom of the panel. it belongs in the
foot for the same reason Administration does: both are about this machine, while
everything above them is about the site's pages.
and it was LOCKED BEHIND A CONNECTION IT NEVER USES. `ready` was one global flag —
"can anything be opened" — and Media is a folder on this disk that never speaks to
fofos. so sections now declare what they need:
'connection' the site's pages, which read fofos
'root' Media, which needs a folder and nothing else
'nothing' Administration, because locking the door you fix things through is
locking somebody out entirely
`sectionOpen` is one function, used by the nav to decide whether to offer a link and
by the detail panel to decide what to render — so the two cannot disagree about a
section. that was already the rule with one flag; it matters more now that each
section asks a different question.
`/status` gained `rootSet`, a BOOLEAN, and stays not-owner-only. the nav needs the
answer for every account that can see the section; the path itself stays behind
`/_config`, which is owner-only. whether a root has been chosen reveals nothing
about the machine — where it is would.
the empty-state line follows too: "set a root location below to begin" before there
is one, "connect fofos to open the rest" after — rather than one sentence naming a
step that is not the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
003613872d |
provision <root>/media, beside the checkout
where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.
BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:
- it must SURVIVE the checkout. deleting `website` and cloning it again is a
normal thing to do when something is wrong with it, and it must never be the
thing that destroys the client's photographs.
- it must never be TRACKED. media in git is a repository nobody can clone a year
from now, and a .gitignore is a promise somebody eventually breaks with
`git add -f`.
both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.
NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.
`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.
no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
df394e29a4 |
the website checkout under the root: clone it, update it, say which
`<root>/website` is now checked, and the block says which of three things it is: not there, there but not a checkout, or a checkout with its branch and its head commit. the middle one is the state worth being loud about — cloning will refuse, and the reason is something already sitting in the way. SSH, hardcoded. the repo is private, so https asks for a username with nobody at a terminal to answer, and the SSH remote is already authenticated by the owner's key. hardcoded because this plugin administers exactly one website: a field for it would be a setting nobody changes and one more thing that can point somewhere wrong. when the checkout's origin is NOT that URL, the block says so — shown always it is noise, shown on a mismatch it answers why an update pulled something unexpected. NOTHING HERE MAY HANG. these run inside an HTTP request, so git gets GIT_TERMINAL_PROMPT=0 and ssh gets BatchMode=yes. without them a key that lost access does not fail, it BLOCKS forever waiting for a password at a terminal that does not exist, and the panel spins until somebody restarts the server. there is a five-minute kill on top for everything else that can wedge. update is `--ff-only`, never a merge. a merge commit made by a button in a checkout nobody is watching is a thing somebody finds days later; refusing is recoverable and legible. it also refuses on a dirty tree rather than failing halfway — and dirty is `--untracked-files=no`, because a build artefact or an uncommitted .env is not a reason to make Update permanently unavailable. clone takes FULL history, not --depth 1. this is a working checkout somebody will pull into and read the log of, and shallow turns both into a support question. it sits inside Root location rather than in a block of its own: the path and the thing at the path are one question, and split apart somebody could change the root in one place and read a status about the old one in the other, both looking equally current. hidden while editing, because it describes what is SAVED. git's own output is shown verbatim on failure. "Permission denied (publickey)" says exactly what is wrong; anything written in its place would be a guess at which of a dozen causes it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df52045eaa |
the path gets the row, the actions get their own
Root location is two states now. reading, it is a path and one Edit. editing, it is a field and the things you can do to it. the actions moved to the block's BOTTOM row, off the value's line. an absolute path is long, and it was being truncated to make room for buttons that are not needed most of the time — the thing somebody opened this to read was the thing squeezed. shown, it wraps with `break-all` rather than truncating, because a path is read from both ends: the machine at the front, the folder at the back, and hiding either is worse than two lines. Edit is a mode and not an always-live field. this is looked at far more often than it is changed, and a live input invites a stray keystroke into a path everything else will depend on. Cancel restores what is stored, so leaving the mode cannot leave half a path on screen looking like the setting. "back to the default" is gone, as asked — and `clearRootPath` and its DELETE route with it, since that button was the only caller. an unreferenced endpoint is the same rot as an unreferenced component, and this one was a second, quieter Save that wrote a value nobody chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d5f2d8f370 |
Root location: an absolute path, with a picker that can make folders
defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a tilde means "whoever is asking", and the people asking will not all be the same person: this plugin is grantable, and the site is in one place regardless of who is looking at it. stored as `~/fofos` it would give every user a different answer to a question that has one. THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on every read, so it follows the machine. writing it in at install freezes whichever home existed on install day, and a restore onto a different machine then points at a directory that is not there while looking deliberately chosen. a saved path is a decision and is stored exactly as given; only its absence is derived. the form says which of the two it is showing, because "not set" reading identically to "set to the default" is how somebody comes to believe they configured something they did not. THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes of this plugin's own. they already refuse to leave home, and they already tell a missing directory from a forbidden one, which is a distinction that cost somebody an afternoon and would have been re-guessed here. shipping a second listing and mkdir would be a second filesystem surface to audit for the sake of one form. that API is an execution capability and never grantable, so for anybody but the owner it 403s — said in a sentence rather than shown as an empty tree, because an empty listing is data and must never be how a refusal looks. it also browses the owner's home and nothing above it; a path outside home is still reachable by typing, since the picker fills the field and the FIELD is what saves. one value on screen, one Save, and picking a folder commits nothing by itself. "Use this folder" takes the one that is OPEN rather than one highlighted in the list. that makes looking inside and choosing one gesture, and it is the only way to choose a folder with nothing in it — which a freshly made one always is. a SECOND table, not a column on fofos_admin_connection, whose url and token are both notNull: a root path could not be saved there without also having an HTTP connection, and that connection is being replaced rather than extended. when it goes, its table is a clean drop instead of surgery on columns that outlived it. the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S work, and this row holds a path somebody can retype. needs `bun db:push`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b87956e44 |
Everything needed to be a plugin
manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.
The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.
db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.
The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:
registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
multipart across the whole protected router, and Hono then caches the parsed
FormData — so asking for the bytes afterwards re-serialises them under a NEW
boundary while the ORIGINAL content-type header is what gets forwarded. Header
and body disagree and every upload fails with "Unexpected end of form".
Narrow to the one route: everything else is JSON that is fine to parse.
The catch-all is LAST in the file. Hono runs matching handlers in registration
order, so /* above /_config would swallow it and the settings form would
forward itself to fofos, which has no such endpoint — a 404 from a plugin that
is working perfectly.
Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.
Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.
Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.
Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.
NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|