f30d40446a2eef3522064930d2ee6d8ef0ddd3d2
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|