Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 | ||
|
|
fe0012635a | ||
|
|
547662842b | ||
|
|
eb1fd8c31a | ||
|
|
d85f089817 | ||
|
|
11710f283a | ||
|
|
b6feca8350 | ||
|
|
1d95ad3d1b | ||
|
|
e881015df5 | ||
|
|
3862558b92 | ||
|
|
977e30e782 | ||
|
|
edbe446b34 | ||
|
|
e36c6bb431 | ||
|
|
4d14e11f6c | ||
|
|
4c33ef7206 | ||
|
|
6c13e0d8f6 | ||
|
|
cbfe376a42 | ||
|
|
64f3de59fb | ||
|
|
081920c61f | ||
|
|
fb0286e1a9 | ||
|
|
d6f01862fe | ||
|
|
bf7e919593 | ||
|
|
2ac58e2007 | ||
|
|
9f15de3448 | ||
|
|
3ca7f5f331 | ||
|
|
2273941e71 | ||
|
|
41cdd1e63a | ||
|
|
7b32f5bc2f | ||
|
|
cc1eab7794 | ||
|
|
8826c2847e | ||
|
|
236a3a5481 | ||
|
|
d7d64cd6d6 | ||
|
|
cd209483e3 | ||
|
|
77f1284925 | ||
|
|
74b4c7908a | ||
|
|
f1cfc0042f | ||
|
|
88c9e96895 | ||
|
|
e5fe966308 | ||
|
|
60ab531294 | ||
|
|
f33e7474b0 | ||
|
|
bedc420d4d | ||
|
|
9b56e04b5e | ||
|
|
ea87ffed04 | ||
|
|
3cca07187e | ||
|
|
b5aa2e0387 | ||
|
|
0da15d78a1 | ||
|
|
e2faad40a3 | ||
|
|
61a3ae720f | ||
|
|
085afe7604 | ||
|
|
62cbf510cb | ||
|
|
b075f1f882 | ||
|
|
7280d13b93 | ||
|
|
fbb6d6c78c | ||
|
|
3cb39662b5 | ||
|
|
ffca309a77 | ||
|
|
1eecc400a7 | ||
|
|
595dd082a7 | ||
|
|
f9cd0a798a | ||
|
|
288b4bf683 | ||
|
|
68f2c55ecf | ||
|
|
4cebae1c85 | ||
|
|
9d6c196848 | ||
|
|
8207824a81 | ||
|
|
1ccb21e67f | ||
|
|
9864fb1a49 |
+17
-19
@@ -1,29 +1,27 @@
|
||||
# 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"
|
||||
|
||||
# ── 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>"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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>"
|
||||
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
|
||||
PUBLIC_URL=http://localhost:9000
|
||||
|
||||
# ── 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.
|
||||
#
|
||||
# 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.
|
||||
|
||||
# ── 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
|
||||
|
||||
+10
@@ -58,3 +58,13 @@ 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/
|
||||
src/apps/officer-web/Plugins.gen.tsx
|
||||
|
||||
@@ -16,13 +16,23 @@ 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.** 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.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
`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"
|
||||
@@ -42,18 +52,20 @@ 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 (`BROWSER_RELAY_PORT`, default 18792)
|
||||
- ~~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.
|
||||
|
||||
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
|
||||
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`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-agent` are not the same thing.** The proxy holds the Anthropic
|
||||
**`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
|
||||
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
|
||||
@@ -112,16 +124,27 @@ 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. Schema in `src/schema/`, hand-written queries in
|
||||
`src/queries/`, types inferred from the schema in `src/types.ts`.
|
||||
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`.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
@@ -163,11 +186,13 @@ 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 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/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/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.
|
||||
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`.
|
||||
**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
|
||||
@@ -194,9 +219,17 @@ 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 # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
@@ -1,250 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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/schema/api-keys.ts` — the table, and why it stores what it stores.
|
||||
- `src/databases/officer_db/src/api-keys/schema.ts` — the table, and why it stores what it stores.
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
# Offscale — the first real plugin
|
||||
|
||||
**Status: LIVE DOCUMENT, opened 2026-08-14.** Decisions and findings from the session that started the
|
||||
plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
### 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, as of 2026-08-14
|
||||
|
||||
The plugin system works end to end for a plugin that has an `api/router.ts`. Verified against a running
|
||||
server, with **no restart at any point**:
|
||||
|
||||
```
|
||||
/api/example/ping BEFORE install 404
|
||||
AFTER install 200 {"plugin":"example","ok":true}
|
||||
AFTER disable 404
|
||||
AFTER enable 200
|
||||
AFTER uninstall 404
|
||||
core routes throughout 200
|
||||
```
|
||||
|
||||
| Piece | Where |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||
| Disk ⋈ database, and the rebuild | `servers/plugins/mount.ts` |
|
||||
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||
| The closure that makes the swap take effect | `server.tsx`, the `/api/*` route |
|
||||
| Install state | `plugin_installs` (`officer_db/src/plugins/`) |
|
||||
| The four verbs | `servers/api/plugins/router.ts`, owner-only |
|
||||
| The screen | `/plugins` — two panels, `?selected=` |
|
||||
| The reference plugin | `plugins/example/` — meant to be read |
|
||||
|
||||
### Not wired yet
|
||||
|
||||
- **The schema push.** A plugin with `db/schema.ts` installs, but its tables are not created. Marked
|
||||
`[open]` in the router.
|
||||
- **The sidecar's PM2 entry.** A plugin with `sidecar/` installs, but no process starts. This is the same
|
||||
hole `app-store/pm2.ts:23-29` documents for the app store, and it is the next thing to build.
|
||||
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||
still the six hardcoded providers.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` remains the core list, so plugin mounts are
|
||||
not covered by the boot check — and the check is reading the wrong list anyway (see below). The
|
||||
assertion wants moving into `buildHonoApp`, which is now the single place routes are mounted.
|
||||
|
||||
### Deliberately not done
|
||||
|
||||
**Offscale is not extracted.** The infrastructure is ready for it, but moving it means deleting working
|
||||
code across ~50 files, and that should happen with someone watching rather than unattended.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
+43
-17
@@ -1,11 +1,19 @@
|
||||
# The secret store
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,7 +77,18 @@ 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, for now
|
||||
### 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
|
||||
|
||||
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||||
|
||||
@@ -94,25 +113,30 @@ 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.
|
||||
|
||||
`[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.
|
||||
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.
|
||||
|
||||
### 4. One secret remains outside
|
||||
### 4. ~~One secret remains outside~~ — none does
|
||||
|
||||
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.**
|
||||
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
|
||||
|
||||
`[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.
|
||||
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.
|
||||
|
||||
### 5. What moves in
|
||||
|
||||
- `VAULT_STORE_KEY` — the at-rest key for everything in the table above.
|
||||
- `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.
|
||||
- `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.
|
||||
@@ -208,8 +232,10 @@ 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: `encryptSecret` / `decryptSecret`. Only where the key comes from
|
||||
changes, so no caller is touched.
|
||||
- ~~`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.
|
||||
- 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/queries/wallet.ts` (where the two meet).
|
||||
`src/databases/officer_db/src/wallet/queries.ts` (where the two meet).
|
||||
|
||||
## The requirement
|
||||
|
||||
|
||||
@@ -7,27 +7,53 @@ agent sessions start.
|
||||
Three directories sit there, and knowing which one a change belongs in is most of the job:
|
||||
|
||||
```
|
||||
officer/
|
||||
$OFFICER_ROOT/
|
||||
├── platform/ the application — a git repo
|
||||
├── capabilities/ what the agent can do — a separate git repo
|
||||
└── data/ runtime state — NOT version controlled
|
||||
├── data/ runtime state — NOT version controlled
|
||||
├── dockers/ containers the app store provisioned
|
||||
└── secrets/ the key store — 0600, and NOT in your data backup
|
||||
```
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -67,13 +93,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-agent`,
|
||||
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`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-agent` is the process that actually runs `claude`.**
|
||||
and proxies API traffic; `officer-claude-code` 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/queries/dashboards.ts:70`) → 23502.
|
||||
(`databases/officer_db/src/dashboards/queries.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/schema/dashboards.ts` declares `id: text('id').primaryKey()`. Live:
|
||||
`databases/officer_db/src/dashboards/schema.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/queries/dashboards.ts:73` —
|
||||
`databases/officer_db/src/dashboards/queries.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.**
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
// 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/setup/officer-setup.sh"
|
||||
"setup": "bash scripts/install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
|
||||
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||
//
|
||||
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||
export const router = createRouter();
|
||||
|
||||
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||
@@ -0,0 +1,36 @@
|
||||
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 capability gate actually has to resolve.
|
||||
permissions: [
|
||||
{
|
||||
key: 'example',
|
||||
label: 'Example',
|
||||
description: 'The reference plugin',
|
||||
ownerOnly: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// The reference sidecar: a long-lived process PM2 supervises.
|
||||
//
|
||||
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||
//
|
||||
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||
// installer, and adding a socket here would test Bun rather than that.
|
||||
|
||||
const name = 'officer-example';
|
||||
console.log(`[${name}] started (pid ${process.pid})`);
|
||||
|
||||
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`[${name}] ${signal} — exiting`);
|
||||
clearInterval(beat);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Routes, Route, Link } from 'react-router';
|
||||
|
||||
// The plugin's own router, mounted by the shell at `<prefix>/*` — so everything below this point is the
|
||||
// plugin's, and react-router nests it natively. The shell knows the prefix; this file does not need to.
|
||||
|
||||
const Home = () => (
|
||||
<div className="p-8">
|
||||
<h1 className="text-xl font-semibold text-duck-dark">Example plugin</h1>
|
||||
<p className="mt-2 text-sm text-duck-dark/60">
|
||||
Rendered from <code>plugins/example/web/Router.tsx</code>, compiled into the shell's bundle by the generated{' '}
|
||||
<code>Plugins.gen.tsx</code>.
|
||||
</p>
|
||||
<Link className="mt-4 inline-block text-sm text-duck-teal underline" to="/example/deeper">
|
||||
A nested route →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Deeper = () => (
|
||||
<div className="p-8">
|
||||
<h1 className="text-xl font-semibold text-duck-dark">Nested</h1>
|
||||
<p className="mt-2 text-sm text-duck-dark/60">Proof the wildcard mount hands the whole subtree to the plugin.</p>
|
||||
<Link className="mt-4 inline-block text-sm text-duck-teal underline" to="/example">
|
||||
← back
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ExampleRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/deeper" element={<Deeper />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Puzzle } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import ExampleRouter from './Router';
|
||||
|
||||
// Panels this plugin contributes to the workspace registry. The shell passes them to `seedAppRegistry`
|
||||
// from the generated module — it never imports this file directly, because officerdev is a dependency of
|
||||
// the shell and importing upward would invert that.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'example-panel', name: 'Example', icon: Puzzle, component: ExampleRouter, availableOnPanel: true },
|
||||
];
|
||||
+25
-4
@@ -16,18 +16,39 @@ 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');
|
||||
|
||||
// The server reads .env through --env-file, but this script runs standalone.
|
||||
// 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();
|
||||
|
||||
const envPath = join(root, '.env');
|
||||
if (!process.env.PUBLIC_URL && existsSync(envPath)) {
|
||||
if (!argUrl && !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 = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
|
||||
const publicUrl = (argUrl || process.env.PUBLIC_URL || '').replace(/\/+$/, '');
|
||||
if (!publicUrl) {
|
||||
console.error('[gen-index] PUBLIC_URL is not set — set it in .env (e.g. https://officer.example.com)');
|
||||
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.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/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,7 +10,9 @@
|
||||
import type { BrowsedFile } from 'officerdb';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { db, finishSoulseekBrowse } from 'officerdb';
|
||||
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/schema';
|
||||
// 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 { buildTree } from '../src/servers/sidecar/slskd/browse';
|
||||
|
||||
const snapshots = await db
|
||||
|
||||
@@ -150,8 +150,54 @@ 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
|
||||
@@ -239,6 +285,37 @@ 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
|
||||
@@ -585,6 +662,15 @@ 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,27 +92,36 @@ 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
|
||||
# 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.
|
||||
"$(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.
|
||||
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"
|
||||
# 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 -s "$shell" "$USERNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -49,6 +49,11 @@ 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)"
|
||||
@@ -67,7 +72,30 @@ install_docker_engine() {
|
||||
>/etc/apt/sources.list.d/docker.list
|
||||
|
||||
pkg_refresh >/dev/null
|
||||
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
# ── 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
|
||||
}
|
||||
|
||||
# A shared network so containers from different compose files can reach each
|
||||
|
||||
@@ -43,10 +43,17 @@ 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
|
||||
|
||||
cmp -s "$src" "$dest" && return 1
|
||||
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
|
||||
|
||||
echo ""
|
||||
warn "${dest} already exists here, and differs from the one this script ships."
|
||||
@@ -56,6 +63,7 @@ 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
|
||||
|
||||
@@ -67,17 +75,20 @@ 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)
|
||||
@@ -125,3 +136,38 @@ 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,28 +84,36 @@ LAST_SKIPPED=()
|
||||
pkgs_core() {
|
||||
case "$PM" in
|
||||
apt)
|
||||
# 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.
|
||||
# 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.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula.
|
||||
echo gnupg git jq wget btop htop tree tmux ripgrep fd
|
||||
# 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
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -211,8 +219,20 @@ pkg_install() {
|
||||
LAST_INSTALLED=("${missing[@]}")
|
||||
LAST_KEPT=("${present[@]}")
|
||||
|
||||
announce_plan "$label" present missing || return 0
|
||||
pkg_install_now "${missing[@]}"
|
||||
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
|
||||
}
|
||||
|
||||
# Print what a section is about to do and ask permission for it.
|
||||
@@ -262,3 +282,23 @@ 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,7 +190,18 @@ tailscale_control_url() {
|
||||
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
||||
}
|
||||
|
||||
tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; }
|
||||
# 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's own coordination server, spelled out.
|
||||
#
|
||||
|
||||
@@ -20,10 +20,17 @@
|
||||
MACHINE_SETUP_TOOLS_LOADED=1
|
||||
|
||||
# The set installed on every machine, in the order they are fetched.
|
||||
tools_default() { echo lazydocker lazygit starship fastfetch; }
|
||||
#
|
||||
# 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; }
|
||||
|
||||
# The command that proves a tool is already here. Same as the tool name for all
|
||||
# four today, but kept as a mapping because that is not a rule — a package and
|
||||
# three 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() {
|
||||
@@ -31,7 +38,6 @@ tool_command() {
|
||||
lazydocker) echo lazydocker ;;
|
||||
lazygit) echo lazygit ;;
|
||||
starship) echo starship ;;
|
||||
fastfetch) echo fastfetch ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -80,23 +86,6 @@ 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,6 +18,12 @@ 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)
|
||||
@@ -32,12 +38,25 @@ 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]"
|
||||
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."
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||
@@ -48,6 +67,7 @@ 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"
|
||||
@@ -87,6 +107,7 @@ echo -e "${BOLD}╚════════════════════
|
||||
[[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE"
|
||||
load_answers
|
||||
|
||||
trap report_flush EXIT
|
||||
detect_os
|
||||
echo ""
|
||||
info "Machine: ${OS_NAME} (${ARCH})"
|
||||
@@ -119,15 +140,58 @@ else
|
||||
echo " so coming back costs nothing."
|
||||
fi
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Please run as root: sudo ./machine-setup.sh"
|
||||
# ── 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[@]}"}
|
||||
fi
|
||||
|
||||
ask_username
|
||||
if id "$USERNAME" &>/dev/null; then
|
||||
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
|
||||
# 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
|
||||
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
|
||||
ask_username
|
||||
if id "$USERNAME" &>/dev/null; then
|
||||
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
|
||||
else
|
||||
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
|
||||
fi
|
||||
fi
|
||||
|
||||
ask_officer_root
|
||||
@@ -140,9 +204,9 @@ fi
|
||||
save_answers
|
||||
|
||||
# Always, and outside any step: everything below reads this index — core utils,
|
||||
# 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.
|
||||
# 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
|
||||
@@ -374,6 +438,34 @@ 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
|
||||
@@ -690,10 +782,9 @@ if ! skip; then
|
||||
echo ""
|
||||
|
||||
while [[ -z "${TIMEZONE:-}" ]]; do
|
||||
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
|
||||
# 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 [[ -z "$TZ_CHOICE" ]]; then
|
||||
TIMEZONE="$CURRENT_TZ"
|
||||
@@ -847,10 +938,7 @@ elif ! skip; then
|
||||
|
||||
BALLAST_FILE=""
|
||||
while [[ -z "$BALLAST_FILE" ]]; do
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer BALLAST_WHERE " Which one? (1/2/3) [1]: "
|
||||
case "${BALLAST_WHERE:-1}" in
|
||||
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
|
||||
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
|
||||
@@ -890,10 +978,7 @@ elif ! skip; then
|
||||
|
||||
BALLAST_PCT=""
|
||||
while [[ -z "$BALLAST_PCT" ]]; do
|
||||
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer BALLAST_SIZE_CHOICE " Which one? (1/2/3) [2]: "
|
||||
case "${BALLAST_SIZE_CHOICE:-2}" in
|
||||
1) BALLAST_PCT=5 ;;
|
||||
2) BALLAST_PCT=10 ;;
|
||||
@@ -1268,10 +1353,7 @@ if ! skip; then
|
||||
DNS_FALLBACK=""
|
||||
DNS_CHOSEN=""
|
||||
while [[ -z "$DNS_CHOSEN" ]]; do
|
||||
if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
|
||||
case "${DNS_CHOICE:-1}" in
|
||||
1) DNS_CHOSEN="keep" ;;
|
||||
2)
|
||||
@@ -1378,10 +1460,7 @@ elif ! skip; then
|
||||
|
||||
NET_CHOICE=""
|
||||
while [[ -z "$NET_CHOICE" ]]; do
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer NET_ANSWER " Which one? (1/2/3) [1]: "
|
||||
case "${NET_ANSWER:-1}" in
|
||||
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
|
||||
*) warn "Pick 1, 2 or 3." ;;
|
||||
@@ -1644,12 +1723,47 @@ 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')"
|
||||
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
|
||||
if [[ "$OS" != "macos" ]]; then
|
||||
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
|
||||
fi
|
||||
|
||||
if ! docker_is_installed; then
|
||||
# ── 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
|
||||
echo ""
|
||||
echo " to install: docker-ce, the CLI, containerd, buildx and compose,"
|
||||
echo " from Docker's own repository"
|
||||
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"
|
||||
if confirm "Install it?"; then
|
||||
if install_docker_engine; then
|
||||
ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
|
||||
@@ -1665,7 +1779,10 @@ if ! skip; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if docker_is_installed; then
|
||||
# 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
|
||||
# ── how this account reaches the daemon ──
|
||||
if user_in_docker_group || docker_rootless_installed; then
|
||||
echo ""
|
||||
@@ -1710,10 +1827,7 @@ if ! skip; then
|
||||
|
||||
DOCKER_ACCESS=""
|
||||
while [[ -z "$DOCKER_ACCESS" ]]; do
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer DOCKER_CHOICE " Which one? (1/2/3) [1]: "
|
||||
case "${DOCKER_CHOICE:-1}" in
|
||||
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
|
||||
*) warn "Pick 1, 2 or 3." ;;
|
||||
@@ -1821,10 +1935,7 @@ if ! skip; then
|
||||
NVIM_REPO=""
|
||||
NVIM_PICK=""
|
||||
while [[ -z "$NVIM_PICK" ]]; do
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer NVIM_CHOICE " Which one? (1/2/3) [1]: "
|
||||
case "${NVIM_CHOICE:-1}" in
|
||||
1)
|
||||
NVIM_REPO="https://github.com/LazyVim/starter"
|
||||
@@ -1984,7 +2095,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-agent; chat does not work without it."
|
||||
echo " spawned by officer-claude-code; chat does not work without it."
|
||||
echo " opencode $(agent_version opencode || echo 'not installed')"
|
||||
echo " the alternative agent, run by officer-opencode."
|
||||
echo ""
|
||||
@@ -2110,10 +2221,20 @@ 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
|
||||
set_login_shell "$(command -v zsh)"
|
||||
ok "login shell is now $(user_login_shell)"
|
||||
SUMMARY+=("Shell: login shell set to zsh")
|
||||
if set_login_shell "$(command -v zsh)"; then
|
||||
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}")
|
||||
@@ -2137,11 +2258,29 @@ if ! skip; then
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ -r "$SCRIPT_DIR/.tmux.conf" ]]; then
|
||||
install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME" && RC=0 || RC=$?
|
||||
# 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=$?
|
||||
case $RC in
|
||||
0) ok "tmux config installed" ;;
|
||||
1) echo " tmux config already matches" ;;
|
||||
0) ok "tmux config installed — ${TMUX_TARGET}" ;;
|
||||
1) echo " tmux config already matches (${TMUX_TARGET})" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -2172,8 +2311,76 @@ 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"
|
||||
@@ -2202,10 +2409,7 @@ EOF
|
||||
|
||||
EDITOR_PICK=""
|
||||
while [[ -z "$EDITOR_PICK" ]]; do
|
||||
if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
menu_answer EDITOR_CHOICE " Which one? (1-${#EDITORS[@]}) [1]: "
|
||||
EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
|
||||
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
|
||||
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
|
||||
@@ -2397,5 +2601,35 @@ 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
|
||||
|
||||
+470
-13
@@ -14,6 +14,13 @@ 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)
|
||||
@@ -24,19 +31,46 @@ 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]"
|
||||
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."
|
||||
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"
|
||||
@@ -48,6 +82,14 @@ 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
|
||||
|
||||
@@ -60,10 +102,37 @@ echo -e "${BOLD}╔════════════════════
|
||||
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Please run as root: sudo ./officer-setup.sh"
|
||||
# ── 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[@]}"}
|
||||
fi
|
||||
|
||||
trap report_flush EXIT
|
||||
|
||||
# ── what machine-setup already established ──
|
||||
echo ""
|
||||
if load_machine_answers; then
|
||||
@@ -114,6 +183,26 @@ 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
|
||||
@@ -167,6 +256,7 @@ fi
|
||||
#
|
||||
# Before the repository, because the repository is cloned into it.
|
||||
|
||||
report_section "Layout"
|
||||
step "Layout"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -210,6 +300,7 @@ fi
|
||||
# 3. Repository
|
||||
# =============================================================================
|
||||
|
||||
report_section "Repository"
|
||||
step "Repository"
|
||||
if ! skip; then
|
||||
PLATFORM_DIR="$(platform_dir)"
|
||||
@@ -282,6 +373,7 @@ fi
|
||||
# 4. Dependencies
|
||||
# =============================================================================
|
||||
|
||||
report_section "Dependencies"
|
||||
step "Dependencies"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -336,6 +428,7 @@ fi
|
||||
#
|
||||
# POSTGRES_URL is set here and written by the environment section below.
|
||||
|
||||
report_section "Database"
|
||||
step "Database"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -353,6 +446,11 @@ if ! skip; then
|
||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||
fi
|
||||
|
||||
# The client goes on the HOST, before any of the container work, because it is the half
|
||||
# that is not in the container. A member has their own Postgres role and no access to the
|
||||
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
||||
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
||||
|
||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||
@@ -453,6 +551,7 @@ fi
|
||||
# 6. Environment
|
||||
# =============================================================================
|
||||
|
||||
report_section "Environment"
|
||||
step "Environment"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
@@ -460,7 +559,7 @@ if ! skip; then
|
||||
|
||||
# Read back before anything is asked; existing values become the defaults.
|
||||
ENV_PORT="$(env_get PORT)"
|
||||
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
|
||||
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
|
||||
|
||||
if env_exists; then
|
||||
echo " exists — its values are the defaults below"
|
||||
@@ -471,11 +570,23 @@ if ! skip; then
|
||||
# ── what is asked ──
|
||||
echo ""
|
||||
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
||||
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
|
||||
|
||||
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")}"
|
||||
|
||||
echo ""
|
||||
echo " to write:"
|
||||
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
|
||||
echo " PORT=${ENV_PORT}"
|
||||
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
|
||||
echo ""
|
||||
echo " the install root is not written here — the platform derives it as the"
|
||||
@@ -486,6 +597,7 @@ 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
|
||||
@@ -496,13 +608,358 @@ if ! skip; then
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# NOT BUILT YET
|
||||
# 7. Secrets
|
||||
# =============================================================================
|
||||
# 6 Schema db:push
|
||||
# 7 Build gen:index
|
||||
# 8 Services pm2 startOrRestart · save · startup
|
||||
# 9 Verify are the processes actually up
|
||||
#
|
||||
# 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
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
|
||||
echo ""
|
||||
|
||||
report_mark_complete
|
||||
|
||||
@@ -113,6 +113,14 @@ 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 ""
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/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,16 +5,14 @@
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── What is NOT here ──
|
||||
# ── No secrets are written here ──
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# So this file holds no credential except POSTGRES_URL, which is a connection
|
||||
# string to a database bound to loopback.
|
||||
#
|
||||
# ── Derived, not asked ──
|
||||
#
|
||||
@@ -65,8 +63,8 @@ write_env() {
|
||||
|
||||
PORT="${ENV_PORT}"
|
||||
|
||||
# The browser relay listens on its own port, separate from the app.
|
||||
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
|
||||
# Where Officer is reached from a browser. Not derivable — see the section.
|
||||
PUBLIC_URL="${ENV_PUBLIC_URL}"
|
||||
|
||||
POSTGRES_URL="${POSTGRES_URL}"
|
||||
|
||||
@@ -78,3 +76,49 @@ 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,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||
PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
# ── The CLIENT, on the host, matching the server in the container ──
|
||||
#
|
||||
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
|
||||
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
|
||||
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
|
||||
# owner's Docker socket.
|
||||
#
|
||||
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
|
||||
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
|
||||
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
|
||||
# archive package is not merely old, it is unusable for dumps. That is also why this lives
|
||||
# beside the server definition rather than in machine-setup's package list — one constant,
|
||||
# one place to bump.
|
||||
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
|
||||
|
||||
pg_client_installed() {
|
||||
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
|
||||
}
|
||||
|
||||
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
return 1
|
||||
}
|
||||
|
||||
if pg_client_installed; then
|
||||
ok "psql ${major} already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -d -m 0755 /usr/share/postgresql-common/pgdg
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
|
||||
warn "could not fetch the PostgreSQL signing key"
|
||||
return 1
|
||||
}
|
||||
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||||
>/etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
|
||||
warn "postgresql-client-${major} did not install"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
|
||||
# matters is whether the binary is there AND is the version we asked for, because apt can
|
||||
# succeed while holding an older client back.
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
|
||||
}
|
||||
|
||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||
ensure_docker_network() {
|
||||
docker_network_exists && return 1
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/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,6 +14,22 @@
|
||||
[[ -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"; }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/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)" ]]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/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}`);
|
||||
}
|
||||
});'
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/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}"
|
||||
}
|
||||
@@ -24,6 +24,12 @@ bind - split-window -v
|
||||
unbind r
|
||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||
|
||||
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
|
||||
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
|
||||
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
|
||||
# in one TCP frame, which over a websocket relay it always does.
|
||||
set -sg escape-time 10
|
||||
|
||||
# switch panes using Alt-arrow without prefix
|
||||
bind -n M-Left select-pane -L
|
||||
bind -n M-Right select-pane -R
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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,6 +5,10 @@ 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 docs/offscale-plugin.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
@@ -64,6 +68,13 @@ export function App() {
|
||||
<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.map((plugin) => (
|
||||
<Route key={plugin.appName} path={`${plugin.route}/*`} element={<plugin.Router />} />
|
||||
))}
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
@@ -91,8 +102,6 @@ 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 />} />
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 };
|
||||
|
||||
@@ -571,7 +572,7 @@ export const PipelineJobDetail = () => {
|
||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||
const text = msg.text || streamBuffers.current.get(key) || '';
|
||||
if (text) {
|
||||
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
}
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||
@@ -583,7 +584,7 @@ export const PipelineJobDetail = () => {
|
||||
// Flush any streaming text before the tool call
|
||||
flushStreamBuffer(key);
|
||||
appendOutput(key, {
|
||||
id: crypto.randomUUID(),
|
||||
id: randomId(),
|
||||
type: 'tool',
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
@@ -630,7 +631,7 @@ export const PipelineJobDetail = () => {
|
||||
const flushStreamBuffer = useCallback((key: string) => {
|
||||
const text = streamBuffers.current.get(key);
|
||||
if (text) {
|
||||
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
||||
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||
streamBuffers.current.delete(key);
|
||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||
}
|
||||
|
||||
@@ -130,7 +130,6 @@ import {
|
||||
FolderOpen,
|
||||
Code,
|
||||
LayoutGrid,
|
||||
ScrollText,
|
||||
FolderKanban,
|
||||
Monitor,
|
||||
Mail,
|
||||
@@ -150,6 +149,7 @@ import {
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -170,15 +170,20 @@ 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.
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
// 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,25 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PluginsScreen';
|
||||
@@ -6,6 +6,7 @@ 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.
|
||||
@@ -39,7 +40,7 @@ const formatDate = (value: string | null) =>
|
||||
|
||||
const copy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
await copyToClipboard(text);
|
||||
toast.success('Key copied');
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -46,7 +47,7 @@ export const BrowserRelay = () => {
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
await copyToClipboard(value);
|
||||
setCopiedField(field);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ 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).
|
||||
//
|
||||
@@ -29,7 +30,7 @@ const formatDate = (value: string | null) =>
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
await copyToClipboard(text);
|
||||
toast.success(`${what} copied`);
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
//
|
||||
@@ -104,7 +105,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
};
|
||||
|
||||
const copy = (value: string, what: string) => {
|
||||
void navigator.clipboard.writeText(value);
|
||||
void copyToClipboard(value);
|
||||
toast.success(`${what} copied`);
|
||||
};
|
||||
|
||||
@@ -283,7 +284,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
size="icon"
|
||||
disabled={!form.password}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(form.password);
|
||||
void copyToClipboard(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, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, 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,6 +16,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CreateUserForm } from './CreateUserForm';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
type ManagedUser = {
|
||||
id: number;
|
||||
@@ -46,6 +47,9 @@ 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,
|
||||
@@ -105,6 +109,27 @@ 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);
|
||||
@@ -209,7 +234,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 navigator.clipboard.writeText(user.osSshPublicKey!);
|
||||
void copyToClipboard(user.osSshPublicKey!);
|
||||
toast.success('Public key copied');
|
||||
}}
|
||||
>
|
||||
@@ -217,6 +242,22 @@ 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"
|
||||
@@ -254,6 +295,69 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
export * from './AppStore';
|
||||
export * from './Plugins';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
@@ -6,7 +7,6 @@ export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
export * from './Skills';
|
||||
export * from './TaskLogs';
|
||||
export * from './Tasks';
|
||||
|
||||
export * from './Files';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { seedAppRegistry, seedWidgetRegistry } from 'officerdev';
|
||||
import { plugins } from './Plugins.gen';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ColorModeProvider } from '@/components/ui/ThemeProvider';
|
||||
import { I18nBridge } from '@/lib/I18nBridge';
|
||||
@@ -20,7 +21,11 @@ const queryClient = new QueryClient({
|
||||
|
||||
// Before the first render, not from a component inside it: a panel that renders before its registry is
|
||||
// populated draws an empty box, and `useGlobal`'s initialData gives no second chance to fill it.
|
||||
seedAppRegistry(queryClient);
|
||||
// Panels contributed by installed plugins, from the generated module. See servers/plugins/generate.ts.
|
||||
seedAppRegistry(
|
||||
queryClient,
|
||||
plugins.flatMap((p) => p.panels),
|
||||
);
|
||||
seedWidgetRegistry(queryClient);
|
||||
|
||||
const elem = document.getElementById('root')!;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
|
||||
import type { PageTitleOverride } from 'officerdev';
|
||||
import { usePageTitleOverride } from 'officerdev';
|
||||
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
|
||||
type TitleRule = { match: (p: string) => boolean; title: string };
|
||||
|
||||
@@ -22,6 +23,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
||||
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
|
||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
@@ -34,7 +36,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
||||
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
|
||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
|
||||
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
||||
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
|
||||
@@ -139,7 +140,7 @@ function claimTabIdentity(): void {
|
||||
|
||||
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
|
||||
function newTabId(): string {
|
||||
return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
return randomId();
|
||||
}
|
||||
|
||||
// Once per document, before React reads the stored name.
|
||||
|
||||
+32
-12
@@ -9,18 +9,38 @@ describing a different codebase.
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: re-exports queries, schema and drizzle helpers
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ └── schema/
|
||||
│ ├── index.ts # re-exports all schema files
|
||||
│ └── *.ts # table definitions, grouped by domain
|
||||
└── package.json # exports "." and "./types"
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
Schema files are grouped by domain, not by table: `auth`, `chat-events`, `dashboards`, `email`,
|
||||
`headscale`, `music`, `operations`, `pipeline-jobs`, `server`, `soulseek`, `user-data`, `vault`,
|
||||
`wallet`.
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
@@ -114,8 +134,8 @@ Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one file per domain under `src/queries/`, importing tables from `../schema` and types from
|
||||
`../types`:
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
@@ -15,7 +15,7 @@ try {
|
||||
} catch {}
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema/index.ts',
|
||||
schema: './src/schema.ts',
|
||||
out: './migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./db": "./src/db.ts",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
"./schema": "./src/schema.ts",
|
||||
"./secret-store": "./src/secret-store.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
listAgentPanels,
|
||||
getAgentPanelByPanelId,
|
||||
getAgentPanelByName,
|
||||
getAgentPanelByHandoffToken,
|
||||
createAgentPanel,
|
||||
updateAgentPanel,
|
||||
markAgentPanelIntroduced,
|
||||
deleteAgentPanel,
|
||||
toAgentPanelView,
|
||||
} from './queries';
|
||||
|
||||
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries';
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { agentPanels } from '../schema';
|
||||
import type { AgentPanelRow } from '../schema/agent-panels';
|
||||
import { agentPanels } from './schema';
|
||||
import type { AgentPanelRow } from './schema';
|
||||
|
||||
export type AgentPanel = AgentPanelRow;
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { pgTable, serial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* One named agent living in one dashboard panel — the address book that lets two chat panels on the
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
findLiveApiKeyByHash,
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
touchApiKey,
|
||||
type ApiKeyIdentity,
|
||||
} from './queries';
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { apiKeys, users } from '../schema';
|
||||
import { apiKeys } from './schema';
|
||||
import { users } from '../auth/schema';
|
||||
import type { ApiKeySelect } from '../types';
|
||||
|
||||
// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// Long-lived credentials a user mints for themselves, so a native app can hold one instead of a password.
|
||||
//
|
||||
@@ -0,0 +1,13 @@
|
||||
// App store — what the owner has installed, and whether it should be running.
|
||||
export {
|
||||
listSidecarInstalls,
|
||||
getSidecarInstall,
|
||||
beginInstall,
|
||||
recordSteps,
|
||||
markInstalled,
|
||||
markFailed,
|
||||
markBlocked,
|
||||
setEnabled,
|
||||
removeInstall,
|
||||
type SidecarInstall,
|
||||
} from './queries';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { sidecarInstalls } from '../schema';
|
||||
import { sidecarInstalls } from './schema';
|
||||
|
||||
// What the owner has installed from the app store. See ../schema/app-store.ts for why there is no
|
||||
// userId and why `installed` and `enabled` are separate.
|
||||
@@ -0,0 +1,27 @@
|
||||
export {
|
||||
getUsers,
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
getOwnerUser,
|
||||
getUserCount,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getPasskeysByUserId,
|
||||
getPasskeysByUserIdAndOrigin,
|
||||
getPasskeyByCredentialId,
|
||||
createPasskey,
|
||||
updatePasskey,
|
||||
storeChallenge,
|
||||
consumeChallenge,
|
||||
blacklistToken,
|
||||
isTokenBlacklisted,
|
||||
cleanupExpiredTokens,
|
||||
} from './queries';
|
||||
|
||||
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
|
||||
// column definition is the only place that list should exist.
|
||||
export { USER_ROLES, OWNER_USER_ID } from './schema';
|
||||
|
||||
export type { UserRole } from './schema';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { eq, and, lt, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from '../schema';
|
||||
import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from './schema';
|
||||
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
|
||||
|
||||
// ── Users ──
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
getAllRoleGrants,
|
||||
getRoleGrants,
|
||||
setRoleGrant,
|
||||
revokeRoleGrant,
|
||||
replaceRoleGrants,
|
||||
} from './queries';
|
||||
|
||||
export type { RoleGrant } from './queries';
|
||||
|
||||
export type { CapabilityLevelValue } from './schema';
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { roleCapabilities } from '../schema';
|
||||
import type { UserRole } from '../schema/auth';
|
||||
import type { CapabilityLevelValue } from '../schema/capabilities';
|
||||
import { roleCapabilities } from './schema';
|
||||
import type { UserRole } from '../auth/schema';
|
||||
import type { CapabilityLevelValue } from './schema';
|
||||
|
||||
// Grants, keyed on role. Absence denies — see the table comment.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { USER_ROLES } from './auth';
|
||||
import { USER_ROLES } from '../auth/schema';
|
||||
|
||||
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
||||
//
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
appendChatEvent,
|
||||
getChatEventsSince,
|
||||
getLastChatEventSeq,
|
||||
pruneChatEventsOlderThan,
|
||||
} from './queries';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { eq, and, gt, asc, desc, lt } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { chatSessionEvents } from '../schema';
|
||||
import { chatSessionEvents } from './schema';
|
||||
|
||||
/** Append one outbound event to a session's durable log; returns its global cursor id. */
|
||||
export async function appendChatEvent(sessionId: string, event: unknown): Promise<number> {
|
||||
@@ -1,40 +1,51 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { getKey } from './secret-store';
|
||||
|
||||
// AES-256-GCM at-rest encryption for vault secrets (the brokered Vaultwarden token set + the Officer-app
|
||||
// protector key). The whole point of the vault store is that a DB dump must not hand over the keys to the
|
||||
// vault, so these columns are never stored plaintext.
|
||||
// AES-256-GCM at-rest encryption for every secret column in Postgres. The property this exists to hold is
|
||||
// that a database dump must not hand over the credentials in it, so these columns are never plaintext.
|
||||
//
|
||||
// Key = SHA-256(VAULT_STORE_KEY) so any sufficiently strong secret works (mirrors the JWT_SECRET style).
|
||||
// Format = base64(iv[12] | authTag[16] | ciphertext). The key is read LAZILY so the platform still boots
|
||||
// without a vault configured — vault storage ops then throw a clear error instead of crashing startup.
|
||||
// Format = base64(iv[12] | authTag[16] | ciphertext).
|
||||
//
|
||||
// ── One key per purpose ──
|
||||
//
|
||||
// This took a single VAULT_STORE_KEY from the environment until 2026-08-13. That key encrypted seven
|
||||
// unrelated things — the Headscale admin credential, the wallet seed, Vaultwarden's token set, Jellyfin,
|
||||
// Immich, InvoiceShelf, and every app-store upstream secret — so one leak opened all of them, and its
|
||||
// name pointed at whichever plugin happened to need it first.
|
||||
//
|
||||
// `purpose` is now the first argument everywhere, and the caller passes the one that owns the data. A
|
||||
// plugin's key is created on first use and cannot decrypt another plugin's column, because the AES key
|
||||
// derives from a different stored secret. See ./secret-store.ts and docs/secret-store.md.
|
||||
//
|
||||
// SHA-256 over the stored key rather than using its bytes directly, so the store is free to change how it
|
||||
// represents a key without every ciphertext in the database becoming unreadable.
|
||||
|
||||
let cachedKey: Buffer | null = null;
|
||||
function key(): Buffer {
|
||||
if (cachedKey) return cachedKey;
|
||||
const secret = process.env.VAULT_STORE_KEY;
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new Error('VAULT_STORE_KEY must be set (>=16 chars) to store vault secrets');
|
||||
}
|
||||
cachedKey = createHash('sha256').update(secret).digest();
|
||||
return cachedKey;
|
||||
const cache = new Map<string, Buffer>();
|
||||
|
||||
function key(purpose: string): Buffer {
|
||||
const hit = cache.get(purpose);
|
||||
if (hit) return hit;
|
||||
const derived = createHash('sha256').update(getKey(purpose)).digest();
|
||||
cache.set(purpose, derived);
|
||||
return derived;
|
||||
}
|
||||
|
||||
/** Encrypt a UTF-8 secret for at-rest storage → base64(iv|tag|ciphertext). */
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
export function encryptSecret(purpose: string, plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(purpose), iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ct]).toString('base64');
|
||||
}
|
||||
|
||||
/** Decrypt a value produced by encryptSecret. Throws if the ciphertext/tag/key don't verify. */
|
||||
export function decryptSecret(blob: string): string {
|
||||
/** Decrypt a value produced by encryptSecret under the SAME purpose. Throws if it does not verify. */
|
||||
export function decryptSecret(purpose: string, blob: string): string {
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const ct = buf.subarray(28);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(purpose), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
getAllDashboardState,
|
||||
upsertDashboard,
|
||||
updateDashboard,
|
||||
deleteDashboard,
|
||||
setDashboardPanelState,
|
||||
upsertScreen,
|
||||
deleteScreen,
|
||||
upsertDefaults,
|
||||
setDefaultsPanelState,
|
||||
} from './queries';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { dashboards, screens, dashboardDefaults } from '../schema';
|
||||
import { dashboards, screens, dashboardDefaults } from './schema';
|
||||
|
||||
// ── Full state read ──
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* A `LayoutNode` is an object, and the whole write path to these columns is `unknown` — the PATCH body is
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
listDavAppPasswords,
|
||||
createDavAppPassword,
|
||||
revokeDavAppPassword,
|
||||
deleteDavAppPassword,
|
||||
verifyDavAppPassword,
|
||||
} from './queries';
|
||||
|
||||
export type { DavAppPassword, DavAppPasswordView } from './queries';
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db } from '../db';
|
||||
import { davAppPasswords } from '../schema/dav';
|
||||
import { davAppPasswords } from './schema';
|
||||
|
||||
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
||||
//
|
||||
@@ -10,3 +10,47 @@ if (!POSTGRES_URL) {
|
||||
|
||||
const client = postgres(POSTGRES_URL);
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
/**
|
||||
* Block until Postgres answers, or give up after `timeoutMs`.
|
||||
*
|
||||
* `postgres()` above is LAZY — it opens no socket until the first query — so nothing here fails at
|
||||
* import time when the database is not up yet. That is the right default and it has a cost: startup
|
||||
* work that queries fires, fails once, and is swallowed by whatever `.catch()` it was written with.
|
||||
*
|
||||
* That is not hypothetical. `server.tsx` runs `initQueue()` and `cleanupOnStartup()` as
|
||||
* fire-and-forget promises, and the second marks jobs that were interrupted by the last restart and
|
||||
* promotes the queued backlog. If Postgres is a few seconds behind — which is exactly what happens on
|
||||
* a reboot, when pm2's resurrect races Docker starting the container — both log a line and do nothing.
|
||||
* Interrupted jobs then stay marked running forever, because the only thing that would have corrected
|
||||
* them already ran.
|
||||
*
|
||||
* So: wait, rather than try once. Callers that genuinely cannot proceed without the database await
|
||||
* this first; request handlers do not, since by then it is either up or the request fails honestly.
|
||||
*
|
||||
* Bounded, and it resolves false rather than throwing on timeout. An unbounded wait here would hold a
|
||||
* process open with no way to tell whether it is starting or hung, and the caller is better placed to
|
||||
* decide what "gave up" means than this function is.
|
||||
*/
|
||||
export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
|
||||
const started = Date.now();
|
||||
let announced = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await client`select 1`;
|
||||
if (announced) console.log('[db] Postgres is up');
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (Date.now() - started >= timeoutMs) {
|
||||
console.error(`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`, err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
}
|
||||
if (!announced) {
|
||||
console.log('[db] waiting for Postgres…');
|
||||
announced = true;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
getEmailAccounts,
|
||||
getEmailAccount,
|
||||
createEmailAccount,
|
||||
deleteEmailAccount,
|
||||
updateEmailAccountStatus,
|
||||
updateEmailAccountSyncMeta,
|
||||
getAllSyncedAccounts,
|
||||
} from './queries';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { emailAccounts } from '../schema';
|
||||
import { emailAccounts } from './schema';
|
||||
import type { EmailAccountInsert, EmailAccountSelect } from '../types';
|
||||
|
||||
export async function getEmailAccounts(userId: number): Promise<EmailAccountSelect[]> {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
export const emailAccounts = pgTable(
|
||||
'email_accounts',
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
listHeadscaleServers,
|
||||
getActiveHeadscaleCredentials,
|
||||
getHeadscaleCredentials,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
recordHeadscaleProbe,
|
||||
} from './queries';
|
||||
|
||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { headscaleServers } from '../schema';
|
||||
import { headscaleServers } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
|
||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||
@@ -54,7 +54,7 @@ export async function getActiveHeadscaleCredentials(userId: number): Promise<Hea
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||
}
|
||||
|
||||
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||
@@ -64,7 +64,7 @@ export async function getHeadscaleCredentials(userId: number, id: number): Promi
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||
}
|
||||
|
||||
type CreateHeadscaleServerParams = {
|
||||
@@ -95,7 +95,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
||||
userId,
|
||||
name,
|
||||
url,
|
||||
apiKey: encryptSecret(apiKey),
|
||||
apiKey: encryptSecret('headscale', apiKey),
|
||||
version,
|
||||
sshHost,
|
||||
isActive: activate,
|
||||
@@ -119,7 +119,7 @@ export async function updateHeadscaleServer(
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (params.name !== undefined) set.name = params.name;
|
||||
if (params.url !== undefined) set.url = params.url;
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
|
||||
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
||||
|
||||
const [row] = await db
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from './auth';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||
@@ -1,297 +1,60 @@
|
||||
export {
|
||||
getUsers,
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
getOwnerUser,
|
||||
getUserCount,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getPasskeysByUserId,
|
||||
getPasskeysByUserIdAndOrigin,
|
||||
getPasskeyByCredentialId,
|
||||
createPasskey,
|
||||
updatePasskey,
|
||||
storeChallenge,
|
||||
consumeChallenge,
|
||||
blacklistToken,
|
||||
isTokenBlacklisted,
|
||||
cleanupExpiredTokens,
|
||||
} from './queries/auth';
|
||||
// The package's public surface.
|
||||
//
|
||||
// One line per feature, and nothing else. What each feature exports is stated in its own index.ts,
|
||||
// beside the schema and queries it exports — so adding a query function means editing one file in one
|
||||
// directory, not that file plus a list three levels up that nobody remembers to update.
|
||||
//
|
||||
// This was 297 lines of hand-written named exports until 2026-08-13. Every symbol was listed here, twice
|
||||
// for most features (values, then types, repeating the path), and `db` and `schema` sat at line 270 with
|
||||
// three feature blocks appended after them.
|
||||
//
|
||||
// The surface is unchanged: the same names are exported, they are just declared next to what they
|
||||
// describe. 107 files import from 'officerdb' and none of them notice.
|
||||
//
|
||||
// ── Core above, plugins below ──
|
||||
//
|
||||
// The split mirrors schema.ts, where the plugin TABLES are commented out so db:push does not create
|
||||
// them. These lines are NOT commented, and the difference is worth stating: commenting them would break
|
||||
// nothing at runtime — hono.ts no longer mounts a single plugin router, so none of this is ever
|
||||
// reached — but `bunx tsgo` checks every file under src/ whether it runs or not, and the plugin
|
||||
// sidecars still import these symbols. So they stay exported until each plugin is extracted, and the
|
||||
// blank line below is the only thing marking the boundary.
|
||||
|
||||
export {
|
||||
findLiveApiKeyByHash,
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
touchApiKey,
|
||||
type ApiKeyIdentity,
|
||||
} from './queries/api-keys';
|
||||
|
||||
export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
|
||||
|
||||
export {
|
||||
getUserSettings,
|
||||
setUserSettings,
|
||||
getUserState,
|
||||
patchUserState,
|
||||
getDockPaths,
|
||||
setDockPaths,
|
||||
} from './queries/user-data';
|
||||
|
||||
export {
|
||||
getServerIntegrations,
|
||||
getServerIntegration,
|
||||
upsertServerIntegration,
|
||||
deleteServerIntegration,
|
||||
getUserIntegrations,
|
||||
getUserIntegration,
|
||||
getIntegrationsByProvider,
|
||||
upsertUserIntegration,
|
||||
deleteUserIntegration,
|
||||
findUserByIntegrationConfig,
|
||||
} from './queries/integrations';
|
||||
|
||||
export {
|
||||
getEmailAccounts,
|
||||
getEmailAccount,
|
||||
createEmailAccount,
|
||||
deleteEmailAccount,
|
||||
updateEmailAccountStatus,
|
||||
updateEmailAccountSyncMeta,
|
||||
getAllSyncedAccounts,
|
||||
} from './queries/email-accounts';
|
||||
|
||||
export {
|
||||
getAllDashboardState,
|
||||
upsertDashboard,
|
||||
updateDashboard,
|
||||
deleteDashboard,
|
||||
setDashboardPanelState,
|
||||
upsertScreen,
|
||||
deleteScreen,
|
||||
upsertDefaults,
|
||||
setDefaultsPanelState,
|
||||
} from './queries/dashboards';
|
||||
|
||||
export {
|
||||
createPipelineJob,
|
||||
getPipelineJob,
|
||||
updatePipelineJob,
|
||||
getPipelineJobsForUser,
|
||||
getOldestPendingJob,
|
||||
getPendingJobs,
|
||||
countPendingJobs,
|
||||
deletePipelineJob,
|
||||
deleteTerminalJobsForUser,
|
||||
markInterruptedJobs,
|
||||
} from './queries/pipeline-jobs';
|
||||
|
||||
export {
|
||||
appendChatEvent,
|
||||
getChatEventsSince,
|
||||
getLastChatEventSeq,
|
||||
pruneChatEventsOlderThan,
|
||||
} from './queries/chat-events';
|
||||
|
||||
export {
|
||||
listAgentPanels,
|
||||
getAgentPanelByPanelId,
|
||||
getAgentPanelByName,
|
||||
getAgentPanelByHandoffToken,
|
||||
createAgentPanel,
|
||||
updateAgentPanel,
|
||||
markAgentPanelIntroduced,
|
||||
deleteAgentPanel,
|
||||
toAgentPanelView,
|
||||
} from './queries/agent-panels';
|
||||
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries/agent-panels';
|
||||
|
||||
export {
|
||||
getMusicFavorites,
|
||||
addMusicFavorite,
|
||||
removeMusicFavorite,
|
||||
getNowPlaying,
|
||||
setNowPlaying,
|
||||
clearNowPlaying,
|
||||
getPlaylists,
|
||||
getPlaylist,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
deletePlaylist,
|
||||
addPlaylistItems,
|
||||
setPlaylistItems,
|
||||
} from './queries/music';
|
||||
export type {
|
||||
FavoriteKind,
|
||||
GroupedFavorites,
|
||||
NowPlaying,
|
||||
NowPlayingInput,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
} from './queries/music';
|
||||
export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries/soulseek';
|
||||
export {
|
||||
getSoulseekBrowseSnapshots,
|
||||
getSoulseekBrowseSnapshot,
|
||||
startSoulseekBrowse,
|
||||
finishSoulseekBrowse,
|
||||
failSoulseekBrowse,
|
||||
resetStaleSoulseekBrowses,
|
||||
getSoulseekBrowseLevel,
|
||||
searchSoulseekBrowseTree,
|
||||
getSoulseekBrowseDirFiles,
|
||||
getSoulseekBrowseDownload,
|
||||
deleteSoulseekBrowse,
|
||||
} from './queries/soulseek';
|
||||
export type {
|
||||
BrowseDownloadFile,
|
||||
BrowsedFile,
|
||||
BrowseDirInput,
|
||||
BrowseDirRow,
|
||||
BrowseTreeNode,
|
||||
BrowseLevel,
|
||||
BrowseTreeSearch,
|
||||
SoulseekBrowseSnapshot,
|
||||
} from './queries/soulseek';
|
||||
export {
|
||||
listHeadscaleServers,
|
||||
getActiveHeadscaleCredentials,
|
||||
getHeadscaleCredentials,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
recordHeadscaleProbe,
|
||||
} from './queries/headscale';
|
||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
|
||||
export {
|
||||
listInvoiceshelfAccounts,
|
||||
getActiveInvoiceshelfCredentials,
|
||||
getInvoiceshelfCredentials,
|
||||
createInvoiceshelfAccount,
|
||||
updateInvoiceshelfAccount,
|
||||
setActiveInvoiceshelfAccount,
|
||||
deleteInvoiceshelfAccount,
|
||||
recordInvoiceshelfProbe,
|
||||
} from './queries/invoiceshelf';
|
||||
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf';
|
||||
export {
|
||||
listJellyfinServers,
|
||||
getActiveJellyfinCredentials,
|
||||
getJellyfinCredentials,
|
||||
createJellyfinServer,
|
||||
updateJellyfinServer,
|
||||
setActiveJellyfinServer,
|
||||
deleteJellyfinServer,
|
||||
recordJellyfinProbe,
|
||||
} from './queries/jellyfin';
|
||||
export type { JellyfinServer, JellyfinCredentials } from './queries/jellyfin';
|
||||
export {
|
||||
listPhotosAccounts,
|
||||
getActivePhotosCredentials,
|
||||
getPhotosCredentials,
|
||||
createPhotosAccount,
|
||||
updatePhotosAccount,
|
||||
setActivePhotosAccount,
|
||||
deletePhotosAccount,
|
||||
recordPhotosProbe,
|
||||
} from './queries/photos';
|
||||
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
|
||||
export {
|
||||
listDavAppPasswords,
|
||||
createDavAppPassword,
|
||||
revokeDavAppPassword,
|
||||
deleteDavAppPassword,
|
||||
verifyDavAppPassword,
|
||||
} from './queries/dav';
|
||||
export type { DavAppPassword, DavAppPasswordView } from './queries/dav';
|
||||
export {
|
||||
getServiceConnection,
|
||||
getServiceCredentials,
|
||||
saveServiceConnection,
|
||||
deleteServiceConnection,
|
||||
recordServiceProbe,
|
||||
getServiceInstanceUrl,
|
||||
getResolvedServiceCredentials,
|
||||
} from './queries/service-connections';
|
||||
export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections';
|
||||
export {
|
||||
getAllRoleGrants,
|
||||
getRoleGrants,
|
||||
setRoleGrant,
|
||||
revokeRoleGrant,
|
||||
replaceRoleGrants,
|
||||
} from './queries/capabilities';
|
||||
export type { RoleGrant } from './queries/capabilities';
|
||||
export type { CapabilityLevelValue } from './schema/capabilities';
|
||||
export {
|
||||
getVaultTokens,
|
||||
setVaultTokens,
|
||||
updateVaultAccess,
|
||||
clearVaultTokens,
|
||||
getVaultUnlockKey,
|
||||
setVaultUnlockKey,
|
||||
clearVaultUnlockKey,
|
||||
} from './queries/vault';
|
||||
export type { VaultTokenSet } from './queries/vault';
|
||||
export {
|
||||
listWallets,
|
||||
getWallet,
|
||||
getActiveWallet,
|
||||
getWalletSecrets,
|
||||
getSealedSeed,
|
||||
createWallet,
|
||||
updateWallet,
|
||||
replaceSealedSeed,
|
||||
setActiveWallet,
|
||||
deleteWallet,
|
||||
getWalletLabels,
|
||||
setWalletLabel,
|
||||
getFrozenOutpoints,
|
||||
setUtxoFrozen,
|
||||
getWalletChainCache,
|
||||
saveWalletChainCache,
|
||||
recordWalletChainError,
|
||||
} from './queries/wallet';
|
||||
export type {
|
||||
WalletKind,
|
||||
WalletSummary,
|
||||
WalletSecrets,
|
||||
WalletLabel,
|
||||
CreateWalletParams,
|
||||
WalletChainCache,
|
||||
} from './queries/wallet';
|
||||
export type { WalletChainSnapshot } from './schema/wallet';
|
||||
|
||||
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
|
||||
// column definition is the only place that list should exist.
|
||||
export { USER_ROLES, OWNER_USER_ID } from './schema/auth';
|
||||
export type { UserRole } from './schema/auth';
|
||||
|
||||
export { db } from './db';
|
||||
// The connection, and drizzle-kit's view of the schema. See ./schema.ts for the core/plugin split.
|
||||
export { db, waitForDatabase } from './db';
|
||||
export * as schema from './schema';
|
||||
|
||||
export {
|
||||
upsertPushDevice,
|
||||
getPushDevices,
|
||||
deletePushDevice,
|
||||
recordPushFailure,
|
||||
markPushDeviceSeen,
|
||||
} from './queries/notify';
|
||||
export type { PushDeviceSelect, PushDeviceInsert } from './types';
|
||||
// ── Core ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// App store — what the owner has installed, and whether it should be running.
|
||||
export {
|
||||
listSidecarInstalls,
|
||||
getSidecarInstall,
|
||||
beginInstall,
|
||||
recordSteps,
|
||||
markInstalled,
|
||||
markFailed,
|
||||
markBlocked,
|
||||
setEnabled,
|
||||
removeInstall,
|
||||
type SidecarInstall,
|
||||
} from './queries/sidecar-installs';
|
||||
export * from './agent-panels';
|
||||
export * from './api-keys';
|
||||
export * from './app-store';
|
||||
export * from './plugins';
|
||||
export * from './auth';
|
||||
export * from './capabilities';
|
||||
export * from './chat-events';
|
||||
export * from './dashboards';
|
||||
export * from './headscale';
|
||||
export * from './integrations';
|
||||
export * from './pipeline-jobs';
|
||||
export * from './server';
|
||||
export * from './service-connections';
|
||||
export * from './user-data';
|
||||
|
||||
|
||||
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||
|
||||
export * from './dav';
|
||||
export * from './email';
|
||||
export * from './invoiceshelf';
|
||||
export * from './jellyfin';
|
||||
export * from './music';
|
||||
export * from './notify';
|
||||
export * from './photos';
|
||||
export * from './soulseek';
|
||||
export * from './vault';
|
||||
export * from './wallet';
|
||||
|
||||
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
||||
// deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read
|
||||
// path over a table nothing could write to.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
getServerIntegrations,
|
||||
getServerIntegration,
|
||||
upsertServerIntegration,
|
||||
deleteServerIntegration,
|
||||
getUserIntegrations,
|
||||
getUserIntegration,
|
||||
getIntegrationsByProvider,
|
||||
upsertUserIntegration,
|
||||
deleteUserIntegration,
|
||||
findUserByIntegrationConfig,
|
||||
} from './queries';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user