Compare commits
57
Commits
fe0012635a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1bd75853d | ||
|
|
9af52fd754 | ||
|
|
1e79b4effd | ||
|
|
a9bf51407e | ||
|
|
8bfcd40bd2 | ||
|
|
e930586878 | ||
|
|
05eb947bd1 | ||
|
|
de3340398c | ||
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 |
@@ -63,3 +63,10 @@ scripts/setup/officer-setup/.setup-progress
|
||||
# the repository has no ecosystem file at all any more, and the next machine
|
||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||
ecosystem.config.cjs
|
||||
|
||||
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||
# rewritten on every install. See servers/plugins/generate.ts.
|
||||
build/
|
||||
build.next/
|
||||
src/apps/officer-web/Plugins.gen.tsx
|
||||
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||
|
||||
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
|
||||
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
||||
coverage = true
|
||||
coverageDir = "coverage"
|
||||
preload = ["./test-setup.ts"]
|
||||
root = "./src"
|
||||
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||
#
|
||||
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||
# extraction would have taken its tests out of the suite the same way.
|
||||
#
|
||||
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||
root = "."
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||
"db:push": "cd src/databases/officer_db && bun run push",
|
||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Extracting a feature into a plugin
|
||||
|
||||
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||
|
||||
**Read first, in this order:**
|
||||
|
||||
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||
3. `plugins/offscale/` — the worked example, all four parts
|
||||
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
||||
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
||||
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||
|
||||
---
|
||||
|
||||
## The rules. These are not preferences
|
||||
|
||||
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||
|
||||
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||
|
||||
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||
|
||||
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||
structural is convention — presence is the declaration:
|
||||
|
||||
```
|
||||
manifest.ts required
|
||||
api/router.ts a backend router, mounted at mountPrefix()
|
||||
db/schema.ts tables, prefixed <app-name>_
|
||||
sidecar/index.ts a process (.mjs instead means node)
|
||||
web/panels.ts panels — REQUIRED with web/
|
||||
web/layout.ts layout — REQUIRED with web/
|
||||
```
|
||||
|
||||
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||
|
||||
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||
|
||||
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||
anywhere else means two systems, and only one gets tested.
|
||||
|
||||
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||
on uninstall. Only deleting a plugin's source can lose its data.
|
||||
|
||||
---
|
||||
|
||||
## The order that worked
|
||||
|
||||
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||
`plugins/<name>/db/`.
|
||||
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||
reference a plugin may make.
|
||||
4. **Write `manifest.ts`.**
|
||||
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||
and `helpers/clipboard` stay as they are.
|
||||
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||
**both** database barrels (`index.ts` and `schema.ts`).
|
||||
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||
8. **Verify on the live server** — see below.
|
||||
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||
|
||||
---
|
||||
|
||||
## Verification — run all of it
|
||||
|
||||
```
|
||||
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||
|
||||
| Step | Expect |
|
||||
| ------------------------------- | ----------------------------------------------------------- |
|
||||
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||
| the plugin's API | answers |
|
||||
| the plugin's screen | renders as a Workspace |
|
||||
| dock | tile appears |
|
||||
| permissions page | its permission is listed, read/write/none |
|
||||
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||
| enable | comes back |
|
||||
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||
| install again | identical to the first install |
|
||||
|
||||
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||
|
||||
---
|
||||
|
||||
## Traps, all of which cost real time once
|
||||
|
||||
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||
`install.ts`; do not reorder it.
|
||||
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||
pending for the whole protocol.
|
||||
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||
contract change. Regenerate rather than hand-edit.
|
||||
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
|
||||
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
|
||||
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||
`catalogue.ts` is the one to read.
|
||||
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||
it. Find those before you plan the split; they decide it for you.
|
||||
|
||||
---
|
||||
|
||||
## Music is done. What it changed about this runbook
|
||||
|
||||
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
||||
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||
|
||||
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||
none, so it came out whole and made the job look cleaner than it is.
|
||||
|
||||
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||
not tidy them into a plugin as a warm-up.
|
||||
|
||||
### The global-overlay question is answered, and the answer is no
|
||||
|
||||
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||
gated on its plugin's permission so it switches itself off with the plugin.
|
||||
|
||||
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||
|
||||
---
|
||||
|
||||
## Which one next
|
||||
|
||||
No decision has been made. What the tree says, for whoever picks it:
|
||||
|
||||
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||
is exactly what its extraction needs.
|
||||
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||
|
||||
## Still open, platform-wide. Do not rediscover these
|
||||
|
||||
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||
music would have been the first and cliamp being out of scope is what let it pass.
|
||||
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
|
||||
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||
owner's. Its own job, not the platform's.
|
||||
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
|
||||
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||
//
|
||||
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||
export const router = createRouter();
|
||||
|
||||
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||
// the smallest thing that is still a real one: a manifest and one route.
|
||||
//
|
||||
// Everything structural is convention, so this directory IS the documentation:
|
||||
//
|
||||
// manifest.ts you are here — only what a directory listing cannot say
|
||||
// api/router.ts exports `router`; mounted at /api/example
|
||||
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||
// web/Router.tsx a frontend, if it had one
|
||||
//
|
||||
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||
// code sits.
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Example',
|
||||
summary: 'The reference plugin — one route, nothing else',
|
||||
icon: 'Puzzle',
|
||||
color: '#94a3b8',
|
||||
|
||||
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
||||
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||
permissions: [
|
||||
{
|
||||
key: 'example',
|
||||
label: 'Example',
|
||||
description: 'The reference plugin',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// The reference sidecar: a long-lived process PM2 supervises.
|
||||
//
|
||||
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||
//
|
||||
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||
// installer, and adding a socket here would test Bun rather than that.
|
||||
|
||||
const name = 'officer-example';
|
||||
console.log(`[${name}] started (pid ${process.pid})`);
|
||||
|
||||
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`[${name}] ${signal} — exiting`);
|
||||
clearInterval(beat);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
// The second panel, reading the URL rather than being told by its sibling.
|
||||
//
|
||||
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
|
||||
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
|
||||
// independently; nothing is passed between them, so they cannot disagree.
|
||||
export const ExampleDetail = () => {
|
||||
const { section } = useParams();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">
|
||||
Section from the URL: <code>{section ?? '(none)'}</code>
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-duck-dark/40">
|
||||
Try <code>/example/anything</code> — this panel reads it from <code>useParams</code>, with no state passed from
|
||||
the panel beside it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
|
||||
//
|
||||
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
|
||||
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
|
||||
export const ExampleOverview = () => {
|
||||
const client = useClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['example', 'ping'],
|
||||
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">
|
||||
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
|
||||
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
|
||||
{isLoading ? <span className="text-duck-dark/40">…</span> : <span>{JSON.stringify(data)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
|
||||
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
|
||||
//
|
||||
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
|
||||
// mismatch falls back rather than rendering another plugin's panel inside this screen.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'example-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
|
||||
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Puzzle, ListTree } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { ExampleOverview } from './ExampleOverview';
|
||||
import { ExampleDetail } from './ExampleDetail';
|
||||
|
||||
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
|
||||
//
|
||||
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
|
||||
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
|
||||
// a rule someone has to remember.
|
||||
//
|
||||
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
|
||||
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -3,7 +3,7 @@
|
||||
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||
|
||||
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
|
||||
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
|
||||
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||
@@ -28,13 +28,13 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
|
||||
|
||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||
|
||||
| Case | Status | Headers |
|
||||
|---|---|---|
|
||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||
| Case | Status | Headers |
|
||||
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||
|
||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
||||
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||
VBR MP3s. No need to scan the file.
|
||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||
|
||||
@@ -51,26 +51,28 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
|
||||
this instead of walking + ID3-parsing the library itself.
|
||||
|
||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||
|
||||
### 2.1 Manifest — one call, whole library
|
||||
|
||||
```
|
||||
GET /api/music/manifest
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||
"albums": {
|
||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||
// …
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||
@@ -82,14 +84,16 @@ may have any mix of `tracks`, `videos`, and `disco`.
|
||||
```
|
||||
GET /api/music/meta?path=<rel>
|
||||
```
|
||||
|
||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||
"cover": "cover.jpg", // present only if a cover exists
|
||||
"cover": "cover.jpg", // present only if a cover exists
|
||||
"tracks": [
|
||||
{
|
||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||
"title": "Hells Bells",
|
||||
"artist": "AC/DC",
|
||||
"albumArtist": "AC/DC",
|
||||
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
||||
"track": "1",
|
||||
"year": "1980",
|
||||
"durationSec": 312,
|
||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||
}
|
||||
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||
},
|
||||
// …
|
||||
],
|
||||
"videos": [ // present only for folders that contain video files
|
||||
"videos": [
|
||||
// present only for folders that contain video files
|
||||
{
|
||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||
"durationSec": 8130,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
||||
}
|
||||
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||
},
|
||||
// …
|
||||
]
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||
omitted entirely when the folder has none.
|
||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
|
||||
```
|
||||
GET /api/music/cover?path=<rel>
|
||||
```
|
||||
|
||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
Only meaningful when the manifest entry has `"cover": true`.
|
||||
|
||||
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
|
||||
```
|
||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||
```
|
||||
|
||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
|
||||
```
|
||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||
```
|
||||
|
||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
|
||||
```
|
||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||
```
|
||||
|
||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"artist": "Anthrax",
|
||||
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
"[1984] Fistful Of Metal": "Studio",
|
||||
"[1985] Armed And Dangerous": "EP",
|
||||
"[1994] The Island Years": "Live",
|
||||
"[1991] Attack Of The Killer B's": "Compilation"
|
||||
"[1991] Attack Of The Killer B's": "Compilation",
|
||||
// …
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
||||
```
|
||||
|
||||
`IndexStatus`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": 1785034701973, "finishedAt": null,
|
||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
||||
"startedAt": 1785034701973,
|
||||
"finishedAt": null,
|
||||
"foldersScanned": 45,
|
||||
"albumsBuilt": 12,
|
||||
"albumsSkipped": 3,
|
||||
"tracksIndexed": 320,
|
||||
"videosIndexed": 4,
|
||||
"coversSaved": 12,
|
||||
"postersSaved": 4,
|
||||
"lyricsIndexed": 45,
|
||||
"discographies": 3,
|
||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||
"error": null
|
||||
"error": null,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
||||
```
|
||||
GET /api/music/reindex/stream
|
||||
```
|
||||
|
||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||
`IndexReport`) and **closes** the stream.
|
||||
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
||||
```
|
||||
|
||||
`IndexReport` (the `done` payload):
|
||||
|
||||
```jsonc
|
||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
||||
{
|
||||
"albums": 15,
|
||||
"built": 12,
|
||||
"skipped": 3,
|
||||
"foldersScanned": 45,
|
||||
"tracksIndexed": 320,
|
||||
"coversSaved": 12,
|
||||
"discographies": 3,
|
||||
"elapsedSec": 37.2,
|
||||
"error": null,
|
||||
}
|
||||
```
|
||||
|
||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||
@@ -256,17 +288,21 @@ Unlike everything above (library data served by the sidecar), these are **per-us
|
||||
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
||||
supplies; the server never interprets them:
|
||||
|
||||
| kind | key |
|
||||
|---|---|
|
||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||
| `artist` | music-rel — `Albums/AC-DC` |
|
||||
| kind | key |
|
||||
| -------- | --------------------------------------------------------------------- |
|
||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||
| `artist` | music-rel — `Albums/AC-DC` |
|
||||
|
||||
### Favorites
|
||||
|
||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||
```json
|
||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
||||
{
|
||||
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||
"artists": ["Albums/AC-DC"]
|
||||
}
|
||||
```
|
||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||
(a repeat add is a no-op).
|
||||
@@ -281,9 +317,16 @@ launch to offer "resume".
|
||||
|
||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||
```json
|
||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
||||
{
|
||||
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||
"title": "Hells Bells",
|
||||
"artist": "AC/DC",
|
||||
"album": "Back in Black",
|
||||
"durationSec": 312.5,
|
||||
"positionSec": 140,
|
||||
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||
}
|
||||
```
|
||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||
@@ -299,15 +342,15 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
||||
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||
|
||||
| method | path | body | returns |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||
| method | path | body | returns |
|
||||
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||
|
||||
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
||||
|
||||
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
||||
|
||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
||||
audio files* is a separate, later feature.)
|
||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||
audio files_ is a separate, later feature.)
|
||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Music — the second plugin
|
||||
|
||||
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||
|
||||
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||
worked second case, and it is interesting mainly for being the messy one.
|
||||
|
||||
---
|
||||
|
||||
## What music is
|
||||
|
||||
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||
free to change.
|
||||
|
||||
```
|
||||
manifest.ts identity, one permission
|
||||
api/router.ts re-exports the platform's proxy — see below
|
||||
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||
web/ two panels and a layout; the shell renders the Workspace
|
||||
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The three things that stayed, and why
|
||||
|
||||
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||
means each one is written down with what would close it.
|
||||
|
||||
### 1. cliamp — parked in the plugin, not left in the platform
|
||||
|
||||
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
|
||||
|
||||
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
||||
|
||||
```
|
||||
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
||||
api/cliamp/relay.ts → cliamp/relay.ts
|
||||
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
||||
```
|
||||
|
||||
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
||||
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
||||
|
||||
Two things went with it that were live rather than inert:
|
||||
|
||||
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
||||
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
||||
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
||||
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
||||
its two menu entries are removed; the components are parked here.
|
||||
- **The two socket routes.** They now 404. Verified live.
|
||||
|
||||
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
||||
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
||||
|
||||
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
||||
and never called. A platform gap, not a music one.
|
||||
|
||||
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
||||
|
||||
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
||||
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
||||
`getMusicServerWsUrl` from it, so it could not move.
|
||||
|
||||
That reason was three layers of nothing:
|
||||
|
||||
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
||||
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
||||
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
||||
|
||||
A dead import held a music-named file in the platform. The proxy is now built in
|
||||
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
||||
|
||||
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
||||
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
||||
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
||||
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
||||
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
||||
to know about provenance. It now calls `mountPrefix`.
|
||||
|
||||
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
||||
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
||||
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
||||
|
||||
### 3. The player — the one open judgement call, and it is decided
|
||||
|
||||
**`officerdev/src/MusicPlayer/` stays in the platform.** The runbook left this open with either answer
|
||||
acceptable. What decided it was not the overlay but the state:
|
||||
|
||||
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by
|
||||
> `src/workspaces/widgets/MusicPlayer/`, the dashboard widget — which is _also_ out of scope and stays.
|
||||
> **The platform cannot import from a plugin.** So the player state stays here whatever is decided about
|
||||
> the UI around it, and a second copy would mean two audio engines fighting over one pair of speakers.
|
||||
|
||||
Given the state had to stay, splitting the engine and the bar away from the thing they drive would have
|
||||
left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided
|
||||
component on **every route** — which is exactly the escape hatch this system deleted on purpose. "There
|
||||
is no way to export a component" is what makes "every plugin route is a Workspace" a property of the
|
||||
shape rather than a rule someone has to remember, and reopening it for one plugin is a bad trade.
|
||||
|
||||
The seam is inert without the plugin: `MusicPlayerHost` gates on `can('music')`, and `music` is now the
|
||||
plugin's permission — registered at install, gone at uninstall.
|
||||
|
||||
What stayed with it, and why each: `gapless-engine` (the engine the state drives), `player-time` (the
|
||||
module-level bridge the lyrics pane meets it through), `useLyricsOpen` and `MusicHeart` +
|
||||
`useMusicFavorites` (the bar renders a heart), and `shared.ts` — the library vocabulary, which the host
|
||||
needs a third of and the plugin needs all of. One definition on the host side beats a copy either side
|
||||
of the boundary drifting apart; `plugins/music/web/shared.ts` re-exports it from the package's declared
|
||||
`officerdev/MusicPlayer/shared` subpath.
|
||||
|
||||
**What would close it:** the widget learning to come from a plugin. Not the overlay slot — that one
|
||||
should stay shut.
|
||||
|
||||
---
|
||||
|
||||
## Two bugs, neither visible from reading
|
||||
|
||||
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||
installed, enabled and healthy.
|
||||
|
||||
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||
it cost minutes instead of an evening. Entry removed.
|
||||
|
||||
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||
every existing `role_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||
for the overlay. Renaming it would have been a silent data change.
|
||||
|
||||
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||
|
||||
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||
|
||||
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||
change inside `db/queries.ts`, not a flag on the manifest.
|
||||
|
||||
---
|
||||
|
||||
## Host dependencies — the field music created
|
||||
|
||||
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||
that. That was the wrong answer, because of HOW music fails without them.
|
||||
|
||||
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||
nobody reads. A refusal wearing the costume of a normal result.
|
||||
|
||||
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||
|
||||
```ts
|
||||
osDependencies: [
|
||||
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||
]
|
||||
```
|
||||
|
||||
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||
a single `ffmpeg` before anything reaches a command line.
|
||||
|
||||
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||
recorded why that indirection was rejected.
|
||||
|
||||
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||
named — nothing had happened, so there was nothing to undo.
|
||||
|
||||
`~/Music` still does not exist, so there is no library to index.
|
||||
|
||||
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||
|
||||
---
|
||||
|
||||
## Verified on the live server, 2026-08-15
|
||||
|
||||
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||
|
||||
| Step | Result |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||
| dock | tile present in `/api/user/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
|
||||
| permissions page | `music` listed among the grantable |
|
||||
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||
| enable | 200 again, sidecar online, favourites still there |
|
||||
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||
|
||||
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||
exist before.
|
||||
|
||||
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||
|
||||
---
|
||||
|
||||
## Still open
|
||||
|
||||
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
||||
|
||||
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
||||
|
||||
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
||||
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** capability, and `files` is
|
||||
**`confined`** — so:
|
||||
|
||||
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
||||
library**, because every listing 403s;
|
||||
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
||||
account with no `osUser`, so it means nothing without a per-user Linux account.
|
||||
|
||||
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
||||
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
||||
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
||||
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
||||
|
||||
Three possible shapes, none chosen:
|
||||
|
||||
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
||||
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||
Most self-contained, and the most work.
|
||||
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
||||
but it makes one plugin's grant conditional on another capability, which is new machinery.
|
||||
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
||||
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
||||
|
||||
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
||||
Not tonight's call.
|
||||
|
||||
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||
wire — noted rather than fixed.
|
||||
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||
- **The cliamp sockets are claimed by no capability**, and are served. Now pinned by a test in
|
||||
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
import { mountPrefix } from '@@/plugins/manifest';
|
||||
import { manifest } from '../manifest';
|
||||
|
||||
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
|
||||
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
|
||||
//
|
||||
// ── This used to live in the platform, and that was the bug ──
|
||||
//
|
||||
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
|
||||
// knew the string 'music' — and this file merely re-exported it. The justification was that
|
||||
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
|
||||
//
|
||||
// That justification was three layers of nothing. The relay's functions were only reachable through
|
||||
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
|
||||
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
|
||||
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
|
||||
//
|
||||
// ── The prefix is DERIVED, not written ──
|
||||
//
|
||||
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
|
||||
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
|
||||
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
|
||||
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
|
||||
//
|
||||
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
|
||||
// become two systems with only one of them tested.
|
||||
//
|
||||
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
|
||||
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
|
||||
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
|
||||
// factory the installer calls instead. Recorded rather than worked around.
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'music',
|
||||
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
|
||||
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
|
||||
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
|
||||
timeoutSeconds: 1800,
|
||||
});
|
||||
|
||||
export const router = proxy.router;
|
||||
|
||||
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
|
||||
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||
+19
-4
@@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
||||
ctxRef.current = audioCtx;
|
||||
|
||||
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||
if (disposed) { audioCtx.close(); return; }
|
||||
if (disposed) {
|
||||
audioCtx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||
outputChannelCount: [CHANNELS],
|
||||
@@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try { wsRef.current?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
|
||||
try {
|
||||
nodeRef.current?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
nodeRef.current = null;
|
||||
try { audioCtx?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
audioCtx?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ctxRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Music } from 'lucide-react';
|
||||
import { TerminalView } from '../Terminal/Terminal';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
||||
|
||||
export const CliampPanelHeader = () => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { getMusicServerWsUrl } from '../music/router';
|
||||
import { getMusicServerWsUrl } from '../api/router';
|
||||
|
||||
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
||||
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { db } from 'officerdb/db';
|
||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||
|
||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// Music — the library, the player, and the phone and tablet apps that stream from it.
|
||||
//
|
||||
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
|
||||
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
|
||||
// of them here after the owner read the code and asked why the platform still had files named for music.
|
||||
// He was right — one of the three "seams" turned out to be dead code holding the door open.
|
||||
//
|
||||
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
|
||||
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||
// web/ the library panels; the shell renders the Workspace
|
||||
//
|
||||
// ── What stayed in the platform, and why ── (one item, down from three)
|
||||
//
|
||||
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
|
||||
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
|
||||
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
|
||||
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
|
||||
//
|
||||
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
|
||||
// the mechanism was not worth inventing for one.
|
||||
//
|
||||
// 3. The global player overlay (`officerdev/src/MusicPlayer/`, mounted by `DashboardLayout`). This was
|
||||
// the one open judgement call and it is decided: THE PLAYER STAYS IN THE PLATFORM. Two reasons, and
|
||||
// the second is the one that settles it.
|
||||
//
|
||||
// - Moving it needs a shell slot that renders a plugin-provided component on every route. That is
|
||||
// exactly the escape hatch this system deleted on purpose — "there is no way to export a component"
|
||||
// is what makes "every plugin route is a Workspace" a property of the shape rather than a rule
|
||||
// someone has to remember. Reopening it for one plugin is a bad trade.
|
||||
// - It would not even work. The widget above 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. Splitting the engine from the state it drives would leave the same seam
|
||||
// in a worse place, and two copies of that state would mean two engines.
|
||||
//
|
||||
// The overlay gates on `can('music')`, which resolves against the permission below — registered at
|
||||
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
|
||||
// that knows why.
|
||||
//
|
||||
// ── Host dependencies ──
|
||||
//
|
||||
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Music',
|
||||
summary: 'The music library — browse, play, favourites and playlists',
|
||||
icon: 'Music',
|
||||
color: '#22c55e',
|
||||
|
||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||
//
|
||||
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
|
||||
// this extraction, so every existing `role_capabilities` grant keeps meaning what it meant, and the
|
||||
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
|
||||
//
|
||||
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
|
||||
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
|
||||
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
|
||||
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
|
||||
permissions: [
|
||||
{
|
||||
key: 'music',
|
||||
label: 'Music',
|
||||
description: 'The music library, playback, and your own favourites and playlists',
|
||||
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
|
||||
//
|
||||
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
|
||||
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
|
||||
// be designing the per-user visibility model that is explicitly not this extraction's work. It
|
||||
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
|
||||
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
|
||||
// a read grant permits exactly the same four paths it permitted yesterday.
|
||||
//
|
||||
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
|
||||
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||
},
|
||||
],
|
||||
|
||||
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||
//
|
||||
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||
osDependencies: [
|
||||
{
|
||||
binary: 'ffprobe',
|
||||
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||
},
|
||||
{
|
||||
binary: 'ffmpeg',
|
||||
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { streamAudioFile } from './stream-audio';
|
||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
||||
import { ensurePulseAudio } from './pulse-audio';
|
||||
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||
import {
|
||||
reindexNow,
|
||||
@@ -37,10 +37,9 @@ import {
|
||||
addPlaylistItems,
|
||||
setPlaylistItems,
|
||||
type FavoriteKind,
|
||||
} from 'officerdb';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
} from '../db/queries';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// ── Per-user state validation ──
|
||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
|
||||
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ── Audio-streaming HTTP server ──
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||
// phone's resync diff (fetch only changed `v`s).
|
||||
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
|
||||
const HOME = homedir();
|
||||
export const MUSIC_ROOT = join(HOME, 'Music');
|
||||
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
||||
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
||||
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
||||
if (prev.version !== next.version) {
|
||||
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
|
||||
console.log(
|
||||
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
|
||||
);
|
||||
}
|
||||
|
||||
const { added, removed, changed } = diffManifest(prev, next);
|
||||
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
|
||||
console.log(
|
||||
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
|
||||
);
|
||||
const sample = (label: string, rels: string[]) => {
|
||||
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
||||
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
||||
+3
-1
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
|
||||
const schedule = () => {
|
||||
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||
const at = new Date(Date.now() + ms);
|
||||
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
|
||||
console.log(
|
||||
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
|
||||
);
|
||||
timer = setTimeout(async () => {
|
||||
console.log('[music] nightly full reindex starting');
|
||||
try {
|
||||
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
|
||||
[
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
|
||||
}
|
||||
return new Response(file.slice(start, end + 1), {
|
||||
status: 206,
|
||||
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||
'Content-Length': String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -3,9 +3,9 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { useMusicFavorites } from './useMusicFavorites';
|
||||
import { useMusicPlayer, type PlayerTrack } from 'officerdev';
|
||||
import { MusicHeart } from 'officerdev';
|
||||
import { useMusicFavorites } from 'officerdev';
|
||||
import {
|
||||
MUSIC_FAV_CHANNEL,
|
||||
coverUrl,
|
||||
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
|
||||
const albumRel = toRel(albumHome);
|
||||
try {
|
||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
||||
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
|
||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||
albumRel,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
player.playQueue(
|
||||
q,
|
||||
Math.max(
|
||||
0,
|
||||
q.findIndex((t) => t.file === file),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
player.playQueue([{ albumRel, file }], 0);
|
||||
}
|
||||
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
|
||||
{empty ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<Heart size={44} className="text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No favorites yet. Click the heart on any artist, album or track.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Loader2, Music4 } from 'lucide-react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { seekPlayer } from './player-time';
|
||||
import { seekPlayer } from 'officerdev';
|
||||
import { useActiveLyricIndex } from './useLyrics';
|
||||
|
||||
type LyricsPaneProps = {
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { useClient } from 'hooks/useClient';
|
||||
import { MicVocal } from 'lucide-react';
|
||||
import { LyricsPane } from './LyricsPane';
|
||||
import { useLyrics } from './useLyrics';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
import { useMusicPlayer } from './useMusicPlayer';
|
||||
import { useLyricsOpen } from 'officerdev';
|
||||
import { useMusicPlayer } from 'officerdev';
|
||||
|
||||
/**
|
||||
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
||||
+8
-8
@@ -4,15 +4,15 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
||||
import { WorkspaceLayout } from '../../components/Workspace';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { MusicHeart } from 'officerdev';
|
||||
import { FavoritesView } from './FavoritesView';
|
||||
import { useMusicPlayer } from '../../MusicPlayer';
|
||||
import type { PlayerTrack } from '../../MusicPlayer';
|
||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
||||
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
|
||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
||||
import { useMusicPlayer } from 'officerdev';
|
||||
import type { PlayerTrack } from 'officerdev';
|
||||
import { LyricsPanel } from './LyricsPanel';
|
||||
import { MusicMiniBar } from './MusicMiniBar';
|
||||
import { useLyricsOpen } from 'officerdev';
|
||||
import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_FAV_CHANNEL,
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import { useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||
import { coverUrl, fmtClock } from '../apps/Music/shared';
|
||||
import { seekPlayer } from './player-time';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
import { useMusicPlayer } from './useMusicPlayer';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { coverUrl, fmtClock } from './shared';
|
||||
import { seekPlayer } from 'officerdev';
|
||||
import { useLyricsOpen } from 'officerdev';
|
||||
import { useMusicPlayer } from 'officerdev';
|
||||
import { usePlayerClock } from './usePlayerClock';
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Music, ListMusic } from 'lucide-react';
|
||||
import { MusicBrowser } from './MusicBrowser';
|
||||
import { MusicDetail } from './MusicDetail';
|
||||
|
||||
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||
// `layout.ts` — a plugin never renders the screen.
|
||||
//
|
||||
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||
// screen.
|
||||
//
|
||||
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
// The library vocabulary, re-exported from the host.
|
||||
//
|
||||
// It lives at `officerdev/src/MusicPlayer/shared.ts` rather than here because `MusicPlayerHost` — the
|
||||
// global player bar, which stays in the platform; see that directory's index.ts for why — needs a third
|
||||
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
|
||||
//
|
||||
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
|
||||
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
|
||||
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
|
||||
//
|
||||
// Every panel in this directory imports from HERE, so the seam is one file to read rather than a
|
||||
// different specifier in each of them.
|
||||
export * from 'officerdev/MusicPlayer/shared';
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
||||
import { getPlayerTime, subscribePlayerTime } from 'officerdev';
|
||||
|
||||
export type UseLyrics = {
|
||||
loading: boolean;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
|
||||
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
|
||||
|
||||
/**
|
||||
* Position + duration, straight off the engine's per-frame feed.
|
||||
@@ -3,7 +3,7 @@ import { useMusicPlayer } from 'officerdev';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Widget } from '../Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
|
||||
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
||||
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
||||
@@ -36,7 +36,14 @@ export const MusicPlayer = () => {
|
||||
// Top-level libraries (once).
|
||||
useEffect(() => {
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
||||
.then((r) =>
|
||||
setLibraries(
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory')
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
),
|
||||
)
|
||||
.catch(() => setLibraries([]));
|
||||
}, []);
|
||||
|
||||
@@ -50,7 +57,12 @@ export const MusicPlayer = () => {
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||
.then(async (r) => {
|
||||
if (cancelled) return;
|
||||
setDirs(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
|
||||
setDirs(
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory')
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
);
|
||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||
if (audio.length) {
|
||||
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
||||
@@ -92,7 +104,12 @@ export const MusicPlayer = () => {
|
||||
};
|
||||
|
||||
const play = (i: number) => {
|
||||
const queue: PlayerTrack[] = songs.map((t) => ({ albumRel: relToMusic, file: t.file, title: t.title, artist: t.artist }));
|
||||
const queue: PlayerTrack[] = songs.map((t) => ({
|
||||
albumRel: relToMusic,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
player.playQueue(queue, i);
|
||||
};
|
||||
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
||||
@@ -107,7 +124,9 @@ export const MusicPlayer = () => {
|
||||
type="button"
|
||||
onClick={() => selectLibrary(lib)}
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
||||
library === lib ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
library === lib
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{lib}
|
||||
@@ -144,7 +163,9 @@ export const MusicPlayer = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{breadcrumb[breadcrumb.length - 1] ?? library}</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{breadcrumb[breadcrumb.length - 1] ?? library}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -0,0 +1,732 @@
|
||||
# Offscale — the first real plugin
|
||||
|
||||
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
|
||||
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||
|
||||
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
|
||||
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
|
||||
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
|
||||
history of something it no longer knows exists.
|
||||
|
||||
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
|
||||
|
||||
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||
|
||||
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||
|
||||
---
|
||||
|
||||
## The reframe
|
||||
|
||||
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||
intuition is that it is one plugin requiring two sidecars.
|
||||
|
||||
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||
written on runs five.
|
||||
|
||||
### Two words, because "core" was doing two jobs
|
||||
|
||||
- **baseline** — what a fresh install actually runs
|
||||
- **first-party** — what Officer Dev publishes
|
||||
|
||||
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||
"is X core?" a question with two answers.
|
||||
|
||||
---
|
||||
|
||||
## What a plugin is made of
|
||||
|
||||
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||
|
||||
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||
- an **ID** (see below)
|
||||
- a **sidecar**
|
||||
- a **backend router** and its routes
|
||||
- a **db schema**
|
||||
- **default permissions per user group**
|
||||
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||
- a **frontend router**, its routes, and the frontend code
|
||||
- how it **mounts into the file browser context menu**
|
||||
- a set of **capabilities added to officer-items**
|
||||
- **plugin settings page** definitions
|
||||
- an accompanying **mobile app**
|
||||
|
||||
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||
|
||||
### What offscale needs
|
||||
|
||||
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||
|
||||
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
|
||||
|
||||
---
|
||||
|
||||
## Identity and routing
|
||||
|
||||
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||
|
||||
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||
|
||||
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||
Needed because a private plugin never asks the marketplace anything.
|
||||
|
||||
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||
restrictions; publishing is what invites review.
|
||||
|
||||
### Mount prefixes
|
||||
|
||||
```
|
||||
first-party /api/<app-name> e.g. /api/offscale
|
||||
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||
```
|
||||
|
||||
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||
|
||||
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||
become two systems, and only one of them gets tested.
|
||||
|
||||
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||
adding core routes forever without breaking installs.
|
||||
|
||||
---
|
||||
|
||||
## The database
|
||||
|
||||
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||
|
||||
### A Postgres schema per plugin was tested and rejected
|
||||
|
||||
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||
earns. Recorded so nobody re-runs the experiment:
|
||||
|
||||
| Property | Result |
|
||||
| ----------------------------------------------------------------- | ------------------------- |
|
||||
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||
| Cross-schema FK to `public.users` | works |
|
||||
| Partial unique index preserved | works |
|
||||
| Push is idempotent, no spurious re-creation | works |
|
||||
| Cascade delete across the schema boundary | works |
|
||||
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||
|
||||
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||
naming the schema in `schemaFilter` made the identical push work.
|
||||
|
||||
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||
costume of a normal result.
|
||||
|
||||
---
|
||||
|
||||
## Mounting — rebuild and swap, at runtime
|
||||
|
||||
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||
|
||||
### What was actually tested
|
||||
|
||||
| Router | `app.route()` after serving has begun |
|
||||
| -------------------------------- | --------------------------------------------------------------------- |
|
||||
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||
| `RegExpRouter` | **throws**, same reason |
|
||||
| `TrieRouter` | works |
|
||||
| `PatternRouter` | works |
|
||||
|
||||
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||
remove a route**, which uninstall needs.
|
||||
|
||||
### The approach that solves both
|
||||
|
||||
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||
|
||||
```ts
|
||||
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||
|
||||
// install: app = buildApp([...installed, 'offscale'])
|
||||
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||
```
|
||||
|
||||
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||
|
||||
```
|
||||
no plugins /offscale/x -> 404 | /core -> 200
|
||||
installed /offscale/x -> 200 | /core -> 200
|
||||
uninstalled /offscale/x -> 404 | /core -> 200
|
||||
```
|
||||
|
||||
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||
|
||||
### The one line that has to change
|
||||
|
||||
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||
This is the whole mechanical cost.
|
||||
|
||||
### Websockets are a separate table, and they reload
|
||||
|
||||
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||
|
||||
```
|
||||
before reload /api/offscale/ws -> refused | /core -> 200
|
||||
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||
after remove /api/offscale/ws -> refused | /core -> 200
|
||||
```
|
||||
|
||||
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||
|
||||
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||
before a plugin install can interrupt somebody's terminal.
|
||||
|
||||
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||
|
||||
### What this means for `assertCapabilityTotality`
|
||||
|
||||
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||
where mounting actually happens instead of once at start-up.
|
||||
|
||||
Two things it must survive, both live today:
|
||||
|
||||
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||
still needs pointing at reality.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
|
||||
|
||||
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||
platform's to assign.
|
||||
|
||||
### The platform grants read or write. Everything richer is the plugin's own job
|
||||
|
||||
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
|
||||
the surface; `write` permits everything.
|
||||
|
||||
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||
means; the platform only knows whether this account got through the door.
|
||||
|
||||
### Offscale v1 uses that model exactly, with nothing added
|
||||
|
||||
One shared resource, role-gated:
|
||||
|
||||
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||
- **write** — can change them, including deleting a server the owner registered
|
||||
|
||||
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||
|
||||
Two implementation consequences, both inside the plugin:
|
||||
|
||||
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||
|
||||
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||
|
||||
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||
delete, policy `PUT` — is a genuine write.
|
||||
|
||||
### Music is where the richer model gets designed
|
||||
|
||||
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||
where offscale's is just "the owner's".
|
||||
|
||||
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||
|
||||
### Three different things are called "capability" here
|
||||
|
||||
A manifest needs three names, not one:
|
||||
|
||||
1. `capabilities/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||
3. `sidecar-registry` `capabilities: ['music']` — **routing keys** for `sendCommand`
|
||||
|
||||
Offscale needs (1) and (3), and not (2).
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||
|
||||
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||
parties touch it.
|
||||
|
||||
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||
the host:
|
||||
|
||||
```ts
|
||||
import { db } from '../db'; // the connection
|
||||
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||
```
|
||||
|
||||
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||
the pilot rather than being invented.
|
||||
|
||||
---
|
||||
|
||||
## `/api/vpn` is being deleted
|
||||
|
||||
Officer had two headscale surfaces:
|
||||
|
||||
| | `/api/vpn` | `/api/headscale` |
|
||||
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||
| surface | one route, `POST /enroll` | the whole admin API |
|
||||
|
||||
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||
anywhere.** Verified against the mobile monorepo:
|
||||
|
||||
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||
3. `embedded` is optional and defaults to `false`
|
||||
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||
|
||||
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||
does the Officer web app. The live database holds no `vpn` grants.
|
||||
|
||||
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||
|
||||
### Everything collapses to one namespace
|
||||
|
||||
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||
contract has no counterparty.
|
||||
|
||||
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||
|
||||
- **phone → Companion** — untouched by anything here
|
||||
- **web admin → Officer → sidecar** — ours to rename freely
|
||||
|
||||
### There are THREE components, not two
|
||||
|
||||
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||
|
||||
| Component | Repo | Enrolment surface |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||
|
||||
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||
|
||||
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||
in the invite (`https://<domain>/officer-api`).
|
||||
|
||||
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||
`POST /_officer/enroll` handler below it is dead.
|
||||
|
||||
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||
|
||||
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||
is correct. The invite flow supersedes it completely:
|
||||
|
||||
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||
3. the Companion turns that into the redirect the phone app claims
|
||||
4. the device joins
|
||||
|
||||
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
|
||||
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||
|
||||
---
|
||||
|
||||
## What headscale actually is — the inventory
|
||||
|
||||
Read end to end on 2026-08-14. This is what has to move.
|
||||
|
||||
### Backend — 2,406 lines
|
||||
|
||||
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||
|
||||
Three things worth knowing before touching it:
|
||||
|
||||
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||
undo.
|
||||
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||
branches on whether the body parses.
|
||||
|
||||
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||
|
||||
### Frontend — 29 files, 27 endpoints
|
||||
|
||||
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||
Diagnostics · Console.
|
||||
|
||||
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||
|
||||
The whole frontend↔host coupling, which becomes the plugin API:
|
||||
|
||||
| Import | Why it matters |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||
| `AppRegistryMeta` | the panel-contribution contract |
|
||||
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
|
||||
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||
|
||||
### `assist.ts` travels, but stays unwired
|
||||
|
||||
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||
later. Do not tidy it away as unused code.
|
||||
|
||||
---
|
||||
|
||||
## The manifest — proposal
|
||||
|
||||
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||
is the floor, not the ceiling.
|
||||
|
||||
```ts
|
||||
// plugins/offscale/manifest.ts
|
||||
export const manifest = {
|
||||
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||
publisher: 'officerdev',
|
||||
/** The plugin's own semver. Updates compare against this. */
|
||||
version: '1.0.0',
|
||||
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||
platform: '>=1.0.0 <2.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
|
||||
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes and ACLs',
|
||||
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||
ownerOnly: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
```
|
||||
|
||||
### THE RULE: every plugin route renders a Workspace with at least one panel
|
||||
|
||||
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
|
||||
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
|
||||
|
||||
```
|
||||
web/panels.ts exports appRegistryMetas — at least one panel
|
||||
web/layout.ts exports defaultLayout — how they are arranged
|
||||
```
|
||||
|
||||
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
|
||||
name and with the reason:
|
||||
|
||||
```
|
||||
probeplug: has a web/ directory but is missing web/layout.ts.
|
||||
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
|
||||
```
|
||||
|
||||
There is deliberately no way to export a component. A plugin that could would be free to render a bare
|
||||
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
|
||||
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
|
||||
is nowhere to put a screen.
|
||||
|
||||
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||
than rendering another plugin's panel inside this one.
|
||||
|
||||
### Everything the tree can say, the tree says
|
||||
|
||||
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||
|
||||
| Path | Means |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||
|
||||
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||
duplication that could only ever drift.
|
||||
|
||||
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||
field that can contradict the file it describes.
|
||||
|
||||
### Install asks nothing, and that is the default
|
||||
|
||||
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||
and no external service to point at.
|
||||
|
||||
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||
how it works today.
|
||||
|
||||
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||
|
||||
### Dropped from the first draft
|
||||
|
||||
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||
when something consumes it.
|
||||
- **`kind`** — see below.
|
||||
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||
|
||||
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||
|
||||
### `admin` has to be allowed, and the pilot proved it immediately
|
||||
|
||||
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||
|
||||
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||
nothing is gained by forbidding it.
|
||||
|
||||
Corrected rule:
|
||||
|
||||
| Kind | May a plugin declare it? | Why |
|
||||
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `app` | yes | the ordinary grantable surface |
|
||||
| `admin` | yes | self-restriction, never an escalation |
|
||||
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||
|
||||
### One function decides the prefix
|
||||
|
||||
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||
|
||||
```ts
|
||||
const mountPrefix = (m: Manifest) =>
|
||||
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||
```
|
||||
|
||||
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||
|
||||
### Notes on the fields
|
||||
|
||||
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||
degrade. This is for the human reading the store.
|
||||
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||
|
||||
---
|
||||
|
||||
## What is built — complete, as of 2026-08-15
|
||||
|
||||
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||
time.
|
||||
|
||||
| Piece | Where |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
|
||||
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||
| Capability registration | `capabilities/registry.ts` → `setPluginCapabilities` |
|
||||
| Install state | `plugin_installs` |
|
||||
| The screen | `/plugins`, two panels, SSE log |
|
||||
| The reference plugin | `plugins/example/` |
|
||||
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
||||
|
||||
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
|
||||
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
|
||||
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||
|
||||
### Three bugs the extraction found
|
||||
|
||||
Worth recording because none were visible from reading:
|
||||
|
||||
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
|
||||
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
|
||||
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
|
||||
has no listener to announce. Install and enable now mount first.
|
||||
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
|
||||
applies to Bun's static serving and not to a programmatic `Bun.build()`.
|
||||
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
|
||||
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
|
||||
|
||||
### Still open
|
||||
|
||||
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||
still the hardcoded providers. No plugin owns a socket yet.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||
`buildHonoApp`, which is now the single place routes are mounted.
|
||||
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
|
||||
plugin system. One when the app store is rebuilt on this.
|
||||
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
|
||||
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||
|
||||
---
|
||||
|
||||
## The state of the app store, as found
|
||||
|
||||
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||
|
||||
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||
|
||||
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||
> and it is not built."_
|
||||
|
||||
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||
|
||||
Also found:
|
||||
|
||||
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||
`bun db:push`.
|
||||
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||
that the directory exists.
|
||||
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||
means editing two files and rebuilding.
|
||||
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||
renders against it. Not to be confused with any of the above.
|
||||
|
||||
---
|
||||
|
||||
## Where the code lives
|
||||
|
||||
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||
|
||||
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||
`extensions/` may become plugins later.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||
platform checkout, so dev-time and build-time are the same mechanism.
|
||||
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||
future, not before.
|
||||
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||
Console section does not travel with the plugin.
|
||||
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
|
||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||
// this file must never grow app logic.
|
||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'headscale',
|
||||
prefix: '/api/headscale',
|
||||
prefix: '/api/offscale',
|
||||
});
|
||||
|
||||
export const headscaleRouter = proxy.router;
|
||||
export const router = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { db } from 'officerdb/db';
|
||||
import { headscaleServers } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
|
||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../auth/schema';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// Offscale — Headscale, plus the Companion that ships beside it.
|
||||
//
|
||||
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
|
||||
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
|
||||
// distinct product rather than a badge on someone else's.
|
||||
//
|
||||
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
|
||||
//
|
||||
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
|
||||
// sidecar/ the whole Headscale contract, holding the admin API keys
|
||||
// db/ offscale_servers, and the only table this plugin owns
|
||||
// web/ panels and a layout; the shell renders the Workspace
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||
//
|
||||
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||
// That is a change inside these queries, not a flag on the manifest.
|
||||
//
|
||||
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes, keys and ACLs',
|
||||
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
|
||||
// Without declaring them a read-level account meets a broken feature where a withheld permission
|
||||
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||
readOnlyWrites: ['/ssh-test', '/policy/assist'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
|
||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
||||
//
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||
|
||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled once:
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||
|
||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OfficerContext } from './routes';
|
||||
import type { OfficerUser } from './normalize';
|
||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
import { arrayField, toUser } from './normalize';
|
||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||
//
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { MIN_VERSION_LABEL } from './version';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// DELETE /_officer/keys/:id delete outright
|
||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||
// for a joining device. userId is only required when the server
|
||||
// has more than one user; reached via /api/vpn/enroll.
|
||||
// has more than one user.
|
||||
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||
// which is deleted. Kept because it is the handler a route under
|
||||
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||
// IS live — dispatches through the same function.
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
deleteHeadscaleServer,
|
||||
getHeadscaleCredentials,
|
||||
recordHeadscaleProbe,
|
||||
} from 'officerdb';
|
||||
} from '../db/queries';
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||
import { headscaleSectionPath } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { TerminalView } from '../Terminal/Terminal';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { Button } from './Cards';
|
||||
|
||||
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
||||
+1
-1
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'headscale-root',
|
||||
id: 'offscale-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||
import { HeadscaleNav } from './HeadscaleNav';
|
||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
||||
+1
-1
@@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
|
||||
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
|
||||
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
||||
|
||||
const BASE = '/headscale/_officer/companion';
|
||||
const BASE = '/offscale/_officer/companion';
|
||||
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
||||
|
||||
/**
|
||||
+15
-18
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: NODES_KEY,
|
||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
|
||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
|
||||
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||
refetchInterval: 20_000,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setTags = useMutation({
|
||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
|
||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
||||
const moveToUser = useMutation({
|
||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
|
||||
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
|
||||
// because Headscale's approve_routes replaces the whole set.
|
||||
const toggleRoute = useMutation({
|
||||
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
|
||||
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const expire = useMutation({
|
||||
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
|
||||
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
|
||||
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: USERS_KEY,
|
||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
|
||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
||||
post('/headscale/_officer/users', input),
|
||||
post('/offscale/_officer/users', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
post(`/headscale/_officer/users/${id}/rename`, { name }),
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
|
||||
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: KEYS_KEY,
|
||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
|
||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -141,17 +138,17 @@ export function useHeadscaleKeys() {
|
||||
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||
const create = useMutation({
|
||||
mutationFn: (input: CreateKeyInput) =>
|
||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
|
||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const expire = useMutation({
|
||||
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
|
||||
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
|
||||
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
|
||||
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
|
||||
// drops it. The list is refetched instead, which returns the same invite without its token.
|
||||
|
||||
const BASE = '/headscale/_officer/enroll/invites';
|
||||
const BASE = '/offscale/_officer/enroll/invites';
|
||||
const INVITES_KEY = ['headscale', 'invites'] as const;
|
||||
|
||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
||||
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
||||
|
||||
const POLICY_KEY = ['headscale', 'policy'] as const;
|
||||
const PATH = '/headscale/_officer/policy';
|
||||
const PATH = '/offscale/_officer/policy';
|
||||
|
||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
||||
+2
-2
@@ -12,7 +12,7 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
|
||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||
const EMPTY: HeadscaleServer[] = [];
|
||||
|
||||
const BASE = '/headscale/_officer/servers';
|
||||
const BASE = '/offscale/_officer/servers';
|
||||
|
||||
/**
|
||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||
@@ -89,7 +89,7 @@ export function useHeadscaleServers() {
|
||||
export function useHeadscaleSshTest() {
|
||||
const { post } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
|
||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { discoverPlugins } from '../src/servers/plugins/discover';
|
||||
import { generatePluginSchemas } from '../src/servers/plugins/schema';
|
||||
|
||||
// Write `plugin-schemas.gen.ts` from whatever plugins are on disk. Runs as the first half of `db:push`.
|
||||
//
|
||||
// ── The bug this closes ──
|
||||
//
|
||||
// `officer_db/src/schema.ts` ends with `export * from './plugin-schemas.gen'` — UNCONDITIONAL, because
|
||||
// drizzle-kit needs one file listing every table. That generated file is gitignored (it describes this
|
||||
// machine, not the project), and until 2026-08-15 its only writer was `installPlugin`.
|
||||
//
|
||||
// So on a fresh clone the file did not exist, and `bun db:push` died with MODULE_NOT_FOUND before
|
||||
// creating a single table. That is exactly what `scripts/setup/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.
|
||||
//
|
||||
// It is wired into the `db:push` SCRIPT rather than added as a setup step on purpose. 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 only forget
|
||||
// once — the barrel now cannot be stale when push reads it, because push regenerates it.
|
||||
//
|
||||
// Empty is a correct answer, and the common one: a machine with no plugins gets a barrel with no
|
||||
// exports, which is what `schema.ts` needs in order to import it at all.
|
||||
|
||||
const { plugins, broken } = await discoverPlugins();
|
||||
|
||||
// Reported, never thrown. A plugin with an unreadable manifest must not stop the platform's own tables
|
||||
// from being created — the same rule discovery follows everywhere else.
|
||||
for (const { appName, error } of broken) {
|
||||
console.warn(`[plugin-schemas] skipping ${appName}: ${error}`);
|
||||
}
|
||||
|
||||
const withSchema = plugins.filter((p) => p.schema);
|
||||
generatePluginSchemas(plugins);
|
||||
|
||||
console.log(
|
||||
withSchema.length
|
||||
? `[plugin-schemas] ${withSchema.length} plugin schema(s): ${withSchema.map((p) => p.appName).join(', ')}`
|
||||
: '[plugin-schemas] no plugin schemas on disk — wrote an empty barrel',
|
||||
);
|
||||
@@ -96,23 +96,24 @@ pkgs_core() {
|
||||
# separate decision from removing the tool that wanted it.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
echo gnupg git jq wget btop htop tree ripgrep fd eza
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -2311,6 +2311,41 @@ EOF
|
||||
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
||||
fi
|
||||
|
||||
# ── Keys ──
|
||||
#
|
||||
# This block existed only in `src/servers/shell-skel/zshrc`, the file the platform seeds into
|
||||
# PROVISIONED MEMBER accounts. The owner's .zshrc is assembled here instead, and never got it — so
|
||||
# the owner had a strictly worse shell than the members they provision: no ctrl-arrow, no
|
||||
# history-prefix search, no Home/End. Confirmed on this machine before writing it, with
|
||||
# `zsh -i -c bindkey`: the owner had `^[b`/`^[f` and nothing else.
|
||||
#
|
||||
# The two files are still separate — one is a template the platform copies, the other is an
|
||||
# idempotent append — but the KEYS have to agree, because a member and the owner sit at the same
|
||||
# web terminal and neither should have to learn which account they are on.
|
||||
if append_once "$ZSHRC" keybindings <<'EOF'
|
||||
bindkey -e
|
||||
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
|
||||
zle -N up-line-or-beginning-search
|
||||
zle -N down-line-or-beginning-search
|
||||
bindkey '^[[A' up-line-or-beginning-search
|
||||
bindkey '^[[B' down-line-or-beginning-search
|
||||
bindkey '^[[1;5C' forward-word
|
||||
bindkey '^[[1;5D' backward-word
|
||||
bindkey '^[[1;3C' forward-word
|
||||
bindkey '^[[1;3D' backward-word
|
||||
bindkey '^[[3~' delete-char
|
||||
bindkey '^[[H' beginning-of-line
|
||||
bindkey '^[[F' end-of-line
|
||||
bindkey '^[[1~' beginning-of-line
|
||||
bindkey '^[[4~' end-of-line
|
||||
bindkey '^H' backward-kill-word
|
||||
bindkey '^[^?' backward-kill-word
|
||||
bindkey '^[[3;5~' kill-word
|
||||
EOF
|
||||
then
|
||||
ok "shell keybindings added (ctrl/alt-arrow, history search, Home/End)"
|
||||
fi
|
||||
|
||||
# The eza aliases are GUARDED and the rest are not, for one reason: these
|
||||
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
|
||||
# failed to install leaves the owner with no working `ls` at all, in every new
|
||||
|
||||
@@ -446,6 +446,11 @@ if ! skip; then
|
||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||
fi
|
||||
|
||||
# The client goes on the HOST, before any of the container work, because it is the half
|
||||
# that is not in the container. A member has their own Postgres role and no access to the
|
||||
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
||||
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
||||
|
||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||
|
||||
@@ -39,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||
PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
# ── The CLIENT, on the host, matching the server in the container ──
|
||||
#
|
||||
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
|
||||
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
|
||||
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
|
||||
# owner's Docker socket.
|
||||
#
|
||||
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
|
||||
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
|
||||
# version 16.x — aborting"). 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 lives
|
||||
# beside the server definition rather than in machine-setup's package list — one constant,
|
||||
# one place to bump.
|
||||
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
|
||||
|
||||
pg_client_installed() {
|
||||
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
|
||||
}
|
||||
|
||||
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
return 1
|
||||
}
|
||||
|
||||
if pg_client_installed; then
|
||||
ok "psql ${major} already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -d -m 0755 /usr/share/postgresql-common/pgdg
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
|
||||
warn "could not fetch the PostgreSQL signing key"
|
||||
return 1
|
||||
}
|
||||
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||||
>/etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
|
||||
warn "postgresql-client-${major} did not install"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
|
||||
# matters is whether the binary is there AND is the version we asked for, because apt can
|
||||
# succeed while holding an older client back.
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
|
||||
}
|
||||
|
||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||
ensure_docker_network() {
|
||||
docker_network_exists && return 1
|
||||
|
||||
@@ -44,7 +44,6 @@ CORE_PROCESSES=(
|
||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
||||
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
|
||||
)
|
||||
|
||||
write_ecosystem() {
|
||||
|
||||
@@ -24,6 +24,12 @@ bind - split-window -v
|
||||
unbind r
|
||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||
|
||||
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
|
||||
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
|
||||
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
|
||||
# in one TCP frame, which over a websocket relay it always does.
|
||||
set -sg escape-time 10
|
||||
|
||||
# switch panes using Alt-arrow without prefix
|
||||
bind -n M-Left select-pane -L
|
||||
bind -n M-Right select-pane -R
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user