963c96505690565ea22295e6a5baa2a27252df0e
218
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7b7d8bbf36 |
add gen:ecosystem script to rebuild ecosystem.config.cjs from the database
bun gen:ecosystem writes the whole file from three sources: CORE_PROCESSES read directly out of scripts/setup/officer-setup/lib/services.sh (shelled out to bash, so there is exactly one list rather than a TS copy to keep in sync by hand), marketplace plugins that are installed AND enabled in Postgres and actually ship a sidecar/, and app-store services that are installed AND enabled, using each catalogue entry's `process` name against the src/servers/sidecar/<id>/index.ts convention. Extracted `pluginPm2Entry` (a pure DiscoveredPlugin -> Pm2App) out of addPluginToEcosystem so the live install/uninstall path and this from-scratch rebuild share one definition of what a plugin's entry looks like, rather than two that can drift apart. addPluginToEcosystem's own behavior is unchanged. Complements, not replaces, the existing incremental add/remove in servers/plugins/ecosystem.ts — that edits the live file at install time; this regenerates the whole thing, for a fresh clone, drift, or restoring from a backup. Found while testing against this machine's real file, worth flagging rather than silently working around: officer-marketplace (the marketplace.officer.dev process, src/servers/marketplace/server.tsx) is in this machine's ecosystem.config.cjs by hand and is modeled by neither the plugin system nor the app-store catalogue, so this script does not know about it and would drop its entry on a real run. Left alone rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
813bc200bf |
a session remembers which model it is using
resume a conversation and the model picker showed the first entry in the catalogue instead of the model the session was actually on. that was invisible for as long as the first entry was `opus`, and became obvious the day `fable` went above it — so this is a pre-existing bug my model-list change exposed, not one it introduced. two defects, stacked. the value did not survive a refresh: `initialModel` came from `props.model ?? locationState?.model`, and both are volatile — a panel channel and react-router location state. on a hard load neither exists, the settings default is skipped (it only runs for NEW chats), and ModelSelector's fallback effect fired `onModelChange(providerModels[0].id)`. and even when it did arrive it could not match. a transcript records the API model; the picker offers CLI aliases. `claude-opus-5` is in a different id space from `claude-code/opus[1m]`, and the mapping is one-way: opus and opus[1m] both serve claude-opus-5, opusplan serves whichever model answered. read out of a real 59MB transcript, the distinct values were claude-opus-5, <synthetic>, claude-sonnet-5, claude-haiku-4-5-20251001, claude-fable-5 — note the synthetic marker and the inconsistent date suffix. so the transcript cannot answer "what should the picker show", and a new table does: chat_session_models (session_id pk, model, updated_at) officer's own id space, so opus[1m] and opusplan round-trip. keyed on the TRANSCRIPT uuid rather than officer's session id, because that is what the session list joins on — which is why the write waits for the harness to announce the id rather than firing at turn start. records the EFFECTIVE model, so a refused switch stores what answered. not derived from chat_session_events, which does now hold model:switched: right data, wrong index. that table holds every assistant message and is unbounded, and "latest model for these 200 sessions" would scan far more than the question deserves. not a chat_sessions table either — a session IS its transcript, and a row holding title and cwd would be a second source of truth for two fields that already have one. BACKFILL RUNS FROM postdb:push, so every server picks it up on its next schema push. bun does fire pre/post hooks for colon-named scripts — verified on 1.3.14 before building on it, because a post hook that silently never runs is the worst outcome here. insert-only, exits 0 on every failure path (the push has already applied; a cosmetic backfill must not make it look failed). seeded 11 of 12 transcripts on this machine. it reads the FIRST model, which sounds wrong for a table storing the current one — but switching only became possible today, so no older transcript can hold two, and first costs a bounded head read instead of reading 59MB. also: the family map returns null for mythos rather than a neighbouring family (Project Glasswing only — a picker entry that 401s is worse than none), and ModelSelector no longer picks a default once a conversation has started. an unset picker is the honest state when the server cannot say. tests: the alias map against the five real transcript values, and the selector's default gated on hasStarted. re-broke the guard to confirm it bites. still to do: `model` on the session LIST rows, which the sessions-list revamp wants. the storage and the batched read (getChatSessionModels) are in place for it. |
||
|
|
f977d26fef |
a ratchet on hardcoded colour, and the docs to match
The three previous attempts at this job died of endlessness, not difficulty. Nothing stopped the next component arriving with its own `text-emerald-400` while an old one was being fixed, so the number never fell for long. `bun run check:theme` counts palette classes per directory against a committed baseline. It does not care what the number is, only which way it moves. 942 across 21 directories today. Every directory can be paid down independently and none can quietly regrow — which turns an infinite job into a finite one. A baseline rather than a ban, deliberately: a rule forbidding palette classes outright fails on its first run, and a check nobody can make pass is a check everybody turns off. Proven by adding one `text-emerald-400` and watching it fail with the directory, the delta, and what to use instead. ── Documentation, which is part of the work rather than after it ── `src/workspaces/theme/README.md` is new and is the contract: how to write a theme, all 84 declarations by family, why colours are unwrapped HSL triplets while surfaces are complete CSS values, why assets are quoted paths, and the rules for components. Start from Plain, not from Duck — every value you change is then one you decided about. `plugins/EXTRACTING-A-PLUGIN.md` gains a rule beside the others: a plugin never hardcodes a colour, and pays its debt down DURING the extraction. The counts are named there — Soulseek 313, Invoices 95, Wallet 68 — because "some colours" does not convey that it is two thirds of the platform's total. The kickoff prompt says the same thing in the form a fresh agent will meet it. That is what makes the plugin exemption safe rather than a way of losing the work: the ratchet stops covering a directory the moment it leaves, so the rule has to travel with it. `THEMES-NOTES.md` rewritten to match reality — it still listed done things as open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ecb6864ff9 |
delete the landing app
Built and shipped from somewhere else now, so this copy was dead weight that still carried its own third copy of the palette — `duck-teal` had three different definitions across three apps, and this was one of them. Removed: `src/apps/landing`, `scripts/build/landing.ts`, the `build:landing` script, and the tree entry in CLAUDE.md. `/landing-page-data` STAYS and is unrelated despite the name — it is the API the sign-in screen asks whether registration is open. Worth saying because a grep for "landing" hits both and only one of them was meant. Nothing imported from the app. tsgo clean, frontend builds, the live app serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1be4bcd39a |
prove the extraction faithful, and fix the one thing that was not
The screenshots said seven dark screens differed and could not say why. Comparing COMPUTED theme variables between the old build and the new one, in the same colour mode, answered it in a single run: 47 of 53 matched exactly, and the six that did not were the `--duck-*` variables — EMPTY in the new build. The utilities were remapped to `--brand-primary`, `--ink` and friends, which is correct. What that missed is code reading the VARIABLE rather than using the utility. `prose.css` does exactly that eleven times — chat markdown links, blockquotes, inline code, checkboxes — and `landing/styles.css` does it six more. All silently resolved to nothing. Fixed by keeping the legacy names as aliases in every theme, pointing at the new source of truth rather than duplicating values. That also covers any plugin or inline style doing the same thing, which I have not enumerated. They retire when the utility rename lands. Every variable now matches. ── What the screenshots are actually worth here ── Less than I assumed. The wallpaper rotates per page load, so a control capture of the same build against itself had 4 of 16 screens differ — and a different 4 each time. Between 5 and 6 come out byte-identical to the baseline on any run; the rest is noise I mistook for signal for two rounds, including one where I removed a `color-scheme` line to test a theory it turned out not to explain. The variable diff is the instrument that settles it, and it is cheap. Added as `scripts/theme-vardiff.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
105766a4eb |
extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7980ee3761 |
photograph the app before touching how it looks
The themes work moves every colour out of the components and into a token layer, and the entire value of that is being INVISIBLE. Nothing here could have told us whether that held: the tests do not render, and comparing two screens from memory an hour apart misses exactly the things this can break — a border colour, a radius, one mode of two. Eight routes, both colour modes, against the running app. Byte comparison rather than a pixel-tolerance diff: a false alarm costs opening two images, a false all-clear costs the refactor. The first version was worthless and looked fine. It reused one browser context and added an init script per mode — Playwright ACCUMULATES those, so the dark pass ran the light script too, and the class toggle after `goto` was undone by the provider's own effect. Six of eight routes came out byte-identical in light and dark, which reads as a clean run. A context per mode fixes it, the class is asserted after settling rather than before, and identical light/dark pairs now print a warning instead of passing quietly. Verified rather than assumed: 15 distinct images of 16, every route differs between modes, and one was opened to confirm it is the authenticated app and not eight variations of an error boundary. Baselines are gitignored — 7MB of machine-specific PNGs. Regenerate with the script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e8eea2b677 |
setup installs poppler, or an attached PDF cannot be read
Reading a PDF attachment needs `pdftoppm`. It was not installed on this machine and is not in any package list, so the first real PDF attachment failed at the last step — uploaded fine, landed on disk fine, unreadable. That failure reads as the feature being broken rather than a package being absent, which is the worst shape for something a fresh install would hit on its first try. Added to all four managers: `poppler-utils` on apt and dnf, `poppler` on pacman and brew. Worth recording WHY it is needed, because it is not obvious: the agent reads a PDF by RENDERING its pages, not by extracting text. On the document that prompted this — a sitemap of tree diagrams and wireframes — extraction returns almost nothing and rendering returns everything. The dependency is on the approach that works. Installed on this machine by hand already; this is so the next one has it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0230e62a71 |
the platform stops explaining what Headscale is
Correcting my own reasoning from the last commit. I left "Headscale's API", "the Headscale admin credential" and similar alone on the grounds that they name the thing the plugin drives, so renaming them would make the sentences false. True as far as it goes, and it misses the point: those sentences are in the PLATFORM. It has no business knowing what Headscale is at all. Correct language in the wrong repository is still a remnant. Ten of them, each replaced with the platform's own vocabulary rather than the plugin's domain: - `crypto.ts` and `secret-store.ts` listed "the Headscale admin credential" among what one key used to protect. It is "the tailnet's admin credential" — the purpose, which is a word this repository owns. - `catalogue.ts` and `availability.ts` explained `alsoServes` — a LIVE mechanism — entirely through a Headscale example that left the repository. Restated as the general shape, with one parenthesis saying which case produced it. - The photos types file explained another plugin's sidecar. - `App.tsx` cited `/headscale/:section` as the example of a route pair; that route does not exist. `/photos/:section` does. - `plugins/router.ts` pointed at `useCompanionLogStream`, a hook that lives in a plugin now. - `registry.ts` said in the present tense that `headscale` is `admin` — the permission is the plugin's and it declares its own, so a member-facing enrolment surface would be the PLUGIN's to add, not this file's. - `proxy.sh` cited `headscale/schema.ts` as a style reference. That file is not in this repository. - machine-setup said "Its Headscale app" — the app is the Offscale plugin. `machine-setup/lib/tailscale.sh` keeps all ten of its mentions: it exists to explain tailscale, headscale and offscale to somebody choosing between them, on a core install, before any plugin exists. That is machine setup's subject, not the plugin's domain leaking upward. What survives elsewhere is only dated history — "was here until 2026-08-15", "It was: music, vault, slskd, headscale, …" — plus the notes about the real `headscale_servers` orphan and the secret-store key removed yesterday. Those record that something called headscale used to be here, which is a different claim from explaining what it does. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27aa72d923 |
finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The extraction was sound — no router, no permission entry, no app-store entry, no screen, no panel app, no env var anything reads. Two things were genuinely wrong, and both were in setup rather than in the server. ── The secret store minted a key for a plugin ── `officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a key for a PLUGIN, under a name the rename had already replaced, on a machine where the plugin is not installed and nothing would ever ask for it. offscale encrypts under `offscale`, and `getKey` creates on demand — which is precisely what lets core know nothing about plugins. Now core bootstraps `jwt` alone. ── `.env.example` documented variables for a deleted router ── HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving `/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them since. Left as a note saying where the tailnet's credentials live now, because somebody with an older `.env` will otherwise wonder why setting them does nothing. ── Names that outlived the thing ── Eleven comments referred to the plugin's route, panels, console or server registry as "headscale": three sidecar config files describing their own shape by pointing at it, two screens citing `/headscale` as the example of a Workspace/Panel route, the terminal's note about the Console, and the photos types. Also `catalogue.ts`, which still claimed headscale "is CORE now", and `working-on-officer.md`, which told the reader a core install runs six processes including `officer-headscale`. Upstream references are deliberately untouched: "Headscale's API", "the Headscale admin credential", "is this a Headscale server?" all name the thing the plugin drives, and renaming those would make the sentences false. `docs/secret-store.md` argued headscale must be core because the tailnet is the perimeter. Marked superseded rather than deleted, with the distinction that settles it: the perimeter is the tailnet EXISTING, which machine-setup still arranges on a core install — not the platform shipping the screen that administers it. Verified by counting: live code, setup provisioning, env references, permission entries, app-store entries, hono mounts, dock defaults, screens and panel apps all zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the bundled catalogue entry, test fixtures using "offscale" as an example app name, and history. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
921fbff9fe |
marketplace.officer.dev: the catalogue becomes a service
The plugins page read a TypeScript constant. It now reads a public API backed by its own database, and falls back to that constant when it cannot. ── Four pieces ── `src/databases/marketplace_db` — a second Postgres database, `officer_marketplace`. Two tables, split by owner and lifetime: `plugins` is the registry (app name, developer, clone URL) and is authoritative; `plugin_metadata` is a CACHE of what each repository says about itself, so the website can render without cloning three repos per page load. Keeping them apart is what makes the cache safe to be wrong — a refresh can never lose the fact that a plugin exists. `scripts/marketplace-sync.ts` — fills it from the plugin repositories. Reads each `manifest.ts` (imported, since a manifest is TypeScript), derives `has` from the directory rather than trusting a declaration, and rewrites SSH remotes to HTTPS: `example` and `offscale` had `git@gitea…` origins, which would have handed every installer a URL that only works on the machine that published it. `src/servers/marketplace/server.tsx` — its own process, its own port, two GET routes and no others. Not part of the platform server: it has no users, no sessions and no request it answers differently per caller, and mounting a public unauthenticated surface inside the process holding mail, a wallet and passkeys means writing permission-gate exemptions that later turn out to be too wide. `src/apps/marketplace` — the public site. A window, deliberately: nothing here installs anything, because installing means cloning a repo and starting a process on someone else's machine. Every plugin page ends at "install it from your own Officer". ── The platform side is the part that had to be careful ── `fetchCatalogue()` now answers live → cached → bundled, in that order, and never throws. The plugins page is where an owner manages plugins they ALREADY have, so the store being down must not take that page with it. Network responses are validated per entry — not against a hostile marketplace, which has already won if the owner installs what it lists, but against a wrong one: a proxy serving HTML, a half-deployed API, a field renamed on one side. A non-https `source` is refused outright because that string reaches `git clone`. 8 tests on the degradation path. Verified end to end against the running service: live catalogue, cached answer surviving the store dying, bundled fallback. ── One thing found on the way ── `bunfig.toml` and CLAUDE.md both document `bun install --no-frozen-lockfile` as the way to change a dependency. Bun 1.3.14 has no such flag — only `--frozen-lockfile` — so the documented escape hatch does not work. Sidestepped by not making marketplace_db a workspace member (relative imports, zero lockfile change), and `bun install` is frozen-clean again. Worth fixing the docs. tsgo clean, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23bfacf412 |
a missing plugin can no longer stop the platform booting
A server died on `Cannot find module '../../../../plugins/music/db/schema'` before a line of its own code ran. The pull that broke it was the one untracking `plugins/` — git deleted those directories from the working tree, and the gitignored `plugin-schemas.gen.ts` still named them. The barrel being wrong is NORMAL. It is written by `db:push` and by installing a plugin, and nothing rewrites it when a directory goes away by other means: a pull, `git clean -xfd`, `rm`. It describes a machine, not the project, which is why it is gitignored in the first place. The defect was not the stale file. It was that a file allowed to be wrong sat in the import graph of the process that must always start — via `db.ts` → `schema.ts` → the barrel. An import graph is resolved before `main`, so this was not catchable, loggable or survivable. Everywhere else the plugin system already holds the line: `refreshPluginMounts` isolates a bad plugin, `discoverPlugins` reports a broken manifest instead of throwing. This was the one remaining path that could take the whole server down, and it took it down. So the two consumers get their own entry points. `schema.ts` keeps the core tables and is what the runtime imports. `drizzle-schema.ts` re-exports it, adds the generated barrel, and is read only by drizzle-kit — which regenerates the barrel before every run and can afford to fail loudly on a genuinely broken plugin. `drizzle.config.ts` points at the new one. Verified by reproducing it: with the barrel naming a plugin directory that does not exist, importing the server's db module now succeeds while importing `drizzle-schema.ts` still fails with that exact error — so the runtime is immune and drizzle-kit's strictness is intact. First attempt at that test was worthless, since I "poisoned" the barrel with a path that existed here; redone with an absent one. `bun db:gen` then loaded the new entry and emitted both core and plugin tables (music_*, offscale_servers), which is what proves `db:push` still works. Pulling this is enough to boot a server in that state — the stale barrel stops being loaded rather than needing to be repaired. tsgo clean, frontend builds, 865 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
abb8fe4320 |
the migration script stops needing the app it migrates
It died on its first real use, on a production server, before running a single statement: error: Cannot find module './plugin-schemas.gen' from officer_db/src/schema.ts It imported `officerdb/db`, which imports `schema.ts`, which imports the gitignored `plugin-schemas.gen.ts`. That file does not exist on a fresh clone — which is exactly the state every machine this script is FOR is in. It had been tested against a scratch database on a machine where the barrel happened to exist, so the one condition that mattered was the one condition never tested. A migration issues ALTER statements. It has no business needing the application's schema barrel, its table objects or its query layer. It now opens its own `postgres` connection and imports none of them. Tested the way it failed: barrel moved out of the tree, script run against a scratch database built in the old shape. Renames the table, the column, both indexes and all three CHECK constraints, keeps the rows, and the idempotent path still no-ops. Verified by reading pg_indexes and pg_constraint afterwards rather than trusting the exit code. Deployed to edge-pertento today with two real users. Nine grants migrated intact, old table gone, permissions page confirmed in the browser. The only casualty was a minute lost to this bug, because the database had not been touched when it failed — the import blew up before the first query, which is the one place a crash costs nothing. `bun db:push` was already immune: it runs scripts/gen-plugin-schemas.ts first. That fix existed because the same trap was found earlier today in the setup path. It was not applied here because I did not think of this script as something that runs on a fresh clone, which is precisely what it is. |
||
|
|
9c2d6a97f7 |
a tested migration script for the capabilities→permissions rename
Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:
neither table nothing to do; db:push will create it
already renamed no-op, prints the grant count
BOTH tables present stops and says a human must decide
old table only migrates, counts before and after, prints every grant
Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.
That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.
Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.
Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
|
||
|
|
9af52fd754 |
db:push regenerates the plugin schema barrel, so setup works on a clean machine
`officer_db/src/schema.ts` ends with an UNCONDITIONAL `export * from './plugin-schemas.gen'`. That file is gitignored — it describes this machine, not the project — and until now its only writer was installPlugin. So on a fresh clone the file does not exist and `bun db:push` dies with MODULE_NOT_FOUND before creating a single table. That is exactly what officer-setup.sh section 8 runs, so setup failed at the schema step on any clean machine, and the only thing that would have written the file was installing a plugin — which needs a working database. A deadlock, shipped with the plugin system on 2026-08-15. Reproduced by deleting the file and running push, not inferred. `db:push` now runs scripts/gen-plugin-schemas.ts first. Wired into the script rather than added as a setup step because setup is not the only caller: pushSchema() shells out to the same command, a developer types it by hand, and `git clean -xfd` removes the file at any time. A step someone has to remember is one they forget once; push regenerating it means the barrel cannot be stale when drizzle reads it. Both paths verified: barrel deleted then push (regenerates, "No changes detected"), and zero plugins on disk (writes a header-only barrel that schema.ts still imports cleanly). Also verified, on a scratch database, the premise the whole barrel design rests on — that push drops what it cannot see. Full schema pushed, a row seeded into music_favorites, then an empty barrel pushed: music_* and headscale_servers were DROPPED along with the row. So "following the directory rather than the install table" is load-bearing, not decorative. Worth recording how close that came to being logged as the opposite. An earlier run with an empty barrel reported "No changes detected", which read as evidence that push does not drop. It was this fix working — the script had regenerated the barrel before drizzle ran. The scratch database is what told them apart. bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Scratch database dropped; live plugin tables and rows verified intact. |
||
|
|
de3340398c |
music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.
Three things stayed, each on purpose.
cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.
The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.
api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.
Two bugs found on the way, neither visible from reading.
The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.
[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.
registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.
music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.
Not yet verified on the live server — that is next.
|
||
|
|
02e049cae8 |
terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0701aba902 |
brotli in the core package set
installed on this host already; verified round-tripping from a member shell. same package name on apt, pacman and dnf. on brew it is there because macOS ships the library but not the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b2349b5480 |
install the psql client, matching the server it talks to
there was no psql on this machine. the server runs in a container, so nothing ever put a client on the host, and `docker exec officer-postgres psql` is the owner's tool — a member has their own Postgres role and no access to the owner's Docker socket. the version is derived from PG_IMAGE rather than typed again, because the pairing is load-bearing: pg_dump refuses a server newer than itself, and Ubuntu 24.04 ships client 16 against this 18 server. so the archive package is not merely old, it is unusable for dumps. that is also why this sits beside the server definition instead of in machine-setup's package list — one constant, one place to bump. PGDG added the same way docker.sh adds Docker's: key in its own file, one sources.list.d entry, no add-apt-repository. non-fatal, and the exit status is not the gate — apt can succeed while holding an older client back, so the check is that psql is present AND is the major we asked for. installed by hand on this host already: psql/pg_dump 18.6, verified as green connecting with their own role. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bbc60b34ac |
headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now, and the catalogue.test CORE[] mirror follows it — not optional, since that list asserts "the catalogue must not offer a core process" and would have blocked adding offscale to the catalogue later. the local generated ecosystem file lost its entry too, and officer-headscale was stopped and deleted from pm2 by hand. the platform still mounts /api/headscale and still declares the headscale and vpn capabilities, so the feature is present-but-unavailable rather than gone. docs/offscale-plugin.md is a live document for the rest of it. what it records that nothing else does: core is now `officer` alone and everything else is a plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for third parties, derived by one function so the two can never become two systems; tables stay in public with an app-name prefix; mounting becomes genuinely dynamic, which retires the "every route stays mounted" premise and relocates assertCapabilityTotality from a boot check to a per-mount transaction. it also records a rejected experiment with evidence — a postgres schema per plugin works completely, including cross-schema FK, idempotent push and DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8 needs schemaFilter naming every schema, contradicting its own docs, and without it push reports "No changes detected" and creates nothing. a plugin install that reports success and makes no tables is the exact failure shape we have hit three times this week. and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no grants in the database. offscale is permanently standalone, so it never comes back. the invite flow is unaffected — the phone claims from the Companion, not from officer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
11710f283a |
wire the reverse proxy in as officer-setup 12
~/npm-setup-draft/setup-npm.sh, adapted to the script's own helpers and placed last
— it is the only step that needs Officer already running.
It ignores --unattended, as asked. Every other question in this script has a
defensible default; a domain name, a DNS provider and that provider's API
credentials do not, and the step is opt-in besides. Its prompts read stdin directly
instead of going through confirm()/ask_required(), and they are NAMED APART
(proxy_confirm, proxy_ask) so nobody later consolidates them into the shared helpers
and quietly makes --unattended agree to publishing a public hostname.
The valve is a TTY check rather than the flag: with no terminal there is nobody to
ask, so it skips and prints the manual instructions. A cron-driven install still
works.
Five fixes to the draft:
- `${OFFICER_REPO}/scripts/store-npm-credential.ts` — OFFICER_REPO is a git URL,
not a directory, so that path was https://…/platform.git/scripts/… and the -f
test could never pass. The whole persist-to-platform branch was dead code
falling through to the print. Dropped it: the comment beside it already argued
that not storing this password is a legitimate outcome, since only a human
logging into the admin UI needs it.
- NOT re-runnable, despite saying so. claim_admin returned early on an already
claimed instance without setting NPM_EMAIL/NPM_PASSWORD, and get_token
dereferenced both under set -u. Second run died on an unbound variable. It now
asks for the existing credentials.
- $HOME/dockers → $OFFICER_ROOT/dockers, matching data-path.ts. And the network
is SETUP_DOCKER_NETWORK (`services`), not a second bridge called `officerdev`.
- dig → getent hosts. dnsutils is not installed by this platform, so the check was
command-not-found on a fresh VPS — and an empty answer is indistinguishable from
"not resolving yet", so it waited the full 30 minutes before failing.
- python3 → jq for host-side JSON. jq is already in the core package list; the one
remaining python3 runs INSIDE the NPM container to read its own credential
template, which is the point of reading it from there.
Failure is contained: every function warns and returns non-zero rather than exiting,
so a proxy that does not come up leaves a finished Officer install behind. Retry
with `--only Proxy`.
Verified: bash -n, shellcheck -S warning clean, --list shows Proxy, all seven
external commands present, and the jq filters checked against sample payloads
including the multi-line DNS credential.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1d95ad3d1b |
install the rootless docker prerequisites with docker itself
Reported from a member's daemon failing: "rootless Docker needs these packages on the host: uidmap". They were being installed — but only inside branch [2] "rootless Docker for <owner>" in section 22. The owner's choice is not the only one that matters: every Developer account the platform provisions gets its own rootless daemon whatever the owner picked for themselves. So on a machine where the owner chose the docker group, the host never got them and every member's daemon failed. Moved into install_docker_engine, so they arrive with Docker rather than with one particular answer to a question about the owner. Three packages, not the one in the error. checkDockerPrerequisites in os-user-docker.ts is the authority and wants uidmap (newuidmap, newgidmap) AND docker-ce-rootless-extras (dockerd-rootless-setuptool.sh); dbus-user-session is what keeps a member's systemd --user alive without a login session. rootless-extras is only RECOMMENDED by docker-ce — installed by default, so usually there by luck, and absent on any host configured with --no-install-recommends. Named explicitly. Reproduced on this machine while checking: rootless-extras present via Recommends, uidmap absent, newuidmap and newgidmap missing. Exactly the reported failure, on a box that chose the docker group. The rootless branch still installs uidmap and dbus-user-session behind its pkg_is_installed guard. Redundant now, kept deliberately: it is the only thing that fixes a machine whose Docker was installed by an older run of this script. Verified: bash -n on both files, and all three packages present in the noble archive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3862558b92 |
real aliases for the owner, and eza to go with them
The owner's `aliases` block was one line — `alias sz`. Members got a full set from shell-skel/zshrc and the owner got that. Replaced with the eza ls family, the oh-my-zsh standards, and n/vim/sz/ld/httpserver. eza added to all four core package lists. It is in the noble archive at 0.18.2-1, so this is a package rather than a binary fetch, and Core utils is section 5 — well before Shell at 26, so `command -v eza` is already true when the block is written. The eza aliases are GUARDED behind `command -v eza` and the rest are not, and the asymmetry is deliberate: these replace `ls`. Unguarded, a machine where eza failed to install has no working `ls` in any new shell, which reads as a broken machine rather than a missing package. `alias ld=lazydocker` without lazydocker is one command-not-found when you type it — that can degrade honestly. Same principle shell-skel/zshrc already holds to. python3, not python, for httpserver: Ubuntu ships no `python` binary at all, so as given it would have been a command-not-found on every machine this targets. Checked the editor block first — it only exports EDITOR/VISUAL/SUDO_EDITOR, so n and vim do not collide with anything already appended. KNOWN: append_once returns 1 when its marker is already present, so a machine that has already run this keeps the old one-line block and gets none of the above. That is the function working as designed — it exists so a second run does not duplicate its work, and it cannot tell a stale block from one the owner edited. Fix by hand: delete the `# >>> machine-setup: aliases >>>` block from ~/.zshrc and re-run `machine-setup.sh --only Shell`. Verified: bash -n, zsh -n on the block, the eza guard leaving ls unset when eza is absent, and vim resolving through n to nvim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
977e30e782 |
point the setup default at public https
was ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git now https://gitea.officer.dev/officerdev/platform.git Bigger than a URL swap. The SSH default could not clone on a genuinely fresh machine: the key machine-setup generates there is brand new and Gitea has never seen it, so `--repo` was effectively mandatory on a first install — which is the problem that flag was added for two hours ago. HTTPS needs no key and no agent, so the default now works on a blank box. The old comment explained SSH-because-private and set the condition for changing it: "back to HTTPS when the repository is public". It now is — verified with an anonymous `git ls-remote`, which lists refs with no credentials. Rewrote the comment to record why it moved and what to do if it ever goes private again, since that reasoning is the part worth keeping. clone_repo already runs GIT_TERMINAL_PROMPT=0, so a private repo would fail fast rather than hang on a username prompt. No change needed there. repo.sh is still the only place that sets this, and --repo / OFFICER_REPO still override it. Verified both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
edbe446b34 |
revert "allow port 22 through the docker-user allowlist"
this reverts
|
||
|
|
e36c6bb431 |
allow port 22 through the docker-user allowlist
the DOCKER-USER chain is the only thing gating docker-published ports from the internet — docker writes its own DNAT/FORWARD rules and bypasses ufw, so `ufw allow <port>` has no effect on a published container port. the allowlist permitted only 80 and 443, so a machine provisioned from this template dropped gitea ssh silently. the failure is hard to spot: the port looks open locally and docker ps shows it published, but external clients hang at TCP connect with no refusal. local tests pass because they arrive via lo and match the loopback RETURN before reaching the DROP. comments added so the next person recognises it faster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d14e11f6c |
--unattended: every question that has a default answers itself
51 yes/no prompts and ~20 free-text ones, of which about six actually need a human.
The line drawn is "a question with a default answers itself; a question with no
possible default still asks", so it stays attended without being a conversation.
Half of it already existed: ASSUME_YES=1 was implemented and honoured by confirm()
in both scripts, returning each question's OWN default — so a "do the thing you
asked for" question goes yes and a genuine extra goes no. --unattended sets it.
The new part is menu_answer(), for the eight numbered menus. It sets the variable
EMPTY rather than passing a default in, because every menu already consumes its
choice as `${CHOICE:-<n>}` — the default lives next to the options it selects
between, which is the right place, and a second copy in the helper could drift from
the one the prompt advertises. Verified all eight consume that way before touching
them. `read <<<''` rather than eval or `declare -g`, which is bash 4.2+ and rules
out the bash 3.2 macOS still ships.
officer-setup's ask_required takes its default too, except where there is none — the
owning account on a machine machine-setup never ran on, where a guess would install
as the wrong user.
STILL ASKS, deliberately: the username; the Tailscale control plane, login server
and auth key; the git identity; and an SSH public key when the account has none.
That last one is a trap I nearly walked into — on a fresh VPS KEY_COUNT==0 forces
ADD_KEY=true with no confirm, and the menu's default is "[1] paste a public key",
which then prompts with no default at all. Auto-answering that menu would hang or
fail, so it is excluded by name. adduser also still asks for a password; that is
the tool, not us.
Two pre-existing bugs fixed on the way: machine-setup's sudo re-exec passed "$@"
after `shift` had emptied it, so --only and --reask stopped existing the moment it
escalated — same bug as officer-setup had. And UNATTENDED/ASSUME_YES are named in
all three sudo lists, because env_reset would otherwise drop the flag at
escalation, which is now the fourth variable lost that way.
Verified: bash -n on five files, --help on all three, and menu_answer + confirm
under the flag showing a menu resolving to its default and a no-default confirm
correctly answering no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4c33ef7206 |
fix the silent death after installing zsh
Reported from a fresh Hetzner VPS: the run stopped dead right after apt finished installing zsh, printing nothing at all — just install.sh's "machine setup did not finish". install_oh_my_zsh carried a comment saying it "Returns 0 whatever happens". It did not. Under `set -e` a failing command inside a function aborts the SHELL at that line when the function is called plainly; `return 0` underneath is never reached. The command is also `>/dev/null 2>&1`, so the cause was invisible — which is why the transcript just ends. `|| true` is what actually makes it non-fatal. The file already uses that idiom correctly in four other places, so this was a slip rather than a misunderstanding. set_login_shell had the identical bug on `chsh`, which the same run would have hit on the very next question. Fixed differently and deliberately: `|| true` there would let the caller announce a login shell that was never set, so it returns chsh's real status and the CALLER guards the call — which is also what keeps set -e out of it. A refusal now reports, names the manual chsh command, and carries on, because a machine with zsh installed and bash at login still works. Does not explain WHY oh-my-zsh failed on that host — the output was discarded. It will now say "oh-my-zsh did not install" and continue, which is enough to see it. Verified: bash -n on both files, and a reduced case proving broken() exits 1 while fixed() survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c13e0d8f6 |
tell the operator they are still root, once
Neither script ever becomes the user it sets the machine up for — a process cannot change its own uid, so both run as root and drop privileges per command instead. Everything Officer owns ends up belonging to that user and every pm2 process runs as them, but the session you are left holding is root's. Two things that fixes are invisible until they bite: group membership is fixed at LOGIN, so the `docker` group just granted is not in the current session, and the shell configuration was written into their home and is not loaded in root's. Both present as "the machine is broken" rather than "log in again". Printed by whichever half runs LAST. The first attempt put it at the end of both, which says it twice on a full install — and the first time it is wrong, because officer-setup is about to run and still needs the root session it tells you to leave. install.sh is the only thing that knows whether anything follows, so it sets OFFICER_SETUP_FOLLOWS and machine-setup stays quiet. Also drops "Pre-flight complete. The remaining sections are not built yet." from the end of officer-setup. All 11 sections exist; that line last made sense when 6 did. Verified: bash -n on all three, the set -e behaviour of `$RUN_OFFICER && export` under --machine-only, and the suppression across all five ways in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cbfe376a42 |
accept the repo URL as an argument
bun setup -- --repo https://github.com/you/platform.git The default is a private Gitea over SSH, which only authenticates on a machine whose key it already knows — so a genuinely fresh server could not clone at all without editing lib/repo.sh or knowing OFFICER_REPO existed. Added to both entry points. install.sh exports it rather than forwarding an argument it does not own; officer-setup.sh sets it before lib/repo.sh is sourced, which reads `${OFFICER_REPO:-<default>}`, so an absent flag still defaults. Two bugs found doing it, both pre-existing: - officer-setup.sh ALREADY had an arg parser, at the top, before the sources. My first attempt added a second one further down that was unreachable — every argument had already been consumed and `*)` would have exited 2 on --repo. Caught because `--help` printed the wrong usage. - both scripts re-execute through sudo passing `"$@"`, which the parse loop had already emptied with `shift`. So `officer-setup.sh --only build` run as a normal user silently became a FULL run the moment it escalated, and `install.sh --officer-only` re-ran the machine half. Nothing said so; the flag just stopped existing. ORIGINAL_ARGS is captured before the loop now. `${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}` is the set -u safe form — expanding an empty array is an error on bash before 4.4, and this runs on whatever the machine came with. Verified: bash -n on both, --help/--list/--repo/--repo=/unknown-option on both, the set -e behaviour of the guarded export, and that args survive the shift loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
081920c61f |
drop fastfetch from machine-setup
It stopped a real install. The guard covered the wrong half: a PPA that fails to ADD is caught and skipped, but one that adds cleanly while carrying no package for the running codename gets past that and dies on `pkg_install_now fastfetch`. It was also the only tool in the set with no source but a third-party PPA on Ubuntu 24.04 and older. A neofetch clone is not worth a branch in a script whose whole job is to survive machines nobody has seen. Removed from tools_default, the tool_command mapping and its installer. No shell config invoked it, so nothing is left calling a missing binary. software-properties-common stays in the core apt list for now, with a note: it provides add-apt-repository, the fastfetch PPA was its only caller, and Docker writes its own sources.list.d entry by hand — so it is now dead weight. Left as a separate decision rather than folded into this one. Verified: bash -n on all four scripts, and no live reference remains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
77f1284925 |
install report: first cut, generated by the helpers
Every run writes a timestamped install-report.md recording what was installed, changed, kept, skipped, started and run as root. Written for an adversarial read: the person who just ran a setup script off the internet hands it to an agent of their choosing and asks whether it did anything it should not have. Recorded by the HELPERS rather than by the sections. pkg_install and install_config report themselves, so anything installed or written through them appears whether or not a section author remembered — a section that has to remember is a section that will forget, and an incomplete report is worse than none because it reads as a full account. "Kept" is recorded as carefully as "changed". Leaving somebody's .zshrc alone is the claim a reviewer most wants substantiated, and it is invisible unless stated. Secrets are redacted at the moment of recording rather than filtered at render, so a credential never sits in memory formatted for printing. Verified against a POSTGRES_URL and an api_key/password pair. REPORT_FILE is passed through the sudo re-exec. It was not, first time, and the report silently vanished — the third variable this evening lost to env_reset. Unfinished on purpose, paused mid-task at the owner's request: machine-setup's 26 sections still only report through the two shared helpers, so the sections that change system state directly — systemd units, netplan, ufw, sshd drop-ins — are not yet recorded. That is the half a reviewer would care most about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f1cfc0042f |
rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly beside officer-anthropic-proxy — a different process doing a different job — so "the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to spend a paragraph insisting the two are not the same thing. It spawns `claude`; the name says so now. Only two references were functional: the generator's CORE_PROCESSES and the CORE list in catalogue.test.ts. Everything else was prose or comments. Left alone deliberately: `x-officer-agent-token`. It looks like the same string and is not — it is the agent-handoff HTTP header, naming a per-panel bearer token, unrelated to any pm2 process. Renaming it would have changed a wire protocol to tidy a label. Historical docs keep the old name. claude-sidecar-isolation.md and open-threads-after-per-user-claude.md are dated investigations that record the PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would make that history unreadable. CLAUDE.md notes the change instead, where somebody reading those will be looking. Also worth recording, from the owner: merging this with officer-anthropic-proxy into one sidecar was investigated tonight and rejected. They stay separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
88c9e96895 |
recover earlier answers on resume
A skipped section leaves its variables unset and later sections read them, so on a resume — which skips every section before the one that stopped — Build announced "PUBLIC_URL <not set — run the Environment section first>" on a machine whose .env had been written twenty minutes earlier. Three variables cross a section boundary: ENV_PORT and ENV_PUBLIC_URL from Environment, POSTGRES_URL from Database. They are read back once near the top, from the file that already holds the answers, rather than per-section — the next variable to cross would otherwise have to remember to do it again. Only fills what is empty, so a value passed on the command line still wins and a section that actually runs still overwrites it. Build had its own late read-back that made the generation work while the screen said it would not. Removed, now that the value is there before anything prints. Verified against the real install at /home/pastilhas/officerdev-test: --only Build now reports the URL that run chose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e5fe966308 |
count the schema tables instead of printing "?"
The Schema section read ${SCHEMA_TABLES:-?} and nothing ever assigned it, so it
announced "? tables" — which reads as "the count could not be determined" rather
than "nobody set this". schema_table_count existed in lib/build.sh and was never
called.
Counted from schema.ts rather than hardcoded, so the number stays true when a
plugin line is uncommented. Reports 21 against the current tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
60ab531294 |
use the short tailnet name, not the FQDN
http://officer-dev:9000 rather than http://officer-dev.ts.pastilhas.dev:9000. All three forms resolve inside the tailnet — short name, FQDN, raw 100.x — and the short one is what anybody actually types. PUBLIC_URL is read by people too: gen:index bakes it into the page's OpenGraph tags. It depends on the tailnet's search domain, which every Tailscale client sets when MagicDNS is on. A device that has lost it resolves the FQDN instead, and the answer there is to type the longer one rather than to default everybody to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f33e7474b0 |
default PUBLIC_URL to the tailnet address, not localhost
localhost is wrong on a machine with a tailnet, and quietly so: it works from the machine itself and nowhere else, so the mistake surfaces on the first phone rather than during setup. And PUBLIC_URL is not decoration — gen:index bakes it into the page's OpenGraph tags, the task API hands it to scripts as OFFICER_API_HOST, and the CalDAV profile builder refuses without it. The tailnet is where Officer is actually reached, and it is the perimeter the whole security model rests on now that origin checking is gone. Its address is the honest default. Prefers the MagicDNS name over the raw 100.x address — both work, but the name survives a node being re-registered and is something a person can type. Falls back to localhost with no tailnet, which is right rather than merely tolerable: a machine with no private network has no better address to guess. Suggests http://officer-dev.ts.pastilhas.dev:9000 on this machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bedc420d4d |
clone over SSH while the repository is private
An HTTPS clone of a private repo prompts for a username, and under sudo with no interactive terminal that hangs or dies with "could not read Username" — which is what gitea.pastilhas.dev does right now. ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git instead, temporarily. Back to HTTPS when it is public; nothing else in the script cares which. Tested the path the script actually takes, not just the URL: the clone runs as the OWNER rather than root, and sudo drops SSH_AUTH_SOCK, so there is no agent to answer a passphrase. `sudo -u pastilhas env -u SSH_AUTH_SOCK git ls-remote` returns HEAD, so the key works unaided on this machine. A passphrase-protected key that relies on an agent would not. Still overridable with OFFICER_REPO. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9b56e04b5e |
point the clone at gitea.pastilhas.dev
gitea.officer.dev is not serving yet. Overridable with OFFICER_REPO, as it always was, so a fork or a mirror needs no edit here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ea87ffed04 |
ask for privileges, do not demand them
Run any of the three as yourself. On Linux they now ask through sudo and re-execute, rather than refusing until you type it. Typing `sudo` still works and changes nothing — it just stops being the price of starting. This also fixes a trap that had nothing to do with taste. `sudo` strips the environment by default (`env_reset`), so `OFFICER_ROOT=/somewhere sudo ./install.sh` silently loses the variable and installs to the default path instead. The re-exec passes OFFICER_ROOT, SETUP_USERNAME and MACHINE_ROLE to sudo BY NAME rather than relying on -E, which env_reset ignores. This project has already lost a variable to that once — see the DATA_PATH commit. The invoking account is recovered the way it always was: sudo sets SUDO_USER, which lib/base.sh already reads, including the check for a SUDO_USER that is itself uid 0 on providers whose default account is root under an ordinary name. macOS never escalates, in any of the three. Homebrew refuses to run as root, the account running the script IS the owner, and the sections that needed root are the ones the macOS path skips. --help and argument errors still work with no privileges at all, since arguments are parsed before any of this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3cca07187e |
scripts/install.sh — one command for both halves
`bun setup` runs it. Machine setup first, then officer setup, stopping if the first does not finish rather than running the second against a machine that is not ready. They stay two scripts because they answer two different questions and are worth running apart — a machine you already trust needs only the second, one you are rebuilding needs only the first. --machine-only and --officer-only say so directly, and both halves remain runnable by path. Privileges are checked here, before anything is done, because the two systems want opposite things: Linux needs root for apt, systemd, useradd, netplan and ufw and for creating directories owned by the service account; macOS must NOT be root, since Homebrew refuses to run as one. Each script already enforces its own rule, so this is only about failing early instead of halfway. officer-setup.sh gained the same OS-aware check. It required root unconditionally, which on macOS would have failed immediately after machine-setup — which must run as the user — and for no reason: there the account running it IS the owner, so there is nothing to chown and nothing to drop privileges to. Arguments are parsed before privileges, so --help works without sudo and an unknown option is rejected before anybody is asked for a password. It did not, first time round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b5aa2e0387 |
keep the shell templates together in scripts/setup
starship.toml, tmux.conf and zshrc side by side. tmux.conf and zshrc moved up out of machine-setup/. starship.toml could not have moved down to join them: the PLATFORM reads it, at src/servers/os-user-shell.ts:34, to deploy to every member's Linux account. That is a runtime path rather than an import, so moving it would have broken member provisioning silently — no build error, members simply get no starship config. So the templates collect where the shared one already had to be. Left as a marker for tomorrow rather than resolved: there are now TWO zshrc templates, this one for the owner and shell-skel/zshrc for members, while starship.toml is deliberately one file for both. Either the owner needs different shell config from a member or they should be the same file. tmux.conf has the same question waiting, since it is going into provisioning too. Noted in the zshrc header where whoever picks it up will be looking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0da15d78a1 |
add an empty zshrc template for the owner
Somewhere to put what the owner actually wants, to be filled in and wired up tomorrow. Not referenced by the Shell section yet. The header records the decision that has to be made when it is: the section does not install a .zshrc today, it appends four marker-wrapped blocks — starship, agent, aliases, editor — through append_once. A template that is installed AND appended to ends up with the same lines twice, so those blocks either move into this file or stay out of it, not both. zshrc, no leading dot: a template in a repository, not a dotfile in a home directory, matching tmux.conf beside it and src/servers/shell-skel/zshrc which has been spelled that way all along. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e2faad40a3 |
rename the tmux template to tmux.conf, no leading dot
It is a template in the repository, not a dotfile in a home directory — and the destination it is increasingly installed to, ~/.config/tmux/tmux.conf, has no dot either. Naming the source after a path it may not be written to is how the wrong file gets read. The two remaining dotted references are correct and stay: they name the DESTINATION ~/.tmux.conf, which does have a dot when that is where tmux looks. .setup-answers and .setup-progress keep theirs too. They are runtime state in a directory, not templates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
61a3ae720f |
install the tmux config where tmux actually reads it
tmux 3.1 added an XDG location and it takes PRECEDENCE over ~/.tmux.conf. Verified on 3.4 here by writing a different marker into each and asking tmux which one it ended up with: both present -> ~/.config/tmux/tmux.conf only ~/.tmux.conf -> ~/.tmux.conf only the XDG one -> the XDG one So the Shell section writing ~/.tmux.conf on a machine that already has the XDG file produced a file tmux will never read, and reported "tmux config installed" having changed nothing anybody could observe. That is the worst shape a config step can have: it looks done. tmux_config_target now picks the path tmux will actually load — the existing XDG file if there is one, otherwise ~/.tmux.conf, which is still what every guide names and what a machine with neither should get. When both exist the section says so out loud before targeting the winner, because "your other file wins" is not something anyone infers from a success message. Also removed an untracked duplicate at scripts/setup/.tmux.conf. The one the script installs is scripts/setup/machine-setup/.tmux.conf — SCRIPT_DIR is the machine-setup directory — and two identical copies with only one of them read is the drift this whole evening has been about. Nothing to change about the config itself: the tracked copy is already byte-for- byte the owner's own ~/.tmux.conf. Not yet wired into per-user provisioning. src/servers/shell-skel/ seeds a zshrc for a member and has no tmux config beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
085afe7604 |
wait for Postgres instead of failing startup work once
The failure here was not a crash — it was the opposite, and that is why it would never have been noticed. server.tsx fired initQueue() and cleanupOnStartup() as bare promises with a .catch() that logged. postgres-js connects lazily, so nothing fails at import; the first query does. If Postgres is a few seconds behind — exactly what a reboot looks like, with pm2's resurrect racing Docker starting the container — both log one line during boot and do nothing else. cleanupOnStartup is the one that matters. It marks jobs interrupted by the previous shutdown and promotes the queued backlog, so failing it once leaves those jobs marked running forever: nothing retries, nothing complains again, and the only thing that would have corrected them has already run. officerdb now exports waitForDatabase(timeoutMs = 60s): polls `select 1`, logs once while waiting, resolves true or false rather than throwing. Bounded on purpose — an unbounded wait holds a process open with no way to tell starting from hung, and the caller decides what giving up means. Deliberately NOT awaited before serve(). The listener is already up by that point and holding it closed would turn a database thirty seconds late into a reverse proxy answering connection-refused instead of a page. Requests needing the database fail honestly in the meantime. The rest of the estate was already fine, which is worth recording so nobody "fixes" it again: postgres() opens no socket at construction, officer-agent's resolveOwner is an unbounded 5s retry loop written after this exact failure cost a session, and opencode, pty, headscale and the anthropic proxy touch no database at boot at all. officer-setup's Services section also waits for pg_isready before starting pm2. Not because starting early breaks anything, but because Verify would then report a failure that is really a race — and a red line that is usually noise is a red line people stop reading. Verified waitForDatabase against a dead port: logged once, returned false after the timeout, did not throw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
62cbf510cb |
no ecosystem files in git; officer-setup generates one
Deleted all four — ecosystem.config.cjs, .light., .mac.light. and the .profile. they derived from. The repository now contains no ecosystem file at all, and .gitignore keeps it that way. officer-setup writes one at the end, describing exactly the six processes a core install runs: officer, officer-anthropic-proxy, officer-agent, officer-opencode, officer-pty and officer-headscale. No profiles, no derivation, no plugins. The four existed because a profile has to subtract from something, so the full list had to name every plugin's process whether or not anybody installed it — and a test then had to assert the two files still agreed. Generating one file removes the subtraction, the second list and the test that policed them. It is .cjs, not the .js PM2's docs use, and that is not a preference: package.json declares "type": "module", so a .js file here is ESM and `module.exports` throws. PM2 require()s the config. Sections 10 (Services) and 11 (Verify) are built on top of it — write, startOrRestart, save, optional boot hook, then check every process is online with a sane restart count AND that the API actually answers on PORT. A process can be `online` and serving nothing, so the port is asked directly rather than inferred. That completes all eleven sections. catalogue.test.ts required both deleted files at import, so it could not even load. Its central assertion — "the store offers exactly what light leaves out" — has no meaning without a full list to subtract from, which is the point of the change. Replaced by two weaker but real checks: the store must not offer a core process, and every process it names must have a sidecar directory to run. The second catches the same typo the old one did without needing a manifest of everything; verified it holds for all 15 catalogue entries. app-store/pm2.ts starts a sidecar with `--only` against this file, which now holds core alone — so it can stop a plugin but cannot start one that was never written in. Marked `[open]` there rather than left to be discovered: appending a plugin's entry is the plugin system's job. Generated one in a scratch directory and required it with node: six apps, correct cwd on each, valid CommonJS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b075f1f882 |
macOS is a dev machine, and machine-setup now treats it as one
Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the homelab or VPS case, so the role is not asked for there — it is `dev`, and every section that exists to make a machine a good server is skipped. Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh with a reason each, rather than an `if macos` threaded through each section. Most would simply fail — no systemd, no ufw, no netplan, no useradd, no /etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse: stopping a laptop from sleeping, or freezing the address of a machine that moves between networks daily. Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker, Neovim, JavaScript runtimes, Agent CLIs. The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to run as root and says so, so the whole script under sudo would have failed at the first brew install having already taken a password. It is now required on Linux and refused on macOS, which works precisely because the macOS path skips everything that needed it. Docker is checked, not installed. Docker Desktop is a GUI app that wants opening, permissions and a running window — not a shell script's business — and colima and lima both cost an evening the first time something does not resolve. So the step reports whether the daemon answers and points at the download otherwise. The group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM owned by whoever is logged in, so there is no group to join. Added the Xcode command line tools as a macOS-only step, before anything that builds. node-pty ships no prebuilt binary on any platform and always falls through to node-gyp, so `bun install` cannot finish without a compiler — and it fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select --install` opens a dialogue and returns immediately, so the step says to come back rather than pretending to have waited. Tailscale takes the cask, not install.sh — that script is a Linux package-manager wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and does not. Not run on a Mac. There isn't one here, so this is read from the code and from what each tool documents, not observed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9d6c196848 |
PUBLIC_URL comes back, and gen:index takes it as an argument
Removing PUBLIC_URL earlier today was wrong, and the Build section is where it would have surfaced: gen-index.ts exits 1 without it, so `bun gen:index` fails, index.gen.html is never written, and `bun start` has no page to serve. It came out because origin validation was being discontinued — but that was one of four consumers and the only one that is gone. gen-index needs it for OpenGraph tags, which crawlers fetch standalone and cannot resolve relative; task-api-env builds OFFICER_API_HOST from it; and dav/router hard-requires it, https only, to build an iOS profile. It is also the one value this machine genuinely cannot derive, which is what separates it from DATA_PATH and the rest that left today. gen:index now takes a URL as its first argument, ahead of the environment and .env: `bun gen:index https://officer.example.com`. Changing the public address is one command rather than an edit plus a regenerate, and a second address can be generated for without touching the install's .env. It also validates now. A relative or scheme-less value substituted silently and produced OpenGraph tags nothing can resolve — invisible until someone shares a link and the preview comes back blank. .env is PORT, PUBLIC_URL, POSTGRES_URL. Verified by running the section; all three paths through gen:index exercised (absent, valid argument, invalid). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |