Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aaea6bfcc | ||
|
|
b9fce2aeb6 |
+18
-16
@@ -1,27 +1,29 @@
|
||||
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
||||
PORT=9000
|
||||
BROWSER_RELAY_PORT=18792
|
||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||
|
||||
# Where Officer is reached from a browser — the one value the machine cannot derive. Read by
|
||||
# `bun gen:index` (OpenGraph tags, which need an absolute URL), the task API host, and the CalDAV iOS
|
||||
# profile builder, which additionally requires https.
|
||||
#
|
||||
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
|
||||
PUBLIC_URL=http://localhost:9000
|
||||
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
|
||||
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
|
||||
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
|
||||
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
|
||||
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
|
||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
|
||||
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
|
||||
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
|
||||
# per purpose, created on first use. See docs/secret-store.md.
|
||||
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
|
||||
# encrypting every secret column in Postgres: Headscale admin API keys, app-store service
|
||||
# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the
|
||||
# owner passphrase that seals it.
|
||||
#
|
||||
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
|
||||
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
|
||||
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
|
||||
#
|
||||
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
|
||||
# unreadable, and for the wallet seed that is unrecoverable.
|
||||
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
|
||||
# passphrase opens the inner envelope and this is the outer one.
|
||||
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
||||
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
|
||||
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
|
||||
# PUBLIC_URL=https://officer.example.com
|
||||
|
||||
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
||||
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
||||
|
||||
-12
@@ -58,15 +58,3 @@ public/plugins/
|
||||
|
||||
# Written by officer-setup.sh; per-machine.
|
||||
scripts/setup/officer-setup/.setup-progress
|
||||
|
||||
# Generated by officer-setup, describing THIS install's processes. Never committed:
|
||||
# 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
|
||||
|
||||
@@ -16,23 +16,13 @@ written: `users` holds six rows. The accurate statement is narrower and more use
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the *owner's*, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
|
||||
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
|
||||
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
|
||||
API refuses to store one.
|
||||
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
|
||||
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
|
||||
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
|
||||
HTTP routes, the websocket doors and the dock together.
|
||||
|
||||
So "which user is this" has a real answer for the **app** surface (gitea, music, photos, email,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
|
||||
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
|
||||
|
||||
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
|
||||
@@ -52,20 +42,18 @@ One Bun process (`src/server.tsx`) serves everything:
|
||||
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
||||
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
||||
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
||||
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
|
||||
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
|
||||
- a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792)
|
||||
|
||||
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
||||
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
||||
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||
source of truth.
|
||||
|
||||
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
|
||||
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
|
||||
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
|
||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
||||
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||
@@ -124,27 +112,16 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
||||
Two stores, and the split matters:
|
||||
|
||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
||||
email accounts, queue and pipeline jobs. One directory per feature holding `schema.ts` and
|
||||
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
|
||||
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
|
||||
email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in
|
||||
`src/queries/`, types inferred from the schema in `src/types.ts`.
|
||||
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the
|
||||
per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens
|
||||
them.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
failing.
|
||||
|
||||
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
|
||||
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
|
||||
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
|
||||
passed).
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory
|
||||
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
|
||||
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
|
||||
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
|
||||
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
|
||||
`DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is
|
||||
where terminals, chats and task runs actually execute).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
@@ -186,13 +163,11 @@ valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). 27 entries as of 2026-08-13.
|
||||
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
|
||||
(owner only, and `execution` is never grantable at any level).
|
||||
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
|
||||
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
||||
@@ -219,17 +194,9 @@ bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
|
||||
```
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
|
||||
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
|
||||
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
|
||||
needs to, and on macOS never does, because Homebrew refuses to run as root.
|
||||
|
||||
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
||||
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ 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` |
|
||||
|
||||
- **`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,14 +51,13 @@ 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,
|
||||
@@ -67,12 +66,11 @@ GET /api/music/manifest
|
||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||
"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
|
||||
@@ -84,9 +82,7 @@ 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",
|
||||
@@ -101,25 +97,23 @@ 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
|
||||
"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`).
|
||||
@@ -129,7 +123,6 @@ 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`.
|
||||
|
||||
@@ -138,7 +131,6 @@ 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.
|
||||
@@ -148,7 +140,6 @@ 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"`).
|
||||
@@ -165,9 +156,7 @@ 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",
|
||||
@@ -175,12 +164,11 @@ 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`,
|
||||
@@ -205,23 +193,14 @@ 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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -230,7 +209,6 @@ 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.
|
||||
@@ -244,19 +222,9 @@ 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`).
|
||||
@@ -289,7 +257,7 @@ platform straight from Postgres — same `/api/music` prefix and same auth. Keys
|
||||
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` |
|
||||
@@ -298,11 +266,7 @@ supplies; the server never interprets them:
|
||||
|
||||
- **`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).
|
||||
@@ -317,16 +281,9 @@ 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? }`
|
||||
@@ -343,7 +300,7 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
||||
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], … }` |
|
||||
@@ -358,6 +315,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.
|
||||
@@ -80,16 +80,6 @@ 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.
|
||||
|
||||
|
||||
+1
-10
@@ -22,13 +22,4 @@ env = "BUN_PUBLIC_*"
|
||||
coverage = true
|
||||
coverageDir = "coverage"
|
||||
preload = ["./test-setup.ts"]
|
||||
# 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 = "."
|
||||
root = "./src"
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# The documentation, triaged
|
||||
|
||||
**2026-08-13.** A map of what is in here, what it is for, and what should happen to it. Made because
|
||||
there are 42 documents and 13,000 lines, and no way to tell from the filenames which describe the
|
||||
system as it is and which are a record of an afternoon in July.
|
||||
|
||||
**How much I verified:** the classifications below are from filenames, status lines, and greps for
|
||||
things that changed on 2026-08-13. Where I actually read the document or checked the code, it says
|
||||
so. The rest is a starting point for a conversation, not a verdict.
|
||||
|
||||
---
|
||||
|
||||
## Living — these describe the system and must stay true
|
||||
|
||||
| doc | state |
|
||||
| --- | --- |
|
||||
| `working-on-officer.md` | **updated 2026-08-13.** Operational guide. |
|
||||
| `secret-store.md` | **updated 2026-08-13.** Built; rotation still open. |
|
||||
| `install-variants.md` | new. The branch tree, for discussion. |
|
||||
| `http-secure-context-audit.md` | new. What breaks over plain http. |
|
||||
| `install-container-testing.md` | new. First container pass and its findings. |
|
||||
| `per-user-linux-accounts.md` | partly updated. `OFFICER_OS_USERS` is gone; check the rest. |
|
||||
| `navigation-audit.md` | authoritative on routing. Unverified against tonight's route removals. |
|
||||
| `workspace-panels.md` + `workspace-panel-todo.md` | the panel framework. 1,300 lines combined — likely the biggest cleanup here. |
|
||||
| `agent-coordination.md` | the north star for panel work. |
|
||||
| `deprovision-os-account.md` | implemented; the `'disabled'` stage it may mention was deleted tonight. |
|
||||
|
||||
## Stale — describe things that changed on 2026-08-13
|
||||
|
||||
Each of these references something that no longer exists. **Not yet corrected.**
|
||||
|
||||
- `sidecar-topology.md` — "ecosystem.config.cjs is the source of truth". It is generated now, and
|
||||
holds six processes.
|
||||
- `sidecar-app-store.md` — derives the catalogue from `full − light`. Those files are gone, and
|
||||
`catalogue.test.ts` was rewritten.
|
||||
- `sidecar-bootstrapping.md` — "20 PM2 entries, 18 sidecar dirs". Six entries now.
|
||||
- `mobile-api-keys.md` — partly corrected; recheck the origin-checking claims.
|
||||
- `wallet-key-custody.md` — `VAULT_STORE_KEY` is now the per-purpose `wallet` key.
|
||||
- `push-notifications.md` — "agreed design, 2026-07-31". Notify is a plugin and unmounted.
|
||||
- `chat-session-lifetime.md`, `chat-ui-walkthrough.md` — reference `officer-agent`, renamed.
|
||||
|
||||
## Historical — a record of a moment, and should stay one
|
||||
|
||||
Do **not** rewrite these to match today's code. They document how a decision was reached, and
|
||||
editing them destroys the reasoning. If they mislead, add a dated header pointing forward.
|
||||
|
||||
- `sidecar-audit-2026-07.md` (1,377 lines)
|
||||
- `claude-sidecar-isolation.md` — records the `officer-claude` → `officer-agent` rename that
|
||||
preceded tonight's `officer-agent` → `officer-claude-code`
|
||||
- `open-threads-after-per-user-claude.md`
|
||||
- `two-agent-field-report-2026-08-12.md`
|
||||
- `api-method-changes-2026-08-06.md`
|
||||
|
||||
## The opencode cluster — nine documents for one migration
|
||||
|
||||
`opencode-fork-decision` · `-parity` · `-api-2-assessment` · `-phase0-review` · `-phase1-report` ·
|
||||
`-phase1-review` · `-serve-migration-plan` · `-serve-path` · `-testing-checklist`
|
||||
|
||||
**The migration landed** — `opencode serve` is in the sidecar, verified. So
|
||||
`opencode-serve-migration-plan.md` saying "Nothing here is implemented" is false.
|
||||
|
||||
This is the clearest consolidation candidate in the whole directory: one document recording what was
|
||||
decided and what shipped, replacing nine that describe stages of getting there. I did not do it
|
||||
because it needs reading all nine, and deleting documents unread is not a thing to do at 4am.
|
||||
|
||||
## The mobile-dav thread — three documents, one conversation
|
||||
|
||||
`mobile-dav-provisioning` · `-feedback` · `-reply`. A correspondence. Almost certainly one document.
|
||||
|
||||
## Unclassified — I have not looked
|
||||
|
||||
`design-language-interface` · `file-sync` · `jobs-unification` · `mobile-photo-sync-api` ·
|
||||
`nextcloud-replacement` · `agent-git-identity`
|
||||
|
||||
---
|
||||
|
||||
## The plugin split, which affects most of the above
|
||||
|
||||
A core install is six processes. **Everything else is a plugin**, switched off tonight but present on
|
||||
disk. Most documents here were written when the estate was twenty processes and every one of them was
|
||||
simply "there", so they describe availability that no longer holds.
|
||||
|
||||
The useful rewrite is usually one line, not a rewrite: say whether the thing described is **core** or
|
||||
**a plugin**, and if a plugin, that it is not mounted on a fresh install.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Waits: how an agent waits for something without burning context
|
||||
|
||||
**Status:** draft, 2026-08-13. One mechanism proven (git remote polling, run twice); everything else here is
|
||||
specification and reasoning. Claims are marked **measured** or **reasoned** — do not let that slip.
|
||||
|
||||
`docs/two-agent-field-report-2026-08-12.md` describes this for one purpose: one agent waiting on another's
|
||||
push. That was where it was discovered, not where it belongs. This file is about the primitive itself,
|
||||
because the same shape answers "wait for CI", "wait for the job to finish", "wait for the container to go
|
||||
healthy", "wait for a reply", and a dozen other things Officer already needs.
|
||||
|
||||
---
|
||||
|
||||
## The primitive
|
||||
|
||||
> A **wait** is a harness-owned process that blocks until a condition holds, then exits — and whose exit
|
||||
> re-invokes the agent.
|
||||
|
||||
Three properties. Drop any one and it breaks in a way that is not visible from watching it run:
|
||||
|
||||
1. **The waiting happens below the model.** No inference per tick. The agent is suspended.
|
||||
2. **The harness owns the process**, so its exit is an event the harness delivers. A process the harness is
|
||||
not tracking can finish perfectly and tell nobody.
|
||||
3. **It exits when it has something to say.** The exit *is* the notification. A wait that detects and keeps
|
||||
running has informed no one.
|
||||
|
||||
Everything below follows from those three.
|
||||
|
||||
---
|
||||
|
||||
## The cost model, which decides everything else
|
||||
|
||||
This is the part that is easy to get half-right, and half-right is what leads people to build the expensive
|
||||
version.
|
||||
|
||||
| | cost |
|
||||
|---|---|
|
||||
| a tick while waiting | **nothing** — no model runs |
|
||||
| a thousand ticks | **nothing** |
|
||||
| **each wake** | a full context read, uncached |
|
||||
|
||||
**Measured** (field report, 2026-08-12): an idle watcher produced 85 bytes over seven minutes with zero
|
||||
inference. **Measured** tonight: two fires, each costing exactly one wake.
|
||||
|
||||
**Reasoned, and the part usually missed:** a wake re-reads the entire conversation, and conversations only
|
||||
grow. So the cost of a wait is not `duration` — it is `fires × context-at-the-time`. Idle is free forever;
|
||||
the tenth notification in a long session costs several times the first.
|
||||
|
||||
Worse, waits are the exact workload the prompt cache cannot help. The TTL is about five minutes; anything
|
||||
worth waiting for takes longer than that. **Every wake is an uncached read, by construction.**
|
||||
|
||||
Two consequences that should drive design:
|
||||
|
||||
- **Say less on wake.** The output that survives to the wake enters the context permanently. One line per
|
||||
tick over 24h is 2,880 lines that land at once and then stay.
|
||||
- **Prefer many short sessions to one long one.** A wait in a fresh session costs a constant amount per
|
||||
event. The same wait in an immortal session costs monotonically more. This is the single strongest
|
||||
argument for event-driven agents over resident ones.
|
||||
|
||||
---
|
||||
|
||||
## Prefer blocking over polling. Prefer events over both.
|
||||
|
||||
The git watcher polls because a git remote can only be *asked*. Most things Officer waits on are not like
|
||||
that, and a poll is the worst of the three options that usually exist.
|
||||
|
||||
**Tier 1 — block on the kernel.** Zero syscalls while waiting, and detection is immediate rather than
|
||||
average-half-an-interval late.
|
||||
|
||||
| waiting for | how to block |
|
||||
|---|---|
|
||||
| a file appearing or changing | `inotifywait -q -e close_write,create,moved_to <path>` |
|
||||
| a process to exit | `tail --pid=<pid> -f /dev/null` |
|
||||
| a lock to release | `flock <file> true` |
|
||||
| a line on a pipe or log | `read -r line < <fifo>` |
|
||||
| an inbound HTTP callback | a listener that blocks on `accept()` |
|
||||
| whichever of several finishes first | `wait -n` over background pids |
|
||||
|
||||
**Tier 2 — block on the service.** Some services will hold a connection open and tell you.
|
||||
|
||||
| waiting for | how |
|
||||
|---|---|
|
||||
| a row to change | Postgres `LISTEN` / `NOTIFY` — the connection blocks, the database pushes |
|
||||
| new mail | IMAP `IDLE` |
|
||||
| a container to change state | `docker events --filter …` (streams, blocks) |
|
||||
| a systemd unit | `systemctl --wait` / journal follow |
|
||||
|
||||
Officer keeps almost everything in one Postgres. `LISTEN`/`NOTIFY` is therefore the highest-leverage
|
||||
unbuilt piece here: job completion, a new chat message, a status flip, all become blocking waits with no
|
||||
polling anywhere.
|
||||
|
||||
**Tier 3 — poll, because the source can only be asked.** A git remote, a third-party HTTP API, a health
|
||||
endpoint. Then the rules are: read-only calls (`git ls-remote`, never `git fetch` — a fetch mutates refs
|
||||
under a working tree that may be mid-edit), a `timeout` on every call so a hung network call cannot leave
|
||||
the wait alive and blind, and an interval matched to how fast the thing actually changes.
|
||||
|
||||
**Never poll in the model.** A scheduled wake-up, a `/loop 30s`, a "check every minute" — these are the same
|
||||
shape wearing the same clothes and they pay a full uncached context read *per tick* to learn nothing. This
|
||||
is the intuitive design and its expense is invisible, which is why it needs saying first.
|
||||
|
||||
---
|
||||
|
||||
## Make firing mean something
|
||||
|
||||
The rest of this file is about how to wait cheaply. This section is about the other half, and it is the one
|
||||
that decides whether a fleet of these is affordable.
|
||||
|
||||
**Most waits find nothing, almost always.** A daily release check answers "no" 360 days a year. A branch
|
||||
watcher wakes on every push, including everyone else's. So the number that matters is not the cost of a
|
||||
useful wake — it is the cost of a useless one, multiplied by how many there will be.
|
||||
|
||||
The fix is not a cheaper wake. It is to **push the relevance test into the wait condition**, so that firing
|
||||
already implies relevance:
|
||||
|
||||
- **Do not** wait on "a push", then wake and check whether it carries a `COMMS/<branch>/NN-*.md`. Wait on a
|
||||
push *that contains one* — a filename test the shell can do with no model at all.
|
||||
- **Do not** wait on "the releases page changed", then wake and read it. Wait on "the version string differs
|
||||
from my cursor" — a string compare.
|
||||
|
||||
Three tiers, and almost everything should die at the first:
|
||||
|
||||
| tier | cost | for |
|
||||
|---|---|---|
|
||||
| **shell condition** | zero | anything expressible as a filename, a diff, a version, a status |
|
||||
| **fresh minimal agent** | one small cold read | relevance genuinely needs judgement, but not history |
|
||||
| **escalate with real context** | a full read of a long session | the event has to be interpreted against what came before |
|
||||
|
||||
A session fork that inherits context but returns nothing to it (Claude Code's `/btw`) is tier two done well.
|
||||
It is still a context read, so it is the fallback when a shell test cannot express relevance — not the
|
||||
default.
|
||||
|
||||
**Corollary for the platform:** a wait's condition should be part of its declaration, not something the agent
|
||||
evaluates after waking. `wait for: push to <branch> touching COMMS/**` is a cheaper and more honest thing to
|
||||
build than `wait for: push` plus an agent that decides.
|
||||
|
||||
## The contract a wait must honour
|
||||
|
||||
Specification. None of this is built yet.
|
||||
|
||||
**Exit codes are the vocabulary.**
|
||||
|
||||
```
|
||||
0 fired — the condition holds; payload on stdout
|
||||
1 timed out — the bounded lifetime elapsed, nothing happened
|
||||
2 broke — the wait itself failed and is no longer trustworthy
|
||||
```
|
||||
|
||||
`1` and `2` must be distinguishable. "Nothing happened" and "I stopped being able to tell" are opposite
|
||||
facts and a wait that conflates them is worse than no wait, because absence reads as reassurance.
|
||||
|
||||
**Output is a payload, not a log.** One line on arm so there is a record of what was watched; silence while
|
||||
waiting; a minimal structured payload on fire. Everything printed is permanent context.
|
||||
|
||||
**A cursor, persisted.** The wait is armed at a position — a SHA, a byte offset, a row id, a timestamp — and
|
||||
that position belongs on disk, not only in the process. Then a re-arm after a restart neither misses events
|
||||
nor re-reports old ones. The git watcher currently holds its base only in memory, which is why a session
|
||||
restart loses the thread.
|
||||
|
||||
**Bounded lifetime, and the bound is not "forever".** `seq 1 2880` is a runaway backstop, not a policy. A
|
||||
wait that times out should re-arm from its cursor rather than die silently.
|
||||
|
||||
**Liveness must be externally checkable.** A dead wait and a quiet one are indistinguishable, and that
|
||||
ambiguity has already cost two missed pushes. Cheapest fix: touch a heartbeat file each tick, so `mtime`
|
||||
answers "is it alive" without asking the process. In a UI that shows running processes — as Officer's chat
|
||||
does — the chip itself is the signal, which is a real advantage and should be kept.
|
||||
|
||||
**Idempotent re-arm, and self-trip protection.** An agent that acts and then wakes on its own action is a
|
||||
loop. Re-arm from the position *after* your own change, and never run two waits on the same condition.
|
||||
|
||||
---
|
||||
|
||||
## Where this applies in Officer
|
||||
|
||||
The reason to generalise. Each of these is a place something currently either blocks a turn, gets polled by
|
||||
a human, or is discovered late.
|
||||
|
||||
| wait | tier | notes |
|
||||
|---|---|---|
|
||||
| a pipeline/script job finishes | 1 or 2 | `data/jobs/<id>.log` is a file — inotify. Or `NOTIFY` on the row |
|
||||
| a download completes | 1 | same, and the progress sentinel already exists |
|
||||
| a container becomes healthy | 2 | `docker events` |
|
||||
| a member logs into `claude` for the first time | 1 | `~/.claude/.credentials.json` appearing — currently polled by `/agent-status` |
|
||||
| new mail arrives | 2 | IMAP IDLE, in the email sidecar |
|
||||
| CI, a deploy, a remote build | 3 | poll, with a timeout |
|
||||
| a push to any repo | 3 today, **event tomorrow** | Gitea is ours: a webhook removes the wait entirely |
|
||||
| a long `db:push` or migration finishes | 1 | process wait |
|
||||
| disk crosses a threshold | 3 | slow-moving; poll infrequently |
|
||||
| **a human replies** | 1 | an approval gate: the agent arms a wait and stops costing anything until answered |
|
||||
|
||||
That last row is the one worth dwelling on. An agent that needs a decision currently either blocks a session
|
||||
or asks and forgets. A wait makes "stopped, pending your answer" cost nothing while it lasts.
|
||||
|
||||
---
|
||||
|
||||
## Choosing a lifetime
|
||||
|
||||
| shape | when | cost |
|
||||
|---|---|---|
|
||||
| **wait inside a live session** | the agent holds context the event needs interpreting against | free while idle, growing per fire |
|
||||
| **wait, then hand off** | context matters up to the fire, not after | one growing session, then reset |
|
||||
| **no wait — event spawns a fresh agent** | the event carries everything needed (a SHA, a job id) | constant per event, forever |
|
||||
|
||||
The third is the destination for anything recurring. The first is right for tonight's watcher, where the
|
||||
value is that I already know what the commits mean.
|
||||
|
||||
The rule: **if the payload plus the repo is enough to act on, do not keep a session alive to receive it.**
|
||||
|
||||
---
|
||||
|
||||
## Failure modes
|
||||
|
||||
| pattern | what it looks like |
|
||||
|---|---|
|
||||
| **launched outside the harness** | `nohup … &` — runs, detects, exits, and no one is told. Looks perfect |
|
||||
| **model-driven poll** | correct behaviour, full context read per tick |
|
||||
| **detects but does not exit** | prints "found it" into a file nobody reads |
|
||||
| **chatty** | per-tick output, deferred, all landing at once on wake |
|
||||
| **silent death** | session restarts, wait dies, quiet branch and dead watcher look identical |
|
||||
| **self-trip** | agent's own push wakes it, usually because an old wait was never stopped |
|
||||
| **timeout mistaken for quiet** | exit 1 treated as "nothing happened" when it means "I stopped looking" |
|
||||
| **mutating poll** | `git fetch` in a loop, moving refs under a working tree |
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Is a wait a platform feature or an agent habit?** Officer has a job runner, a Gitea instance and a
|
||||
sidecar pattern. `POST /waits {condition, payload}` returning when it fires is a plausible platform
|
||||
primitive — and would make waits available to capabilities, not only to agents.
|
||||
2. **What arms a wait for an agent that is not running?** The webhook shape needs the platform to spawn the
|
||||
agent, which is `send-claude-code` plus a trigger. Most of that exists.
|
||||
3. **Should waits be declarative?** `wait for: file:<path>` / `pg:notify:<channel>` / `git:<remote>/<branch>`
|
||||
— a small vocabulary compiled to the right tier, so nobody hand-writes a poll for something inotify could
|
||||
have blocked on.
|
||||
4. **How does a wait survive a session restart** without either missing its event or re-firing on an old
|
||||
one? The cursor answers half of it; the other half is who re-arms.
|
||||
5. **What is the right granularity of notification?** One wake per push, or one wake per batch after a quiet
|
||||
period? Batching trades latency for context, and context is the scarce thing.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
The mechanism, the three properties and the four wrong ways to launch it come from
|
||||
`docs/two-agent-field-report-2026-08-12.md`, which recorded them after they were learned the hard way. What
|
||||
this file adds is the cost model stated as a formula rather than an anecdote, the block-over-poll hierarchy,
|
||||
the exit-code contract, and the argument that the destination is event-spawned short-lived agents rather
|
||||
than resident ones.
|
||||
|
||||
Nothing in "the contract" or "where this applies" has been implemented. The only thing running today is a
|
||||
tier-3 git poll, which is the good version of the wrong shape.
|
||||
@@ -1,84 +0,0 @@
|
||||
# What breaks over plain http
|
||||
|
||||
**Audited 2026-08-13**, after `crypto.randomUUID` took the chat page down at the end of every turn.
|
||||
|
||||
Officer is reached at `http://officer-dev:9000` — a tailnet address, so **neither https nor
|
||||
localhost**, and therefore not a [secure context]. A set of browser APIs are unavailable there by
|
||||
specification, not by policy, and there is no flag that changes it.
|
||||
|
||||
The failure mode is what makes this worth a document. Two of the three shapes below are silent:
|
||||
|
||||
| shape | what a user sees |
|
||||
| --- | --- |
|
||||
| `crypto.randomUUID()` | `TypeError` — and if it is inside a `useState` initialiser, the whole tree unmounts |
|
||||
| `navigator.clipboard.writeText()` | `TypeError`, killing the click handler |
|
||||
| `navigator.clipboard?.writeText()` | **nothing at all** — the button reports success and copies nothing |
|
||||
|
||||
The optional-chained one is the worst: indistinguishable from working until somebody pastes.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### `crypto.randomUUID` — 18 call sites
|
||||
Secure-context only. `crypto.getRandomValues` is **not** — it lives on `Crypto` rather than
|
||||
`SubtleCrypto` — so `helpers/random-id.ts` builds the same v4 UUID from the same CSPRNG when
|
||||
`randomUUID` is absent. Same entropy, same version and variant bits.
|
||||
|
||||
### `navigator.clipboard.writeText` — 20 call sites across 18 files
|
||||
Secure-context only. `helpers/clipboard.ts` falls back to `document.execCommand('copy')` over an
|
||||
off-screen textarea, which predates the secure-context rule and works on any origin. Deprecated and
|
||||
working beats modern and absent.
|
||||
|
||||
One call site carried the comment *"Officer is always behind HTTPS"*. It was not.
|
||||
|
||||
---
|
||||
|
||||
## Cannot be fixed this way
|
||||
|
||||
### `navigator.clipboard.read()` — pasting a file in the file browser
|
||||
No fallback exists. `document.execCommand('paste')` was never permitted from script, so on an
|
||||
insecure origin there is no way to pull clipboard contents on demand — only a real paste event the
|
||||
user initiates, which is a different interaction. Now guarded by `canReadClipboard()` and refuses
|
||||
with an explanation instead of throwing.
|
||||
|
||||
### `getUserMedia` — audio recording, 4 files
|
||||
`apps/Chat/useAudioRecording.ts`, `apps/FileBrowser/.../DictateDialog.tsx`,
|
||||
`apps/QrTransfer/Receiver.tsx`, and a test. Requires a secure context and cannot be polyfilled — the
|
||||
browser will not hand out a microphone or camera over http.
|
||||
|
||||
**Being removed** rather than guarded: the owner uses an external dictation app. Note `QrTransfer`
|
||||
uses it for the CAMERA rather than a microphone, so removing "audio" does not cover it — that one
|
||||
needs its own decision.
|
||||
|
||||
### `navigator.credentials` — passkeys
|
||||
WebAuthn is secure-context only. `helpers/passkeys.ts` exists and cannot work over http, whatever is
|
||||
done to it. Not currently reachable, so nothing is broken today.
|
||||
|
||||
---
|
||||
|
||||
## Checked and clear
|
||||
|
||||
- **`crypto.subtle`** — not used anywhere in the frontend. This was the one worth confirming, since
|
||||
it would have had no cheap fallback.
|
||||
- **`Notification`** — the six matches are type names, not the browser API. Nothing calls
|
||||
`new Notification` or `requestPermission`.
|
||||
- **Service workers, WebUSB, WebSerial, WebBluetooth, Payment Request, Wake Lock, Storage Manager,
|
||||
`SharedArrayBuffer`** — not used.
|
||||
- **`navigator.geolocation`** (`widgets/Weather`) — secure-context only, but already guarded with
|
||||
`if (!navigator.geolocation) return;`, so it degrades rather than throws. The widget simply cannot
|
||||
locate you over http.
|
||||
- **`navigator.share`** (`Headscale/InvitesView`) — already guarded with a `typeof` check, and its
|
||||
comment notes it is absent on desktop browsers anyway.
|
||||
- **WebSockets, IndexedDB, localStorage, EventSource** — no secure-context restriction. Chat,
|
||||
terminal and the sidecar transports are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## The alternative
|
||||
|
||||
All of this disappears with a certificate, and `tailscale cert` issues a real one for the MagicDNS
|
||||
name in about one command — no public DNS, no port 80 challenge, no renewal to remember. Worth
|
||||
knowing that the choice here was "make it work over http", not "http is the only option".
|
||||
|
||||
[secure context]: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
||||
@@ -1,74 +0,0 @@
|
||||
# Testing the installer in containers
|
||||
|
||||
**2026-08-13.** First pass. Ubuntu 24.04, Debian 12, Arch, Fedora 41.
|
||||
|
||||
## What passed
|
||||
|
||||
**OS and package-manager detection is correct on all four.**
|
||||
|
||||
| image | `OS` | `PM` |
|
||||
| --- | --- | --- |
|
||||
| ubuntu:24.04 | `ubuntu` | `apt` |
|
||||
| debian:12 | `debian` | `apt` |
|
||||
| archlinux | `arch` | `pacman` |
|
||||
| fedora:41 | `fedora` | `dnf` |
|
||||
|
||||
**`--help` and argument handling work unprivileged** in a clean container, before any escalation.
|
||||
|
||||
**The install report is written**, end to end, in a container that had never seen this code. That is
|
||||
task 1's mechanism confirmed outside the machine it was written on.
|
||||
|
||||
**Refusing beats hanging.** With no answer available the run stopped with
|
||||
`FAIL: No answer. Set ASSUME_YES=1 to run without prompts.` rather than blocking forever on a prompt
|
||||
nobody could see. That is the behaviour an unattended run needs, and it already exists.
|
||||
|
||||
---
|
||||
|
||||
## What it found
|
||||
|
||||
### 1. `--only` does not isolate a step
|
||||
|
||||
Running `--only "Core utils"` still **created a user account**, because `ask_username` and the
|
||||
account creation happen in the preamble, above the step framework. Everything before the first
|
||||
`step` call runs on every invocation.
|
||||
|
||||
Defensible — every step needs to know who it is installing for — but it means `--only` is not the
|
||||
surgical tool it appears to be, and a first-time reader will assume it is. Either the preamble
|
||||
becomes lazy, or `--only` says plainly what it will still do.
|
||||
|
||||
### 2. `.setup-answers` travels with a copy of the tree
|
||||
|
||||
It lives at `scripts/setup/machine-setup/.setup-answers`, is correctly gitignored, and is `0600`
|
||||
root-owned. But it is **inside the repository directory**, so `cp -r` or a tarball of the tree
|
||||
carries it — which is exactly what happened here: a container that had never run setup came up
|
||||
already knowing the username `pastilhas` and created that account.
|
||||
|
||||
Not a leak (username and install path, nothing secret). It is a surprise, and surprises in an
|
||||
installer are the expensive kind. Worth moving outside the repo, next to the progress file.
|
||||
|
||||
### 3. `adduser` leaks its own prompts
|
||||
|
||||
```
|
||||
Use of uninitialized value $answer in pattern match (m//) at /usr/sbin/adduser line 848.
|
||||
Try again? [y/N]
|
||||
```
|
||||
|
||||
The account-creation path reaches an interactive `adduser` question the script does not answer.
|
||||
Harmless here because the run stopped anyway, but on a real unattended install this is a hang.
|
||||
|
||||
---
|
||||
|
||||
## Coverage this cannot reach
|
||||
|
||||
Containers have no init by default, so **`systemctl`, netplan, ufw and the sshd drop-ins were not
|
||||
exercised**. Those sections can only be verified as "wrote the right file", not "the service came
|
||||
up". Running privileged containers with systemd would close most of that gap and is the obvious next
|
||||
step.
|
||||
|
||||
**Docker-in-Docker** was not attempted, so the Docker section and Postgres provisioning are
|
||||
untested. Mounting the host socket would test the section's logic while telling us nothing about the
|
||||
install path.
|
||||
|
||||
**macOS is untestable here entirely.** The 17 skipped sections, the Homebrew paths, the Xcode
|
||||
command line tools step and the refusal-to-run-as-root are all reasoned from documentation and
|
||||
unverified by execution.
|
||||
@@ -1,99 +0,0 @@
|
||||
# The install page, and the scripts behind it
|
||||
|
||||
**Status: for discussion, 2026-08-13.** Nothing here is built. It exists so tomorrow's conversation
|
||||
is about real branches rather than sketched ones — every question below is one the scripts already
|
||||
ask today.
|
||||
|
||||
## The shape agreed
|
||||
|
||||
- One **source** — the interactive scripts as they are.
|
||||
- A **build script** that compiles them into single files, because `curl | bash` cannot fetch libs.
|
||||
- The build emits **one script per leaf** of the question tree, not one script with pre-seeded
|
||||
answers. A person auditing before running reads only their own path.
|
||||
- Verification is of the **generator**, once: anyone regenerates the leaves from source and diffs
|
||||
them against what is published. One thing to trust rather than N.
|
||||
|
||||
---
|
||||
|
||||
## The questions that actually exist
|
||||
|
||||
Forty-seven prompts across the two scripts. Almost none of them should become a branch — the
|
||||
distinction that matters is:
|
||||
|
||||
**A branch** changes which *code* runs. Removing it makes a script genuinely shorter.
|
||||
|
||||
**A value** changes a *string*. Removing it makes a script no shorter — it just moves the answer
|
||||
from a prompt to a constant.
|
||||
|
||||
**A consent** is a yes/no about doing a step at all. These are the interesting middle: pre-answering
|
||||
one lets the build delete the section entirely.
|
||||
|
||||
### Branches — these change what code exists
|
||||
|
||||
| question | answers | what it eliminates |
|
||||
| --- | --- | --- |
|
||||
| operating system | macOS · Debian/Ubuntu · Arch · Fedora | 17 of 26 machine-setup sections on macOS; the whole `case $PM` ladder collapses to one arm |
|
||||
| machine role | homelab · vps · dev | swap, ballast, earlyoom, sleep/suspend, boot-hang, static addressing — each is role-gated today |
|
||||
| tailnet | already connected · set one up · none | the entire Tailscale section, its four sub-options and the offscale explanation |
|
||||
| which half | machine + officer · officer only · machine only | one of the two scripts disappears |
|
||||
|
||||
### Consents — pre-answering deletes a section
|
||||
|
||||
Docker · fail2ban · unattended-upgrades · Neovim · agent CLIs · shell config · firewall · SSH
|
||||
hardening · DNS · swap · ballast · earlyoom · inotify · boot-on-start.
|
||||
|
||||
Fourteen sections that a leaf script can simply not contain.
|
||||
|
||||
### Values — never a branch
|
||||
|
||||
Username · install path · git name and email · port · public URL · Postgres connection · timezone ·
|
||||
locale · LAN CIDR · swap size · swappiness.
|
||||
|
||||
These stay as prompts even in a generated script, or arrive as environment variables. Baking them
|
||||
into a published file would mean publishing somebody's hostname.
|
||||
|
||||
---
|
||||
|
||||
## Where this collides with `--unattended`
|
||||
|
||||
`--unattended` and a generated leaf are the *same mechanism seen twice*: both are "answer these in
|
||||
advance". The difference is only whether the answer is baked in at build time or supplied at run
|
||||
time.
|
||||
|
||||
Worth deciding tomorrow whether a leaf script is literally `base.sh --unattended` with a header of
|
||||
constants, or whether the build truly strips the dead branches. The second is what makes it
|
||||
auditable-by-being-short; the first is what makes it maintainable. **They are not the same artifact,
|
||||
and the whole plan rests on which one we mean.**
|
||||
|
||||
One thing that already exists and should be preserved either way: with no tty, `install_config`
|
||||
keeps the user's file rather than replacing it. Every unattended answer needs to be conservative in
|
||||
that same way, and that is a property of each prompt, not of the flag.
|
||||
|
||||
---
|
||||
|
||||
## The combinatorics
|
||||
|
||||
4 OS × 3 roles × 3 tailnet states = **36 leaves** before any consent is considered, and consents
|
||||
multiply it past anything anyone would publish.
|
||||
|
||||
So the tree the install page walks cannot be the full product. Two ways out, to choose between:
|
||||
|
||||
1. **Publish a few opinionated leaves** — "Ubuntu VPS, new tailnet", "macOS dev machine", "Ubuntu
|
||||
homelab, existing tailnet" — and send everything else to the full interactive script.
|
||||
2. **Generate on demand** — the page composes the leaf when the questions are answered. Stronger, but
|
||||
the artifact is no longer a static file anyone can diff against the repo, which costs the
|
||||
verification property the whole design was for.
|
||||
|
||||
My inclination is (1), because (2) quietly trades away the thing that made per-leaf scripts worth
|
||||
building. But it is a real trade and it is yours.
|
||||
|
||||
---
|
||||
|
||||
## Open, for tomorrow
|
||||
|
||||
- Does a leaf strip dead code, or set constants and call the base?
|
||||
- How many leaves get published, and what happens to the rest?
|
||||
- Does the install page show the script before running it? It should — that is the moment auditing
|
||||
is cheap and nobody will do it afterwards.
|
||||
- The report from `install-report.md` names a script commit. A generated leaf needs to name the
|
||||
source commit it was generated from, or the report cannot be checked against anything.
|
||||
@@ -327,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
|
||||
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
||||
WebSocket upgrade in `server.tsx`, and the vault socket.
|
||||
- `src/servers/api/api-keys/router.ts` — the three endpoints.
|
||||
- `src/databases/officer_db/src/api-keys/schema.ts` — the table, and why it stores what it stores.
|
||||
- `src/databases/officer_db/src/schema/api-keys.ts` — the table, and why it stores what it stores.
|
||||
|
||||
+17
-43
@@ -1,19 +1,11 @@
|
||||
# The secret store
|
||||
|
||||
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
|
||||
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
|
||||
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
|
||||
yet.
|
||||
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
|
||||
current code was checked against the tree on that date; the file:line references are live.
|
||||
|
||||
A small SQLite database holding every encryption and signing key the platform uses. It replaced
|
||||
`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing
|
||||
its own.
|
||||
|
||||
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
|
||||
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
|
||||
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
|
||||
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
|
||||
`headscale` — and every other purpose is created when its plugin first asks.
|
||||
A small SQLite database, created during setup, holding every encryption and signing key the platform
|
||||
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
|
||||
instead of inventing its own.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,18 +69,7 @@ both to still exist. That is a table with `id, purpose, key, created_at, retired
|
||||
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
|
||||
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
|
||||
|
||||
### 2. It is NOT encrypted at rest — and that decision changed shape
|
||||
|
||||
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
|
||||
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
|
||||
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
|
||||
|
||||
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
|
||||
|
||||
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
|
||||
finding is still true and still the reason whole-file encryption is not on the table.
|
||||
|
||||
#### The original note
|
||||
### 2. It is NOT encrypted at rest, for now
|
||||
|
||||
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||||
|
||||
@@ -113,30 +94,25 @@ trade and it is written down here so nobody later assumes the file is opaque.
|
||||
|
||||
### 3. Where the file goes
|
||||
|
||||
**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13.
|
||||
|
||||
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
|
||||
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
|
||||
problem this design exists to avoid.
|
||||
|
||||
The setup script says so out loud when it creates the store, because "back this up, but not next to the
|
||||
other thing you back up" is not a rule anyone infers.
|
||||
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
|
||||
documented loudly enough that a backup script excludes it deliberately.
|
||||
|
||||
### 4. ~~One secret remains outside~~ — none does
|
||||
### 4. One secret remains outside
|
||||
|
||||
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
|
||||
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
|
||||
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
|
||||
two processes that need it.**
|
||||
|
||||
The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N
|
||||
secrets in twenty process environments becomes a file read on demand by the few processes that need
|
||||
it.** `.env` is auto-loaded by bun into every pm2 process, so a key there is readable from
|
||||
`/proc/<pid>/environ` of twenty processes — `officer-music` held the key that decrypts wallet seed
|
||||
envelopes. A file opened by the two or three processes that actually use a key does not.
|
||||
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
|
||||
one value — or comes from a file read on demand.
|
||||
|
||||
### 5. What moves in
|
||||
|
||||
- `VAULT_STORE_KEY` — **split into one key per purpose**, rather than moved. See the status note at the
|
||||
top: the table above is seven unrelated things, and one key for all of them meant one leak opened all
|
||||
of them.
|
||||
- `VAULT_STORE_KEY` — the at-rest key for everything in the table above.
|
||||
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
|
||||
survive restarts, must never be regenerated silently, and benefits from versioning during a rotation.
|
||||
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
|
||||
@@ -232,10 +208,8 @@ wallet table and it cannot be interrupted safely, which argues for something tha
|
||||
## What this does not change
|
||||
|
||||
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
||||
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
|
||||
at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules
|
||||
were touched. That was the cost of the split, and it is worth stating plainly because this line
|
||||
originally promised the opposite.
|
||||
- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from
|
||||
changes, so no caller is touched.
|
||||
- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent
|
||||
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ does and does not protect against.
|
||||
|
||||
Authoritative for the crypto design. The code is `src/servers/sidecar/wallet/keys.ts` (sealing,
|
||||
derivation, unlock sessions), `src/databases/officer_db/src/crypto.ts` (storage encryption) and
|
||||
`src/databases/officer_db/src/wallet/queries.ts` (where the two meet).
|
||||
`src/databases/officer_db/src/queries/wallet.ts` (where the two meet).
|
||||
|
||||
## The requirement
|
||||
|
||||
|
||||
@@ -7,53 +7,27 @@ agent sessions start.
|
||||
Three directories sit there, and knowing which one a change belongs in is most of the job:
|
||||
|
||||
```
|
||||
$OFFICER_ROOT/
|
||||
officer/
|
||||
├── platform/ the application — a git repo
|
||||
├── capabilities/ what the agent can do — a separate git repo
|
||||
├── data/ runtime state — NOT version controlled
|
||||
├── dockers/ containers the app store provisioned
|
||||
└── secrets/ the key store — 0600, and NOT in your data backup
|
||||
└── data/ runtime state — NOT version controlled
|
||||
```
|
||||
|
||||
None of those paths is configured. `src/servers/data-path.ts` derives the root as
|
||||
`resolve(process.cwd(), '..')` and hangs the rest off it, which is why the pm2 `cwd` pin matters and
|
||||
why `assertInstallLayout` refuses to boot from the wrong directory.
|
||||
|
||||
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
||||
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
||||
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
||||
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
|
||||
|
||||
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
|
||||
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
|
||||
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
|
||||
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
|
||||
For **execution** — tasks, items, desktop, browser — it is still always the owner, and those can
|
||||
never be granted at any level.
|
||||
|
||||
That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. Terminal, chat and
|
||||
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
|
||||
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
|
||||
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
|
||||
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
|
||||
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
|
||||
granted, because they run as the owner's OS user in the owner's home.
|
||||
|
||||
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
|
||||
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
||||
prose, here or elsewhere.
|
||||
|
||||
## What is switched off (2026-08-13)
|
||||
|
||||
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
|
||||
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
|
||||
plugin, and every plugin router is commented out in `hono.ts` with its capability's `api` claim
|
||||
commented beside it — they must move together or `assertCapabilityTotality` refuses to boot.
|
||||
|
||||
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
|
||||
extraction into the plugin system.
|
||||
|
||||
Also gone: the four ecosystem files (generated now, at setup, and gitignored), origin validation,
|
||||
`OFFICER_OS_USERS` (per-user Linux accounts are unconditional), and the Task Logs feature.
|
||||
|
||||
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
|
||||
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
|
||||
|
||||
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||
above them: where things live, how to change them safely, and the things that are true of the running
|
||||
system but written down nowhere else.
|
||||
@@ -93,13 +67,13 @@ Commit messages: simple lowercase, no prefixes, explaining *why*.
|
||||
|
||||
## Running and checking your work
|
||||
|
||||
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
|
||||
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-agent`,
|
||||
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
|
||||
`pm2 list` shows them; `pm2 logs officer` follows.
|
||||
|
||||
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
|
||||
and proxies API traffic; `officer-claude-code` is the process that actually runs `claude`.**
|
||||
and proxies API traffic; `officer-agent` is the process that actually runs `claude`.**
|
||||
|
||||
**Which process to restart.** A change under `src/servers/sidecar/<name>/` needs that sidecar restarted;
|
||||
a change anywhere else needs `officer`. Both, if you changed the wire between them. Restarting `officer`
|
||||
|
||||
@@ -135,7 +135,7 @@ and the rename sequence leaves `workspaces` with no zombie.
|
||||
- [x] **`ws-terminals-{id}: null` on a live dashboard is a 500.** Same file, `:61-66` — the
|
||||
`ws-layout-*` branch has a `value === null` → `deleteDashboard` case (`:42`); the terminals
|
||||
branches do not. A null falls to the UPDATE branch and sets a `NOT NULL` column
|
||||
(`databases/officer_db/src/dashboards/queries.ts:70`) → 23502.
|
||||
(`databases/officer_db/src/queries/dashboards.ts:70`) → 23502.
|
||||
**Resolved.** A null on either terminals branch is now a no-op: it means "forget this key", and it
|
||||
only ever arrives paired with `ws-layout-{id}: null` on a rename, by which point the row is gone.
|
||||
|
||||
@@ -202,7 +202,7 @@ these.
|
||||
> which uuid ids would not.
|
||||
|
||||
- [ ] **`dashboards.id` is a global primary key but ids are `slugify(name)`.**
|
||||
`databases/officer_db/src/dashboards/schema.ts` declares `id: text('id').primaryKey()`. Live:
|
||||
`databases/officer_db/src/schema/dashboards.ts` declares `id: text('id').primaryKey()`. Live:
|
||||
`"dashboards_pkey" PRIMARY KEY, btree (id)` plus a redundant
|
||||
`"uq_dashboards_user_id" UNIQUE, btree (user_id, id)` — evidence per-user ids were intended and
|
||||
half-built. Ids come from `DashboardPreview.tsx:300` (`slugify(trimmed) || generateSlug()`) and the
|
||||
@@ -213,7 +213,7 @@ these.
|
||||
(see `databases/CLAUDE.md` → "Composite keys") — harmless churn, but read the plan.
|
||||
|
||||
- [x] **`upsertDashboard`'s UPDATE has no `userId` predicate.**
|
||||
`databases/officer_db/src/dashboards/queries.ts:73` —
|
||||
`databases/officer_db/src/queries/dashboards.ts:73` —
|
||||
`db.update(dashboards).set(set).where(eq(dashboards.id, id))`. The `existing` lookup above it _is_
|
||||
scoped, so it cannot reach another user's row today, but it is a non-transactional read-then-write.
|
||||
**It becomes a live cross-user overwrite the moment the PK above is made composite.**
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'officer',
|
||||
script: 'bun',
|
||||
args: 'start',
|
||||
watch: false,
|
||||
},
|
||||
// The Anthropic credential proxy. Despite the old name (`officer-claude`) this process does NOT
|
||||
// run agents — it holds the proxy secret and forwards to api.anthropic.com. The process that runs
|
||||
// agents is `officer-agent` below.
|
||||
{
|
||||
name: 'officer-anthropic-proxy',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/claude/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The process that actually runs `claude`. It used to be spawned on demand by the main server,
|
||||
// which made every agent session a grandchild of `officer` and killed it on every restart. As a PM2
|
||||
// peer it survives them. It resolves the owner from the database and the proxy secret from the
|
||||
// proxy's state file, so it needs nothing from `officer` in order to start.
|
||||
{
|
||||
name: 'officer-agent',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/claude/user-instance.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-opencode',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/opencode/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-email',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/email/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The only sidecar run by `node` rather than `bun`, and the only one that is not TypeScript: node-pty
|
||||
// is a native addon. It also does not use sidecar/connect.ts, and carries its own copy of the
|
||||
// reconnect loop.
|
||||
{
|
||||
name: 'officer-pty',
|
||||
script: 'node',
|
||||
args: 'src/servers/sidecar/pty/index.mjs',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-vnc',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/vnc/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-music',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/music/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-vault',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/vault/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-slskd',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/slskd/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-headscale',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/headscale/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-transmission',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/transmission/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The books. Wraps a self-hosted InvoiceShelf. Instances, their Sanctum tokens and the company each one
|
||||
// is pinned to are set by the owner from /invoices/settings and stored encrypted in
|
||||
// `invoiceshelf_accounts` — read here, never from the environment, because Bun auto-loads `.env` into
|
||||
// every process in this directory and `officer` would hold the token too.
|
||||
{
|
||||
name: 'officer-invoiceshelf',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set
|
||||
// by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the
|
||||
// environment. Video only: Officer's own player owns audio.
|
||||
{
|
||||
name: 'officer-jellyfin',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/jellyfin/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
|
||||
// owner from the UI and stored in `service_connections` — read here, never from the environment.
|
||||
{
|
||||
name: 'officer-memos',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/memos/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by
|
||||
// the owner from /gitea and stored in `service_connections` — read here, never from the environment.
|
||||
{
|
||||
name: 'officer-gitea',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/gitea/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
|
||||
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
|
||||
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
|
||||
// the platform authenticates and forwards. See docs/nextcloud-replacement.md.
|
||||
{
|
||||
name: 'officer-caldav',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/caldav/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from
|
||||
// /photos/settings and stored encrypted in `photos_config` — read here, never from the environment,
|
||||
// because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too.
|
||||
{
|
||||
name: 'officer-photos',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/photos/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
|
||||
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
|
||||
// The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the
|
||||
// Discord webhook, behind one interface. A sidecar rather than platform code because the producers
|
||||
// are spread across sidecars, and a platform-owned notifier would make every one of them call back in.
|
||||
{
|
||||
name: 'officer-notify',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/notify/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-wallet',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/wallet/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Linux light profile — the platform without the self-hosted estate around it.
|
||||
//
|
||||
// For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing
|
||||
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup/setup.sh`, which installs only what these
|
||||
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
|
||||
//
|
||||
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
|
||||
// for the two checks that make a drifted profile fail loudly instead of silently starting less than it
|
||||
// claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs
|
||||
// and every profile follows.
|
||||
//
|
||||
// The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent
|
||||
// report themselves unavailable rather than disappearing. A profile decides which processes start, not
|
||||
// which code ships.
|
||||
//
|
||||
// Start with: pm2 startOrRestart ecosystem.light.config.cjs
|
||||
|
||||
const { defineProfile } = require('./ecosystem.profile.cjs');
|
||||
|
||||
module.exports = defineProfile({
|
||||
file: 'ecosystem.light.config.cjs',
|
||||
|
||||
include: [
|
||||
'officer', // the app: SPA, /api, websockets
|
||||
'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream
|
||||
'officer-agent', // spawns `claude` — chat is dead without it
|
||||
'officer-opencode', // the alternative agent
|
||||
'officer-pty', // the terminal
|
||||
],
|
||||
|
||||
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
|
||||
// A light install simply is not running the thing behind it.
|
||||
excluded: {
|
||||
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
|
||||
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
|
||||
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
|
||||
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
|
||||
// makes "is Gitea here" one question with one answer instead of two that disagree.
|
||||
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
||||
'officer-vnc': 'no desktop to mirror on a light install',
|
||||
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
|
||||
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
|
||||
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
|
||||
'officer-slskd': 'supervises the slskd daemon',
|
||||
'officer-headscale': 'fronts a headscale server',
|
||||
'officer-transmission': 'fronts a transmission daemon',
|
||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
||||
'officer-jellyfin': 'fronts a Jellyfin container',
|
||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
||||
'officer-caldav': 'supervises Radicale, which the light profile does not install',
|
||||
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
|
||||
'officer-wallet': 'holds seed and node credentials',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// macOS light profile — the same process set as the Linux light profile, on a laptop.
|
||||
//
|
||||
// Paired with scripts/setup/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
|
||||
// chat; nothing else.
|
||||
//
|
||||
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
|
||||
// file specifically: written on 2026-07-28 as a hand-copied process list, it was broken within days by
|
||||
// two changes it could not see. It ran `officer-claude` against the Anthropic proxy's entry point
|
||||
// while the process that actually spawns `claude` was never started, and it pointed at a pty sidecar
|
||||
// that had moved. Both failures were silent — the processes simply did not come up. See
|
||||
// ecosystem.profile.cjs for the checks that now make that loud.
|
||||
//
|
||||
// WHY THIS IS SEPARATE FROM ecosystem.light.config.cjs, given both currently run the same five apps:
|
||||
// the exclusions mean different things. On macOS officer-vnc cannot run — there is no Xorg to mirror.
|
||||
// On a Linux light install it could run perfectly well; you have chosen not to. Those diverge as soon
|
||||
// as one profile gains something the other cannot have, and collapsing them would lose the reason.
|
||||
//
|
||||
// Start with: pm2 startOrRestart ecosystem.mac.light.config.cjs
|
||||
|
||||
const { defineProfile } = require('./ecosystem.profile.cjs');
|
||||
|
||||
module.exports = defineProfile({
|
||||
file: 'ecosystem.mac.light.config.cjs',
|
||||
|
||||
include: [
|
||||
'officer', // the app: SPA, /api, websockets
|
||||
'officer-anthropic-proxy', // holds the Anthropic credential, forwards to api.anthropic.com
|
||||
// Spawns `claude`. Reads the proxy secret from disk, so it needs no ordering against the proxy
|
||||
// above: if the secret is not written yet it warns and re-reads before the next spawn.
|
||||
'officer-agent',
|
||||
'officer-opencode', // the alternative agent
|
||||
// The terminal. Runs under node rather than bun — node-pty binds a native addon built against
|
||||
// node's ABI. That detail lives in ecosystem.config.cjs, not here.
|
||||
'officer-pty',
|
||||
],
|
||||
|
||||
excluded: {
|
||||
// Cannot run on macOS at all.
|
||||
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
|
||||
|
||||
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
|
||||
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
|
||||
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
|
||||
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
|
||||
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
||||
|
||||
// Would run, but needs something setup_mac_light.sh deliberately does not install.
|
||||
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
|
||||
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
|
||||
'officer-music': 'the ffprobe indexer works, but a full ~/Music index is expensive to start by default',
|
||||
|
||||
// Fronts a container or daemon a laptop is not running.
|
||||
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
|
||||
'officer-slskd': 'supervises the slskd daemon',
|
||||
'officer-headscale': 'fronts a headscale server',
|
||||
'officer-transmission': 'fronts a transmission daemon',
|
||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
||||
'officer-jellyfin': 'fronts a Jellyfin container',
|
||||
|
||||
// Needs an owner-configured external service.
|
||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
||||
|
||||
// Deliberate, for what it holds or who feeds it.
|
||||
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
|
||||
'officer-wallet': 'holds seed and node credentials; not on a laptop',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs,
|
||||
// ecosystem.mac.light.config.cjs).
|
||||
//
|
||||
// A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how
|
||||
// a process is launched — `script` and `args` are read from the host file at load — because a
|
||||
// hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and
|
||||
// within days was starting a sidecar that had been split in two and pointing at a pty entry point that
|
||||
// had moved. Neither failure said anything; the processes simply did not come up.
|
||||
//
|
||||
// So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a
|
||||
// profile only decides which of them to run.
|
||||
//
|
||||
// Two consistency checks, both of which turn a silent breakage into a loud one at load:
|
||||
// 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed
|
||||
// 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which
|
||||
// must be classified deliberately rather than defaulting to absent because nobody noticed
|
||||
//
|
||||
// The second is the one that matters over time. Without it, every sidecar added to the host silently
|
||||
// stays out of every profile, and the profiles quietly stop meaning what their comments claim.
|
||||
|
||||
/**
|
||||
* @param {object} spec
|
||||
* @param {string} spec.file this profile's filename, for error messages
|
||||
* @param {string[]} spec.include app names to run, in start order
|
||||
* @param {Record<string,string>} spec.excluded app name → why it is not in this profile
|
||||
*/
|
||||
// The directory holding the platform's package.json, found by walking up from this file. Independent of
|
||||
// where in the tree this config is kept, and of where pm2 was invoked from.
|
||||
function repoRoot() {
|
||||
const { existsSync, readFileSync } = require('node:fs');
|
||||
const { dirname, join } = require('node:path');
|
||||
let dir = __dirname;
|
||||
for (;;) {
|
||||
const manifest = join(dir, 'package.json');
|
||||
if (existsSync(manifest)) {
|
||||
try {
|
||||
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir;
|
||||
} catch {
|
||||
// Unparseable is not ours; keep walking.
|
||||
}
|
||||
}
|
||||
const up = dirname(dir);
|
||||
if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname);
|
||||
dir = up;
|
||||
}
|
||||
}
|
||||
|
||||
function defineProfile({ file, include, excluded }) {
|
||||
const full = require('./ecosystem.config.cjs');
|
||||
const byName = new Map(full.apps.map((app) => [app.name, app]));
|
||||
|
||||
const missing = include.filter((name) => !byName.has(name));
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` +
|
||||
`removed. Update this profile's include list.`,
|
||||
);
|
||||
}
|
||||
|
||||
const unclassified = full.apps
|
||||
.map((app) => app.name)
|
||||
.filter((name) => !include.includes(name) && !(name in excluded));
|
||||
if (unclassified.length) {
|
||||
throw new Error(
|
||||
`${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` +
|
||||
`excluded here. Add it to the include list, or to the excluded map with a reason.`,
|
||||
);
|
||||
}
|
||||
|
||||
// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does
|
||||
// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls
|
||||
// back to the default PORT with no POSTGRES_URL.
|
||||
//
|
||||
// It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of
|
||||
// the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not
|
||||
// fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it.
|
||||
//
|
||||
// This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside
|
||||
// ecosystem.config.cjs". That stopped being true the moment these files were moved into
|
||||
// ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is
|
||||
// true wherever this file ends up living.
|
||||
return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) };
|
||||
}
|
||||
|
||||
module.exports = { defineProfile };
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||
"setup": "bash scripts/install.sh"
|
||||
"setup": "bash scripts/setup/officer-setup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,10 +0,0 @@
|
||||
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 }));
|
||||
@@ -1,35 +0,0 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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'));
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
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 },
|
||||
];
|
||||
@@ -1,247 +0,0 @@
|
||||
# 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 — out of scope by decision
|
||||
|
||||
`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. The owner's call was that it is the least
|
||||
important part of music and not worth blocking the extraction on.
|
||||
|
||||
It was already inert before any of this — the two sockets are declared in `server.tsx`'s route table and
|
||||
upgrade into `handlers` entries that are commented out. So:
|
||||
|
||||
- `src/servers/sidecar/music/` still holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and
|
||||
`cliamp-ws.test.ts`. **Untouched.**
|
||||
- This plugin's sidecar still serves those sockets, so it imports both modules from
|
||||
`@@/sidecar/music/`. A plugin importing platform code is ordinary; the reverse would not be.
|
||||
- `src/servers/api/cliamp/relay.ts` stays, and it is what keeps the next item alive.
|
||||
|
||||
### 2. `src/servers/api/music/router.ts` — kept alive by the relay
|
||||
|
||||
`relay.ts` imports `getMusicServerWsUrl` from it. So the platform's proxy could not move, and this
|
||||
plugin's `api/router.ts` **re-exports it** rather than building a second one.
|
||||
|
||||
That is not laziness. `createSidecarProxy` learns its port from a one-shot `music:server` event and
|
||||
subscribes at import. Two proxies would mean two subscribers, both working today, and a `503` on the
|
||||
first reconnect where only one of them happened to be listening — the same class of failure as the
|
||||
install-order bug offscale found, and just as invisible from reading.
|
||||
|
||||
### 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.
|
||||
@@ -1,18 +0,0 @@
|
||||
import { musicRouter } from '@@/api/music/router';
|
||||
|
||||
// /api/music/* — auth, then forward to officer-music.
|
||||
//
|
||||
// ── Why this re-exports the platform's proxy instead of creating its own ──
|
||||
//
|
||||
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
|
||||
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
|
||||
// deliberately out of scope — it is a second playback path that the platform still owns.
|
||||
//
|
||||
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
|
||||
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
|
||||
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
|
||||
// proxy, one subscription, mounted by whoever needs it.
|
||||
//
|
||||
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
|
||||
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
|
||||
export const router = musicRouter;
|
||||
@@ -1,106 +0,0 @@
|
||||
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: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
|
||||
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
|
||||
//
|
||||
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
|
||||
// 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 ──
|
||||
//
|
||||
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
|
||||
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
|
||||
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
|
||||
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
|
||||
// imports both modules from `@@/sidecar/music/`.
|
||||
//
|
||||
// 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,18 +0,0 @@
|
||||
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 },
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
// 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,732 +0,0 @@
|
||||
# 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,47 +0,0 @@
|
||||
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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
+4
-25
@@ -16,39 +16,18 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const template = join(root, 'src/apps/officer-web/index.html');
|
||||
const output = join(root, 'src/apps/officer-web/index.gen.html');
|
||||
|
||||
// Where the URL comes from, most specific first:
|
||||
//
|
||||
// 1. the first argument `bun gen:index https://officer.example.com`
|
||||
// 2. PUBLIC_URL in the environment
|
||||
// 3. PUBLIC_URL in .env (this script runs standalone; the server gets it via --env-file)
|
||||
//
|
||||
// The argument exists so changing the public address is one command rather than an edit plus a
|
||||
// regenerate — and so a second address can be generated for without touching the install's own .env.
|
||||
const argUrl = process.argv[2]?.trim();
|
||||
|
||||
// The server reads .env through --env-file, but this script runs standalone.
|
||||
const envPath = join(root, '.env');
|
||||
if (!argUrl && !process.env.PUBLIC_URL && existsSync(envPath)) {
|
||||
if (!process.env.PUBLIC_URL && existsSync(envPath)) {
|
||||
for (const line of (await Bun.file(envPath).text()).split('\n')) {
|
||||
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
|
||||
if (match) process.env.PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const publicUrl = (argUrl || process.env.PUBLIC_URL || '').replace(/\/+$/, '');
|
||||
const publicUrl = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
|
||||
if (!publicUrl) {
|
||||
console.error('[gen-index] no public URL. Pass one — `bun gen:index https://officer.example.com` —');
|
||||
console.error('[gen-index] or set PUBLIC_URL in .env.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Caught here rather than left to a crawler: a relative or scheme-less value substitutes without
|
||||
// complaint and produces OpenGraph tags nothing can resolve, which is invisible until someone shares a
|
||||
// link and the preview is blank.
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('not http(s)');
|
||||
} catch {
|
||||
console.error(`[gen-index] "${publicUrl}" is not an absolute http(s) URL — OpenGraph tags need one.`);
|
||||
console.error('[gen-index] PUBLIC_URL is not set — set it in .env (e.g. https://officer.example.com)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Officer — install
|
||||
# =============================================================================
|
||||
#
|
||||
# One command, blank machine to running platform. It runs the two halves in
|
||||
# order and does nothing else itself:
|
||||
#
|
||||
# setup/machine-setup/machine-setup.sh a usable machine — packages, tailnet,
|
||||
# runtimes, docker, shell
|
||||
# setup/officer-setup.sh the platform on top of it — repo,
|
||||
# dependencies, postgres, .env, secret
|
||||
# store, schema, build, pm2
|
||||
#
|
||||
# They stay two scripts because they answer two different questions and are worth
|
||||
# running separately: a machine you already trust needs only the second, and a
|
||||
# machine you are rebuilding needs only the first. This is the wrapper for the
|
||||
# case where you want both, which is most first runs.
|
||||
#
|
||||
# Both are re-runnable. Each remembers the steps it finished and skips them, so
|
||||
# stopping halfway and coming back costs nothing.
|
||||
#
|
||||
# Run it as yourself — it asks for administrator rights when it needs them.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MACHINE="$SCRIPT_DIR/setup/machine-setup/machine-setup.sh"
|
||||
OFFICER="$SCRIPT_DIR/setup/officer-setup.sh"
|
||||
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
say() { echo -e "$*"; }
|
||||
die() {
|
||||
echo -e "${YELLOW}error:${NC} $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -r "$MACHINE" ]] || die "missing $MACHINE"
|
||||
[[ -r "$OFFICER" ]] || die "missing $OFFICER"
|
||||
|
||||
# Which halves to run. Both by default.
|
||||
RUN_MACHINE=true
|
||||
RUN_OFFICER=true
|
||||
|
||||
# Kept before the loop below eats them: this script re-executes itself through sudo
|
||||
# further down, and `shift` would otherwise leave it re-running with no arguments —
|
||||
# silently dropping --officer-only and turning a platform-only run into a full one.
|
||||
#
|
||||
# The `${x[@]+"${x[@]}"}` form is for `set -u`: expanding an empty array unquoted-safe
|
||||
# is an error on bash before 4.4, and this runs on whatever the machine came with.
|
||||
ORIGINAL_ARGS=(${@+"$@"})
|
||||
|
||||
# A `while`/`shift` loop rather than `for arg in "$@"`, because --repo takes a value
|
||||
# and a for-loop cannot consume the argument after it.
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--machine-only) RUN_OFFICER=false ;;
|
||||
--officer-only) RUN_MACHINE=false ;;
|
||||
--repo)
|
||||
[[ -n "${2:-}" ]] || die "--repo needs a URL"
|
||||
OFFICER_REPO="$2"
|
||||
shift
|
||||
;;
|
||||
--repo=*) OFFICER_REPO="${1#--repo=}" ;;
|
||||
# Every question that HAS a default answers itself. The ones with no possible
|
||||
# default still ask — see the note above the run below.
|
||||
--unattended | -y)
|
||||
export UNATTENDED=1 ASSUME_YES=1
|
||||
;;
|
||||
-h | --help)
|
||||
say "usage: install.sh [--machine-only | --officer-only] [--repo <url>]"
|
||||
say ""
|
||||
say " no flags both halves, machine first"
|
||||
say " --machine-only stop after the machine is provisioned"
|
||||
say " --officer-only the platform only, on a machine you already trust"
|
||||
say " --repo <url> clone the platform from here instead of the default"
|
||||
say " --unattended take the default for every question that has one (-y)"
|
||||
say ""
|
||||
say " The default is a private Gitea over SSH, which only authenticates on a"
|
||||
say " machine whose key it already knows. Pass an https URL on a fresh box."
|
||||
say ""
|
||||
say " --unattended still asks the questions that have no possible default:"
|
||||
say " the username, the Tailscale control plane / login server / auth key,"
|
||||
say " an SSH public key when the account has none, and the git identity."
|
||||
say " Answer those ahead of time with SETUP_USERNAME, TS_LOGIN_SERVER,"
|
||||
say " TS_AUTHKEY and TIMEZONE to reduce it further."
|
||||
exit 0
|
||||
;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Exported so `officer-setup.sh` reads it from the environment and this script does
|
||||
# not have to forward arguments it does not own. `lib/repo.sh` takes it as
|
||||
# `${OFFICER_REPO:-<default>}`, so unset here still means the default there.
|
||||
[[ -n "${OFFICER_REPO:-}" ]] && export OFFICER_REPO
|
||||
|
||||
KERNEL="$(uname -s)"
|
||||
case "$KERNEL" in
|
||||
Darwin)
|
||||
[[ "$EUID" -eq 0 ]] && die "do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
|
||||
;;
|
||||
Linux) ;;
|
||||
*) die "unsupported system: $KERNEL. Officer installs on Linux and macOS." ;;
|
||||
esac
|
||||
|
||||
SELF="$SCRIPT_DIR/install.sh"
|
||||
|
||||
# One report for the whole run, not one per half. Both scripts append to this
|
||||
# file, so the person reviewing it sees a single account of what happened rather
|
||||
# than two they have to stitch together and hope are complete.
|
||||
#
|
||||
# Exported before either half starts, and timestamped once here — if each script
|
||||
# made its own name they would differ by however long the first one took.
|
||||
export REPORT_FILE="${REPORT_FILE:-${HOME}/officer-install-report-$(date '+%Y%m%d-%H%M%S').md}"
|
||||
|
||||
# ── Privileges: asked for, not demanded ──
|
||||
#
|
||||
# Run this as YOURSELF. On Linux it needs root for apt, systemd units, useradd,
|
||||
# netplan, ufw and for creating directories owned by the service account — so it
|
||||
# asks, once, through sudo, and re-executes itself. Typing `sudo` yourself works
|
||||
# too and changes nothing, but it should not be the price of starting.
|
||||
#
|
||||
# Variables are passed to sudo explicitly rather than with -E. `env_reset` is the
|
||||
# sudoers default and strips the environment, which is how DATA_PATH was lost
|
||||
# once already; naming them on the command line survives it.
|
||||
#
|
||||
# macOS never escalates. Homebrew refuses to run as root, and nothing in the
|
||||
# macOS path needs it — the account running this IS the owner, so there is
|
||||
# nothing to chown and nothing to drop privileges to.
|
||||
if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || die "this needs root and sudo is not installed — run it as root"
|
||||
say ""
|
||||
say " This needs administrator rights. You will be asked for your password."
|
||||
say ""
|
||||
exec sudo \
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||
REPORT_FILE="${REPORT_FILE:-}" \
|
||||
UNATTENDED="${UNATTENDED:-}" \
|
||||
ASSUME_YES="${ASSUME_YES:-}" \
|
||||
OFFICER_REPO="${OFFICER_REPO:-}" \
|
||||
bash "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||
fi
|
||||
|
||||
|
||||
say ""
|
||||
say "${BOLD}Officer install${NC}"
|
||||
say " system: $KERNEL"
|
||||
$RUN_MACHINE && say " 1/2 machine setup"
|
||||
$RUN_OFFICER && say " $($RUN_MACHINE && echo 2/2 || echo 1/1) officer setup"
|
||||
say ""
|
||||
say " Either half can be run on its own later:"
|
||||
say " scripts/setup/machine-setup/machine-setup.sh"
|
||||
say " scripts/setup/officer-setup.sh"
|
||||
say ""
|
||||
|
||||
# Not `set -e`'s job: a half that exits non-zero should say which half, and stop
|
||||
# before the next one starts on a machine that is not ready for it.
|
||||
# ── Who says "you are still root" ──
|
||||
#
|
||||
# Both halves end as root and both need to say so, but only the LAST one to run
|
||||
# should — otherwise a full install says it twice, once in the middle where it is
|
||||
# wrong, because officer-setup is about to run and still needs the privilege.
|
||||
#
|
||||
# So the rule is "say it if nothing follows you", and this is the only place that
|
||||
# knows whether anything does.
|
||||
if $RUN_MACHINE; then
|
||||
$RUN_OFFICER && export OFFICER_SETUP_FOLLOWS=1
|
||||
bash "$MACHINE" || die "machine setup did not finish — fix what it reported, then run this again"
|
||||
unset OFFICER_SETUP_FOLLOWS
|
||||
fi
|
||||
|
||||
if $RUN_OFFICER; then
|
||||
bash "$OFFICER" || die "officer setup did not finish — fix what it reported, then run this again"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "${GREEN}Done.${NC}"
|
||||
@@ -10,9 +10,7 @@
|
||||
import type { BrowsedFile } from 'officerdb';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { db, finishSoulseekBrowse } from 'officerdb';
|
||||
// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator —
|
||||
// import them from the feature directly.
|
||||
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema';
|
||||
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/schema';
|
||||
import { buildTree } from '../src/servers/sidecar/slskd/browse';
|
||||
|
||||
const snapshots = await db
|
||||
|
||||
@@ -24,12 +24,6 @@ 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
|
||||
@@ -150,54 +150,8 @@ load_answers() {
|
||||
# file — the point of asking for a single step is to run that step.
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
# ── Steps that do not exist on macOS ──
|
||||
#
|
||||
# A Mac running Officer is a DEV MACHINE, never a server. That is not a
|
||||
# simplification to revisit: nobody puts a laptop behind a public hostname and
|
||||
# hands it a tailnet exit node, and the sections below are all about being a
|
||||
# server that is on all the time.
|
||||
#
|
||||
# Most would fail rather than misbehave — there is no systemd, no ufw, no
|
||||
# netplan, no useradd, no /etc/ssh/sshd_config.d. But a few would SUCCEED and be
|
||||
# wrong, which is worse: stopping a laptop from sleeping, or freezing its address
|
||||
# on a network it moves between every day.
|
||||
#
|
||||
# Keyed on the step title, so the sections themselves stay Linux code with no
|
||||
# `if macos` branches threaded through them. The reason is printed, because a
|
||||
# silent skip and a missing step look identical.
|
||||
declare -A MACOS_SKIP=(
|
||||
["User account"]="accounts are System Settings' business on a Mac, not a script's"
|
||||
["Disk space"]="ballast and swap tuning are server concerns"
|
||||
["Locale"]="macOS manages locale itself"
|
||||
["Timezone"]="macOS manages the timezone itself"
|
||||
["Swap"]="macOS sizes its own swap dynamically"
|
||||
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
|
||||
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
|
||||
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
|
||||
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
|
||||
["Boot hang"]="a systemd boot ordering fix"
|
||||
["SSH access"]="hardening a door a dev machine should not be opening"
|
||||
["DNS"]="systemd-resolved"
|
||||
["Network address"]="netplan, and a laptop moves between networks by design"
|
||||
["fail2ban"]="brute-force protection for an exposed SSH port"
|
||||
["Unattended upgrades"]="apt; macOS updates through Software Update"
|
||||
["Firewall"]="ufw; macOS has its own application firewall"
|
||||
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
|
||||
)
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
# The report follows the step, rather than each section remembering to say
|
||||
# which one it is. Twenty-six sections, one place.
|
||||
declare -F report_section >/dev/null && report_section "$1"
|
||||
|
||||
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
@@ -285,37 +239,6 @@ page() {
|
||||
# deliberate keystroke would train people to hold the y key down.
|
||||
#
|
||||
# ASSUME_YES=1 answers all of them, for an unattended run.
|
||||
|
||||
# A numbered menu's answer, or its own default when running unattended.
|
||||
#
|
||||
# menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
|
||||
#
|
||||
# ── Why empty, rather than a default passed in ──
|
||||
#
|
||||
# Every menu in this script reads its choice and then consumes it as
|
||||
# `${CHOICE:-<n>}`, so the default already lives at the point of use — which is the
|
||||
# right place, next to the options it selects between. Setting the variable EMPTY is
|
||||
# therefore exactly what pressing Enter does, and it cannot drift from the default
|
||||
# the prompt advertises the way a second copy passed in here would.
|
||||
#
|
||||
# `read <<<''` rather than `eval` or `declare -g`: no eval, and `declare -g` is bash
|
||||
# 4.2+, which rules out the bash 3.2 that macOS still ships.
|
||||
#
|
||||
# The prompt is still printed, with the reason, because a transcript that silently
|
||||
# skips a question reads as a question that was never asked.
|
||||
menu_answer() {
|
||||
local var="$1" prompt="$2"
|
||||
if [[ "${UNATTENDED:-}" == "1" ]]; then
|
||||
printf '%s%s\n' "$prompt" "— unattended, taking the default"
|
||||
read -r "$var" <<<''
|
||||
return 0
|
||||
fi
|
||||
read -rp "$prompt" "$var" || {
|
||||
echo ""
|
||||
fail "No answer."
|
||||
}
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local message="${1:-Proceed?}"
|
||||
# Second argument flips the default. Most questions here are "do the thing you
|
||||
@@ -662,15 +585,6 @@ default_iface() {
|
||||
# firewall open on one. Every branch downstream is about what this machine is
|
||||
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
|
||||
ask_machine_role() {
|
||||
# Not a question on a Mac. Officer on macOS is a dev helper on a machine
|
||||
# somebody sits at — there is no homelab or VPS answer that would make sense,
|
||||
# and every section that branches on the role branches toward "server".
|
||||
if [[ "${OS:-}" == "macos" && -z "$MACHINE_ROLE" ]]; then
|
||||
MACHINE_ROLE="dev"
|
||||
info "macOS — treated as a dev machine. The server-only sections are skipped."
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "$MACHINE_ROLE" ]]; then
|
||||
case "$MACHINE_ROLE" in
|
||||
homelab | vps | dev) return ;;
|
||||
|
||||
@@ -92,36 +92,27 @@ oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
|
||||
install_oh_my_zsh() {
|
||||
# The installer refuses to run unattended over an existing install, so this is
|
||||
# only ever called when there is none.
|
||||
#
|
||||
# ── `|| true` is what makes this non-fatal, NOT the `return 0` below ──
|
||||
#
|
||||
# It used to be `return 0` alone, with a comment claiming the function returned
|
||||
# zero whatever happened. It did not. Under `set -e` a failing command inside a
|
||||
# function aborts the SHELL at that line when the function is called plainly —
|
||||
# `return 0` is never reached. So a machine where this curl or the installer
|
||||
# failed died here, silently, because the output is redirected: the run just
|
||||
# stopped after apt finished installing zsh, with nothing said. Observed on a
|
||||
# fresh Hetzner VPS, 2026-08-14.
|
||||
sudo -H -u "$USERNAME" sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
|
||||
true
|
||||
# Belt and braces: `|| true` above already makes the last command succeed, and
|
||||
# this states the contract for anyone adding a line beneath it.
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# `chsh` is what actually changes the login shell. Asked separately from
|
||||
# installing zsh, because having a shell available and being handed it at every
|
||||
# login are different decisions.
|
||||
# Reports whether chsh worked, rather than swallowing it. The same `set -e` trap as
|
||||
# install_oh_my_zsh applies — a bare `chsh` that fails kills the run at this line —
|
||||
# but here the answer matters: the caller announces the new login shell, and `|| true`
|
||||
# would have it announce one that was never set. So the status comes back and the
|
||||
# CALLER guards the call, which is also what keeps set -e out of it.
|
||||
set_login_shell() {
|
||||
local shell="$1"
|
||||
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
||||
chsh -s "$shell" "$USERNAME" >/dev/null 2>&1
|
||||
chsh -s "$shell" "$USERNAME"
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -49,11 +49,6 @@ docker_repo_distro() {
|
||||
}
|
||||
|
||||
install_docker_engine() {
|
||||
# Linux only, and never reached on macOS: the Docker step there checks for
|
||||
# Docker Desktop and tells the owner to install it rather than doing it — a GUI
|
||||
# app that wants opening, permissions and a running window is not a shell
|
||||
# script's job, and colima/lima are not worth the evening they cost.
|
||||
|
||||
local distro codename
|
||||
distro="$(docker_repo_distro)"
|
||||
codename="$(docker_repo_codename)"
|
||||
@@ -72,30 +67,7 @@ install_docker_engine() {
|
||||
>/etc/apt/sources.list.d/docker.list
|
||||
|
||||
pkg_refresh >/dev/null
|
||||
|
||||
# ── The rootless prerequisites go in HERE, not in the rootless branch ──
|
||||
#
|
||||
# They used to be installed only when the owner picked "[2] rootless Docker for
|
||||
# me" in section 22. But the OWNER's choice is not the only one that matters:
|
||||
# every Developer account the platform provisions gets its own rootless daemon,
|
||||
# whatever the owner picked for themselves. So on a machine where the owner chose
|
||||
# the docker group, the host never got these and every member's daemon failed
|
||||
# with `rootless Docker needs these packages on the host: uidmap`.
|
||||
#
|
||||
# `src/servers/os-user-docker.ts` → checkDockerPrerequisites is the authority on
|
||||
# this list, and it wants both:
|
||||
#
|
||||
# uidmap /usr/bin/newuidmap, /usr/bin/newgidmap
|
||||
# docker-ce-rootless-extras /usr/bin/dockerd-rootless-setuptool.sh
|
||||
#
|
||||
# docker-ce only RECOMMENDS rootless-extras. That is installed by default, so it
|
||||
# is usually there by luck — and is not on a host configured with
|
||||
# --no-install-recommends. Named explicitly so it does not depend on that.
|
||||
#
|
||||
# dbus-user-session is what lets a member's systemd --user survive without a
|
||||
# login session, which is how the daemon stays up.
|
||||
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
||||
docker-ce-rootless-extras uidmap dbus-user-session
|
||||
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
}
|
||||
|
||||
# A shared network so containers from different compose files can reach each
|
||||
|
||||
@@ -43,17 +43,10 @@ install_config() {
|
||||
|
||||
if [[ ! -f "$dest" ]]; then
|
||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||
# Recorded here rather than at the call site: "which files did it write" is
|
||||
# the question a reviewer asks first, and a per-section report would drift
|
||||
# from what this function actually did.
|
||||
declare -F report_changed >/dev/null && report_changed "wrote ${dest} (0644, owner ${owner}) — did not exist"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if cmp -s "$src" "$dest"; then
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} already identical to the shipped version — not touched"
|
||||
return 1
|
||||
fi
|
||||
cmp -s "$src" "$dest" && return 1
|
||||
|
||||
echo ""
|
||||
warn "${dest} already exists here, and differs from the one this script ships."
|
||||
@@ -63,7 +56,6 @@ install_config() {
|
||||
# was present to defend.
|
||||
if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then
|
||||
echo " keeping yours (nothing was asked, so nothing is replaced)"
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — unattended run, nothing replaced"
|
||||
return 2
|
||||
fi
|
||||
|
||||
@@ -75,20 +67,17 @@ install_config() {
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " answer; then
|
||||
echo ""
|
||||
echo " keeping yours"
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — no answer available"
|
||||
return 2
|
||||
fi
|
||||
case "${answer:-1}" in
|
||||
1)
|
||||
echo " keeping yours"
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT by choice"
|
||||
return 2
|
||||
;;
|
||||
2)
|
||||
cp -a "$dest" "${dest}.before-machine-setup"
|
||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||
ok "replaced — yours is at ${dest}.before-machine-setup"
|
||||
declare -F report_changed >/dev/null && report_changed "REPLACED ${dest} by choice — previous kept at ${dest}.before-machine-setup"
|
||||
return 0
|
||||
;;
|
||||
3)
|
||||
@@ -136,38 +125,3 @@ append_once() {
|
||||
echo "$end"
|
||||
} >>"$file"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Where tmux actually reads its config
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# tmux 3.1 added an XDG location and it takes PRECEDENCE. Verified on 3.4 by
|
||||
# creating both and asking tmux which marker it ended up with:
|
||||
#
|
||||
# both present -> ~/.config/tmux/tmux.conf
|
||||
# only ~/.tmux.conf -> ~/.tmux.conf
|
||||
# only the XDG one -> the XDG one
|
||||
#
|
||||
# So installing to ~/.tmux.conf on a machine that has the XDG file writes a file
|
||||
# tmux will never read, and the script would report success having changed
|
||||
# nothing anybody can see. That is the failure this exists to prevent.
|
||||
#
|
||||
# Rules, in order:
|
||||
# 1. an existing XDG config wins -> that is their real config, target it
|
||||
# 2. an existing ~/.tmux.conf -> target it, since it is what tmux reads
|
||||
# 3. neither -> ~/.tmux.conf, the path every guide names
|
||||
tmux_config_target() {
|
||||
local home="$1"
|
||||
local xdg="${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf"
|
||||
if [[ -f "$xdg" ]]; then
|
||||
echo "$xdg"
|
||||
else
|
||||
echo "$home/.tmux.conf"
|
||||
fi
|
||||
}
|
||||
|
||||
# True when a ~/.tmux.conf would be shadowed by an XDG config that already exists.
|
||||
tmux_dot_conf_is_shadowed() {
|
||||
local home="$1"
|
||||
[[ -f "${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf" && -f "$home/.tmux.conf" ]]
|
||||
}
|
||||
|
||||
@@ -84,36 +84,28 @@ LAST_SKIPPED=()
|
||||
pkgs_core() {
|
||||
case "$PM" in
|
||||
apt)
|
||||
# apt-transport-https and lsb-release are not tools — they are what lets a
|
||||
# later step add the Docker repository. They have no counterpart on the
|
||||
# other systems.
|
||||
#
|
||||
# software-properties-common is still here and is no longer needed by
|
||||
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
|
||||
# only caller until that was removed on 2026-08-14 (Docker writes its own
|
||||
# sources.list.d entry by hand). Left in deliberately rather than dropped
|
||||
# in the same change — it is one small package, and pulling it is a
|
||||
# separate decision from removing the tool that wanted it.
|
||||
# apt-transport-https, lsb-release and software-properties-common are not
|
||||
# tools — they are what lets later steps add the Docker repository and the
|
||||
# fastfetch PPA. They have no counterpart on the other systems.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \
|
||||
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_*.
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
|
||||
# the Xcode command line tools, which is not a formula.
|
||||
echo gnupg git jq wget btop htop tree tmux ripgrep fd
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -219,20 +211,8 @@ pkg_install() {
|
||||
LAST_INSTALLED=("${missing[@]}")
|
||||
LAST_KEPT=("${present[@]}")
|
||||
|
||||
announce_plan "$label" present missing || {
|
||||
# Declining is a fact a reviewer wants: it explains a package being absent
|
||||
# later without having to guess whether the script failed or was refused.
|
||||
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
|
||||
return 0
|
||||
}
|
||||
|
||||
if pkg_install_now "${missing[@]}"; then
|
||||
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
|
||||
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
|
||||
else
|
||||
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
|
||||
return 1
|
||||
fi
|
||||
announce_plan "$label" present missing || return 0
|
||||
pkg_install_now "${missing[@]}"
|
||||
}
|
||||
|
||||
# Print what a section is about to do and ask permission for it.
|
||||
@@ -282,23 +262,3 @@ summarise_last() {
|
||||
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The Xcode command line tools
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# macOS's build-essential, and not installable as a formula. It matters here for
|
||||
# one specific reason: node-pty ships no prebuilt binary for any platform, so
|
||||
# `bun install` always falls through to node-gyp and needs a working compiler.
|
||||
# Without this the platform install fails deep inside a dependency tree with an
|
||||
# error that names neither Xcode nor node-pty.
|
||||
#
|
||||
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
|
||||
# not block until the download finishes. So this asks, and then says to come back,
|
||||
# rather than pretending to have waited.
|
||||
|
||||
xcode_clt_installed() { xcode-select -p &>/dev/null; }
|
||||
|
||||
xcode_clt_install() {
|
||||
xcode-select --install 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -190,18 +190,7 @@ tailscale_control_url() {
|
||||
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
||||
}
|
||||
|
||||
# The official install.sh is a Linux package-manager script. macOS gets the same
|
||||
# daemon wrapped in a GUI app, and the cask is the version with a CLI at
|
||||
# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build
|
||||
# is sandboxed and ships no usable `tailscale` binary, which is the difference
|
||||
# that matters to a script.
|
||||
tailscale_install() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install --cask tailscale
|
||||
return
|
||||
fi
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
}
|
||||
tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; }
|
||||
|
||||
# Tailscale's own coordination server, spelled out.
|
||||
#
|
||||
|
||||
@@ -20,17 +20,10 @@
|
||||
MACHINE_SETUP_TOOLS_LOADED=1
|
||||
|
||||
# The set installed on every machine, in the order they are fetched.
|
||||
#
|
||||
# fastfetch was here until 2026-08-14 and was removed after it stopped a real
|
||||
# install. It is the only one of these with no source but a third-party PPA on
|
||||
# Ubuntu 24.04 and older, and the failure was in the half that was not guarded:
|
||||
# a PPA that ADDS cleanly but carries no package for the running codename gets
|
||||
# past the `|| skip` and dies on the install instead. A neofetch clone is not
|
||||
# worth a branch in a script that has to survive on machines nobody has seen.
|
||||
tools_default() { echo lazydocker lazygit starship; }
|
||||
tools_default() { echo lazydocker lazygit starship fastfetch; }
|
||||
|
||||
# The command that proves a tool is already here. Same as the tool name for all
|
||||
# three today, but kept as a mapping because that is not a rule — a package and
|
||||
# four today, but kept as a mapping because that is not a rule — a package and
|
||||
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
||||
# indirection.
|
||||
tool_command() {
|
||||
@@ -38,6 +31,7 @@ tool_command() {
|
||||
lazydocker) echo lazydocker ;;
|
||||
lazygit) echo lazygit ;;
|
||||
starship) echo starship ;;
|
||||
fastfetch) echo fastfetch ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -86,6 +80,23 @@ tool_install_starship() {
|
||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||
}
|
||||
|
||||
# A distribution package everywhere, but not always one the distribution ships:
|
||||
# Ubuntu only picked fastfetch up in 24.10, so on noble and older the PPA is the
|
||||
# only source. Checked rather than assumed, so the PPA stops being added the
|
||||
# moment the archive has it.
|
||||
tool_install_fastfetch() {
|
||||
if [[ "$PM" == "apt" ]] && ! apt-cache policy fastfetch 2>/dev/null | grep -q 'Candidate: [0-9]'; then
|
||||
info " fastfetch is not in this release's archive — adding the upstream PPA"
|
||||
add-apt-repository -y ppa:zhangsongcui3371/fastfetch >/dev/null 2>&1 ||
|
||||
{
|
||||
warn "could not add the fastfetch PPA — skipping"
|
||||
return 0
|
||||
}
|
||||
pkg_refresh >/dev/null 2>&1
|
||||
fi
|
||||
pkg_install_now fastfetch
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Acting
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -18,12 +18,6 @@ ANSWERS_FILE="$SCRIPT_DIR/.setup-answers"
|
||||
# still runs, because every section needs what it establishes — the system, the
|
||||
# role, the account and its home.
|
||||
ONLY_STEP=""
|
||||
|
||||
# Kept before the loop consumes them: this script re-executes itself through sudo
|
||||
# below and was passing `"$@"`, which `shift` had already emptied — so `--only` and
|
||||
# `--reask` silently stopped existing the moment it escalated.
|
||||
ORIGINAL_ARGS=(${@+"$@"})
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--only)
|
||||
@@ -38,25 +32,12 @@ while [[ $# -gt 0 ]]; do
|
||||
RE_ASK=1
|
||||
shift
|
||||
;;
|
||||
# Every question that HAS a default answers itself; the ones with none still ask.
|
||||
# ASSUME_YES drives confirm(), UNATTENDED drives the numbered menus and the
|
||||
# free-text prompts that carry a default.
|
||||
--unattended | -y)
|
||||
export UNATTENDED=1 ASSUME_YES=1
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list] [--unattended]"
|
||||
echo ""
|
||||
echo " --unattended take the default for every question that has one (-y)."
|
||||
echo " Still asks the ones with no possible default: the"
|
||||
echo " username, the Tailscale control plane / login server /"
|
||||
echo " auth key, an SSH public key when the account has none,"
|
||||
echo " and the git identity."
|
||||
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list]"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||
@@ -67,7 +48,6 @@ done
|
||||
# lib/ so a step can eventually be read — or run — on its own without dragging the
|
||||
# whole script in. Definitions only; nothing in there acts.
|
||||
# shellcheck source=lib/base.sh
|
||||
source "$SCRIPT_DIR/../report.sh"
|
||||
source "$SCRIPT_DIR/lib/base.sh"
|
||||
# shellcheck source=lib/packages.sh
|
||||
source "$SCRIPT_DIR/lib/packages.sh"
|
||||
@@ -107,7 +87,6 @@ echo -e "${BOLD}╚════════════════════
|
||||
[[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE"
|
||||
load_answers
|
||||
|
||||
trap report_flush EXIT
|
||||
detect_os
|
||||
echo ""
|
||||
info "Machine: ${OS_NAME} (${ARCH})"
|
||||
@@ -140,58 +119,15 @@ else
|
||||
echo " so coming back costs nothing."
|
||||
fi
|
||||
|
||||
# ── root on Linux, NOT root on macOS ──
|
||||
#
|
||||
# The two are opposites and it is not a preference. On Linux nearly every section
|
||||
# needs root — apt, systemd units, useradd, netplan, ufw. On macOS Homebrew
|
||||
# REFUSES to run as root and says so; running the whole script under sudo there
|
||||
# would fail at the first `brew install` having already asked for a password.
|
||||
#
|
||||
# It works out because the macOS path skips everything that needed root in the
|
||||
# first place (see MACOS_SKIP in lib/base.sh). What is left — brew, the Xcode
|
||||
# command line tools, the agent CLIs, bun — is all per-user by design.
|
||||
# ── Privileges: asked for, not demanded ──
|
||||
#
|
||||
# Run this as YOURSELF. Linux needs root for apt, systemd units, useradd, netplan
|
||||
# and ufw, so it asks through sudo and re-executes itself rather than making you
|
||||
# type it. Variables go to sudo by name rather than with -E: `env_reset` is the
|
||||
# sudoers default and strips the environment, which is how DATA_PATH was lost
|
||||
# once already.
|
||||
#
|
||||
# macOS never escalates — Homebrew refuses to run as root, and the sections that
|
||||
# needed root are the ones the macOS path skips.
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
if [[ "$EUID" -eq 0 ]]; then
|
||||
fail "Do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
|
||||
fi
|
||||
elif [[ "$EUID" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
|
||||
echo ""
|
||||
echo " This needs administrator rights. You will be asked for your password."
|
||||
echo ""
|
||||
exec sudo \
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||
REPORT_FILE="${REPORT_FILE:-}" \
|
||||
UNATTENDED="${UNATTENDED:-}" \
|
||||
ASSUME_YES="${ASSUME_YES:-}" \
|
||||
bash "$SCRIPT_DIR/machine-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Please run as root: sudo ./machine-setup.sh"
|
||||
fi
|
||||
|
||||
# On macOS the account running the script IS the account, and there is nothing to
|
||||
# create — the User account step is skipped entirely.
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
USERNAME="$(id -un)"
|
||||
USER_HOME="$HOME"
|
||||
info "Account: ${USERNAME} (you — macOS creates no accounts here)"
|
||||
else
|
||||
ask_username
|
||||
if id "$USERNAME" &>/dev/null; then
|
||||
ask_username
|
||||
if id "$USERNAME" &>/dev/null; then
|
||||
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
|
||||
else
|
||||
else
|
||||
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
|
||||
fi
|
||||
fi
|
||||
|
||||
ask_officer_root
|
||||
@@ -204,9 +140,9 @@ fi
|
||||
save_answers
|
||||
|
||||
# Always, and outside any step: everything below reads this index — core utils,
|
||||
# the Docker repo — and `step` skips a step whose name is already in the progress
|
||||
# file. With the refresh inside one of those, a resumed run installed against
|
||||
# whatever the index happened to say hours or days ago.
|
||||
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
|
||||
# already in the progress file. With the refresh inside one of those, a resumed
|
||||
# run installed against whatever the index happened to say hours or days ago.
|
||||
echo ""
|
||||
info "Refreshing the package index..."
|
||||
pkg_refresh >/dev/null
|
||||
@@ -438,34 +374,6 @@ fi
|
||||
# What the distribution provides: the six this script would break without, and
|
||||
# the command-line tools that make a machine worth sitting at.
|
||||
|
||||
# macOS's compilers, before anything that might need to build a native module.
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
step "Xcode command line tools"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
if xcode_clt_installed; then
|
||||
ok "already installed ($(xcode-select -p))"
|
||||
SUMMARY+=("Xcode CLT: already installed")
|
||||
else
|
||||
info "Xcode command line tools — macOS's compilers"
|
||||
echo " Needed because node-pty ships no prebuilt binary and compiles"
|
||||
echo " from source on every machine, so 'bun install' cannot finish"
|
||||
echo " without a compiler."
|
||||
echo ""
|
||||
if confirm "Start the install?"; then
|
||||
xcode_clt_install
|
||||
warn "a macOS dialogue has opened — finish it there, then re-run this step"
|
||||
echo " ./machine-setup.sh --only 'Xcode command line tools'"
|
||||
SUMMARY+=("Xcode CLT: install started in a GUI dialogue — finish it, then re-run")
|
||||
else
|
||||
warn "skipped — 'bun install' will fail on node-pty without it"
|
||||
SUMMARY+=("Xcode CLT: SKIPPED by request")
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Core utils"
|
||||
if ! skip; then
|
||||
# shellcheck disable=SC2046 # word splitting is how the list is passed
|
||||
@@ -782,9 +690,10 @@ if ! skip; then
|
||||
echo ""
|
||||
|
||||
while [[ -z "${TIMEZONE:-}" ]]; do
|
||||
# Unattended keeps the current zone, which is what Enter does here. TIMEZONE=<zone>
|
||||
# in the environment answers it ahead of time and skips this block entirely.
|
||||
menu_answer TZ_CHOICE " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: "
|
||||
if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer. Set TIMEZONE=<zone> to answer this ahead of time."
|
||||
fi
|
||||
|
||||
if [[ -z "$TZ_CHOICE" ]]; then
|
||||
TIMEZONE="$CURRENT_TZ"
|
||||
@@ -938,7 +847,10 @@ elif ! skip; then
|
||||
|
||||
BALLAST_FILE=""
|
||||
while [[ -z "$BALLAST_FILE" ]]; do
|
||||
menu_answer BALLAST_WHERE " Which one? (1/2/3) [1]: "
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${BALLAST_WHERE:-1}" in
|
||||
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
|
||||
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
|
||||
@@ -978,7 +890,10 @@ elif ! skip; then
|
||||
|
||||
BALLAST_PCT=""
|
||||
while [[ -z "$BALLAST_PCT" ]]; do
|
||||
menu_answer BALLAST_SIZE_CHOICE " Which one? (1/2/3) [2]: "
|
||||
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${BALLAST_SIZE_CHOICE:-2}" in
|
||||
1) BALLAST_PCT=5 ;;
|
||||
2) BALLAST_PCT=10 ;;
|
||||
@@ -1353,7 +1268,10 @@ if ! skip; then
|
||||
DNS_FALLBACK=""
|
||||
DNS_CHOSEN=""
|
||||
while [[ -z "$DNS_CHOSEN" ]]; do
|
||||
menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
|
||||
if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${DNS_CHOICE:-1}" in
|
||||
1) DNS_CHOSEN="keep" ;;
|
||||
2)
|
||||
@@ -1460,7 +1378,10 @@ elif ! skip; then
|
||||
|
||||
NET_CHOICE=""
|
||||
while [[ -z "$NET_CHOICE" ]]; do
|
||||
menu_answer NET_ANSWER " Which one? (1/2/3) [1]: "
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${NET_ANSWER:-1}" in
|
||||
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
|
||||
*) warn "Pick 1, 2 or 3." ;;
|
||||
@@ -1723,47 +1644,12 @@ if ! skip; then
|
||||
info "Docker — containers, and how ${USERNAME} is allowed to talk to them"
|
||||
echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')"
|
||||
echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')"
|
||||
if [[ "$OS" != "macos" ]]; then
|
||||
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
|
||||
fi
|
||||
|
||||
# ── macOS: we do not install Docker, we check for it ──
|
||||
#
|
||||
# Docker Desktop is the only thing that works here without a fight. Lima and
|
||||
# colima both technically run containers on a Mac and both cost an evening the
|
||||
# first time something does not resolve, so this asks for Desktop by name
|
||||
# rather than installing an alternative that will disappoint later.
|
||||
#
|
||||
# Not installed by the script either: it is a GUI app that wants to be opened,
|
||||
# granted permissions and left running, none of which a shell script should be
|
||||
# doing on somebody's laptop.
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
if docker_daemon_ok; then
|
||||
ok "Docker Desktop is running"
|
||||
SUMMARY+=("Docker: Docker Desktop running")
|
||||
elif docker_is_installed; then
|
||||
warn "the docker CLI is here but the daemon is not answering"
|
||||
echo " Open Docker Desktop from Applications and let it finish starting."
|
||||
SUMMARY+=("Docker: installed but not running — open Docker Desktop")
|
||||
else
|
||||
warn "Docker is not installed"
|
||||
echo ""
|
||||
echo " Officer needs it for Postgres and for anything the app store"
|
||||
echo " installs. Get Docker Desktop:"
|
||||
echo ""
|
||||
echo " https://www.docker.com/products/docker-desktop/"
|
||||
echo ""
|
||||
echo " Open it once after installing, then run this step again:"
|
||||
echo " ./machine-setup.sh --only Docker"
|
||||
SUMMARY+=("Docker: NOT installed — install Docker Desktop, then re-run this step")
|
||||
fi
|
||||
step_ok
|
||||
elif ! docker_is_installed; then
|
||||
if ! docker_is_installed; then
|
||||
echo ""
|
||||
echo " to install: docker-ce, the CLI, containerd, buildx and compose,"
|
||||
echo " from Docker's own repository — plus uidmap,"
|
||||
echo " dbus-user-session and docker-ce-rootless-extras,"
|
||||
echo " which every member's own rootless daemon needs"
|
||||
echo " from Docker's own repository"
|
||||
if confirm "Install it?"; then
|
||||
if install_docker_engine; then
|
||||
ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
|
||||
@@ -1779,10 +1665,7 @@ if ! skip; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# The group-vs-rootless choice below is Linux only: Docker Desktop runs
|
||||
# containers in a VM owned by whoever is logged in, so there is no group to
|
||||
# join and no rootless variant to pick.
|
||||
if [[ "$OS" != "macos" ]] && docker_is_installed; then
|
||||
if docker_is_installed; then
|
||||
# ── how this account reaches the daemon ──
|
||||
if user_in_docker_group || docker_rootless_installed; then
|
||||
echo ""
|
||||
@@ -1827,7 +1710,10 @@ if ! skip; then
|
||||
|
||||
DOCKER_ACCESS=""
|
||||
while [[ -z "$DOCKER_ACCESS" ]]; do
|
||||
menu_answer DOCKER_CHOICE " Which one? (1/2/3) [1]: "
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${DOCKER_CHOICE:-1}" in
|
||||
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
|
||||
*) warn "Pick 1, 2 or 3." ;;
|
||||
@@ -1935,7 +1821,10 @@ if ! skip; then
|
||||
NVIM_REPO=""
|
||||
NVIM_PICK=""
|
||||
while [[ -z "$NVIM_PICK" ]]; do
|
||||
menu_answer NVIM_CHOICE " Which one? (1/2/3) [1]: "
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${NVIM_CHOICE:-1}" in
|
||||
1)
|
||||
NVIM_REPO="https://github.com/LazyVim/starter"
|
||||
@@ -2095,7 +1984,7 @@ if ! skip; then
|
||||
echo ""
|
||||
info "Agent CLIs — the programs Officer's chat actually runs"
|
||||
echo " claude $(agent_version claude || echo 'not installed')"
|
||||
echo " spawned by officer-claude-code; chat does not work without it."
|
||||
echo " spawned by officer-agent; chat does not work without it."
|
||||
echo " opencode $(agent_version opencode || echo 'not installed')"
|
||||
echo " the alternative agent, run by officer-opencode."
|
||||
echo ""
|
||||
@@ -2221,20 +2110,10 @@ if ! skip; then
|
||||
echo ""
|
||||
echo " ${USERNAME}'s login shell is ${SHELL_NOW}. Changing it to zsh takes"
|
||||
echo " effect at the next login, and does not affect this session."
|
||||
# Guarded, not bare: `chsh` can refuse — a PAM policy, a shell missing from
|
||||
# /etc/shells, an account whose password field blocks it — and a bare call would
|
||||
# end the run there under `set -e`. Reported instead, because a machine with the
|
||||
# right shell installed and the wrong one at login still works.
|
||||
if confirm "Make zsh the login shell?"; then
|
||||
if set_login_shell "$(command -v zsh)"; then
|
||||
set_login_shell "$(command -v zsh)"
|
||||
ok "login shell is now $(user_login_shell)"
|
||||
SUMMARY+=("Shell: login shell set to zsh")
|
||||
else
|
||||
warn "chsh refused — login shell is still $(user_login_shell)"
|
||||
echo " Change it later with: chsh -s $(command -v zsh) ${USERNAME}"
|
||||
ERRORS+=("Shell: chsh refused, login shell left as $(user_login_shell)")
|
||||
SUMMARY+=("Shell: login shell NOT changed")
|
||||
fi
|
||||
else
|
||||
warn "left as ${SHELL_NOW}"
|
||||
SUMMARY+=("Shell: login shell left as ${SHELL_NOW}")
|
||||
@@ -2258,29 +2137,11 @@ if ! skip; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# scripts/setup/tmux.conf, one level up — the shell templates live together
|
||||
# beside starship.toml, which has to be there because the PLATFORM reads it
|
||||
# too (os-user-shell.ts, for every member's Linux account). Keeping them in
|
||||
# one directory means "where do the dotfile templates live" has one answer.
|
||||
#
|
||||
# No leading dot on any of them: they are templates in a repository, not
|
||||
# dotfiles in a home directory, and tmux's destination is increasingly
|
||||
# ~/.config/tmux/tmux.conf, which has no dot either.
|
||||
if [[ -r "$SCRIPT_DIR/../tmux.conf" ]]; then
|
||||
# Not always ~/.tmux.conf — see tmux_config_target. tmux 3.1+ prefers
|
||||
# ~/.config/tmux/tmux.conf, so writing the old path on a machine that has
|
||||
# the new one produces a file tmux never reads and a success message that
|
||||
# means nothing.
|
||||
TMUX_TARGET="$(tmux_config_target "$USER_HOME")"
|
||||
if tmux_dot_conf_is_shadowed "$USER_HOME"; then
|
||||
warn "you have BOTH ~/.tmux.conf and ~/.config/tmux/tmux.conf — tmux reads the second"
|
||||
echo " Targeting the one it actually reads: ${TMUX_TARGET}"
|
||||
fi
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$(dirname "$TMUX_TARGET")"
|
||||
install_config "$SCRIPT_DIR/../tmux.conf" "$TMUX_TARGET" "$USERNAME" && RC=0 || RC=$?
|
||||
if [[ -r "$SCRIPT_DIR/.tmux.conf" ]]; then
|
||||
install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME" && RC=0 || RC=$?
|
||||
case $RC in
|
||||
0) ok "tmux config installed — ${TMUX_TARGET}" ;;
|
||||
1) echo " tmux config already matches (${TMUX_TARGET})" ;;
|
||||
0) ok "tmux config installed" ;;
|
||||
1) echo " tmux config already matches" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -2311,76 +2172,8 @@ 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
|
||||
# shell, which reads as a broken machine rather than a missing package. The
|
||||
# others degrade honestly — `alias ld=lazydocker` without lazydocker is one
|
||||
# command-not-found when you type it, not a core utility gone.
|
||||
#
|
||||
# Same principle shell-skel/zshrc already holds to: every optional tool is used
|
||||
# only if present, so one file works on a minimal VPS and a full workstation.
|
||||
if append_once "$ZSHRC" aliases <<'EOF'
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --icons'
|
||||
alias la='eza --icons -la'
|
||||
alias ll='eza --icons -l'
|
||||
alias lll='eza --icons -lA'
|
||||
alias lh='eza --icons -lhA'
|
||||
alias ltr='eza --icons -ltr'
|
||||
alias l='eza --icons -la'
|
||||
fi
|
||||
|
||||
alias grep='grep --color=auto'
|
||||
alias less='less -R'
|
||||
alias diff='diff --color=auto'
|
||||
alias cp='cp -iv'
|
||||
alias mv='mv -iv'
|
||||
alias rm='rm -i'
|
||||
alias mkdir='mkdir -p'
|
||||
alias which='which -a'
|
||||
alias history='fc -l 1'
|
||||
|
||||
alias n="nvim"
|
||||
alias vim="n"
|
||||
alias sz="source ~/.zshrc"
|
||||
alias ld="lazydocker"
|
||||
alias httpserver="python3 -m http.server 8888"
|
||||
EOF
|
||||
then
|
||||
ok "shell aliases added"
|
||||
@@ -2409,7 +2202,10 @@ EOF
|
||||
|
||||
EDITOR_PICK=""
|
||||
while [[ -z "$EDITOR_PICK" ]]; do
|
||||
menu_answer EDITOR_CHOICE " Which one? (1-${#EDITORS[@]}) [1]: "
|
||||
if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
|
||||
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
|
||||
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
|
||||
@@ -2601,35 +2397,5 @@ echo " Officer: $OFFICER_ROOT"
|
||||
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
|
||||
echo ""
|
||||
|
||||
# ── Who you are when this exits ──
|
||||
#
|
||||
# Root. This script never becomes ${USERNAME} — it cannot, since a process cannot
|
||||
# change its own uid — so it stays root and drops privileges per command instead.
|
||||
# Everything written into their home was written that way.
|
||||
#
|
||||
# Worth saying out loud because the two things a fresh session fixes are both
|
||||
# invisible until they bite: group membership is fixed at LOGIN, so the `docker`
|
||||
# group just granted does not exist in this session, and their shell configuration
|
||||
# lives in their home and is not loaded in root's.
|
||||
#
|
||||
# Suppressed when officer-setup is about to run — install.sh sets the variable. It
|
||||
# would be wrong advice in the middle of an install, because the half that follows
|
||||
# still needs the root session this would tell you to leave.
|
||||
if [[ "$EUID" -eq 0 && -z "${OFFICER_SETUP_FOLLOWS:-}" ]]; then
|
||||
echo -e "${BOLD} You are still root.${NC}"
|
||||
echo ""
|
||||
echo " This machine is set up for ${USERNAME}. To carry on as them:"
|
||||
echo ""
|
||||
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
|
||||
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
|
||||
echo ""
|
||||
echo " A new session is what makes their docker group membership and their"
|
||||
echo " shell configuration take effect — neither applies to the session you"
|
||||
echo " are in now."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Clean up progress file on success
|
||||
rm -f "$PROGRESS_FILE"
|
||||
|
||||
report_mark_complete
|
||||
|
||||
+13
-470
@@ -14,13 +14,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
|
||||
|
||||
ONLY_STEP=""
|
||||
|
||||
# Kept before the loop consumes them. This script re-executes itself through sudo
|
||||
# further down and was passing `"$@"`, which `shift` had already emptied — so
|
||||
# `officer-setup.sh --only build` run as a normal user silently became a FULL run
|
||||
# the moment it escalated. Nothing said so; the flag just stopped existing.
|
||||
ORIGINAL_ARGS=(${@+"$@"})
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--only)
|
||||
@@ -31,46 +24,19 @@ while [[ $# -gt 0 ]]; do
|
||||
ONLY_STEP="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
# Set before lib/repo.sh is sourced below, which reads it as
|
||||
# `${OFFICER_REPO:-<default>}` — so this wins and an absent flag still defaults.
|
||||
--repo)
|
||||
[[ -n "${2:-}" ]] || {
|
||||
echo "--repo needs a URL" >&2
|
||||
exit 2
|
||||
}
|
||||
OFFICER_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--repo=*)
|
||||
OFFICER_REPO="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--unattended | -y)
|
||||
export UNATTENDED=1 ASSUME_YES=1
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
|
||||
echo ""
|
||||
echo " --only <step> run one step; --list names them"
|
||||
echo " --unattended take the default for every question that has one (-y)"
|
||||
echo " --repo <url> clone from here instead of the default, which is a"
|
||||
echo " private Gitea over SSH and only authenticates on a"
|
||||
echo " machine whose key it already knows. Same as exporting"
|
||||
echo " OFFICER_REPO. Ignored once the repo is checked out."
|
||||
echo "usage: officer-setup.sh [--only <step>] [--list]"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||
esac
|
||||
done
|
||||
export OFFICER_REPO="${OFFICER_REPO:-}"
|
||||
|
||||
# shellcheck source=officer-setup/lib/base.sh
|
||||
source "$SCRIPT_DIR/report.sh"
|
||||
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
|
||||
# shellcheck source=officer-setup/lib/preflight.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
|
||||
@@ -82,14 +48,6 @@ source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
|
||||
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
|
||||
# shellcheck source=officer-setup/lib/env.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
|
||||
# shellcheck source=officer-setup/lib/secrets.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/secrets.sh"
|
||||
# shellcheck source=officer-setup/lib/build.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/build.sh"
|
||||
# shellcheck source=officer-setup/lib/services.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/services.sh"
|
||||
# shellcheck source=officer-setup/lib/proxy.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/proxy.sh"
|
||||
|
||||
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
||||
|
||||
@@ -102,37 +60,10 @@ echo -e "${BOLD}╔════════════════════
|
||||
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# ── Privileges: asked for, not demanded ──
|
||||
#
|
||||
# Run this as YOURSELF. It needs root on Linux, so it asks through sudo and
|
||||
# re-executes itself rather than making you type it. Variables are passed to sudo
|
||||
# by name rather than with -E, because `env_reset` is the sudoers default and
|
||||
# strips the environment — which is how DATA_PATH was lost once already.
|
||||
#
|
||||
# macOS never escalates: Homebrew refuses to run as root, and the account running
|
||||
# this IS the owner, so there is nothing to chown and nothing to drop to.
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
if [[ "$EUID" -eq 0 ]]; then
|
||||
fail "Do not run this with sudo on macOS — run it as yourself."
|
||||
fi
|
||||
elif [[ "$EUID" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
|
||||
echo ""
|
||||
echo " This needs administrator rights. You will be asked for your password."
|
||||
echo ""
|
||||
exec sudo \
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||
REPORT_FILE="${REPORT_FILE:-}" \
|
||||
UNATTENDED="${UNATTENDED:-}" \
|
||||
ASSUME_YES="${ASSUME_YES:-}" \
|
||||
OFFICER_REPO="${OFFICER_REPO:-}" \
|
||||
bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Please run as root: sudo ./officer-setup.sh"
|
||||
fi
|
||||
|
||||
trap report_flush EXIT
|
||||
|
||||
# ── what machine-setup already established ──
|
||||
echo ""
|
||||
if load_machine_answers; then
|
||||
@@ -183,26 +114,6 @@ info "Account: ${USERNAME} (home ${USER_HOME})"
|
||||
info "Officer: ${OFFICER_ROOT}"
|
||||
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
|
||||
|
||||
# ── recover what earlier runs already decided ──
|
||||
#
|
||||
# A skipped section leaves its variables unset, and later sections read them. On
|
||||
# a resume that is every section before the one it stopped at, so Build announced
|
||||
# "PUBLIC_URL <not set — run the Environment section first>" on a machine whose
|
||||
# .env had been written twenty minutes earlier.
|
||||
#
|
||||
# Read back here, once, from the file that already holds the answers, rather than
|
||||
# per-section — three variables cross a section boundary (ENV_PORT and
|
||||
# ENV_PUBLIC_URL from Environment, POSTGRES_URL from Database) and the next one
|
||||
# added would have to remember to do this again.
|
||||
#
|
||||
# Only fills what is EMPTY, so a variable passed in on the command line still
|
||||
# wins, and a section that runs for real still overwrites it with its own answer.
|
||||
if [[ -f "$(env_file)" ]]; then
|
||||
ENV_PORT="${ENV_PORT:-$(env_get PORT)}"
|
||||
ENV_PUBLIC_URL="${ENV_PUBLIC_URL:-$(env_get PUBLIC_URL)}"
|
||||
POSTGRES_URL="${POSTGRES_URL:-$(env_get POSTGRES_URL)}"
|
||||
fi
|
||||
|
||||
# ── is the machine actually ready ──
|
||||
#
|
||||
# Checked and reported together. Finding out about a missing bun three sections
|
||||
@@ -256,7 +167,6 @@ fi
|
||||
#
|
||||
# Before the repository, because the repository is cloned into it.
|
||||
|
||||
report_section "Layout"
|
||||
step "Layout"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -300,7 +210,6 @@ fi
|
||||
# 3. Repository
|
||||
# =============================================================================
|
||||
|
||||
report_section "Repository"
|
||||
step "Repository"
|
||||
if ! skip; then
|
||||
PLATFORM_DIR="$(platform_dir)"
|
||||
@@ -373,7 +282,6 @@ fi
|
||||
# 4. Dependencies
|
||||
# =============================================================================
|
||||
|
||||
report_section "Dependencies"
|
||||
step "Dependencies"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -428,7 +336,6 @@ fi
|
||||
#
|
||||
# POSTGRES_URL is set here and written by the environment section below.
|
||||
|
||||
report_section "Database"
|
||||
step "Database"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -446,11 +353,6 @@ 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')"
|
||||
@@ -551,7 +453,6 @@ fi
|
||||
# 6. Environment
|
||||
# =============================================================================
|
||||
|
||||
report_section "Environment"
|
||||
step "Environment"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -559,7 +460,7 @@ if ! skip; then
|
||||
|
||||
# Read back before anything is asked; existing values become the defaults.
|
||||
ENV_PORT="$(env_get PORT)"
|
||||
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
|
||||
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
|
||||
|
||||
if env_exists; then
|
||||
echo " exists — its values are the defaults below"
|
||||
@@ -570,23 +471,11 @@ if ! skip; then
|
||||
# ── what is asked ──
|
||||
echo ""
|
||||
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
||||
|
||||
echo ""
|
||||
echo " PUBLIC_URL is where Officer is reached from a browser. It is the one"
|
||||
echo " thing this machine cannot work out for itself, and three things need"
|
||||
echo " it: the OpenGraph tags baked into the page by 'bun gen:index', the"
|
||||
echo " host the task API hands to scripts, and the CalDAV profile an iPhone"
|
||||
echo " installs — that last one requires https."
|
||||
echo ""
|
||||
echo " Defaulting to this machine's tailnet address, not localhost: the"
|
||||
echo " tailnet is where Officer is actually reached from, and localhost"
|
||||
echo " works from here and nowhere else."
|
||||
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-$(default_public_url "$ENV_PORT")}"
|
||||
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
|
||||
|
||||
echo ""
|
||||
echo " to write:"
|
||||
echo " PORT=${ENV_PORT}"
|
||||
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
|
||||
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
|
||||
echo ""
|
||||
echo " the install root is not written here — the platform derives it as the"
|
||||
@@ -597,7 +486,6 @@ if ! skip; then
|
||||
if confirm "Write it?"; then
|
||||
write_env
|
||||
ok "written, 0600, owned by ${USERNAME}"
|
||||
report_changed "wrote $(env_file) (0600, owner ${USERNAME}) — PORT, PUBLIC_URL, POSTGRES_URL. No secrets: every key lives in the secret store."
|
||||
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
|
||||
SUMMARY+=("Environment: $(env_file)")
|
||||
else
|
||||
@@ -608,358 +496,13 @@ if ! skip; then
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 7. Secrets
|
||||
# NOT BUILT YET
|
||||
# =============================================================================
|
||||
#
|
||||
# The store creates keys on demand, so this section is not strictly required —
|
||||
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
|
||||
# reasons: the file should exist with the right owner and mode before anything
|
||||
# races to create it, and an install that finishes without ever saying the words
|
||||
# "back this up" is one where nobody learns the file matters until it is gone.
|
||||
|
||||
report_section "Secrets"
|
||||
step "Secrets"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Secret store — $(secret_store_path)"
|
||||
echo " Every encryption and signing key the platform holds, one SQLite file,"
|
||||
echo " one key per purpose. Nothing goes in .env."
|
||||
echo ""
|
||||
echo " bootstrapped now:"
|
||||
echo " jwt signs every session token"
|
||||
echo " headscale encrypts the Headscale admin API key in Postgres"
|
||||
echo ""
|
||||
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
|
||||
echo " service-connections — is created when its plugin is installed. A"
|
||||
echo " plugin cannot read another plugin's key."
|
||||
echo ""
|
||||
|
||||
if confirm "Create it?"; then
|
||||
if bootstrap_secret_store; then
|
||||
ok "created, 0600, owned by ${USERNAME}"
|
||||
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted."
|
||||
echo ""
|
||||
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
|
||||
echo " Losing it signs everyone out and makes every encrypted column in"
|
||||
echo " Postgres unreadable. For the wallet seed that is unrecoverable:"
|
||||
echo " the passphrase opens the inner envelope, this is the outer one."
|
||||
echo ""
|
||||
echo " Keeping it beside a dump defeats it — the dump is the ciphertext"
|
||||
echo " and this is the key. Separate backups, or it is one theft."
|
||||
SUMMARY+=("Secrets: $(secret_store_path)")
|
||||
else
|
||||
warn "could not create the store — the platform will create it on first use"
|
||||
SUMMARY+=("Secrets: NOT created; the platform will do it on first use")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the platform will create it on first use"
|
||||
SUMMARY+=("Secrets: SKIPPED; the platform will create it on first use")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 8. Schema
|
||||
# =============================================================================
|
||||
|
||||
report_section "Schema"
|
||||
step "Schema"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
# Counted from the aggregator rather than hardcoded, so the number is the truth
|
||||
# even when a plugin line is uncommented. It was written as ${SCHEMA_TABLES:-?}
|
||||
# and never assigned, so the section said "? tables" — a placeholder that looked
|
||||
# like the count could not be determined rather than like nobody had set it.
|
||||
SCHEMA_TABLES="$(schema_table_count)"
|
||||
info "Database schema"
|
||||
echo " ${SCHEMA_TABLES:-?} tables, applied with 'bun db:push' — drizzle-kit"
|
||||
echo " diffs the schema code against Postgres and alters it directly. There"
|
||||
echo " are no migration files and no migration table; the code is the source"
|
||||
echo " of truth."
|
||||
echo ""
|
||||
echo " Only the CORE tables. Every plugin's tables are commented out in"
|
||||
echo " src/databases/officer_db/src/schema.ts and get created when the"
|
||||
echo " plugin is installed."
|
||||
echo ""
|
||||
|
||||
if confirm "Push it?"; then
|
||||
if OUT="$(push_schema)"; then
|
||||
ok "schema applied"
|
||||
report_changed "applied ${SCHEMA_TABLES} tables to Postgres with 'bun db:push' (drizzle-kit; no migration files)"
|
||||
SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed")
|
||||
else
|
||||
warn "db:push failed"
|
||||
echo "$OUT" | tail -12 | sed 's/^/ /'
|
||||
SUMMARY+=("Schema: FAILED — see the output above")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the platform will not start without it"
|
||||
SUMMARY+=("Schema: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 9. Build
|
||||
# =============================================================================
|
||||
|
||||
report_section "Build"
|
||||
step "Build"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "index.gen.html"
|
||||
echo " 'bun gen:index' substitutes your public URL into index.html and"
|
||||
echo " writes index.gen.html, which is the file the server imports. It is"
|
||||
echo " gitignored, so a fresh clone never has one and the server has no page"
|
||||
echo " to serve until this runs."
|
||||
echo ""
|
||||
echo " URL: ${ENV_PUBLIC_URL:-<not set — run the Environment section first>}"
|
||||
echo ""
|
||||
echo " To change it later: bun gen:index https://your.new.url"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$ENV_PUBLIC_URL" ]]; then
|
||||
warn "PUBLIC_URL is not in $(env_file) — run the Environment section, then this one"
|
||||
SUMMARY+=("Build: SKIPPED — no PUBLIC_URL")
|
||||
elif confirm "Generate it?"; then
|
||||
if OUT="$(gen_index)"; then
|
||||
ok "$(gen_index_output)"
|
||||
report_changed "generated $(gen_index_output) from index.html, substituting PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||
SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}")
|
||||
else
|
||||
warn "gen:index failed"
|
||||
echo "$OUT" | tail -8 | sed 's/^/ /'
|
||||
SUMMARY+=("Build: FAILED — see the output above")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the server has no page to serve without it"
|
||||
SUMMARY+=("Build: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 10. Services
|
||||
# =============================================================================
|
||||
|
||||
report_section "Services"
|
||||
step "Services"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "pm2 — $(ecosystem_file)"
|
||||
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
||||
echo " install and nothing else, so nothing in git can drift from it."
|
||||
echo ""
|
||||
echo " six processes:"
|
||||
for entry in "${CORE_PROCESSES[@]}"; do
|
||||
IFS='|' read -r _name _script _args <<<"$entry"
|
||||
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
||||
done
|
||||
echo ""
|
||||
echo " Nothing else. Every plugin adds its own entry when it is installed."
|
||||
echo ""
|
||||
|
||||
if confirm "Write it and start them?"; then
|
||||
write_ecosystem
|
||||
ok "written — $(ecosystem_file)"
|
||||
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
|
||||
# Starting against a database that is not answering is not fatal — the server
|
||||
# waits and the agent retries forever — but it makes the Verify section below
|
||||
# report a failure that is really just a race, and that is the kind of noise
|
||||
# that teaches people to ignore a red line.
|
||||
if pg_container_running && ! pg_wait_ready 30; then
|
||||
warn "Postgres is not answering — starting anyway, but Verify may report failures"
|
||||
fi
|
||||
|
||||
if OUT="$(pm2_start)"; then
|
||||
ok "processes started"
|
||||
report_started "pm2 startOrRestart: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)"
|
||||
|
||||
echo ""
|
||||
if confirm "Start them on boot too?"; then
|
||||
if pm2_enable_startup; then
|
||||
ok "pm2 will resurrect them at boot"
|
||||
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
||||
SUMMARY+=("Services: 6 processes started, enabled at boot")
|
||||
else
|
||||
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
||||
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
|
||||
fi
|
||||
else
|
||||
SUMMARY+=("Services: 6 processes started; not enabled at boot")
|
||||
fi
|
||||
else
|
||||
warn "pm2 did not start cleanly"
|
||||
echo "$OUT" | tail -12 | sed 's/^/ /'
|
||||
SUMMARY+=("Services: FAILED to start — see the output above")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request"
|
||||
SUMMARY+=("Services: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 11. Verify
|
||||
# =============================================================================
|
||||
|
||||
report_section "Verify"
|
||||
step "Verify"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Are the processes actually up?"
|
||||
echo ""
|
||||
|
||||
VERIFY_BAD=0
|
||||
while IFS='|' read -r vname vstatus vrestarts; do
|
||||
[[ -z "$vname" ]] && continue
|
||||
if [[ "$vstatus" == "online" ]]; then
|
||||
if (( vrestarts > 3 )); then
|
||||
warn "$(printf '%-24s online, but restarted %s times — check: pm2 logs %s' "$vname" "$vrestarts" "$vname")"
|
||||
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||
else
|
||||
ok "$(printf '%-24s online' "$vname")"
|
||||
fi
|
||||
else
|
||||
warn "$(printf '%-24s %s — check: pm2 logs %s' "$vname" "$vstatus" "$vname")"
|
||||
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||
fi
|
||||
done < <(pm2_status_lines)
|
||||
|
||||
echo ""
|
||||
# A process can be `online` and still be failing to serve — a restart loop takes
|
||||
# a few seconds to show up in the counter, and the app can be up with a broken
|
||||
# database. So the port is asked directly.
|
||||
if curl -fsS --max-time 5 "http://127.0.0.1:${ENV_PORT:-9000}/api" >/dev/null 2>&1; then
|
||||
ok "the API answers on 127.0.0.1:${ENV_PORT:-9000}"
|
||||
SUMMARY+=("Verify: API answering on port ${ENV_PORT:-9000}")
|
||||
else
|
||||
warn "nothing answered on 127.0.0.1:${ENV_PORT:-9000}/api"
|
||||
echo " pm2 logs officer is where the reason will be."
|
||||
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||
SUMMARY+=("Verify: the API did NOT answer on port ${ENV_PORT:-9000}")
|
||||
fi
|
||||
|
||||
if (( VERIFY_BAD == 0 )); then
|
||||
echo ""
|
||||
ok "Officer is running. Open ${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT:-9000}} and the"
|
||||
echo " first-run screen will create the owner account."
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 12. Proxy
|
||||
# =============================================================================
|
||||
#
|
||||
# Optional, and last, because it is the only step that needs Officer to be already
|
||||
# running: NPM proxies to it, and the gate below checks the bind address rather than
|
||||
# taking a curl to loopback as proof.
|
||||
#
|
||||
# ── Why this section ignores --unattended ──
|
||||
#
|
||||
# Every other question in this script has a defensible default. None of these do — a
|
||||
# domain name, a DNS provider and that provider's API credentials cannot be guessed —
|
||||
# and the step is opt-in besides. So its prompts read stdin directly instead of going
|
||||
# through confirm()/ask_required(), which honour ASSUME_YES.
|
||||
#
|
||||
# The valve is a TTY check, not the flag: with no terminal there is nobody to ask, so
|
||||
# it skips and prints the manual instructions. That keeps a cron-driven install working
|
||||
# without letting --unattended silently agree to publishing a public hostname.
|
||||
|
||||
report_section "Proxy"
|
||||
step "Proxy"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Reverse proxy — a real hostname and an HTTPS certificate"
|
||||
echo " Optional. Skip it if you already run a proxy elsewhere, or if you"
|
||||
echo " reach this instance over the tailnet and are happy with that."
|
||||
echo ""
|
||||
|
||||
PROXY_PORT="${ENV_PORT:-9000}"
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
warn "no terminal — skipping the proxy, which cannot be answered unattended"
|
||||
proxy_skip_instructions "$PROXY_PORT"
|
||||
SUMMARY+=("Proxy: skipped (no terminal)")
|
||||
elif ! proxy_require_listening "$PROXY_PORT"; then
|
||||
warn "skipping the proxy — Officer is not reachable the way NPM would reach it"
|
||||
echo " Fix the bind address, then: officer-setup.sh --only Proxy"
|
||||
SUMMARY+=("Proxy: skipped (Officer not listening on 0.0.0.0)")
|
||||
elif ! proxy_confirm "Set up Nginx Proxy Manager now?"; then
|
||||
proxy_skip_instructions "$PROXY_PORT"
|
||||
SUMMARY+=("Proxy: skipped by request")
|
||||
else
|
||||
# One failure path for all of it: every function warns and returns non-zero rather
|
||||
# than exiting, so a proxy that does not come up leaves a finished Officer install
|
||||
# behind rather than a failed one. It is the last section for that reason.
|
||||
PROXY_DOMAIN="$(proxy_ask 'Domain for this instance (e.g. officer.example.com)')"
|
||||
if [[ -z "$PROXY_DOMAIN" ]]; then
|
||||
warn "no domain given — skipping"
|
||||
SUMMARY+=("Proxy: skipped (no domain)")
|
||||
elif
|
||||
proxy_detect_target &&
|
||||
proxy_ensure_network &&
|
||||
proxy_order_docker_after_tailscaled &&
|
||||
proxy_write_compose &&
|
||||
proxy_start &&
|
||||
proxy_claim_admin &&
|
||||
proxy_get_token &&
|
||||
{ [[ "$CHALLENGE" != "dns" ]] || proxy_prompt_dns_credentials; } &&
|
||||
proxy_wait_for_dns "$PROXY_DOMAIN" "$TARGET_IP" &&
|
||||
proxy_allow_bridge_to_host "$PROXY_PORT" &&
|
||||
proxy_create_host "$PROXY_DOMAIN" "$PROXY_PORT" &&
|
||||
proxy_issue_certificate "$PROXY_DOMAIN" &&
|
||||
proxy_attach_certificate
|
||||
then
|
||||
proxy_verify "$PROXY_DOMAIN"
|
||||
echo ""
|
||||
ok "Officer is published at https://${PROXY_DOMAIN}"
|
||||
echo " NPM admin: http://127.0.0.1:81$([[ "$CHALLENGE" == "dns" ]] && echo " or http://${TARGET_IP}:81")"
|
||||
SUMMARY+=("Proxy: https://${PROXY_DOMAIN}")
|
||||
else
|
||||
warn "the proxy did not finish — Officer itself is unaffected and still running"
|
||||
echo " Retry just this part with: officer-setup.sh --only Proxy"
|
||||
ERRORS+=("Proxy: did not finish")
|
||||
SUMMARY+=("Proxy: FAILED — retry with --only Proxy")
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# ── Who you are when this exits ──
|
||||
#
|
||||
# Root, and that surprises people — reasonably, because everything this script just
|
||||
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
||||
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
|
||||
# the installer's privilege, never the platform's.
|
||||
#
|
||||
# Saying so matters for two things that are invisible until they bite:
|
||||
#
|
||||
# - group membership is fixed at login. ${USERNAME} was added to `docker` during
|
||||
# machine setup, and a session that started before that does not have it — so
|
||||
# `docker ps` fails for a reason that has nothing to do with docker.
|
||||
# - the shell config was written into THEIR home. Staying as root means none of it
|
||||
# is loaded, and the machine looks unconfigured.
|
||||
if [[ "$EUID" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD} One more thing — you are still root.${NC}"
|
||||
echo ""
|
||||
echo " Officer runs as ${USERNAME}, and everything it installed is theirs."
|
||||
echo " Nothing here needs root any more. To carry on as them:"
|
||||
echo ""
|
||||
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
|
||||
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
|
||||
echo ""
|
||||
echo " Either gives a new session, which is what makes their docker group"
|
||||
echo " membership and their shell configuration take effect. Staying as root"
|
||||
echo " means neither does, and the machine will look half-configured."
|
||||
fi
|
||||
# 6 Schema db:push
|
||||
# 7 Build gen:index
|
||||
# 8 Services pm2 startOrRestart · save · startup
|
||||
# 9 Verify are the processes actually up
|
||||
|
||||
echo ""
|
||||
|
||||
report_mark_complete
|
||||
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
|
||||
echo ""
|
||||
|
||||
@@ -113,14 +113,6 @@ confirm() {
|
||||
|
||||
ask_required() {
|
||||
local __var="$1" message="$2" default="$3" answer=""
|
||||
# Unattended takes the default where there IS one. Where there is not — the owning
|
||||
# account on a machine that machine-setup never ran on — it still asks, because
|
||||
# there is nothing to fall back to and a guess would install as the wrong user.
|
||||
if [[ "${UNATTENDED:-}" == "1" && -n "$default" ]]; then
|
||||
printf ' %s [%s] — unattended, taking the default\n' "$message" "$default"
|
||||
printf -v "$__var" '%s' "$default"
|
||||
return 0
|
||||
fi
|
||||
while [[ -z "$answer" ]]; do
|
||||
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — schema and build
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# Both run AS the owner, from the repo. Neither is idempotent in the sense of
|
||||
# "does nothing the second time" — both are safe to repeat, which is not the same
|
||||
# thing and is the property that matters for a script people re-run.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_BUILD_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_BUILD_LOADED=1
|
||||
|
||||
# `bun db:push` — drizzle-kit diffs the schema code against the live database.
|
||||
#
|
||||
# No migrations here and no __drizzle_migrations table: the schema code IS the
|
||||
# source of truth (src/databases/CLAUDE.md). On the empty database section 5 just
|
||||
# created there is nothing to drop, so the prompt drizzle-kit shows for a
|
||||
# destructive change cannot appear.
|
||||
#
|
||||
# It can still appear on a RE-RUN against a database with data, and a prompt
|
||||
# nobody sees would hang the script forever — so stdin is closed rather than left
|
||||
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
|
||||
# want at 3am.
|
||||
push_schema() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||
}
|
||||
|
||||
# What tables the schema will create, read from the aggregator rather than
|
||||
# guessed. This is what makes the section able to say what it is about to do.
|
||||
schema_table_count() {
|
||||
local dir
|
||||
dir="$(platform_dir)/src/databases/officer_db/src"
|
||||
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
|
||||
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
|
||||
done | awk '{s+=$1} END {print s+0}'
|
||||
}
|
||||
|
||||
# `bun gen:index` — substitutes PUBLIC_URL into index.html and writes
|
||||
# index.gen.html, which is what the server actually imports.
|
||||
#
|
||||
# Not optional and not cosmetic: without it the server has no page to serve. It
|
||||
# is gitignored, so a fresh clone never has one.
|
||||
gen_index() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||
}
|
||||
|
||||
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
|
||||
@@ -5,14 +5,16 @@
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── No secrets are written here ──
|
||||
# ── What is NOT here ──
|
||||
#
|
||||
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
|
||||
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
|
||||
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
|
||||
# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite
|
||||
# key store (docs/secret-store.md), and writing them here in the meantime would
|
||||
# mean generating a value that the store then has to be reconciled with — two
|
||||
# origins for one secret, which is the failure the store exists to end.
|
||||
#
|
||||
# So this file holds no credential except POSTGRES_URL, which is a connection
|
||||
# string to a database bound to loopback.
|
||||
# The consequence is honest and deliberate: jwt.ts throws at module load without
|
||||
# JWT_SECRET, so an install made by this script does not boot until the store
|
||||
# lands. That sequencing was chosen rather than stumbled into.
|
||||
#
|
||||
# ── Derived, not asked ──
|
||||
#
|
||||
@@ -63,8 +65,8 @@ write_env() {
|
||||
|
||||
PORT="${ENV_PORT}"
|
||||
|
||||
# Where Officer is reached from a browser. Not derivable — see the section.
|
||||
PUBLIC_URL="${ENV_PUBLIC_URL}"
|
||||
# The browser relay listens on its own port, separate from the app.
|
||||
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
|
||||
|
||||
POSTGRES_URL="${POSTGRES_URL}"
|
||||
|
||||
@@ -76,49 +78,3 @@ ENVF
|
||||
chmod 600 "$dest"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# A sensible default for PUBLIC_URL
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# localhost is the wrong default on a machine with a tailnet, and quietly so:
|
||||
# it works from the machine itself and from nowhere else, so the mistake shows up
|
||||
# on the first phone, not during setup.
|
||||
#
|
||||
# The tailnet is where Officer is actually reached — it is the perimeter the
|
||||
# whole security model rests on — so its address is the honest default.
|
||||
#
|
||||
# The SHORT MagicDNS name — `officer-dev`, not `officer-dev.ts.example.dev` and
|
||||
# not the raw 100.x address. All three resolve inside the tailnet; the short one
|
||||
# is the one anybody actually types, and PUBLIC_URL ends up baked into the page's
|
||||
# OpenGraph tags by `bun gen:index`, so it is read by people as well as machines.
|
||||
#
|
||||
# It relies on the tailnet's search domain, which every Tailscale client sets when
|
||||
# MagicDNS is on. A device that has somehow lost it resolves the FQDN and not the
|
||||
# short name — the fix there is to type the longer one, not to default to it.
|
||||
#
|
||||
# Falls back to localhost when there is no tailnet, which is correct rather than
|
||||
# merely tolerable: a machine with no private network has no other address that
|
||||
# is any better a guess.
|
||||
tailnet_hostname() {
|
||||
local dns ip
|
||||
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
|
||||
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
|
||||
dns="${dns%%.*}" # and we want the short name
|
||||
if [[ -n "$dns" ]]; then
|
||||
echo "$dns"
|
||||
return 0
|
||||
fi
|
||||
ip="$(tailscale ip -4 2>/dev/null | head -1)"
|
||||
[[ -n "$ip" ]] && echo "$ip"
|
||||
}
|
||||
|
||||
default_public_url() {
|
||||
local host
|
||||
host="$(tailnet_hostname)"
|
||||
if [[ -n "$host" ]]; then
|
||||
echo "http://${host}:${1}"
|
||||
else
|
||||
echo "http://localhost:${1}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -39,73 +39,6 @@ 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
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
#!/bin/bash
|
||||
# officer-setup — Nginx Proxy Manager, the optional last step.
|
||||
#
|
||||
# Publishes the running instance on a real hostname with a Let's Encrypt certificate.
|
||||
# Entirely optional: someone with a proxy elsewhere declines and is printed the values
|
||||
# they need instead.
|
||||
#
|
||||
# PRECONDITION: Officer is running and bound to 0.0.0.0. Checked, not assumed — see
|
||||
# proxy_require_listening.
|
||||
#
|
||||
# ── Why this section ignores --unattended ──
|
||||
#
|
||||
# Every other question in this script has a defensible default. None of these do: a
|
||||
# domain name, a DNS provider and that provider's API credentials cannot be guessed,
|
||||
# and the whole step is opt-in besides. So the prompts here read stdin directly rather
|
||||
# than going through confirm()/ask_required(), which honour ASSUME_YES.
|
||||
#
|
||||
# The safety valve is a TTY check rather than the flag: with no terminal there is
|
||||
# nobody to ask, so the section skips itself and prints the manual instructions. That
|
||||
# covers a cron-driven install without making --unattended silently agree to a proxy.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_PROXY_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_PROXY_LOADED=1
|
||||
|
||||
# `${OFFICER_ROOT}/dockers`, matching src/servers/data-path.ts, which derives that
|
||||
# directory from the install root. The draft used $HOME/dockers, which is a different
|
||||
# place on every machine and not the one the app store provisions into.
|
||||
proxy_dir() { echo "${OFFICER_ROOT}/dockers/nginx-proxy-manager"; }
|
||||
|
||||
# The network machine-setup already created. It defaults to `services` there, so a
|
||||
# second name would leave two bridges on the same box with containers unable to see
|
||||
# each other by name.
|
||||
PROXY_NET="${SETUP_DOCKER_NETWORK:-services}"
|
||||
PROXY_API="http://127.0.0.1:81/api"
|
||||
|
||||
# ── prompts that always ask ──
|
||||
#
|
||||
# Deliberately not confirm()/ask_required(): see the header. Named apart so nobody
|
||||
# later "fixes" them into the shared helpers and quietly makes --unattended agree to
|
||||
# provisioning a public hostname.
|
||||
proxy_confirm() {
|
||||
local answer
|
||||
read -rp " $1 [y/N]: " answer || return 1
|
||||
[[ "$answer" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
proxy_ask() {
|
||||
local answer
|
||||
read -rp " $1: " answer || return 1
|
||||
printf '%s' "$answer"
|
||||
}
|
||||
|
||||
# ── 0. is Officer reachable the way NPM will reach it? ──
|
||||
#
|
||||
# `curl 127.0.0.1:$PORT` succeeds even when the process binds loopback ONLY, which is
|
||||
# exactly the case NPM cannot reach: it dials from inside a container, where 127.0.0.1
|
||||
# is the container itself. Passing this gate on a curl check produces a 504 later that
|
||||
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
|
||||
proxy_require_listening() {
|
||||
local port="$1" listen
|
||||
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
|
||||
[[ -n "$listen" ]] || {
|
||||
warn "nothing is listening on port ${port} — start Officer first"
|
||||
return 1
|
||||
}
|
||||
if ! grep -qE '(^|\s)(0\.0\.0\.0|\*):'"$port"'$' <<<"$listen"; then
|
||||
warn "Officer is listening on: ${listen}"
|
||||
info "NPM runs in a container, so 127.0.0.1 there is the container itself."
|
||||
info "A loopback-only listener is invisible to it and yields a 504."
|
||||
return 1
|
||||
fi
|
||||
ok "Officer is listening on 0.0.0.0:${port}"
|
||||
}
|
||||
|
||||
# ── 1. where will the hostname point? ──
|
||||
#
|
||||
# Tailnet DNS-01 is mandatory. Let's Encrypt cannot reach 100.64.0.0/10, so HTTP-01
|
||||
# always fails. The A record is not needed to ISSUE (validation is a TXT
|
||||
# record) but is needed to USE the name.
|
||||
# Public HTTP-01 works with no API keys, but the A record must already resolve here.
|
||||
proxy_detect_target() {
|
||||
local ts=""
|
||||
command -v tailscale >/dev/null 2>&1 && ts="$(tailscale ip -4 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$ts" ]]; then
|
||||
TARGET_IP="$ts"
|
||||
CHALLENGE="dns"
|
||||
ok "Tailscale detected — ${TARGET_IP}"
|
||||
info "Tailnet addresses are unreachable from Let's Encrypt, so the certificate"
|
||||
info "needs a DNS-01 challenge, which needs your DNS provider's API credentials."
|
||||
else
|
||||
TARGET_IP="$(curl -sf --max-time 10 https://api.ipify.org || true)"
|
||||
[[ -n "$TARGET_IP" ]] || {
|
||||
warn "could not determine this machine's public IP"
|
||||
return 1
|
||||
}
|
||||
CHALLENGE="http"
|
||||
ok "No Tailscale — public IP ${TARGET_IP} (HTTP-01, no API keys needed)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Read by indirect expansion — `${!hint}` where hint is "DNS_HINT_${DNS_PROVIDER}" —
|
||||
# which shellcheck cannot follow, hence the disable rather than a rewrite. Naming them
|
||||
# this way is what lets a provider with no hint simply not have one.
|
||||
# shellcheck disable=SC2034
|
||||
DNS_HINT_godaddy="Create an API key at https://developer.godaddy.com/keys (Production).
|
||||
You need both the Key and the Secret. Scope it to DNS only if offered."
|
||||
# shellcheck disable=SC2034
|
||||
DNS_HINT_cloudflare="Create a token at https://dash.cloudflare.com/profile/api-tokens
|
||||
Use template 'Edit zone DNS'. Permissions: Zone:DNS:Edit for the zone."
|
||||
# shellcheck disable=SC2034
|
||||
DNS_HINT_digitalocean="Create a Personal Access Token with WRITE scope at
|
||||
https://cloud.digitalocean.com/account/api/tokens"
|
||||
|
||||
# The exact credential file format per provider ships INSIDE the NPM image, so it is
|
||||
# read from there rather than hardcoded — that keeps working as certbot plugins change.
|
||||
proxy_prompt_dns_credentials() {
|
||||
echo ""
|
||||
info "Supported providers include: cloudflare, godaddy, digitalocean, route53,"
|
||||
info "namecheap, ovh, linode, vultr, hetzner, gandi, google, azure …"
|
||||
DNS_PROVIDER="$(proxy_ask 'DNS provider')"
|
||||
[[ -n "$DNS_PROVIDER" ]] || {
|
||||
warn "no provider given"
|
||||
return 1
|
||||
}
|
||||
|
||||
local hint="DNS_HINT_${DNS_PROVIDER}"
|
||||
[[ -n "${!hint:-}" ]] && {
|
||||
echo ""
|
||||
info "${!hint}"
|
||||
}
|
||||
|
||||
echo ""
|
||||
info "Credential format this provider expects:"
|
||||
docker exec npm python3 -c \
|
||||
"import json;d=json.load(open('/app/certbot/dns-plugins.json'));print(d['${DNS_PROVIDER}']['credentials'])" \
|
||||
2>/dev/null | sed 's/^/ /' ||
|
||||
warn "could not read the template — check the provider name is spelled correctly"
|
||||
|
||||
echo ""
|
||||
info "Paste the credential lines exactly as shown above (blank line to finish):"
|
||||
DNS_CREDENTIALS=""
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && break
|
||||
DNS_CREDENTIALS+="$line"$'\n'
|
||||
done
|
||||
[[ -n "$DNS_CREDENTIALS" ]] || {
|
||||
warn "no credentials entered"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── 2. wait for DNS ──
|
||||
#
|
||||
# `getent hosts` rather than `dig`: dig comes from dnsutils, which this platform does
|
||||
# not install, so the draft's version was command-not-found on a fresh VPS — and since
|
||||
# an empty answer is indistinguishable from "not resolving yet", it waited the full
|
||||
# thirty minutes before failing. getent is in libc and always there.
|
||||
#
|
||||
# The cost is that it reads the system resolver rather than a public one, so a stale
|
||||
# local cache can satisfy it. Worth it against a check that cannot run at all.
|
||||
proxy_wait_for_dns() {
|
||||
local domain="$1" want="$2" got elapsed=0 interval=15 timeout=1800
|
||||
echo ""
|
||||
info "Point this DNS record at the machine now:"
|
||||
echo ""
|
||||
info " ${domain}. A ${want}"
|
||||
echo ""
|
||||
[[ "$CHALLENGE" == "dns" ]] &&
|
||||
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
|
||||
|
||||
while ((elapsed < timeout)); do
|
||||
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
ok "${domain} resolves to ${want}"
|
||||
return 0
|
||||
fi
|
||||
printf '\r waiting — %s (%ss) ' "${got:-not resolving yet}" "$elapsed"
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
echo ""
|
||||
warn "${domain} still does not resolve to ${want} after $((timeout / 60)) minutes"
|
||||
[[ "$CHALLENGE" == "dns" ]] && proxy_confirm "Continue anyway and issue the certificate?" && return 0
|
||||
warn "cannot issue an HTTP-01 certificate until DNS resolves here"
|
||||
return 1
|
||||
}
|
||||
|
||||
proxy_ensure_network() {
|
||||
docker network inspect "$PROXY_NET" >/dev/null 2>&1 && return 0
|
||||
docker network create "$PROXY_NET" >/dev/null && ok "created docker network ${PROXY_NET}"
|
||||
}
|
||||
|
||||
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
|
||||
# address does not exist yet and the WHOLE container fails to start, not just that port.
|
||||
proxy_order_docker_after_tailscaled() {
|
||||
[[ "$CHALLENGE" == "dns" ]] || return 0
|
||||
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
|
||||
[[ -f "$f" ]] && return 0
|
||||
mkdir -p "$(dirname "$f")"
|
||||
cat >"$f" <<'EOF'
|
||||
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled, that
|
||||
# address does not exist and the container fails to start entirely.
|
||||
[Unit]
|
||||
After=tailscaled.service
|
||||
Wants=tailscaled.service
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
ok "docker ordered after tailscaled"
|
||||
report_changed "$f" "docker ordered after tailscaled so NPM can bind the tailnet IP"
|
||||
}
|
||||
|
||||
# Admin UI (81) is NEVER published on 0.0.0.0. Until it is claimed, anyone who reaches
|
||||
# it can take the instance; afterwards it can issue certificates and re-point every
|
||||
# proxied service on the box. 80/443 are public only when they need to be.
|
||||
proxy_write_compose() {
|
||||
local dir admin_binds public_binds
|
||||
dir="$(proxy_dir)"
|
||||
install -d -o "$USERNAME" -g "$(user_group)" "$dir" "$dir/npm_data" "$dir/letsencrypt"
|
||||
|
||||
admin_binds=" - \"127.0.0.1:81:81\""
|
||||
if [[ "$CHALLENGE" == "dns" ]]; then
|
||||
admin_binds+=$'\n'" - \"${TARGET_IP}:81:81\""
|
||||
public_binds=" - \"${TARGET_IP}:80:80\""$'\n'" - \"${TARGET_IP}:443:443\""
|
||||
else
|
||||
public_binds=" - \"80:80\""$'\n'" - \"443:443\""
|
||||
fi
|
||||
|
||||
cat >"${dir}/docker-compose.yaml" <<EOF
|
||||
# Generated by officer-setup. Reverse proxy for this Officer instance.
|
||||
#
|
||||
# The admin UI (81) is bound to loopback$([[ "$CHALLENGE" == "dns" ]] && echo " and the tailnet") only, never
|
||||
# 0.0.0.0 — it can issue certificates and re-point every proxied service on this box.
|
||||
#
|
||||
# NOTE: ufw does NOT filter docker-published ports. Exposure is decided by the bind
|
||||
# addresses below and by the DOCKER-USER chain in /etc/ufw/after.rules.
|
||||
name: npm
|
||||
services:
|
||||
npm:
|
||||
image: jc21/nginx-proxy-manager:latest
|
||||
container_name: npm
|
||||
restart: always
|
||||
networks: [${PROXY_NET}]
|
||||
ports:
|
||||
${public_binds}
|
||||
${admin_binds}
|
||||
volumes:
|
||||
- ./npm_data:/data
|
||||
- ./letsencrypt:/etc/letsencrypt
|
||||
|
||||
networks:
|
||||
${PROXY_NET}:
|
||||
external: true
|
||||
EOF
|
||||
chown "${USERNAME}:$(user_group)" "${dir}/docker-compose.yaml"
|
||||
ok "wrote ${dir}/docker-compose.yaml"
|
||||
report_changed "${dir}/docker-compose.yaml" "nginx-proxy-manager compose file"
|
||||
}
|
||||
|
||||
proxy_start() {
|
||||
as_owner "docker compose --project-directory '$(proxy_dir)' up -d" / >/dev/null
|
||||
local i
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf "$PROXY_API/" >/dev/null 2>&1 && {
|
||||
ok "NPM answered after ${i}s"
|
||||
report_started "npm" "nginx-proxy-manager container"
|
||||
return 0
|
||||
}
|
||||
sleep 1
|
||||
done
|
||||
warn "NPM did not become ready — check: docker logs npm"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── claim the admin account immediately ──
|
||||
#
|
||||
# NPM 2.15 replaced the fixed default login with a first-run wizard: while the user
|
||||
# count is zero, ANYONE who reaches port 81 can claim admin. Done in the same breath as
|
||||
# starting the container. The bind addresses above already make that window unreachable
|
||||
# from outside, but this does not rely on that alone.
|
||||
#
|
||||
# The re-run path is the half the draft was missing: it returned early on an already
|
||||
# claimed instance WITHOUT setting NPM_EMAIL/NPM_PASSWORD, and the next function
|
||||
# dereferenced both under `set -u`. So the second run of a "re-runnable" script died on
|
||||
# an unbound variable. An existing instance asks for the credentials instead.
|
||||
proxy_claim_admin() {
|
||||
if curl -sf "$PROXY_API/" | grep -q '"setup":true'; then
|
||||
ok "NPM admin is already claimed"
|
||||
echo ""
|
||||
info "This instance already has an admin account. Its credentials are needed to"
|
||||
info "add the proxy host below."
|
||||
NPM_EMAIL="$(proxy_ask 'NPM admin email')"
|
||||
NPM_PASSWORD="$(proxy_ask 'NPM admin password')"
|
||||
[[ -n "$NPM_EMAIL" && -n "$NPM_PASSWORD" ]] || {
|
||||
warn "both are needed to continue"
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Create the NPM admin account."
|
||||
NPM_EMAIL="$(proxy_ask 'Admin email')"
|
||||
[[ -n "$NPM_EMAIL" ]] || {
|
||||
warn "no email given"
|
||||
return 1
|
||||
}
|
||||
NPM_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)"
|
||||
|
||||
curl -sf -X POST "$PROXY_API/users" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg e "$NPM_EMAIL" --arg p "$NPM_PASSWORD" \
|
||||
'{name:"Admin",nickname:"Admin",email:$e,roles:["admin"],is_disabled:false,auth:{type:"password",secret:$p}}')" \
|
||||
>/dev/null || {
|
||||
warn "failed to create the NPM admin user"
|
||||
return 1
|
||||
}
|
||||
|
||||
curl -sf "$PROXY_API/" | grep -q '"setup":true' || {
|
||||
warn "admin creation did not take"
|
||||
return 1
|
||||
}
|
||||
ok "NPM admin claimed: ${NPM_EMAIL}"
|
||||
|
||||
# ── the admin password ──
|
||||
#
|
||||
# Deliberately NOT written to a file. The platform's shape is that
|
||||
# secrets/officer-keys.db holds ENCRYPTION KEYS, one per purpose, and the credential
|
||||
# itself lives encrypted in Postgres. A third plaintext location is the pattern
|
||||
# headscale/schema.ts calls "debt to avoid copying, not a precedent to follow".
|
||||
#
|
||||
# Nothing programmatic needs this after setup — only a human logging into the admin
|
||||
# UI — so not storing it is a legitimate outcome rather than a gap.
|
||||
#
|
||||
# The DNS API credentials are deliberately never handled either: NPM must keep a
|
||||
# plaintext copy in npm_data/database.sqlite for certbot to auto-renew, so copying
|
||||
# them anywhere else adds exposure without adding protection.
|
||||
echo ""
|
||||
warn "This password is shown ONCE and is not stored anywhere:"
|
||||
echo ""
|
||||
echo " ${NPM_EMAIL}"
|
||||
echo " ${NPM_PASSWORD}"
|
||||
echo ""
|
||||
info "Put it in your password manager now."
|
||||
proxy_confirm "Saved it?" || {
|
||||
warn "stopping so the password is not lost — the container is running and claimed"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
proxy_api() {
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' -d "$body"
|
||||
else
|
||||
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN"
|
||||
fi
|
||||
}
|
||||
|
||||
proxy_get_token() {
|
||||
TOKEN="$(curl -sf -X POST "$PROXY_API/tokens" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg i "$NPM_EMAIL" --arg s "$NPM_PASSWORD" '{identity:$i,secret:$s}')" |
|
||||
jq -r '.token')" || {
|
||||
warn "could not authenticate to the NPM API"
|
||||
return 1
|
||||
}
|
||||
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || {
|
||||
warn "NPM rejected those admin credentials"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── let the bridge reach the host process ──
|
||||
#
|
||||
# Officer runs on the HOST under pm2, not in a container. Bridge → host traffic DOES
|
||||
# traverse INPUT, so ufw's default-deny drops it — unlike docker-published ports, which
|
||||
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
|
||||
# upstream would need none of this, which is why container upstreams are preferable when
|
||||
# there is a choice.
|
||||
proxy_allow_bridge_to_host() {
|
||||
local port="$1" subnet
|
||||
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
|
||||
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
|
||||
ok "ufw already allows the bridge to reach port ${port}"
|
||||
else
|
||||
ufw allow from "$subnet" to any port "$port" proto tcp >/dev/null
|
||||
ok "ufw: allowed ${subnet} → :${port}"
|
||||
report_changed "ufw" "allowed ${subnet} to reach port ${port} (bridge to host)"
|
||||
fi
|
||||
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
|
||||
}
|
||||
|
||||
# Created WITHOUT ssl first, deliberately. Enabling force-SSL before a certificate
|
||||
# exists gives a host that 301s to https and then fails the handshake — curl reports
|
||||
# 000, which reads like a network fault rather than a config mistake.
|
||||
proxy_create_host() {
|
||||
local domain="$1" port="$2" existing
|
||||
existing="$(proxy_api GET /nginx/proxy-hosts | jq -r --arg d "$domain" \
|
||||
'map(select(.domain_names | index($d))) | .[0].id // empty')"
|
||||
if [[ -n "$existing" ]]; then
|
||||
HOST_ID="$existing"
|
||||
ok "proxy host already exists (id ${HOST_ID})"
|
||||
return 0
|
||||
fi
|
||||
|
||||
HOST_ID="$(proxy_api POST /nginx/proxy-hosts "$(jq -nc \
|
||||
--arg d "$domain" --arg h "$BRIDGE_GATEWAY" --argjson p "$port" \
|
||||
'{domain_names:[$d],forward_scheme:"http",forward_host:$h,forward_port:$p,
|
||||
access_list_id:0,certificate_id:0,block_exploits:true,caching_enabled:false,
|
||||
allow_websocket_upgrade:true,ssl_forced:false,http2_support:false,
|
||||
hsts_enabled:false,hsts_subdomains:false,meta:{},advanced_config:"",locations:[]}')" |
|
||||
jq -r '.id')"
|
||||
[[ -n "$HOST_ID" && "$HOST_ID" != "null" ]] || {
|
||||
warn "could not create the proxy host"
|
||||
return 1
|
||||
}
|
||||
ok "proxy host created (id ${HOST_ID}) → ${BRIDGE_GATEWAY}:${port}"
|
||||
}
|
||||
|
||||
# NPM 2.15 REMOVED letsencrypt_email and letsencrypt_agree from the certificate schema.
|
||||
# Sending them returns: 400 data/meta must NOT have additional properties.
|
||||
proxy_issue_certificate() {
|
||||
local domain="$1" meta
|
||||
CERT_ID="$(proxy_api GET /nginx/certificates | jq -r --arg d "$domain" \
|
||||
'map(select(.domain_names | index($d))) | .[0].id // empty')"
|
||||
[[ -n "$CERT_ID" ]] && {
|
||||
ok "certificate already exists (id ${CERT_ID})"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$CHALLENGE" == "dns" ]]; then
|
||||
meta="$(jq -nc --arg p "$DNS_PROVIDER" --arg c "$DNS_CREDENTIALS" \
|
||||
'{dns_challenge:true,dns_provider:$p,dns_provider_credentials:$c,propagation_seconds:120}')"
|
||||
info "Requesting the certificate via DNS-01 — about two minutes, for the plugin"
|
||||
info "install and DNS propagation."
|
||||
else
|
||||
meta='{"dns_challenge":false}'
|
||||
info "Requesting the certificate via HTTP-01"
|
||||
fi
|
||||
|
||||
CERT_ID="$(proxy_api POST /nginx/certificates "$(jq -nc \
|
||||
--arg d "$domain" --argjson m "$meta" \
|
||||
'{provider:"letsencrypt",nice_name:$d,domain_names:[$d],meta:$m}')" | jq -r '.id')"
|
||||
[[ -n "$CERT_ID" && "$CERT_ID" != "null" ]] || {
|
||||
warn "the certificate request failed — see: docker logs npm"
|
||||
return 1
|
||||
}
|
||||
ok "certificate issued (id ${CERT_ID})"
|
||||
}
|
||||
|
||||
proxy_attach_certificate() {
|
||||
proxy_api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(jq -nc --argjson c "$CERT_ID" \
|
||||
'{certificate_id:$c,ssl_forced:true,http2_support:true,hsts_enabled:false,hsts_subdomains:false}')" \
|
||||
>/dev/null || {
|
||||
warn "could not attach the certificate"
|
||||
return 1
|
||||
}
|
||||
ok "certificate attached, force-SSL and HTTP/2 on"
|
||||
}
|
||||
|
||||
proxy_verify() {
|
||||
local domain="$1" code
|
||||
code="$(curl -so /dev/null -w '%{http_code}' --max-time 20 \
|
||||
--resolve "${domain}:443:${TARGET_IP}" "https://${domain}/" || echo 000)"
|
||||
case "$code" in
|
||||
200 | 30[0-9]) ok "https://${domain} → ${code}" ;;
|
||||
000) warn "TLS handshake failed — certificate not attached, or force-SSL set before it existed" ;;
|
||||
502) warn "502 — nothing listening on the upstream port" ;;
|
||||
504) warn "504 — upstream unreachable: ufw dropping bridge→host, or the wrong forward_host" ;;
|
||||
*) warn "unexpected response: ${code}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
proxy_skip_instructions() {
|
||||
local port="$1" gw
|
||||
gw="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || echo '<bridge-gateway>')"
|
||||
cat <<EOF
|
||||
|
||||
To put Officer behind your own proxy, point it at:
|
||||
|
||||
http://<this-machine>:${port}
|
||||
|
||||
If that proxy runs in a container ON this machine, use ${gw}:${port} — inside a
|
||||
container 127.0.0.1 is the container itself — and let it through ufw:
|
||||
|
||||
ufw allow from <container-subnet> to any port ${port} proto tcp
|
||||
|
||||
Officer must bind 0.0.0.0, not 127.0.0.1, or the proxy cannot reach it.
|
||||
|
||||
EOF
|
||||
}
|
||||
@@ -14,22 +14,6 @@
|
||||
[[ -n "${OFFICER_SETUP_REPO_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_REPO_LOADED=1
|
||||
|
||||
# Public HTTPS, which is what this needed all along.
|
||||
#
|
||||
# It was ssh://git@gitea.pastilhas.dev:2222/... until 2026-08-14, and the reason was
|
||||
# that the repository was private: an HTTPS clone of a private repo prompts for a
|
||||
# username, and under sudo with no interactive terminal that hangs or dies with
|
||||
# "could not read Username". The note here said "back to HTTPS when the repository is
|
||||
# public", and it now is — verified with an anonymous `git ls-remote`.
|
||||
#
|
||||
# The change matters more than a URL swap. An SSH default cannot clone on a genuinely
|
||||
# fresh machine: the key machine-setup generates there is brand new and Gitea has
|
||||
# never seen it, so `--repo` was effectively mandatory on a first install. HTTPS needs
|
||||
# no key and no agent, so the default now works on a blank box.
|
||||
#
|
||||
# If this ever goes private again, SSH is the answer and the constraint above is the
|
||||
# reason — plus one more: the clone runs as the OWNER, and sudo drops SSH_AUTH_SOCK,
|
||||
# so a passphrase-protected key has no agent to answer it.
|
||||
OFFICER_REPO="${OFFICER_REPO:-https://gitea.officer.dev/officerdev/platform.git}"
|
||||
|
||||
platform_dir() { echo "${OFFICER_ROOT}/platform"; }
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the secret store
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# The store is $OFFICER_ROOT/secrets/officer-keys.db, deliberately a sibling of
|
||||
# the repo and NOT under data/ — that directory holds the managed homes and
|
||||
# attachments people back up, and a key store travelling in the same tarball as a
|
||||
# database dump rebuilds the exact problem it exists to avoid.
|
||||
#
|
||||
# Bootstrapping runs the platform's own module rather than reimplementing the
|
||||
# schema in bash. There is exactly one writer of this file's format, and a second
|
||||
# one in shell would drift the first time a column is added.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_SECRETS_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_SECRETS_LOADED=1
|
||||
|
||||
secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; }
|
||||
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; }
|
||||
|
||||
# Create the store and the two purposes a core install needs.
|
||||
#
|
||||
# Run AS the owner, not as root: the platform runs as them, and a store root
|
||||
# created would be a store they cannot write. `install -d -o` sets the owner in
|
||||
# one step rather than mkdir-then-chown, so it is never briefly root's.
|
||||
bootstrap_secret_store() {
|
||||
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(secret_store_dir)" || return 1
|
||||
|
||||
# From the repo, because the module derives the install root as the parent of
|
||||
# the working directory — the same rule as src/servers/data-path.ts.
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
|
||||
const { getKey } = await import('officerdb/secret-store');
|
||||
getKey('jwt');
|
||||
getKey('headscale');
|
||||
\"" >/dev/null 2>&1 || return 1
|
||||
|
||||
[[ -f "$(secret_store_path)" ]]
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the pm2 ecosystem file, and starting the processes
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── The ecosystem file is GENERATED, and is not in git ──
|
||||
#
|
||||
# There used to be four of them — ecosystem.config.cjs, .light., .mac.light. and
|
||||
# a .profile. that the others derived from. A profile deriving from a full list
|
||||
# means the full list has to exist, which means every plugin's process is
|
||||
# described in the repository whether or not anybody installed it, and a test had
|
||||
# to assert that the two files still agreed with each other.
|
||||
#
|
||||
# One generated file removes all of that. It describes exactly the processes this
|
||||
# install runs, it is written once at setup, and nothing in git can drift from
|
||||
# it. A plugin adds its own entry when it is installed.
|
||||
#
|
||||
# ── Why .cjs and not .js ──
|
||||
#
|
||||
# PM2's own convention is ecosystem.config.js, and it would be wrong here:
|
||||
# package.json declares "type": "module", so a .js file in this directory is ESM
|
||||
# and `module.exports` throws "module is not defined in ES module scope". PM2
|
||||
# require()s the config, so the extension has to say CommonJS out loud.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_SERVICES_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_SERVICES_LOADED=1
|
||||
|
||||
ecosystem_file() { echo "$(platform_dir)/ecosystem.config.cjs"; }
|
||||
|
||||
# The processes a core install runs. Everything else is a plugin.
|
||||
#
|
||||
# `officer-pty` is node rather than bun, and that is not an oversight: it loads
|
||||
# node-pty, a native module built against Node's ABI. Everything else is bun.
|
||||
#
|
||||
# `officer-claude-code` was `officer-agent` until 2026-08-13. The old name said
|
||||
# nothing about what it runs, and it sits beside officer-anthropic-proxy — which
|
||||
# is a different process doing a different job — so "the agent" was ambiguous
|
||||
# exactly where it mattered. It spawns `claude`; the name says so now.
|
||||
CORE_PROCESSES=(
|
||||
"officer|bun|start"
|
||||
"officer-anthropic-proxy|bun|run src/servers/sidecar/claude/index.ts"
|
||||
"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"
|
||||
)
|
||||
|
||||
write_ecosystem() {
|
||||
local dest entry name script args
|
||||
dest="$(ecosystem_file)"
|
||||
|
||||
{
|
||||
cat <<'HEADER'
|
||||
// Generated by officer-setup. Not in git, and not meant to be — it describes THIS
|
||||
// install, and the next machine generates its own.
|
||||
//
|
||||
// `cwd` is pinned on every app for two reasons. Bun auto-loads .env from the
|
||||
// working directory (and the pty sidecar does `import 'dotenv/config'`), so
|
||||
// without it a process started from anywhere else comes up with no POSTGRES_URL.
|
||||
// And src/servers/data-path.ts derives the install root as the PARENT of the
|
||||
// working directory, so a wrong cwd does not fail — it relocates data/,
|
||||
// capabilities/ and dockers/ somewhere else entirely. `assertInstallLayout`
|
||||
// refuses to boot when that happens.
|
||||
//
|
||||
// To add a plugin later, add its entry here. Nothing derives this file from
|
||||
// anything, so there is no second list to keep it agreeing with.
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
HEADER
|
||||
for entry in "${CORE_PROCESSES[@]}"; do
|
||||
IFS='|' read -r name script args <<<"$entry"
|
||||
printf " { name: '%s', script: '%s', args: '%s', cwd: '%s', watch: false },\n" \
|
||||
"$name" "$script" "$args" "$(platform_dir)"
|
||||
done
|
||||
cat <<'FOOTER'
|
||||
],
|
||||
};
|
||||
FOOTER
|
||||
} >"$dest"
|
||||
|
||||
chown "${USERNAME}:$(user_group)" "$dest"
|
||||
return 0
|
||||
}
|
||||
|
||||
pm2_start() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
||||
}
|
||||
|
||||
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
|
||||
|
||||
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
|
||||
# doing it — so this runs what it prints, which is the whole point of already
|
||||
# being root here.
|
||||
pm2_enable_startup() {
|
||||
local cmd
|
||||
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
||||
[[ -z "$cmd" ]] && return 1
|
||||
eval "${cmd#sudo }"
|
||||
}
|
||||
|
||||
# One line per process: name, status, restarts.
|
||||
pm2_status_lines() {
|
||||
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
|
||||
node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
let apps = []; try { apps = JSON.parse(s); } catch { }
|
||||
for (const a of apps) {
|
||||
const st = a.pm2_env?.status ?? "?";
|
||||
console.log(`${a.name}|${st}|${a.pm2_env?.restart_time ?? 0}`);
|
||||
}
|
||||
});'
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# The install report
|
||||
# =============================================================================
|
||||
#
|
||||
# Every run writes a timestamped markdown file recording what it installed, what
|
||||
# it changed, what it left alone, and what it ran as root.
|
||||
#
|
||||
# ── Who it is for ──
|
||||
#
|
||||
# Not us. It exists so the person who just ran a setup script off the internet
|
||||
# can hand the result to an agent of THEIR choosing and ask "did this do anything
|
||||
# it should not have". That is an adversarial read by someone who does not trust
|
||||
# us, which decides almost every choice below:
|
||||
#
|
||||
# Facts, not narration. "installed docker-ce" is checkable. "set up Docker" is
|
||||
# a claim. Every entry names the thing precisely enough to verify against the
|
||||
# machine afterwards.
|
||||
#
|
||||
# Recorded by the HELPERS, not by the sections. A section that has to remember
|
||||
# to report is a section that will forget, and an incomplete report is worse
|
||||
# than none — it reads as a full account. `pkg_install` and `install_config`
|
||||
# record themselves, so anything installed or written through them appears
|
||||
# whether or not the section author thought about it.
|
||||
#
|
||||
# Kept and skipped are recorded too. "Left your .zshrc alone" is the claim a
|
||||
# reviewer most wants substantiated, and it is invisible unless stated.
|
||||
#
|
||||
# NO SECRETS. The whole point is that this file gets shared. Passwords, keys
|
||||
# and connection strings are redacted at the moment of recording rather than
|
||||
# filtered later — see `report_redact`.
|
||||
#
|
||||
# ── Shape ──
|
||||
#
|
||||
# Facts accumulate in an array during the run and the file is rendered at the
|
||||
# end, so a crash halfway leaves no half-written report claiming to be complete.
|
||||
# `report_flush` is called by the exit trap, which marks it INCOMPLETE and says
|
||||
# where it stopped.
|
||||
|
||||
[[ -n "${OFFICER_REPORT_LOADED:-}" ]] && return 0
|
||||
OFFICER_REPORT_LOADED=1
|
||||
|
||||
REPORT_FACTS=()
|
||||
REPORT_SECTION="(start)"
|
||||
REPORT_STARTED="$(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
REPORT_COMPLETE=false
|
||||
|
||||
# Where it goes. install.sh exports REPORT_FILE so both halves land in ONE file;
|
||||
# a half run on its own makes its own.
|
||||
report_path() {
|
||||
if [[ -n "${REPORT_FILE:-}" ]]; then
|
||||
echo "$REPORT_FILE"
|
||||
return
|
||||
fi
|
||||
local base="${OFFICER_ROOT:-${USER_HOME:-$HOME}}"
|
||||
[[ -d "$base" ]] || base="${USER_HOME:-$HOME}"
|
||||
echo "${base}/install-report-$(date '+%Y%m%d-%H%M%S').md"
|
||||
}
|
||||
|
||||
# Redact anything that looks like a credential.
|
||||
#
|
||||
# Applied when the fact is RECORDED, not when it is rendered, so a secret never
|
||||
# sits in memory formatted for printing and cannot be leaked by a future change
|
||||
# to the renderer. Deliberately blunt: a password that survives is a leak, a URL
|
||||
# over-redacted is an inconvenience.
|
||||
report_redact() {
|
||||
sed -E \
|
||||
-e 's#(://[^:/@[:space:]]+):[^@[:space:]]+@#\1:REDACTED@#g' \
|
||||
-e 's#((password|passwd|secret|token|key|apikey|api_key)[[:space:]]*[=:][[:space:]]*)[^[:space:]]+#\1REDACTED#gI'
|
||||
}
|
||||
|
||||
report_section() { REPORT_SECTION="$1"; }
|
||||
|
||||
# One fact. `kind` is what a reviewer scans for: installed, kept, changed,
|
||||
# skipped, ran, started, failed.
|
||||
report_fact() {
|
||||
local kind="$1" text="$2"
|
||||
REPORT_FACTS+=("${REPORT_SECTION}|${kind}|$(printf '%s' "$text" | report_redact | tr '\n' ' ')")
|
||||
}
|
||||
|
||||
report_installed() { report_fact installed "$1"; }
|
||||
report_kept() { report_fact kept "$1"; }
|
||||
report_changed() { report_fact changed "$1"; }
|
||||
report_skipped() { report_fact skipped "$1"; }
|
||||
report_started() { report_fact started "$1"; }
|
||||
report_failed() { report_fact failed "$1"; }
|
||||
|
||||
# A command run with privilege. The reviewer's first question is "what did it run
|
||||
# as root", and the honest answer is a list rather than a promise.
|
||||
report_ran() { report_fact ran "$1"; }
|
||||
|
||||
report_mark_complete() { REPORT_COMPLETE=true; }
|
||||
|
||||
# Render. Safe to call twice; the trap and a normal finish both reach it.
|
||||
report_flush() {
|
||||
local dest kinds k
|
||||
dest="$(report_path)"
|
||||
[[ -n "${REPORT_WRITTEN:-}" ]] && return 0
|
||||
REPORT_WRITTEN=1
|
||||
|
||||
{
|
||||
echo "# Officer install report"
|
||||
echo ""
|
||||
if $REPORT_COMPLETE; then
|
||||
echo "**Status:** finished."
|
||||
else
|
||||
echo "**Status: INCOMPLETE — the run stopped during \`${REPORT_SECTION}\`.**"
|
||||
echo "Everything below still happened; what comes after it did not."
|
||||
fi
|
||||
echo ""
|
||||
echo "| | |"
|
||||
echo "| --- | --- |"
|
||||
echo "| started | ${REPORT_STARTED} |"
|
||||
echo "| finished | $(date '+%Y-%m-%d %H:%M:%S %Z') |"
|
||||
echo "| host | $(hostname 2>/dev/null || echo unknown) |"
|
||||
echo "| system | $(uname -srm) |"
|
||||
echo "| account | ${USERNAME:-$(id -un)} |"
|
||||
echo "| script commit | $(git -C "${SCRIPT_DIR:-.}" rev-parse --short HEAD 2>/dev/null || echo 'not a git checkout') |"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## How to review this"
|
||||
echo ""
|
||||
echo "This file exists so you can hand it to someone — or something — that does"
|
||||
echo "not trust the script that wrote it. It is a list of facts, each meant to be"
|
||||
echo "checkable against the machine rather than taken on faith."
|
||||
echo ""
|
||||
echo "Worth asking of it:"
|
||||
echo ""
|
||||
echo "- Does anything under **installed** come from somewhere other than your"
|
||||
echo " distribution's repositories, Homebrew, or a vendor's documented installer?"
|
||||
echo "- Does anything under **changed** touch a file outside this install, your"
|
||||
echo " home directory, or the system configuration a setup script would be"
|
||||
echo " expected to touch?"
|
||||
echo "- Does anything under **ran** do more than the section it sits under claims?"
|
||||
echo "- Is anything **started** that you did not ask for?"
|
||||
echo ""
|
||||
echo "Credentials are redacted where they were recorded. If you find one that is"
|
||||
echo "not, that is a bug worth reporting — this file is meant to be shareable."
|
||||
echo ""
|
||||
echo "What this report does NOT cover: anything a package's own post-install"
|
||||
echo "script did. Reviewing \`docker-ce\` itself is a different exercise from"
|
||||
echo "reviewing the script that installed it."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
if ((${#REPORT_FACTS[@]} == 0)); then
|
||||
echo "_Nothing was recorded — no section made a change._"
|
||||
else
|
||||
local last=""
|
||||
local line section kind text
|
||||
for line in "${REPORT_FACTS[@]}"; do
|
||||
section="${line%%|*}"
|
||||
kind="${line#*|}"; kind="${kind%%|*}"
|
||||
text="${line#*|*|}"
|
||||
if [[ "$section" != "$last" ]]; then
|
||||
[[ -n "$last" ]] && echo ""
|
||||
echo "## ${section}"
|
||||
echo ""
|
||||
last="$section"
|
||||
fi
|
||||
printf -- '- **%s** — %s\n' "$kind" "$text"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## Summary by kind"
|
||||
echo ""
|
||||
for k in installed changed kept skipped started ran failed; do
|
||||
local n
|
||||
n="$(printf '%s\n' "${REPORT_FACTS[@]}" | grep -c "|${k}|" || true)"
|
||||
printf -- '- %-10s %s\n' "$k" "$n"
|
||||
done
|
||||
} >"$dest" 2>/dev/null
|
||||
|
||||
[[ -n "${USERNAME:-}" ]] && chown "${USERNAME}:$(id -gn "$USERNAME" 2>/dev/null || echo "$USERNAME")" "$dest" 2>/dev/null || true
|
||||
chmod 0644 "$dest" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo " Install report: ${dest}"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# Officer — the owner's shell configuration.
|
||||
#
|
||||
# EMPTY ON PURPOSE, for now. Created 2026-08-13 so there is somewhere to put the
|
||||
# things the owner actually wants, and it is not wired into the Shell section yet.
|
||||
#
|
||||
# ── What this replaces, and the decision still to make ──
|
||||
#
|
||||
# The Shell section does not install a .zshrc today. It APPENDS four
|
||||
# marker-wrapped blocks to whatever is already there — `starship`, `agent`,
|
||||
# `aliases` and `editor` — via `append_once`, which recognises its own work so a
|
||||
# second run does not duplicate it. That was the right call for a machine whose
|
||||
# .zshrc already belongs to somebody.
|
||||
#
|
||||
# Installing a whole file is a different promise, and the two do not compose: a
|
||||
# template that gets installed AND appended to ends up with the same lines twice,
|
||||
# once from the file and once from a block. So when this is wired in, the four
|
||||
# append_once blocks either move INTO this file or stay out of it — not both.
|
||||
#
|
||||
# `install_config` already handles the careful half: it writes only when the
|
||||
# destination is missing or still byte-for-byte the template, and offers a diff
|
||||
# otherwise, so an owner's own edits are never overwritten.
|
||||
#
|
||||
# ── Where the shell templates live ──
|
||||
#
|
||||
# scripts/setup/{starship.toml, tmux.conf, zshrc}, together. starship.toml has to
|
||||
# be here rather than inside machine-setup/, because the PLATFORM reads it too —
|
||||
# os-user-shell.ts:34 deploys it to every member's Linux account — so it is not
|
||||
# machine-setup's private file. The other two joined it so there is one answer to
|
||||
# "where do the dotfile templates live".
|
||||
#
|
||||
# No leading dot on any of them: templates in a repository, not dotfiles in a
|
||||
# home directory. src/servers/shell-skel/zshrc has been spelled that way all
|
||||
# along.
|
||||
#
|
||||
# `[open]` TOMORROW. There are now two zshrc templates — this one for the owner
|
||||
# and shell-skel/zshrc for members — while starship.toml is deliberately ONE file
|
||||
# for both audiences. Either the owner genuinely needs different shell config
|
||||
# from a member, or these should be the same file the way starship is. The tmux
|
||||
# config has the same question waiting, since it is going into provisioning too.
|
||||
#
|
||||
# ── The one thing worth keeping when this is filled in ──
|
||||
#
|
||||
# shell-skel/zshrc depends on nothing but zsh: starship, eza, nvim and bun are
|
||||
# each used only if present, so the same file works on a minimal VPS and on a
|
||||
# fully equipped workstation. Worth holding to here, since this file will be read
|
||||
# on machines that have had none of the optional sections run.
|
||||
@@ -5,10 +5,6 @@ import { useAuth } from 'hooks/useAuth';
|
||||
import { useServerSettings } from 'state/useServerSettings';
|
||||
import { useServerEnvironment } from 'state/useServerEnvironment';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
@@ -60,26 +56,14 @@ export function App() {
|
||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{installedPlugins.flatMap((plugin) => [
|
||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
// The section pair, exactly as the core screens do it (`/headscale/:section`): the plugin's
|
||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||
// passed between them.
|
||||
<Route
|
||||
key={`${plugin.appName}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
])}
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
@@ -107,6 +91,8 @@ export function App() {
|
||||
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/processes/:dirName" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/task-logs/:id" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/jobs" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ResetPassword() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||
<div className="text-duck-dark/60">Enter your new password</div>
|
||||
|
||||
@@ -105,3 +105,4 @@ const validateForm = (state: Partial<LoginFormState>) => {
|
||||
if (!email || !password) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
<Background />
|
||||
<div className="absolute inset-0 z-20">
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">{children}</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DuckAvatar } from './DuckAvatar';
|
||||
import { PixelGrid } from '@/components/PixelGrid';
|
||||
import { DuckAvatar } from "./DuckAvatar";
|
||||
import { PixelGrid } from "@/components/PixelGrid";
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
const landscapebg = '/landscape1.webp';
|
||||
|
||||
@@ -19,4 +19,4 @@ export function Background() {
|
||||
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,3 +14,4 @@ export const SignoutScreen = () => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,33 +27,16 @@ export const ActivityScreen = () => {
|
||||
// Poll the registry (harness task files + announced detached jobs).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () =>
|
||||
get<Registry>('/activity/tasks')
|
||||
.then((r) => {
|
||||
if (alive) {
|
||||
setReg(r);
|
||||
setRegLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
const tick = () => get<Registry>('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {});
|
||||
tick();
|
||||
const iv = setInterval(tick, POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(iv);
|
||||
};
|
||||
return () => { alive = false; clearInterval(iv); };
|
||||
}, []);
|
||||
|
||||
// The row backing the open id, and the stream query it implies. A string rather than the row object,
|
||||
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
|
||||
const row = selectedId
|
||||
? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId))
|
||||
: undefined;
|
||||
const query = !row
|
||||
? null
|
||||
: row.source === 'harness'
|
||||
? `task=${encodeURIComponent(row.id)}`
|
||||
: `path=${encodeURIComponent(row.path)}`;
|
||||
const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined;
|
||||
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`;
|
||||
|
||||
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
|
||||
useEffect(() => {
|
||||
@@ -67,18 +50,13 @@ export const ActivityScreen = () => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
|
||||
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
|
||||
else if (d.kind === 'line' && typeof d.text === 'string')
|
||||
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
return () => es.close();
|
||||
}, [query, token]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
||||
}, [lines]);
|
||||
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
|
||||
|
||||
const rowCls = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
|
||||
@@ -90,36 +68,20 @@ export const ActivityScreen = () => {
|
||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||
</div>
|
||||
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Background tasks
|
||||
</div>
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
|
||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||
{reg.tasks.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||
className={rowCls(selectedId === t.id)}
|
||||
title={t.cwd}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
/>
|
||||
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}>
|
||||
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
|
||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{reg.detached.length > 0 && (
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Detached
|
||||
</div>
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
<Link
|
||||
key={d.id}
|
||||
to={`/activity/${encodeURIComponent(d.id)}`}
|
||||
className={rowCls(selectedId === d.id)}
|
||||
title={d.path}
|
||||
>
|
||||
<Link key={d.id} to={`/activity/${encodeURIComponent(d.id)}`} className={rowCls(selectedId === d.id)} title={d.path}>
|
||||
<FileText size={13} className="shrink-0" />
|
||||
<span className="truncate">{d.id}</span>
|
||||
</Link>
|
||||
@@ -141,54 +103,33 @@ export const ActivityScreen = () => {
|
||||
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
|
||||
{progress.status ? ` (${progress.status})` : ''}
|
||||
</span>
|
||||
<span className="shrink-0 pl-2">
|
||||
{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
|
||||
</span>
|
||||
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
|
||||
</div>
|
||||
{typeof progress.pct === 'number' && (
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }}
|
||||
/>
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80"
|
||||
>
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
|
||||
{lines.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{query ? (
|
||||
'waiting for output…'
|
||||
) : regLoaded ? (
|
||||
{query ? 'waiting for output…' : regLoaded ? (
|
||||
<>
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||
it never started.{' '}
|
||||
<Link to="/activity" className="underline">
|
||||
Back to the list
|
||||
</Link>
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or it never started.{' '}
|
||||
<Link to="/activity" className="underline">Back to the list</Link>
|
||||
</>
|
||||
) : (
|
||||
'loading…'
|
||||
)}
|
||||
) : 'loading…'}
|
||||
</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-words">
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a task to follow its live output
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -87,13 +87,7 @@ export const TabPreview = () => {
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* URL bar */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => void refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
|
||||
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<form
|
||||
@@ -110,13 +104,7 @@ export const TabPreview = () => {
|
||||
placeholder="Navigate to URL..."
|
||||
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
type="submit"
|
||||
disabled={isNavigating || !navUrl.trim()}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</form>
|
||||
@@ -155,13 +143,7 @@ export const TabPreview = () => {
|
||||
placeholder="Evaluate JavaScript..."
|
||||
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 shrink-0"
|
||||
type="submit"
|
||||
disabled={isEvaluating || !evalExpr.trim()}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
|
||||
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -14,17 +14,7 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
|
||||
type Contact = { address: string; name: string };
|
||||
|
||||
// A recipient field with contact autocomplete on the last comma-separated segment.
|
||||
const RecipientInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
autoFocus?: boolean;
|
||||
}) => {
|
||||
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
|
||||
const client = useClient();
|
||||
const [suggestions, setSuggestions] = useState<Contact[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -36,10 +26,7 @@ const RecipientInput = ({
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
client
|
||||
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
|
||||
.then(setSuggestions)
|
||||
.catch(() => {});
|
||||
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
|
||||
}, 180);
|
||||
return () => clearTimeout(t);
|
||||
}, [seg]);
|
||||
@@ -135,16 +122,14 @@ export const ComposeModal = () => {
|
||||
inlineMap.current.clear();
|
||||
nextImgId.current = 0;
|
||||
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
|
||||
if (editorRef.current)
|
||||
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
||||
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
||||
refreshEmpty();
|
||||
}
|
||||
if (!draft) seeded.current = null;
|
||||
}, [draft]);
|
||||
|
||||
// Clipboard images often come nameless — give them a sensible filename.
|
||||
const named = (f: File) =>
|
||||
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
|
||||
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
|
||||
|
||||
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
|
||||
const addAttachments = (incoming: FileList | File[]) => {
|
||||
@@ -263,8 +248,7 @@ export const ComposeModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
if (!draft) return null;
|
||||
|
||||
@@ -331,9 +315,7 @@ export const ComposeModal = () => {
|
||||
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{bodyEmpty && (
|
||||
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
|
||||
)}
|
||||
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
@@ -380,10 +362,7 @@ export const ComposeModal = () => {
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@@ -403,21 +382,12 @@ export const ComposeModal = () => {
|
||||
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
|
||||
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
|
||||
// is a follow-up: store the Message-Id header on ingest.)
|
||||
export const replyDraft = (m: {
|
||||
from: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
text?: string;
|
||||
snippet?: string;
|
||||
}): ComposeDraft => {
|
||||
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
|
||||
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
|
||||
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
|
||||
const original = (m.text || m.snippet || '').trim();
|
||||
const quoted = original
|
||||
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
|
||||
.split('\n')
|
||||
.map((l) => `> ${l}`)
|
||||
.join('\n')}`
|
||||
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
|
||||
: '';
|
||||
return { to: addr, subject, body: quoted };
|
||||
};
|
||||
|
||||
@@ -63,13 +63,8 @@ type MessagePanelProps = {
|
||||
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
|
||||
>
|
||||
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
|
||||
{senderName(message.from)}
|
||||
</span>
|
||||
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
|
||||
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
|
||||
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
|
||||
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
|
||||
@@ -119,11 +114,7 @@ const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: Me
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.html ? (
|
||||
<HtmlBody html={message.html} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
|
||||
)}
|
||||
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -218,11 +209,7 @@ export const EmailReader = () => {
|
||||
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
|
||||
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
|
||||
{openAttachment && (
|
||||
<FileViewerProvider
|
||||
filePath={openAttachment.filePath}
|
||||
fileName={openAttachment.fileName}
|
||||
root={openAttachment.root}
|
||||
>
|
||||
<FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
|
||||
<FileViewerHeader />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /headscale uses the Workspace/Panel system (like /soulseek and /music): the server picker
|
||||
// (headscale-servers) above the section nav (headscale-nav) on the left, and the section view
|
||||
// (headscale-view) on the right. All three talk to the officer-headscale sidecar through the /api/headscale
|
||||
// auth proxy, which holds no Headscale credentials of its own — the registered servers and their keys live
|
||||
// in the sidecar.
|
||||
//
|
||||
// The open section is :section in the URL, so every panel reads it with useParams instead of passing it
|
||||
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
|
||||
// single place that decides what an absent or bogus section means.
|
||||
|
||||
function hasAppType(node: LayoutNode, appType: string): boolean {
|
||||
if (node.type === 'panel') return node.appType === appType;
|
||||
return node.children.some((c) => hasAppType(c.node, appType));
|
||||
}
|
||||
|
||||
export const HeadscaleScreen = () => {
|
||||
const { section } = useParams();
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
|
||||
|
||||
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
|
||||
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
|
||||
// cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is
|
||||
// the part the framework can't do, because it is about a panel that is *missing* rather than wrong.
|
||||
const workspace = useMemo(() => {
|
||||
if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: defaultLayout };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.value);
|
||||
}
|
||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||
|
||||
// Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||
// default while the address bar says something else — the nav highlight is derived from the URL, so a URL
|
||||
// that names nothing would leave nothing highlighted.
|
||||
if (!isHeadscaleSection(section)) {
|
||||
return <Navigate to={headscaleSectionPath(DEFAULT_HEADSCALE_SECTION)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{
|
||||
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
|
||||
fallback: 'headscale-view',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'offscale-root',
|
||||
id: 'headscale-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
@@ -0,0 +1 @@
|
||||
export * from './HeadscaleScreen';
|
||||
@@ -1,23 +1,14 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
StopCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Square,
|
||||
ChevronRight,
|
||||
Wrench,
|
||||
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square,
|
||||
ChevronRight, Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
|
||||
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
|
||||
@@ -61,58 +52,21 @@ type JobData = {
|
||||
// Output entries for the right panel
|
||||
type OutputEntry =
|
||||
| { id: string; type: 'text'; text: string }
|
||||
| {
|
||||
id: string;
|
||||
type: 'tool';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
| { id: string; type: 'tool'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; output?: string; isError?: boolean };
|
||||
|
||||
type ServerMessage =
|
||||
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'step:start';
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
iteration?: { current: number; total: number; label: string };
|
||||
}
|
||||
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: Cost }
|
||||
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'step:parallel';
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
iterations: string[];
|
||||
concurrency: number;
|
||||
}
|
||||
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
|
||||
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost }
|
||||
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { jobId: string; type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'tool:start';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
stepIndex: number;
|
||||
iterationLabel?: string;
|
||||
}
|
||||
| {
|
||||
jobId: string;
|
||||
type: 'tool:result';
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
stepIndex: number;
|
||||
iterationLabel?: string;
|
||||
}
|
||||
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
|
||||
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
|
||||
| { jobId: string; type: 'pipeline:complete'; totalCost: Cost }
|
||||
| { jobId: string; type: 'error'; message: string }
|
||||
| { jobId: string; type: 'stopped' }
|
||||
@@ -131,7 +85,7 @@ const formatElapsed = (seconds: number) => {
|
||||
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
|
||||
const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
||||
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
|
||||
/** Build a unique key for grouping output by step/iteration */
|
||||
const outputKey = (stepIndex: number, iterationLabel?: string) =>
|
||||
@@ -173,12 +127,15 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
<Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
|
||||
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
|
||||
{entry.output !== undefined && (
|
||||
<StatusIcon status={entry.isError ? 'error' : 'complete'} className="h-3 w-3 shrink-0 ml-auto" />
|
||||
)}
|
||||
{entry.output === undefined && <Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />}
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
<StatusIcon
|
||||
status={entry.isError ? 'error' : 'complete'}
|
||||
className="h-3 w-3 shrink-0 ml-auto"
|
||||
/>
|
||||
)}
|
||||
{entry.output === undefined && (
|
||||
<Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />
|
||||
)}
|
||||
<ChevronRight className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
||||
@@ -191,9 +148,7 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
{entry.output !== undefined && (
|
||||
<div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||
>
|
||||
<pre className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}>
|
||||
{entry.output}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -238,18 +193,9 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
||||
const StepsPanel = () => {
|
||||
const ctx = useJobPanel();
|
||||
const {
|
||||
displaySteps,
|
||||
isLive,
|
||||
isRunning,
|
||||
completedSteps,
|
||||
activeStepIndex,
|
||||
progressStepIndex,
|
||||
jobStatus,
|
||||
selectedKey,
|
||||
selectOutput,
|
||||
displayParallel,
|
||||
skippedItems,
|
||||
outputMap,
|
||||
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
||||
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
|
||||
skippedItems, outputMap,
|
||||
} = ctx;
|
||||
|
||||
return (
|
||||
@@ -314,16 +260,16 @@ const StepsPanel = () => {
|
||||
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" />
|
||||
<span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
|
||||
{it.cost && (
|
||||
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">
|
||||
{formatCost(it.cost.totalUSD)}
|
||||
</span>
|
||||
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">{formatCost(it.cost.totalUSD)}</span>
|
||||
)}
|
||||
{itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{skippedItems.length > 0 && (
|
||||
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">{skippedItems.length} skipped</div>
|
||||
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">
|
||||
{skippedItems.length} skipped
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -347,9 +293,7 @@ const OutputPanel = () => {
|
||||
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
|
||||
{selectedKey && (
|
||||
<span className="text-xs text-duck-dark/40 truncate">
|
||||
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/40 truncate">{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
||||
@@ -371,7 +315,9 @@ const OutputPanel = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <ToolCallEntry key={entry.id} entry={entry} />;
|
||||
return (
|
||||
<ToolCallEntry key={entry.id} entry={entry} />
|
||||
);
|
||||
})}
|
||||
{selectedStreaming && (
|
||||
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
|
||||
@@ -418,10 +364,7 @@ export const PipelineJobDetail = () => {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
||||
}, []);
|
||||
|
||||
const addCost = useCallback((cost: Cost) => {
|
||||
@@ -446,10 +389,9 @@ export const PipelineJobDetail = () => {
|
||||
const arr = prev.get(key);
|
||||
if (!arr) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
key,
|
||||
arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)),
|
||||
);
|
||||
next.set(key, arr.map((e) =>
|
||||
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e,
|
||||
));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -518,25 +460,16 @@ export const PipelineJobDetail = () => {
|
||||
};
|
||||
}, [id, job?.status]);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(msg: ServerMessage) => {
|
||||
const handleEvent = useCallback((msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'job:state':
|
||||
if (
|
||||
msg.status === 'completed' ||
|
||||
msg.status === 'failed' ||
|
||||
msg.status === 'stopped' ||
|
||||
msg.status === 'interrupted'
|
||||
) {
|
||||
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
|
||||
setLiveStatus('done');
|
||||
if (msg.cost) setTotalCost(msg.cost as Cost);
|
||||
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
|
||||
stopTimer();
|
||||
if (id) {
|
||||
client
|
||||
.get<JobData>(`/pipeline-jobs/${id}`)
|
||||
.then(setJob)
|
||||
.catch(() => {});
|
||||
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (msg.progress) {
|
||||
@@ -553,7 +486,9 @@ export const PipelineJobDetail = () => {
|
||||
case 'step:start': {
|
||||
setParallelStep(null);
|
||||
setActiveStepIndex(msg.stepIndex);
|
||||
const key = msg.iteration ? outputKey(msg.stepIndex, msg.iteration.label) : outputKey(msg.stepIndex);
|
||||
const key = msg.iteration
|
||||
? outputKey(msg.stepIndex, msg.iteration.label)
|
||||
: outputKey(msg.stepIndex);
|
||||
if (autoFollowRef.current) setSelectedKey(key);
|
||||
break;
|
||||
}
|
||||
@@ -590,7 +525,9 @@ export const PipelineJobDetail = () => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'running' } : it,
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
@@ -634,14 +571,10 @@ export const PipelineJobDetail = () => {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
const text = msg.text || streamBuffers.current.get(key) || '';
|
||||
if (text) {
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
||||
}
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => {
|
||||
const n = new Map(prev);
|
||||
n.delete(key);
|
||||
return n;
|
||||
});
|
||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -650,7 +583,7 @@ export const PipelineJobDetail = () => {
|
||||
// Flush any streaming text before the tool call
|
||||
flushStreamBuffer(key);
|
||||
appendOutput(key, {
|
||||
id: randomId(),
|
||||
id: crypto.randomUUID(),
|
||||
type: 'tool',
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
@@ -670,20 +603,14 @@ export const PipelineJobDetail = () => {
|
||||
setLiveStatus('done');
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
setSteps((s) => {
|
||||
s.forEach((_, i) => next.add(i));
|
||||
return s;
|
||||
});
|
||||
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; });
|
||||
return next;
|
||||
});
|
||||
setActiveStepIndex(-1);
|
||||
setParallelStep(null);
|
||||
stopTimer();
|
||||
if (id) {
|
||||
client
|
||||
.get<JobData>(`/pipeline-jobs/${id}`)
|
||||
.then(setJob)
|
||||
.catch(() => {});
|
||||
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -698,25 +625,16 @@ export const PipelineJobDetail = () => {
|
||||
stopTimer();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[id, stopTimer, addCost, appendOutput, updateToolOutput],
|
||||
);
|
||||
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]);
|
||||
|
||||
const flushStreamBuffer = useCallback(
|
||||
(key: string) => {
|
||||
const flushStreamBuffer = useCallback((key: string) => {
|
||||
const text = streamBuffers.current.get(key);
|
||||
if (text) {
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => {
|
||||
const n = new Map(prev);
|
||||
n.delete(key);
|
||||
return n;
|
||||
});
|
||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||
}
|
||||
},
|
||||
[appendOutput],
|
||||
);
|
||||
}, [appendOutput]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
|
||||
@@ -729,13 +647,10 @@ export const PipelineJobDetail = () => {
|
||||
setSelectedKey(key);
|
||||
}, []);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
const panelComponents: PanelComponents = useMemo(() => ({
|
||||
steps: StepsPanel,
|
||||
output: OutputPanel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}), []);
|
||||
|
||||
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
|
||||
const isRunning = displayStatus === 'running';
|
||||
@@ -743,10 +658,7 @@ export const PipelineJobDetail = () => {
|
||||
const jobDone = !isRunning && !isLive;
|
||||
const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
|
||||
|
||||
const displayParallel: ParallelStep | null =
|
||||
parallelStep ??
|
||||
(jobDone && job?.progress?.parallel
|
||||
? {
|
||||
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? {
|
||||
stepIndex: progressStepIndex,
|
||||
taskName: job.progress.parallel.taskName,
|
||||
concurrency: job.progress.parallel.concurrency,
|
||||
@@ -754,54 +666,29 @@ export const PipelineJobDetail = () => {
|
||||
label: it.label,
|
||||
status: it.status as IterationStatus['status'],
|
||||
})),
|
||||
}
|
||||
: null);
|
||||
} : null);
|
||||
|
||||
const panelCtx = useMemo<JobPanelContext>(
|
||||
() => ({
|
||||
displaySteps,
|
||||
isLive,
|
||||
isRunning,
|
||||
completedSteps,
|
||||
activeStepIndex,
|
||||
progressStepIndex,
|
||||
jobStatus: job?.status ?? 'pending',
|
||||
selectedKey,
|
||||
selectOutput,
|
||||
displayParallel,
|
||||
skippedItems,
|
||||
outputMap,
|
||||
streamingMap,
|
||||
outputPanelRef,
|
||||
}),
|
||||
[
|
||||
displaySteps,
|
||||
isLive,
|
||||
isRunning,
|
||||
completedSteps,
|
||||
activeStepIndex,
|
||||
progressStepIndex,
|
||||
job?.status,
|
||||
selectedKey,
|
||||
selectOutput,
|
||||
displayParallel,
|
||||
skippedItems,
|
||||
outputMap,
|
||||
streamingMap,
|
||||
],
|
||||
);
|
||||
const panelCtx = useMemo<JobPanelContext>(() => ({
|
||||
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
||||
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput,
|
||||
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef,
|
||||
}), [
|
||||
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
||||
progressStepIndex, job?.status, selectedKey, selectOutput,
|
||||
displayParallel, skippedItems, outputMap, streamingMap,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
||||
<span>Job not found</span>
|
||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
|
||||
Back to jobs
|
||||
</Link>
|
||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">Back to jobs</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -811,9 +698,7 @@ export const PipelineJobDetail = () => {
|
||||
? elapsed
|
||||
: job.startedAt && job.completedAt
|
||||
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
|
||||
: elapsed > 0
|
||||
? elapsed
|
||||
: null;
|
||||
: elapsed > 0 ? elapsed : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
|
||||
@@ -872,9 +757,7 @@ export const PipelineJobDetail = () => {
|
||||
<Card className="px-4 py-3 shrink-0 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">
|
||||
{job.error ?? 'An error occurred during execution'}
|
||||
</span>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{job.error ?? 'An error occurred during execution'}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -888,3 +771,4 @@ export const PipelineJobDetail = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PixelGrid } from '@/components/PixelGrid';
|
||||
import { PixelGrid } from "@/components/PixelGrid";
|
||||
const landscapebg = '/landscape1.webp';
|
||||
|
||||
export function Background() {
|
||||
@@ -14,4 +14,4 @@ export function Background() {
|
||||
<PixelGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,14 +13,7 @@ type BugReportDialogProps = {
|
||||
onSubmit: (description: string) => void;
|
||||
};
|
||||
|
||||
export const BugReportDialog = ({
|
||||
open,
|
||||
capturing,
|
||||
submitting,
|
||||
screenshot,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BugReportDialogProps) => {
|
||||
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
FolderOpen,
|
||||
Code,
|
||||
LayoutGrid,
|
||||
ScrollText,
|
||||
FolderKanban,
|
||||
Monitor,
|
||||
Mail,
|
||||
@@ -149,7 +150,6 @@ import {
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -170,19 +170,15 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
|
||||
// (music-browser) and the content/detail (music-detail). They do not coordinate with each other; both
|
||||
// read `?path=` off the URL. No route pair and no guard: the bare /music is the library root, a real
|
||||
// state, and an unknown path gets an empty listing rather than a rewritten address.
|
||||
|
||||
export const MusicScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['music-browser', 'music-detail'], fallback: 'music-detail' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './MusicScreen';
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { LayoutNode, AppRegistryMeta } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
|
||||
// The screen every plugin route renders. THE PLUGIN DOES NOT RENDER A SCREEN.
|
||||
//
|
||||
// ── The rule, and why it is shape rather than policy ──
|
||||
//
|
||||
// Every plugin route renders a Workspace with at least one panel. A plugin that exported a component
|
||||
// could render anything at all — a bare div, a full-page form, its own navigation — and the platform
|
||||
// would be a shell hosting strangers' layouts rather than one application. So a plugin does not get to
|
||||
// render the screen: it contributes panels and says how they are arranged, and this renders the
|
||||
// Workspace around them.
|
||||
//
|
||||
// Non-compliance is therefore not refused, it is unrepresentable. There is nowhere to put a screen.
|
||||
//
|
||||
// `locked`, like every core screen: a plugin's layout is its author's design, not a workspace the user
|
||||
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
|
||||
// something else falls back rather than rendering another plugin's panel inside this one.
|
||||
export function PluginScreen({
|
||||
appName,
|
||||
panels,
|
||||
layout,
|
||||
}: {
|
||||
appName: string;
|
||||
panels: AppRegistryMeta[];
|
||||
layout: LayoutNode;
|
||||
}) {
|
||||
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
|
||||
const workspace = useDashboardState<LayoutNode>(`screens/plugin/${appName}`, layout);
|
||||
const allowed = panels.map((panel) => panel.key);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView workspace={workspace} locked appTypes={{ allowed, fallback: allowed[0] ?? '' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
|
||||
// handler. This screen is the courtesy half of that.
|
||||
//
|
||||
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
|
||||
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
|
||||
export const PluginsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
|
||||
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
|
||||
// and destroy the side-by-side. See docs/navigation-audit.md.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'plugins-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './PluginsScreen';
|
||||
@@ -6,7 +6,6 @@ import { useClient } from 'hooks/useClient';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own
|
||||
// instead of a session everything shares.
|
||||
@@ -40,7 +39,7 @@ const formatDate = (value: string | null) =>
|
||||
|
||||
const copy = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Key copied');
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
|
||||
+2
-9
@@ -51,9 +51,7 @@ export const ApifyConfig = () => {
|
||||
<div className="grid gap-5">
|
||||
{status && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div
|
||||
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
|
||||
/>
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
|
||||
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||
{status.configured ? 'API token configured' : 'Not configured'}
|
||||
</span>
|
||||
@@ -72,12 +70,7 @@ export const ApifyConfig = () => {
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Get your token at{' '}
|
||||
<a
|
||||
href="https://console.apify.com/account/integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.apify.com/account/integrations" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
console.apify.com/account/integrations
|
||||
</a>
|
||||
</span>
|
||||
|
||||
+15
-16
@@ -4,7 +4,6 @@ import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-r
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
type RelayToken = {
|
||||
token: string;
|
||||
@@ -47,7 +46,7 @@ export const BrowserRelay = () => {
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
try {
|
||||
await copyToClipboard(value);
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedField(field);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
@@ -69,9 +68,7 @@ export const BrowserRelay = () => {
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
|
||||
{' — '}
|
||||
<a href="/browser" className="text-duck-teal underline">
|
||||
view tabs
|
||||
</a>
|
||||
<a href="/browser" className="text-duck-teal underline">view tabs</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,9 +95,7 @@ export const BrowserRelay = () => {
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<li>
|
||||
Open{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
|
||||
chrome://extensions
|
||||
</code>{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
|
||||
in Chrome
|
||||
</li>
|
||||
<li>
|
||||
@@ -143,7 +138,12 @@ export const BrowserRelay = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => regenerate.mutate()}
|
||||
disabled={regenerate.isPending}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Regenerate
|
||||
</Button>
|
||||
@@ -165,8 +165,8 @@ export const BrowserRelay = () => {
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
|
||||
means the tab is connected. Then go to{' '}
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
|
||||
the tab is connected. Then go to{' '}
|
||||
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
|
||||
/browser <ExternalLink className="h-3 w-3" />
|
||||
</a>{' '}
|
||||
@@ -188,11 +188,10 @@ type CredentialRowProps = {
|
||||
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
|
||||
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
|
||||
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
|
||||
>
|
||||
<code className="flex-1 text-xs truncate">
|
||||
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
|
||||
</code>
|
||||
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+1
-2
@@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
||||
//
|
||||
@@ -30,7 +29,7 @@ const formatDate = (value: string | null) =>
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${what} copied`);
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
|
||||
+4
-1
@@ -196,7 +196,10 @@ export const EmailAccounts = () => {
|
||||
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
|
||||
|
||||
return (
|
||||
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div
|
||||
key={account.id}
|
||||
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon provider={account.provider} />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
+26
-78
@@ -32,12 +32,7 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
|
||||
<p className="mt-1">
|
||||
Go to the{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/projectcreate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
New Project
|
||||
</a>{' '}
|
||||
page. Give it a name (e.g. "Officer") and click <strong>Create</strong>.
|
||||
@@ -48,56 +43,32 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/library"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
API Library
|
||||
</a>
|
||||
. Search for and enable each of these:
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
<strong>Gmail API</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google Calendar API</strong>
|
||||
</li>
|
||||
<li><strong>Gmail API</strong></li>
|
||||
<li><strong>Google Calendar API</strong></li>
|
||||
</ul>
|
||||
<p className="mt-1">
|
||||
Click each one, then click <strong>Enable</strong>.
|
||||
</p>
|
||||
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/branding"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
OAuth Branding
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
Set <strong>App name</strong> to your organization name or "Officer"
|
||||
</li>
|
||||
<li>
|
||||
Set <strong>User support email</strong> to your admin email
|
||||
</li>
|
||||
<li>
|
||||
Add your admin email under <strong>Developer contact information</strong>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Save</strong>
|
||||
</li>
|
||||
<li>Set <strong>App name</strong> to your organization name or "Officer"</li>
|
||||
<li>Set <strong>User support email</strong> to your admin email</li>
|
||||
<li>Add your admin email under <strong>Developer contact information</strong></li>
|
||||
<li>Click <strong>Save</strong></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -105,12 +76,7 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/audience"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
OAuth Audience
|
||||
</a>
|
||||
.
|
||||
@@ -120,8 +86,7 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
|
||||
</li>
|
||||
<li>
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
|
||||
(required while the app is unverified; limit of 100 test users)
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -130,12 +95,7 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/scopes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
Data Access
|
||||
</a>
|
||||
, then click <strong>Add or remove scopes</strong>. Search for and add:
|
||||
@@ -143,20 +103,18 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
{SCOPES.map((s) => (
|
||||
<li key={s.scope}>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
|
||||
{s.description}
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
|
||||
{s.scope}
|
||||
</code>{' '}
|
||||
— {s.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1">
|
||||
Click <strong>Update</strong>, then <strong>Save</strong>.
|
||||
</p>
|
||||
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
|
||||
is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
|
||||
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
|
||||
in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
|
||||
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -164,20 +122,13 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/clients"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
Clients
|
||||
</a>
|
||||
, then click <strong>Create OAuth client</strong>.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
Application type: <strong>Web application</strong>
|
||||
</li>
|
||||
<li>Application type: <strong>Web application</strong></li>
|
||||
<li>Name: anything (e.g. "Officer")</li>
|
||||
<li>
|
||||
Authorized redirect URIs: add{' '}
|
||||
@@ -185,17 +136,14 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
{redirectUri}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
<li>Click <strong>Create</strong></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
|
||||
<p className="mt-1">
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
|
||||
them into the fields below.
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -222,7 +170,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
|
||||
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
|
||||
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,25 +87,19 @@ export const AIModels = () => {
|
||||
<div className="grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat from the home screen
|
||||
</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
|
||||
{renderModelSelect(chatModel, setChatModel, 'System default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat inside a project dashboard
|
||||
</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when running tasks from the file browser
|
||||
</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
|
||||
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
|
||||
+11
-38
@@ -1,15 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
|
||||
@@ -104,16 +96,8 @@ export const VoicePreference = () => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => {
|
||||
audioRef.current = null;
|
||||
setListening('idle');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
audioRef.current = null;
|
||||
setListening('idle');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||
await audio.play();
|
||||
setListening('playing');
|
||||
} catch {
|
||||
@@ -136,9 +120,7 @@ export const VoicePreference = () => {
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -147,27 +129,20 @@ export const VoicePreference = () => {
|
||||
<SelectValue placeholder="Server default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
<SelectItem value={SERVER_DEFAULT}>
|
||||
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
|
||||
</SelectItem>
|
||||
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
|
||||
{groups.length > 0
|
||||
? groups.map((g) => (
|
||||
<SelectGroup key={g.label}>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
||||
{g.label}
|
||||
</SelectLabel>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||
{g.voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{prettify(v)}
|
||||
</SelectItem>
|
||||
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
@@ -186,9 +161,7 @@ export const VoicePreference = () => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-8
@@ -466,9 +466,7 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
@@ -498,8 +496,7 @@ export const AIHarnessesSection = () => {
|
||||
{editingProvider &&
|
||||
(() => {
|
||||
const provider = CHAT_PROVIDERS.find((p) => p.key === editingProvider);
|
||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
|
||||
return null;
|
||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId)) return null;
|
||||
return (
|
||||
<div key={provider.providerId} className="flex items-center gap-2">
|
||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
|
||||
@@ -510,9 +507,7 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Enter API key"
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
|
||||
@@ -81,7 +81,9 @@ export const SMTPSection = () => {
|
||||
provider,
|
||||
fromName,
|
||||
fromEmail,
|
||||
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
|
||||
...(provider === 'resend'
|
||||
? { apiKey }
|
||||
: { host, port: parseInt(port) || 587, username, password, secure }),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -107,11 +109,7 @@ export const SMTPSection = () => {
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Connection failed';
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
@@ -122,10 +120,7 @@ export const SMTPSection = () => {
|
||||
if (isTesting || !testEmail) return;
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', {
|
||||
...buildConfig(),
|
||||
to: testEmail,
|
||||
});
|
||||
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail });
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
@@ -134,11 +129,7 @@ export const SMTPSection = () => {
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Failed to send test email';
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
|
||||
@@ -5,15 +5,7 @@ import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type Provider = 'openai' | 'elevenlabs';
|
||||
@@ -55,11 +47,7 @@ export const TTSSection = () => {
|
||||
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
|
||||
setVoicesLoading(true);
|
||||
try {
|
||||
const res = await client.post<{
|
||||
voices?: string[];
|
||||
groups?: { label: string; voices: string[] }[];
|
||||
error?: string;
|
||||
}>('/server-settings/tts/voices', {
|
||||
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
|
||||
provider: p,
|
||||
url: u,
|
||||
apiKey: key || undefined,
|
||||
@@ -179,9 +167,7 @@ export const TTSSection = () => {
|
||||
)}
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">
|
||||
API Key {provider === 'openai' ? '(optional)' : ''}
|
||||
</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Key {provider === 'openai' ? '(optional)' : ''}</span>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
@@ -211,9 +197,7 @@ export const TTSSection = () => {
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
{voices.length > 0 ? (
|
||||
@@ -225,21 +209,16 @@ export const TTSSection = () => {
|
||||
{voiceGroups.length > 0
|
||||
? voiceGroups.map((g) => (
|
||||
<SelectGroup key={g.label}>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
||||
{g.label}
|
||||
</SelectLabel>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||
{g.voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</SelectItem>
|
||||
<SelectItem key={v} value={v}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
|
||||
@@ -37,17 +37,13 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<section.icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
|
||||
/>
|
||||
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{section.title}</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
|
||||
@@ -57,14 +53,7 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const SettingsSidebar = ({
|
||||
basePath,
|
||||
icon: Icon,
|
||||
label,
|
||||
sections,
|
||||
groups,
|
||||
hideHeader,
|
||||
}: SettingsSidebarProps) => {
|
||||
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const query = search.toLowerCase();
|
||||
@@ -82,12 +71,7 @@ export const SettingsSidebar = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||
{groups
|
||||
@@ -108,9 +92,9 @@ export const SettingsSidebar = ({
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: sections
|
||||
.filter(matchesSearch)
|
||||
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
|
||||
: sections.filter(matchesSearch).map((s) => (
|
||||
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -171,22 +155,10 @@ type CreateSettingsPanelParams = {
|
||||
groups?: SettingsSectionGroup[];
|
||||
};
|
||||
|
||||
export const createSettingsPanelComponents = ({
|
||||
basePath,
|
||||
sidebarIcon,
|
||||
sidebarLabel,
|
||||
sections = [],
|
||||
groups,
|
||||
}: CreateSettingsPanelParams) => {
|
||||
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
|
||||
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
|
||||
const Sidebar: ComponentType = () => (
|
||||
<SettingsSidebar
|
||||
basePath={basePath}
|
||||
icon={sidebarIcon}
|
||||
label={sidebarLabel}
|
||||
sections={allSections}
|
||||
groups={groups}
|
||||
/>
|
||||
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
|
||||
);
|
||||
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
|
||||
return { Sidebar, Content, allSections };
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
|
||||
//
|
||||
@@ -105,7 +104,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
};
|
||||
|
||||
const copy = (value: string, what: string) => {
|
||||
void copyToClipboard(value);
|
||||
void navigator.clipboard.writeText(value);
|
||||
toast.success(`${what} copied`);
|
||||
};
|
||||
|
||||
@@ -284,7 +283,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
size="icon"
|
||||
disabled={!form.password}
|
||||
onClick={() => {
|
||||
void copyToClipboard(form.password);
|
||||
void navigator.clipboard.writeText(form.password);
|
||||
toast.success('Password copied');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||
import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CreateUserForm } from './CreateUserForm';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
type ManagedUser = {
|
||||
id: number;
|
||||
@@ -47,9 +46,6 @@ export const UsersSection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | null>(null);
|
||||
const [confirmReset, setConfirmReset] = useState<ManagedUser | null>(null);
|
||||
/** The one and only sighting of a generated password. Cleared when the dialog closes, and gone for good. */
|
||||
const [newPassword, setNewPassword] = useState<{ email: string; password: string } | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<UsersResponse>({
|
||||
queryKey: USERS_KEY,
|
||||
@@ -109,27 +105,6 @@ export const UsersSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A new platform password, generated by the server and shown once.
|
||||
*
|
||||
* Generated rather than typed because the failure this exists for is "I forgot to copy it down", and an
|
||||
* owner typing a replacement can lose it the same way twice. Only the argon2 hash is stored, so the
|
||||
* dialog below really is the only time anyone sees it — which is why it is a dialog and not a toast.
|
||||
*/
|
||||
const resetPassword = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmReset(null);
|
||||
try {
|
||||
const result = await client.post<{ email: string; password: string }>(`/users/${user.id}/password`, {});
|
||||
setNewPassword(result);
|
||||
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not reset the password');
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmDelete(null);
|
||||
@@ -234,7 +209,7 @@ export const UsersSection = () => {
|
||||
aria-label={`Copy ${user.email}'s SSH public key`}
|
||||
title="Copy their SSH public key (add it to their Gitea account)"
|
||||
onClick={() => {
|
||||
void copyToClipboard(user.osSshPublicKey!);
|
||||
void navigator.clipboard.writeText(user.osSshPublicKey!);
|
||||
toast.success('Public key copied');
|
||||
}}
|
||||
>
|
||||
@@ -242,22 +217,6 @@ export const UsersSection = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* The owner is excluded because they have change-password, which asks for the current one —
|
||||
and resetting themselves from here would sign them out of the session doing it. */}
|
||||
{!user.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={busy}
|
||||
aria-label={`Reset ${user.email}'s password`}
|
||||
title="Generate a new password — shown once, and signs them out everywhere"
|
||||
onClick={() => setConfirmReset(user)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -295,69 +254,6 @@ export const UsersSection = () => {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Confirmed rather than immediate: this ends every session the account has, including one they may
|
||||
be in the middle of using. Not destructive enough for the red button, so it keeps the default. */}
|
||||
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up
|
||||
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
|
||||
{confirmReset?.osUser ? (
|
||||
<>
|
||||
{' '}
|
||||
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
|
||||
unchanged.
|
||||
</>
|
||||
) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
|
||||
Generate a new password
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* The only time this password is ever visible. A dialog rather than a toast for exactly that reason:
|
||||
a toast that times out while somebody is finding a pen loses the thing they came for. */}
|
||||
<AlertDialog open={!!newPassword} onOpenChange={(open) => !open && setNewPassword(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>New password for {newPassword?.email}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Copy it now and give it to them. Only its hash is stored, so closing this dialog is the last anyone sees
|
||||
of it — if it is lost, generate another one.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/50 p-3">
|
||||
<code className="flex-1 select-all break-all font-mono text-sm">{newPassword?.password}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label="Copy the new password"
|
||||
onClick={() => {
|
||||
if (!newPassword) return;
|
||||
void copyToClipboard(newPassword.password).then((ok) =>
|
||||
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { MessageBubble, type ChatMessage } from 'officerdev';
|
||||
|
||||
type LogMetadata = {
|
||||
id: number;
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
isError: boolean;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
type FullLog = LogMetadata & {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const ProviderBadge = ({ provider }: { provider: string }) => (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' : 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'}`}
|
||||
>
|
||||
{provider}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Which run is open is `/task-logs/:id`. No redirect guard — the bare route is the list with nothing
|
||||
// open, and an id that no longer exists gets the empty pane rather than a rewritten address.
|
||||
export const TaskLogs = () => {
|
||||
const client = useClient();
|
||||
const [logs, setLogs] = useState<LogMetadata[]>([]);
|
||||
const selectedId = useParams<{ id: string }>().id ?? null;
|
||||
const [selectedLog, setSelectedLog] = useState<FullLog | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
client
|
||||
.get<LogMetadata[]>('/task-logs')
|
||||
.then((data) => {
|
||||
setLogs(data);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setSelectedLog(null);
|
||||
return;
|
||||
}
|
||||
client
|
||||
.get<FullLog>(`/task-logs/${selectedId}`)
|
||||
.then(setSelectedLog)
|
||||
.catch(() => setSelectedLog(null));
|
||||
}, [selectedId]);
|
||||
|
||||
const filtered = search
|
||||
? logs.filter((l) => {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
l.taskName.toLowerCase().includes(q) ||
|
||||
l.entryName.toLowerCase().includes(q) ||
|
||||
l.provider.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: logs;
|
||||
|
||||
return (
|
||||
<div className="flex h-full p-3 md:p-6 gap-4">
|
||||
{/* Left panel: list */}
|
||||
<Card
|
||||
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${selectedId ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="p-3 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
|
||||
)}
|
||||
{filtered.map((log) => (
|
||||
<Link
|
||||
key={log.id}
|
||||
to={`/task-logs/${log.id}`}
|
||||
className={`block w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedId === String(log.id) ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{log.isError ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
) : log.completedAt ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{log.taskName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-5.5">
|
||||
<span className="text-xs text-duck-dark/50 truncate">{log.entryName}</span>
|
||||
<ProviderBadge provider={log.provider} />
|
||||
</div>
|
||||
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right panel: log viewer */}
|
||||
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${selectedId ? 'flex' : 'hidden md:flex'}`}>
|
||||
{!selectedLog && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
|
||||
Select a log to view
|
||||
<Link to="/task-logs" className="md:hidden text-duck-teal text-xs cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 inline mr-1" />
|
||||
Back to list
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog && (
|
||||
<>
|
||||
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
|
||||
<Link to="/task-logs" className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
|
||||
<ProviderBadge provider={selectedLog.provider} />
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/50 mt-0.5">
|
||||
{selectedLog.entryName} · {selectedLog.model} · {formatDate(selectedLog.startedAt)}
|
||||
{selectedLog.completedAt && ` — ${formatDate(selectedLog.completedAt)}`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog.isError && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{selectedLog.messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from './AppStore';
|
||||
export * from './Plugins';
|
||||
export * from './PluginScreen';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
@@ -8,12 +6,15 @@ export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
export * from './Skills';
|
||||
export * from './TaskLogs';
|
||||
export * from './Tasks';
|
||||
|
||||
export * from './Files';
|
||||
export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './Music';
|
||||
export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './Photos';
|
||||
export * from './Jellyfin';
|
||||
export * from './Transmission';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user