Compare commits
198
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
571d0a62ff | ||
|
|
f063fc0c08 | ||
|
|
c5adb4aa08 | ||
|
|
3c7f52ab77 | ||
|
|
e72cae4830 | ||
|
|
3bc06bba3c | ||
|
|
3f071c0b24 | ||
|
|
86079adb9a | ||
|
|
040ea41dbc | ||
|
|
32af97e260 | ||
|
|
cb67b22f80 | ||
|
|
9eabc3ee4c | ||
|
|
0bceace6f1 | ||
|
|
a64d5610e6 | ||
|
|
911b79b6a2 | ||
|
|
a0b083db8f | ||
|
|
8a0946ea39 | ||
|
|
ee21fa16f0 | ||
|
|
a5a8cd4e9c | ||
|
|
b724e3ffbe | ||
|
|
e120dfa36e | ||
|
|
30052e3295 | ||
|
|
d6a78d7b5a | ||
|
|
607a962115 | ||
|
|
cb3e7062b9 | ||
|
|
00e58931ff | ||
|
|
999362948f | ||
|
|
068a310bd3 | ||
|
|
0286cc6db6 | ||
|
|
5a33a517e7 | ||
|
|
2e70a6dd21 | ||
|
|
2b9e16c11e | ||
|
|
03cc01a135 | ||
|
|
4fcc34de18 | ||
|
|
a989f8fbfa | ||
|
|
c629a849d9 | ||
|
|
06492c3297 | ||
|
|
22a661131d | ||
|
|
85d8fa62f1 | ||
|
|
766c8ee655 | ||
|
|
2d544db07e | ||
|
|
46ce58dd12 | ||
|
|
2803bc34b7 | ||
|
|
538a2d3b6d | ||
|
|
ff7035a47a | ||
|
|
4d478cc0f1 | ||
|
|
6a29c39b74 | ||
|
|
059f0f6df2 | ||
|
|
a63a327065 | ||
|
|
09303299cc | ||
|
|
9d53ff506e | ||
|
|
f9c6b7925a | ||
|
|
4339ab829d | ||
|
|
28673869c3 | ||
|
|
08e7de1b6d | ||
|
|
0da184d082 | ||
|
|
f176b92378 | ||
|
|
bdf13331ae | ||
|
|
7a5cf89819 | ||
|
|
192293cdca | ||
|
|
9591f917f5 | ||
|
|
b1cc916258 | ||
|
|
d9d4085033 | ||
|
|
c0eb3e3a85 | ||
|
|
fbb90c917d | ||
|
|
3fb0e5c887 | ||
|
|
458510a0a8 | ||
|
|
c87127ff93 | ||
|
|
6ffd3534bd | ||
|
|
d19dc5a92a | ||
|
|
d4b9b7b334 | ||
|
|
6947284f1b | ||
|
|
9b0e05bfd8 | ||
|
|
716a6e2750 | ||
|
|
b5948f9673 | ||
|
|
6480788979 | ||
|
|
163f8d5899 | ||
|
|
1beb357f2e | ||
|
|
454faf5406 | ||
|
|
f896d4882f | ||
|
|
a5ef9f7662 | ||
|
|
dd655577e3 | ||
|
|
5cb243eed9 | ||
|
|
7622239949 | ||
|
|
bba6d854bc | ||
|
|
dbdef23d29 | ||
|
|
41ff8030e9 | ||
|
|
34bb8fc22a | ||
|
|
44141faf0a | ||
|
|
dda214ffb0 | ||
|
|
483bb15d8a | ||
|
|
315073cf3b | ||
|
|
79da78008a | ||
|
|
015e280e5c | ||
|
|
e9d0261e87 | ||
|
|
2eedbcda54 | ||
|
|
4e404f17c8 | ||
|
|
cec8fbe57e | ||
|
|
76cd7c20bf | ||
|
|
a2f63dc534 | ||
|
|
46799dada8 | ||
|
|
f34d7fef70 | ||
|
|
35715546e2 | ||
|
|
f5f509a99d | ||
|
|
9c353f5f0d | ||
|
|
37adc65a12 | ||
|
|
a6acfea9a6 | ||
|
|
a730fc0fe0 | ||
|
|
dcaee2fc95 | ||
|
|
7040536f1f |
+36
-17
@@ -1,18 +1,37 @@
|
|||||||
|
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
||||||
PORT=9000
|
PORT=9000
|
||||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
|
||||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||||
MAIL_TRANSPORT="smtp://localhost:1025"
|
|
||||||
|
# Where Officer is reached from a browser — the one value the machine cannot derive. Read by
|
||||||
|
# `bun gen:index` (OpenGraph tags, which need an absolute URL), the task API host, and the CalDAV iOS
|
||||||
|
# profile builder, which additionally requires https.
|
||||||
|
#
|
||||||
|
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
|
||||||
PUBLIC_URL=http://localhost:9000
|
PUBLIC_URL=http://localhost:9000
|
||||||
|
|
||||||
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
|
||||||
# "dev" or "development". Leave it unset or set it to "production" for a real deployment; only set
|
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
|
||||||
# it to "dev" on a local machine you trust, since that disables all three.
|
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
|
||||||
PUBLIC_BUILD_ENV=production
|
# 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.
|
||||||
|
|
||||||
DATA_PATH=/path/to/data
|
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
||||||
OFFICER_ITEMS_DIR=/path/to/officer-items
|
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||||
HOME_DIR=/home/user
|
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
||||||
BROWSER_RELAY_PORT=18792
|
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
||||||
|
# only loads this file, so `bun dev` against a production .env runs fully hardened.
|
||||||
|
# PUBLIC_BUILD_ENV=dev
|
||||||
|
|
||||||
|
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR were here until 2026-08-12 and are no longer read.
|
||||||
|
# The install root is derived as the parent of the working directory (src/servers/data-path.ts), so
|
||||||
|
# data/, capabilities/ and dockers/ follow from it; the owner's home comes from the OS. Three values
|
||||||
|
# that had to agree with each other and with the disk became one that cannot disagree.
|
||||||
|
|
||||||
# ── Sidecars ────────────────────────────────────────────────────────────────────────────────────
|
# ── Sidecars ────────────────────────────────────────────────────────────────────────────────────
|
||||||
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
|
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
|
||||||
@@ -31,14 +50,14 @@ BROWSER_RELAY_PORT=18792
|
|||||||
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
|
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
|
||||||
# X-API-Key on every forwarded request.
|
# X-API-Key on every forwarded request.
|
||||||
|
|
||||||
# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret
|
# Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's
|
||||||
# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE.
|
# key, not Vaultwarden's, however much the name and its old position here suggested otherwise.
|
||||||
VAULTWARDEN_URL=http://127.0.0.1:8222
|
# VAULTWARDEN_URL=http://127.0.0.1:8222
|
||||||
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
|
||||||
|
|
||||||
# Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which
|
# The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see
|
||||||
# lives in the host env rather than here.
|
# src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either:
|
||||||
# ANTHROPIC_PROXY_PORT=5051
|
# the upstream token is the OAuth one `claude` writes to ~/.claude/.credentials.json, and the
|
||||||
|
# ANTHROPIC_API_KEY the agent presents to it is the proxy's own generated secret.
|
||||||
|
|
||||||
# ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to
|
# ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to
|
||||||
# http://localhost:8899.
|
# http://localhost:8899.
|
||||||
|
|||||||
+12
@@ -51,3 +51,15 @@ src/apps/officer-web/index.gen.html
|
|||||||
|
|
||||||
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
|
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
|
||||||
public/plugins/
|
public/plugins/
|
||||||
|
|
||||||
|
# Written by machine-setup.sh to record completed steps; per-machine, never shared.
|
||||||
|
.setup-progress
|
||||||
|
.setup-answers
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -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`;
|
- **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
|
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.
|
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
|
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||||
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
|
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||||
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
|
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||||
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,
|
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||||
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
|
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.
|
`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"
|
**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,
|
- 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
|
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.
|
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
|
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
|
`/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-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
`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
|
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||||
source of truth.
|
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
|
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
|
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
|
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||||
@@ -72,7 +84,7 @@ src/
|
|||||||
│ └── landing/ # marketing landing page
|
│ └── landing/ # marketing landing page
|
||||||
├── servers/
|
├── servers/
|
||||||
│ ├── hono.ts # router composition; everything under /api
|
│ ├── hono.ts # router composition; everything under /api
|
||||||
│ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting
|
│ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting
|
||||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||||
│ ├── queue/ # background job engine
|
│ ├── queue/ # background job engine
|
||||||
@@ -92,7 +104,15 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime**: Bun (Node 22 is enforced by a `preinstall` check)
|
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
|
||||||
|
|
||||||
|
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build
|
||||||
|
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
|
||||||
|
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
|
||||||
|
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
|
||||||
|
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
|
||||||
|
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
||||||
|
first. The source build also needs `build-essential` and `python3`.
|
||||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
||||||
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||||
- **Backend**: Hono
|
- **Backend**: Hono
|
||||||
@@ -104,16 +124,27 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
|||||||
Two stores, and the split matters:
|
Two stores, and the split matters:
|
||||||
|
|
||||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
**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
|
email accounts, queue and pipeline jobs. One directory per feature holding `schema.ts` and
|
||||||
`src/queries/`, types inferred from the schema in `src/types.ts`.
|
`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
|
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||||
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
|
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||||
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
|
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the
|
||||||
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
|
per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens
|
||||||
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
|
them.
|
||||||
`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).
|
**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
|
### Schema changes use `push`, not migrations
|
||||||
|
|
||||||
@@ -134,14 +165,14 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
|
|||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
||||||
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
|
||||||
limiting and password rules all key off it — they fail closed.
|
rules key off it — they fail closed.
|
||||||
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
|
||||||
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
|
||||||
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`.
|
what came out was documented defence in depth that was already switched off. Origin was never
|
||||||
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid
|
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||||
token is still required on every protected route. It is defence in depth that is currently switched
|
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||||
off, not the lock.
|
is a valid token on every protected route plus the capability gate below.
|
||||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||||
@@ -151,15 +182,17 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
|
|||||||
### Capabilities — read this before mounting a router
|
### Capabilities — read this before mounting a router
|
||||||
|
|
||||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||||
valid"). It is `originScopeMiddleware` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
||||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||||
`userMiddleware`.
|
`userMiddleware`.
|
||||||
|
|
||||||
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
|
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||||
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
|
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||||
(owner only, and `execution` is never grantable at any level).
|
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
|
- `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
|
**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`.
|
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
|
||||||
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
||||||
@@ -186,9 +219,17 @@ bunx tsgo # typecheck (not tsc)
|
|||||||
bun test # tests
|
bun test # tests
|
||||||
bun format # prettier over every dirty file — see the note below before running it
|
bun format # prettier over every dirty file — see the note below before running it
|
||||||
bun db:push # apply the schema to Postgres
|
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>`.
|
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.
|
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||||
|
|
||||||
@@ -379,6 +420,9 @@ explaining why it was safe.
|
|||||||
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
||||||
persistence key families. Read before building a panel app. Its defect list is
|
persistence key families. Read before building a panel app. Its defect list is
|
||||||
`docs/workspace-panel-todo.md`.
|
`docs/workspace-panel-todo.md`.
|
||||||
|
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
||||||
|
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
||||||
|
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
||||||
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
||||||
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
||||||
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
||||||
|
|||||||
@@ -14,24 +14,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
## Multi-user
|
## Multi-user
|
||||||
|
|
||||||
- [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.**
|
- [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in
|
||||||
`deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the
|
`os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
|
||||||
production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running
|
the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards
|
||||||
Postgres container and 454M of data, and their uid was free for the next `useradd` to reissue. Spec in
|
(`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
|
||||||
`docs/deprovision-os-account.md`. The ordering that matters: reap processes explicitly (`terminate-user`
|
sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still
|
||||||
does **not** reap a stale shell, and `userdel` fails while one lives), then `chown -R` to the service
|
unproven" has the five-step validation, and it has to happen on the production host with a throwaway
|
||||||
user, then `userdel` — sever before release, and abort if the `chown` fails.
|
account that has **a shell left open** and **a container writing as a non-root user** — those are the
|
||||||
|
two cases the quiet path passes vacuously.
|
||||||
|
|
||||||
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
|
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
|
||||||
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
|
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
|
||||||
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
|
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
|
||||||
`appendBuffer`, so a replay reproduces output and never re-issues requests.
|
`appendBuffer`, so a replay reproduces output and never re-issues requests.
|
||||||
|
|
||||||
|
- [ ] **The web terminal renders a long URL as unreadable fragments.** Claude Code's first-run login prints
|
||||||
|
a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing
|
||||||
|
selectable. Half worked around by `2a8f004` (OSC 52, so "press c to copy" reaches the clipboard) — the
|
||||||
|
rendering itself is undiagnosed. This is every new member's first five minutes.
|
||||||
|
`docs/open-threads-after-per-user-claude.md` §1 has what is known and where to start.
|
||||||
|
|
||||||
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
|
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
|
||||||
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
|
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
|
||||||
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
|
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
|
||||||
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
|
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
|
||||||
missing property.
|
missing property. `docs/open-threads-after-per-user-claude.md` §2.
|
||||||
|
|
||||||
|
- [ ] **`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained.** A
|
||||||
|
floating rejection inside the SDK's own input pump, with no frames from our code, so no `await` of ours
|
||||||
|
can catch it. It crashed `officer-agent` four times on 2026-08-11, once truncating a turn mid-sentence;
|
||||||
|
the `unhandledRejection` backstop has caught it once since. Best hypothesis is the `claude` CLI exiting
|
||||||
|
while `streamInput` is still pumping. It needs looking at after the next occurrence, not catching in the
|
||||||
|
act — markers to grep in `docs/open-threads-after-per-user-claude.md` §3.
|
||||||
|
|
||||||
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
|
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
|
||||||
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
|
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
|
||||||
@@ -66,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
|
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.
|
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
|
- [ ] **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.
|
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,11 +1,15 @@
|
|||||||
# Deprovisioning a member's Linux account
|
# Deprovisioning a member's Linux account
|
||||||
|
|
||||||
**Status:** specification. Not implemented. Written from a manual teardown performed on the production host on
|
**Status:** implemented 2026-08-12 in `src/servers/os-user-deprovision.ts`, called by `deleteUserHandler`.
|
||||||
2026-08-11, so the ordering constraints below are measured rather than reasoned.
|
**Run against a real account on 2026-08-12 and verified clean** — `green`, uid 1001, with a live systemd
|
||||||
|
session, a running rootless Docker stack and a shell parented outside the session cgroup. Nine processes
|
||||||
|
reaped in ~60s, then all ten checks of `scripts/assert-uid-free.sh` passed, and the `preserve` policy left
|
||||||
|
316 MB reassigned to the service user with zero ACL entries naming the freed uid.
|
||||||
|
Written from a manual teardown performed on the production host on 2026-08-11, so the ordering constraints
|
||||||
|
below are measured rather than reasoned.
|
||||||
|
|
||||||
**Trigger for implementing:** before the first account that does not belong to the server owner. Not "after
|
The spec is kept as written rather than rewritten in the past tense: it is the reasoning the implementation
|
||||||
per-user Claude" — the risk opens when a real person has an account that might later be deleted, which may or
|
has to keep satisfying, and the failure modes it names are still the ones a change would reintroduce.
|
||||||
may not be the same moment.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,6 +115,26 @@ keeping every byte.
|
|||||||
rm -rf <member-tree>
|
rm -rf <member-tree>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Ownership is not the only link.** `confineUserTree` grants the member a NAMED ACL entry on their whole
|
||||||
|
tree — `u:<uid>:rwx` and a `default:` copy, inherited by everything either party creates. `chown` does not
|
||||||
|
remove them: they are xattrs rather than ownership, and they store the uid **numerically**. Measured — a
|
||||||
|
`chown -h -R` to the service user leaves `user:<uid>:rwx` intact on the directory, its children and their
|
||||||
|
defaults.
|
||||||
|
|
||||||
|
So a tree reassigned to the service user still grants the freed uid read and write on every byte, and the next
|
||||||
|
account allocated that number inherits it: home, SSH keys, `.credentials.json`, transcripts, container
|
||||||
|
storage. That is the hazard this function exists to prevent, arriving through ACLs instead of ownership.
|
||||||
|
|
||||||
|
Severing therefore has two parts:
|
||||||
|
|
||||||
|
```
|
||||||
|
chown -h -R <service-user>:<service-group> <member-tree>
|
||||||
|
setfacl -R -b <member-tree> # or -x u:<uid> -x d:u:<uid> to keep the platform's own entry
|
||||||
|
```
|
||||||
|
|
||||||
|
`-b` is the simpler answer for a preserved tree: the service user owns every byte afterwards, so a named
|
||||||
|
entry granting themselves access is redundant.
|
||||||
|
|
||||||
**This step must complete before step 4.** That is the one ordering choice the manual teardown got wrong: it
|
**This step must complete before step 4.** That is the one ordering choice the manual teardown got wrong: it
|
||||||
released the uid first and removed the data afterwards, which leaves a window where the uid is free while
|
released the uid first and removed the data afterwards, which leaves a window where the uid is free while
|
||||||
files still carry it. If the process dies in that window, the next `useradd` inherits them. Sever first, then
|
files still carry it. If the process dies in that window, the next `useradd` inherits them. Sever first, then
|
||||||
@@ -145,9 +169,37 @@ All of these must hold for the freed uid *and* its freed subuid range:
|
|||||||
- `/var/lib/systemd/linger/<user>` absent
|
- `/var/lib/systemd/linger/<user>` absent
|
||||||
- `/run/user/<uid>` absent
|
- `/run/user/<uid>` absent
|
||||||
- no processes owned by the uid
|
- no processes owned by the uid
|
||||||
|
- **no ACL entry naming the uid** anywhere under `DATA_PATH` — `getfacl -R -n` and look for
|
||||||
|
`user:<uid>:` / `default:user:<uid>:`. Ownership checks cannot see these, and `chown` does not clear them.
|
||||||
|
|
||||||
Worth extracting as `assertUidFree(uid, subuidRange)` and reusing it as the post-condition of the function and
|
`scripts/assert-uid-free.sh` implements exactly this, deliberately **outside** the function: a checker the
|
||||||
as a test.
|
implementation calls is a restatement of its own beliefs, not an audit. Two modes, and the split matters —
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/assert-uid-free.sh --capture green # BEFORE: prints "green 1001 165536 65536"
|
||||||
|
sudo DATA_PATH="$DATA_PATH" ./scripts/assert-uid-free.sh --check green 1001 165536 65536 # AFTER: exit 1 unless clean
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pass `DATA_PATH` through explicitly.** sudo's `env_reset` drops it, so the plain `sudo ./assert-uid-free.sh`
|
||||||
|
this used to say fell back to a hardcoded default — and every check here reports `ok` on finding nothing, so
|
||||||
|
a wrong root reports `CLEAN — uid safe to reissue` without having looked at a single member tree. The ACL
|
||||||
|
check is the one that failed silently and completely, because it is the only one scoped to `DATA_PATH` alone.
|
||||||
|
The script now refuses to run when a search root is missing rather than passing vacuously.
|
||||||
|
|
||||||
|
The range has to be captured **before** deletion, because `userdel` removes the `/etc/subuid` entry with the
|
||||||
|
account. After that there is no way to ask what range it held — and a check that silently skips that half is
|
||||||
|
the exact failure this section exists to prevent.
|
||||||
|
|
||||||
|
**The subuid check passes vacuously on most accounts, and that is a trap.** Container files are owned by a
|
||||||
|
mapped id only when a process inside the container runs as a NON-root user; an image whose files are root-owned
|
||||||
|
maps to the member's own uid and leaves nothing in the range. Measured on green after a night of real use —
|
||||||
|
`claude` installed, images pulled, transcripts written — the range check found **zero** files and passed
|
||||||
|
without testing anything.
|
||||||
|
|
||||||
|
To build a specimen that actually exercises it, run a container whose process writes as a non-root user. The
|
||||||
|
`postgres:18-alpine` case from the same night is the natural one: its entrypoint drops to uid 70, and the data
|
||||||
|
directory came out owned by `subuid_start + 70` on the host. Verify the range check *fails* on that tree before
|
||||||
|
trusting it to pass on a cleaned one.
|
||||||
|
|
||||||
**Trap for the verifier:** do not use `sudo -u <user> …` to check anything after step 2. Creating a session
|
**Trap for the verifier:** do not use `sudo -u <user> …` to check anything after step 2. Creating a session
|
||||||
starts a user manager and recreates `/run/user/<uid>`, so the check would undo the step it is verifying.
|
starts a user manager and recreates `/run/user/<uid>`, so the check would undo the step it is verifying.
|
||||||
@@ -208,3 +260,49 @@ subuid ranges, and the final verified-clean state (no accounts ≥ 1000 but the
|
|||||||
|
|
||||||
The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable
|
The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable
|
||||||
test database. `chown -R` as a severing mechanism is reasoned, not observed.
|
test database. `chown -R` as a severing mechanism is reasoned, not observed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the implementation decided, where the spec left it open
|
||||||
|
|
||||||
|
- **Q1, where severed data goes:** in place. A `deleted/` location is a second thing that can fail between
|
||||||
|
severing and releasing, and the ordering rule already says nothing may come between them.
|
||||||
|
- **Q2, destroy in the UI:** no. `policy: 'destroy'` exists and has no call site; `deleteUserHandler` always
|
||||||
|
preserves. It is also implemented as *chown, then delete as the service user* rather than `sudo rm -rf`, so
|
||||||
|
a recursive delete as root built from a database column does not exist in the codebase at all.
|
||||||
|
- **Q3, monotonic uid allocation:** not done. Severing addresses the same hazard and also fixes files orphaned
|
||||||
|
by any other route; the two are not exclusive and this one is still available later.
|
||||||
|
- **Q4, a preserved member's Docker images:** unchanged — they stay on disk, owned by the service user,
|
||||||
|
readable by nobody who wants them. Correct and wasteful, as the spec predicted.
|
||||||
|
|
||||||
|
Two guards were added that the spec did not ask for, both exported and unit-tested:
|
||||||
|
|
||||||
|
- `guardDeletable` — the adoption rule from `ensureOsUser` read backwards. An account is only deletable if its
|
||||||
|
passwd home is the home the platform would have confined, and its uid is ≥ 1000. Without it, `userdel root`
|
||||||
|
is one bad `users.osUser` value away, and nothing else in the sequence would object.
|
||||||
|
- `guardMemberTree` — the member tree must resolve to a direct child of `DATA_PATH`. The email reaches
|
||||||
|
`join()` from a database row and the result is the argument to a recursive `chown`.
|
||||||
|
|
||||||
|
`chown` runs with `-h`. Measured on this host that `chown -R` already declines to follow a symlink out of the
|
||||||
|
tree and re-owns the link itself, but the flag states it in the argv rather than resting on traversal
|
||||||
|
semantics — and re-owning links is what makes `find -uid` (which uses `lstat`) a meaningful check.
|
||||||
|
|
||||||
|
## What is still unproven
|
||||||
|
|
||||||
|
The whole thing has been exercised only by unit tests over the pure guards. Nothing has run against a live
|
||||||
|
account, and this dev machine is deliberately not the place to try it.
|
||||||
|
|
||||||
|
To validate on the production host, against a throwaway account:
|
||||||
|
|
||||||
|
1. Create a member, open a terminal as them, and **leave a shell running** — that is the case
|
||||||
|
`terminate-user` does not handle, and the reap loop is the part most likely to be wrong.
|
||||||
|
2. Give them a container that writes as a non-root user, so the subuid range is genuinely populated:
|
||||||
|
`postgres:18-alpine` drops to uid 70 and its data directory lands on `subuid_start + 70`.
|
||||||
|
3. `./scripts/assert-uid-free.sh --capture <user>` — **before** deleting, or the range is gone.
|
||||||
|
4. Confirm the range check *fails* on that tree while the account still exists. A checker that has never
|
||||||
|
failed has not been tested.
|
||||||
|
5. Delete through the UI, then
|
||||||
|
`sudo DATA_PATH="$DATA_PATH" ./scripts/assert-uid-free.sh --check <user> <uid> <start> <count>`.
|
||||||
|
|
||||||
|
The delete handler logs that exact command line with the captured values after a successful deprovision,
|
||||||
|
because after `userdel` nothing else on the machine remembers the range.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -213,7 +213,8 @@ both, so the endpoint cannot be used to discover whether an id exists.
|
|||||||
|
|
||||||
## Things that will surprise you
|
## Things that will surprise you
|
||||||
|
|
||||||
- **No `Origin` header is needed today.** `ALLOW_ANY_ORIGIN` defaults to on, so origin checking is off
|
- **No `Origin` header is needed.** Origin checking was removed entirely on 2026-08-13; before that it
|
||||||
|
was off by default
|
||||||
and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
|
and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
|
||||||
switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
|
switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
|
||||||
is a separate conversation, not part of this work.
|
is a separate conversation, not part of this work.
|
||||||
@@ -326,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
|
|||||||
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
||||||
WebSocket upgrade in `server.tsx`, and the vault socket.
|
WebSocket upgrade in `server.tsx`, and the vault socket.
|
||||||
- `src/servers/api/api-keys/router.ts` — the three endpoints.
|
- `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,644 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Open threads after per-user Claude
|
||||||
|
|
||||||
|
Three things found on 2026-08-11/12 that are understood but not finished. They were written up in the
|
||||||
|
`COMMS/sidecar-app-store` channel, which was deleted when the feature merged — this file is what survives.
|
||||||
|
None of them blocks per-user Claude; all three were found while proving it worked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The web terminal renders a long URL unreadably
|
||||||
|
|
||||||
|
**Half fixed.** `2a8f0049` added an OSC 52 handler, so a program's "press `c` to copy" now reaches the
|
||||||
|
browser clipboard. That is the path a user is meant to take, and it works.
|
||||||
|
|
||||||
|
**Not fixed:** the URL itself renders as fragments. Claude Code's first-run login prints an OAuth URL of
|
||||||
|
~400 characters; in the web terminal it appeared as scattered characters with large gaps (`h : l`), with
|
||||||
|
nothing selectable or readable. On a normal terminal the same output wraps and reads fine.
|
||||||
|
|
||||||
|
Why it matters: first-run login is every new member's first five minutes, and the workaround was running
|
||||||
|
`claude` under `tmux` on the server, capturing the pane, and reassembling the URL by hand across three wrapped
|
||||||
|
lines. That is not something a member can be asked to do, and without OSC 52 there was no other way out.
|
||||||
|
|
||||||
|
Not diagnosed. What is known:
|
||||||
|
|
||||||
|
- the frontend loads `FitAddon`, `Unicode11Addon` and `WebLinksAddon`, and `allowProposedApi` is on
|
||||||
|
- `cols`/`rows` are sent on connect (`Terminal.tsx`) and on resize, so it is not obviously a sizing problem
|
||||||
|
- the pty gives `cols: Number(...) || 0` (`sidecar/pty/server.mjs:62`), so a client that omits them yields 0
|
||||||
|
|
||||||
|
Where I would start: capture the raw bytes the pty emits for that line and compare against what xterm renders.
|
||||||
|
Either the TUI is positioning with escapes xterm handles differently, or the width the program believes it has
|
||||||
|
disagrees with the width the terminal has.
|
||||||
|
|
||||||
|
## 2. Agent sessions do not survive a restart — one property behind three symptoms
|
||||||
|
|
||||||
|
Worth fixing as one thing, because it currently presents as three and invites three separate fixes:
|
||||||
|
|
||||||
|
- **Blast radius.** An unhandled rejection used to kill the agent sidecar and every live session with it.
|
||||||
|
`8c4f150c` made that survivable, but any *real* restart still loses every session.
|
||||||
|
- **The restart sweep must skip.** `endTurnIfAgentIsGone` asks the agent whether a session is really still
|
||||||
|
generating. Scoped by `userId` since `d59adbf1`, so a session with no recorded `userId` has no safe identity
|
||||||
|
to ask as and is skipped — correct, and it leaves that session marked generating.
|
||||||
|
- **Stuck "generating".** The user-visible face of the above. A spinner that never resolves after an agent
|
||||||
|
restart is this, not the UI.
|
||||||
|
|
||||||
|
The missing property is that a session does not survive a restart with its identity intact. Given that, the
|
||||||
|
sweep would not need to skip, a restart would be an inconvenience rather than a loss, and the spinner would
|
||||||
|
resolve itself.
|
||||||
|
|
||||||
|
## 3. `ProcessTransport is not ready for writing` — survivable, still unexplained
|
||||||
|
|
||||||
|
```
|
||||||
|
error: ProcessTransport is not ready for writing
|
||||||
|
at write (…/claude-agent-sdk/sdk.mjs)
|
||||||
|
at streamInput (…/claude-agent-sdk/sdk.mjs)
|
||||||
|
```
|
||||||
|
|
||||||
|
Four fatal crashes on 2026-08-11, one of which truncated a turn mid-sentence. There are **no frames from our
|
||||||
|
code** — it is a floating rejection inside the SDK's own input pump, so no `await` of ours can catch it. With
|
||||||
|
no handler registered it reached the top level and Bun exited, taking every session on the machine.
|
||||||
|
|
||||||
|
`8c4f150c` registered an `unhandledRejection` handler in `sidecar/claude/user-instance.ts`, which is the
|
||||||
|
process `ecosystem.config.cjs` starts as `officer-agent`. Verified on Bun 1.3.9: the handler fires and the
|
||||||
|
process survives. It has fired once in production since.
|
||||||
|
|
||||||
|
**The cause is still unknown.** Best hypothesis: the `claude` CLI exits while `streamInput` is still pumping,
|
||||||
|
so the transport's `ready` flips false mid-write. Unconfirmed.
|
||||||
|
|
||||||
|
It no longer needs to be caught in the act — it needs someone to look after it happens. The next occurrence
|
||||||
|
logs a full rejection in a *live* process with every other session still attached, which is a much better
|
||||||
|
vantage point than a corpse.
|
||||||
|
|
||||||
|
Markers in `~/.pm2/logs/officer-agent-error.log`:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -c 'Bun v1.3' → fatal exits. Was 4. A fifth means the backstop stopped working.
|
||||||
|
grep -c 'UNHANDLED REJECTION' → caught and survived. Was 1.
|
||||||
|
```
|
||||||
|
|
||||||
|
`uncaughtException` is deliberately not handled the same way: a rejection leaves the process's state intact,
|
||||||
|
whereas a synchronous throw that unwound to the top supports no such claim, and continuing on a possibly
|
||||||
|
corrupted heap is worse than restarting. That asymmetry is an argument for §2 rather than against itself.
|
||||||
@@ -146,7 +146,7 @@ test (`os-user.test.ts` → "does not pass the platform environment through").
|
|||||||
sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||||
shell" must not depend on a config file someone may have edited.
|
shell" must not depend on a config file someone may have edited.
|
||||||
|
|
||||||
Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
|
Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
|
||||||
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
|
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
|
||||||
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
|
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
|
||||||
rule anyway.
|
rule anyway.
|
||||||
@@ -279,7 +279,12 @@ Three bugs surfaced only by running it:
|
|||||||
also *unreachable*.
|
also *unreachable*.
|
||||||
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
|
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
|
||||||
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
|
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
|
||||||
start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable.
|
start while any `.env` in the project root is group- or world-readable.
|
||||||
|
|
||||||
|
That check was itself conditional on `OFFICER_OS_USERS` until 2026-08-12, which meant the guarantee was
|
||||||
|
opt-in. The flag is gone and the check is unconditional: a security prerequisite that only holds when
|
||||||
|
somebody remembers to set a variable is not a prerequisite. Per-user Linux accounts are now simply what
|
||||||
|
the platform does, so there is nothing to enable and nothing to forget.
|
||||||
|
|
||||||
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
|
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
|
||||||
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
|
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# The secret store
|
||||||
|
|
||||||
|
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
|
||||||
|
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
|
||||||
|
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
|
||||||
|
yet.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is wrong with today
|
||||||
|
|
||||||
|
Nothing is insecure. The separation is already right — the thing worth keeping is stated first so it is
|
||||||
|
not lost in a refactor:
|
||||||
|
|
||||||
|
> **Secrets live in Postgres. The key that opens them does not.**
|
||||||
|
|
||||||
|
That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what
|
||||||
|
makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless
|
||||||
|
without the passphrase"*.
|
||||||
|
|
||||||
|
What is wrong is narrower, and it is about **blast radius across processes**.
|
||||||
|
|
||||||
|
`.env` sits in the repository root, and Bun auto-loads it. `ecosystem.config.cjs` says so in as many
|
||||||
|
words — it is the reason the Anthropic credential was moved out of the main process. So today
|
||||||
|
`VAULT_STORE_KEY` is present in the environment of **all twenty pm2 processes**. `officer-music` holds
|
||||||
|
the key that decrypts wallet seed envelopes. Anything that can read `/proc/<pid>/environ` for those
|
||||||
|
processes has it, and nineteen of them have no reason to.
|
||||||
|
|
||||||
|
The second problem is that changing the key is currently unrecoverable rather than an operation. See
|
||||||
|
[Rotation](#rotation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the key actually protects
|
||||||
|
|
||||||
|
Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in
|
||||||
|
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
|
||||||
|
|
||||||
|
| column | what it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
|
||||||
|
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
|
||||||
|
| `jellyfin_servers.access_token` | Jellyfin session token |
|
||||||
|
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
|
||||||
|
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
|
||||||
|
|
||||||
|
`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every
|
||||||
|
one of those becoming unreadable at once.
|
||||||
|
|
||||||
|
The seed envelope is the only one protected by a second, independent secret (the owner passphrase, never
|
||||||
|
persisted). Everything else in that table has exactly one lock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. The store is SQLite, in the install, outside Postgres
|
||||||
|
|
||||||
|
Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the
|
||||||
|
thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a
|
||||||
|
second key only moves the question — eventually exactly one secret has to be readable without any other
|
||||||
|
secret, and the only real decision is *where it lives*.
|
||||||
|
|
||||||
|
SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A
|
||||||
|
rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs
|
||||||
|
both to still exist. That is a table with `id, purpose, key, created_at, retired_at`, and it is awkward
|
||||||
|
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
|
||||||
|
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
|
||||||
|
|
||||||
|
### 2. It is NOT encrypted at rest — and that decision changed shape
|
||||||
|
|
||||||
|
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
|
||||||
|
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
|
||||||
|
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
|
||||||
|
|
||||||
|
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
|
||||||
|
|
||||||
|
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
|
||||||
|
finding is still true and still the reason whole-file encryption is not on the table.
|
||||||
|
|
||||||
|
#### The original note
|
||||||
|
|
||||||
|
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ bun --eval 'db.exec("PRAGMA key = \"supersecret\""); … insert …'
|
||||||
|
read without key: THE-SECRET-VALUE
|
||||||
|
strings enc.db | grep THE-SECRET-VALUE -> found
|
||||||
|
```
|
||||||
|
|
||||||
|
Stock SQLite **silently ignores unknown pragmas**, so `PRAGMA key` succeeds, encrypts nothing, and the
|
||||||
|
value sits in the file in plaintext. `bun:sqlite` ships stock SQLite 3.53.0, not SQLCipher.
|
||||||
|
|
||||||
|
Whole-file encryption therefore needs SQLCipher, which means a native module — and this project already
|
||||||
|
knows what one of those costs, since node-pty has no Linux prebuild and compiles from source on every
|
||||||
|
machine.
|
||||||
|
|
||||||
|
So the store holds **encrypted values in an unencrypted file**, the same shape as the Postgres columns.
|
||||||
|
What leaks is metadata: which purposes have keys, and when they were rotated. That is an acceptable
|
||||||
|
trade and it is written down here so nobody later assumes the file is opaque.
|
||||||
|
|
||||||
|
`[open]` SQLCipher, if the native-dependency cost ever becomes worth paying.
|
||||||
|
|
||||||
|
### 3. Where the file goes
|
||||||
|
|
||||||
|
**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13.
|
||||||
|
|
||||||
|
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
|
||||||
|
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
|
||||||
|
problem this design exists to avoid.
|
||||||
|
|
||||||
|
The setup script says so out loud when it creates the store, because "back this up, but not next to the
|
||||||
|
other thing you back up" is not a rule anyone infers.
|
||||||
|
|
||||||
|
### 4. ~~One secret remains outside~~ — none does
|
||||||
|
|
||||||
|
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
|
||||||
|
|
||||||
|
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` — **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.
|
||||||
|
- **The anthropic proxy secret**, purpose `anthropic-proxy`. Agreed 2026-08-12. Neither an encryption
|
||||||
|
key nor a signing key — a bearer credential, generated once by `ensureProxySecret` and presented by
|
||||||
|
`officer-agent` to `officer-anthropic-proxy` on `127.0.0.1`. It qualifies on the same three
|
||||||
|
properties: generated once, shared between two processes, fatal to regenerate silently.
|
||||||
|
|
||||||
|
It is in the store for a sharper reason than the other two, though. It is not in `.env` today — it
|
||||||
|
is in `$DATA_PATH/sidecar/claude-state.json`, mixed in with session records. That is the one
|
||||||
|
location [decision 3](#3-where-the-file-goes) rules out by name: `DATA_PATH` is what people back up,
|
||||||
|
so the secret already travels in the same tarball as the data it protects.
|
||||||
|
|
||||||
|
**Naming.** It is called `ANTHROPIC_API_KEY` in `ensureAnthropicEnv`, and that name is wrong in both
|
||||||
|
halves — it is not Anthropic's and it is not an API key. Anthropic's real credential is the OAuth
|
||||||
|
token in `~/.claude/.credentials.json`, which the proxy swaps this one for on the way out. Our name
|
||||||
|
for it is **anthropic-proxy-secret** everywhere we control.
|
||||||
|
|
||||||
|
The exception is the last line before the spawn. `claude` reads the variable `ANTHROPIC_API_KEY` and
|
||||||
|
format-checks the `sk-ant-api03-` prefix, so both are the CLI's contract rather than ours and both
|
||||||
|
stay. That one assignment keeps the CLI's name, with a comment saying why.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core, and plugins
|
||||||
|
|
||||||
|
The store is core infrastructure, created at first boot. It is **not** a side effect of installing any
|
||||||
|
one sidecar — it exists on a machine that installs nothing, so that a plugin installed in six months
|
||||||
|
finds it already there.
|
||||||
|
|
||||||
|
The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
|
||||||
|
`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**.
|
||||||
|
|
||||||
|
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the
|
||||||
|
perimeter — origin checking was removed on 2026-08-13 precisely because the tailnet is what stands in
|
||||||
|
its place, so the tailnet is now load-bearing rather than one layer of two. A security model
|
||||||
|
that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and
|
||||||
|
the wallet are not load-bearing that way — nothing else stops working without them — so they become
|
||||||
|
plugins.
|
||||||
|
|
||||||
|
Moving headscale into the light profile also removes it from the app store automatically:
|
||||||
|
`catalogue.test.ts` asserts the catalogue equals `full − light`, so the test fails until the entry is
|
||||||
|
deleted. That derivation is doing its job and should not be worked around.
|
||||||
|
|
||||||
|
Headscale is then the store's **first user**, not its creator — `headscale_servers.api_key` is the first
|
||||||
|
core credential needing a key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
What a plugin gets, and is bound by. To be written properly when the first one uses it; the shape is:
|
||||||
|
|
||||||
|
- **Ask for a key by purpose**, not by name. `getKey('vault')` returns the active key for that purpose,
|
||||||
|
creating one on first use.
|
||||||
|
- **Never hold it.** Read it at the point of use. A key cached in a long-lived process is the
|
||||||
|
process-environment problem in a different container.
|
||||||
|
- **Never write to another plugin's purpose.** Same rule `service_connections` already has for rows.
|
||||||
|
- **Tolerate rotation.** A key may change between two calls. Anything that decrypts must be prepared to
|
||||||
|
be handed the retired key for data written before a rotation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rotation
|
||||||
|
|
||||||
|
The feature that makes the store worth building, and the reason versions exist.
|
||||||
|
|
||||||
|
Today, changing `VAULT_STORE_KEY` is not an operation — it is data loss. Every column above becomes
|
||||||
|
unreadable, and for `wallets.seed_envelope` that is unrecoverable: the owner passphrase does not help,
|
||||||
|
because it opens the inner envelope and the outer one is gone. Unless the mnemonic was written down
|
||||||
|
offline, the coins are gone with it.
|
||||||
|
|
||||||
|
Rotation turns that into a supported action:
|
||||||
|
|
||||||
|
1. Mint a new key for the purpose, leaving the old one in the store as retired.
|
||||||
|
2. For every ciphertext column belonging to that purpose: decrypt with the retired key, re-encrypt with
|
||||||
|
the new one.
|
||||||
|
3. Retire the old key only when every row has moved.
|
||||||
|
|
||||||
|
Two properties it must have, both learned from the failure it replaces:
|
||||||
|
|
||||||
|
- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
|
||||||
|
tell which rows are which.
|
||||||
|
- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written.
|
||||||
|
A key that is already wrong should fail loudly on row one rather than produce a second layer of
|
||||||
|
unreadable data.
|
||||||
|
|
||||||
|
`[open]` Whether rotation is a UI action, a CLI command, or both. It is a long operation on a large
|
||||||
|
wallet table and it cannot be interrupted safely, which argues for something that reports progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this does not change
|
||||||
|
|
||||||
|
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
||||||
|
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
|
||||||
|
at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules
|
||||||
|
were touched. That was the cost of the split, and it is worth stating plainly because this line
|
||||||
|
originally promised the opposite.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Where the file lives, given it must not be swept up by a backup of `data/`.
|
||||||
|
2. Whether the store's own key stays in `.env` or moves to a file read on demand.
|
||||||
|
3. Whether rotation is UI, CLI, or both — and how it reports progress on a table that takes minutes.
|
||||||
|
4. SQLCipher, and whether whole-file encryption is ever worth a second native dependency.
|
||||||
|
5. What happens to a plugin's keys when it is uninstalled. The app store already decided that
|
||||||
|
uninstalling never deletes data; the same answer probably applies, but "probably" is not a decision.
|
||||||
@@ -107,9 +107,13 @@ consequences, both wanted:
|
|||||||
|
|
||||||
### Docker is assumed, and nothing guarantees it
|
### Docker is assumed, and nothing guarantees it
|
||||||
|
|
||||||
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** `setup.sh` calls
|
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** The host installer
|
||||||
`setup-dockers.sh`, which invokes `docker compose` with no preflight, so a fresh host without Docker
|
`scripts/setup/setup.sh` calls `scripts/setup/setup-dockers.sh`, which invokes `docker compose` with no
|
||||||
fails partway through setup with a bare "command not found".
|
preflight, so a fresh host without Docker fails partway through setup with a bare "command not found".
|
||||||
|
|
||||||
|
(Not to be confused with the per-template `setup.sh` below — `app-store/templates/<name>/setup.sh` — which
|
||||||
|
is a different file with a different contract. The host one provisions the machine; a template one
|
||||||
|
provisions a single sidecar.)
|
||||||
|
|
||||||
That is the seam where this project's origin shows — it began as one person's own machine, provisioned
|
That is the seam where this project's origin shows — it began as one person's own machine, provisioned
|
||||||
by his own scripts, where Docker was simply always there.
|
by his own scripts, where Docker was simply always there.
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
# Two agents on one branch: a field report
|
||||||
|
|
||||||
|
**What this is:** an account of 2026-08-11/12, when two agents worked the same branch for roughly ten hours
|
||||||
|
with the owner arbitrating, and shipped per-user Claude end to end. It is evidence rather than proposal.
|
||||||
|
|
||||||
|
`docs/agent-coordination.md` states the objective — several agents on one body of work, *"coordinating with
|
||||||
|
each other rather than through the human"*. That was written in theory on 2026-08-07. This is what happened
|
||||||
|
when it ran, and the ways the theory was wrong.
|
||||||
|
|
||||||
|
Read it as a record of what to build, not as a design. Where something worked it says so; where it broke it
|
||||||
|
says how, because the failures are more useful than the successes and there were more of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The shape that emerged
|
||||||
|
|
||||||
|
Nobody designed this. It settled into place in the first hour and held.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Agent A** (dev machine) | wrote the platform code |
|
||||||
|
| **Agent B** (production host) | verified against a real machine, never wrote the feature |
|
||||||
|
| **The owner** | arbitrated, held every irreversible decision, and pushed for real tests |
|
||||||
|
|
||||||
|
The split was not "two reviewers are better than one". It was **the author and the verifier being different
|
||||||
|
people**, and the mechanism is narrower than it sounds:
|
||||||
|
|
||||||
|
> The person who writes the sentence explaining why something is safe is the worst-placed person to notice
|
||||||
|
> that the code disagrees with it.
|
||||||
|
|
||||||
|
That is not a claim about carelessness. Agent A wrote *"a wrong answer here is the one thing that must not
|
||||||
|
happen by accident"* and shipped exactly that accident in the same commit. Agent B wrote a verification script
|
||||||
|
that could not fail on Agent A's machine. Neither was sloppy. Each was reading their own reasoning back and
|
||||||
|
finding it agreed with itself.
|
||||||
|
|
||||||
|
Re-reading your own diff does not reach this. You read the comment, agree, and move on.
|
||||||
|
|
||||||
|
## What each half was actually good for
|
||||||
|
|
||||||
|
**A machine is not a code review.** The defects split cleanly into two kinds, and the split is the most
|
||||||
|
useful thing in this report.
|
||||||
|
|
||||||
|
*Found by reading, almost always by the non-author:* two environment guards that could never fire; a binary
|
||||||
|
check comparing paths in a way that would have thrown on every turn; a credential resolver that answered "I
|
||||||
|
don't know whose turn this is" with the owner's identity; a function whose parameter changed meaning from an
|
||||||
|
email to a filesystem path while three callers kept passing emails, invisible to the compiler because both
|
||||||
|
are `string`.
|
||||||
|
|
||||||
|
*Found only by running, and invisible to any amount of reading:* an installer piped into `sh` when it needs
|
||||||
|
`bash`; a parent directory created `root:root` as a side effect of `install -d`; an ACL mask silently clamped
|
||||||
|
so the file browser could not read a member's home; a chat working directory the member could not enter; ACL
|
||||||
|
entries surviving a `chown` and granting a freed uid access to everything.
|
||||||
|
|
||||||
|
Every "found by running" defect appeared on a **first execution**. Provisioning a real account found three in
|
||||||
|
twenty minutes. The first real chat turn found the cwd. The first teardown was the only thing that could have
|
||||||
|
proved the process reaper.
|
||||||
|
|
||||||
|
The owner drove this repeatedly — *"I'm anxious to see this work"* — against both agents' instinct to keep
|
||||||
|
building. That instinct was wrong every time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The communications paradigm
|
||||||
|
|
||||||
|
Agents coordinated through `COMMS/<branch>/` — markdown files committed to the repo, alongside the code they
|
||||||
|
discuss, deleted when the feature merged.
|
||||||
|
|
||||||
|
**Why a directory in the repo and not chat.** It survives a context window. Both agents' reasoning outlived
|
||||||
|
the sessions that produced it, a third party could read the argument rather than a summary of it, and it
|
||||||
|
travels with the branch. Chat has none of those properties, and the owner relaying findings by hand between
|
||||||
|
two agents at the end of long sessions is the failure this replaces.
|
||||||
|
|
||||||
|
### The rules, as they ended up
|
||||||
|
|
||||||
|
**Location and lifetime.** `COMMS/<branch-name>/`. It is a *channel*, not documentation: when the feature
|
||||||
|
merges, the directory is deleted. Anything that will still be true in a month must be moved to `docs/` or next
|
||||||
|
to the code **before** the merge, or it is lost. We nearly lost three findings this way and only caught it
|
||||||
|
because someone checked.
|
||||||
|
|
||||||
|
**Numbered, alternating, parity is the author.** One agent takes odd numbers, the other even. `01`, `02`,
|
||||||
|
`03`… Never "your doc" / "his doc", which inverts depending on who is reading. The parity *is* the
|
||||||
|
attribution, and given that git could not attribute anything (see below), it was the only attribution that
|
||||||
|
worked.
|
||||||
|
|
||||||
|
**Numbers are ordered, not necessarily consecutive.** An agent needing two in a row takes `03` and `05` and
|
||||||
|
leaves `04` unused, rather than forcing a reply out of the other side to keep the count. A gap is legal and
|
||||||
|
means "no turn was taken".
|
||||||
|
|
||||||
|
**The slug is the content.** `02-verify-results.md`, `24-resolvememberrun-fails-open.md`. Not `02-reply.md`.
|
||||||
|
The filename is the index; a reader should know whether to open it without opening it.
|
||||||
|
|
||||||
|
**Reply in a new file. Never edit someone else's.** An edited handoff loses what was believed at the moment a
|
||||||
|
decision was made, which is usually the thing that explains the decision.
|
||||||
|
|
||||||
|
**Editing your own is allowed if it has not been read** — and say so in the commit. Better than a prediction
|
||||||
|
standing next to its own correction in two documents.
|
||||||
|
|
||||||
|
**Refer to commits by SHA, never by branch name.** Three remotes were in play with the same branch names on
|
||||||
|
each; one agent's `origin` was the other's `pertento`. A SHA is the only unambiguous reference, and this cost
|
||||||
|
real time before it was noticed.
|
||||||
|
|
||||||
|
### What a handoff must contain
|
||||||
|
|
||||||
|
This is the part that carried the most weight, and it is one rule:
|
||||||
|
|
||||||
|
> **State what you verified and what you assumed, separately and explicitly.**
|
||||||
|
|
||||||
|
A handoff that reads as confident about something untested is *worse than no handoff*, because the reader
|
||||||
|
builds on it. Every serious mistake of the night traces back to something asserted with more confidence than
|
||||||
|
it had been earned.
|
||||||
|
|
||||||
|
In practice, each document ended up with:
|
||||||
|
|
||||||
|
- **What changed** — with `file:line` throughout. Costs nothing to write, saves the reader a search, and
|
||||||
|
makes a claim checkable rather than believable.
|
||||||
|
- **VERIFIED** — what was actually run, on what, with the output.
|
||||||
|
- **NOT VERIFIED** — stated as prominently as the verified part. `provisionClaudeCli` carried "never executed
|
||||||
|
anywhere" through four documents, and that label is what eventually made someone run it.
|
||||||
|
- **What I am least sure of** — the author's own suspicions. One agent listed three; the second was a real
|
||||||
|
defect, found because it had been pointed at.
|
||||||
|
- **What I did not do** — so nobody assumes it. "I did not restart anything", "I did not touch the gates".
|
||||||
|
- **Open items with an owner** — see termination, below.
|
||||||
|
|
||||||
|
### Termination: the rule that took four attempts
|
||||||
|
|
||||||
|
This broke more times than anything else, so the failures are worth listing in order:
|
||||||
|
|
||||||
|
1. **Terminate by guess.** `NO REPLY NEEDED unless the test fails` — a prediction about content the sender had
|
||||||
|
not seen. It ended an exchange with items open.
|
||||||
|
2. **Terminate by politeness.** The fix — always reply, even with nothing to say — has no exit. "Nothing to
|
||||||
|
report" obligates another "nothing to report", indefinitely, at real cost.
|
||||||
|
3. **Terminate when the list is empty.** Too strong: the list is never empty and will not be for days.
|
||||||
|
4. **What actually works:** *the exchange pauses when no open item is actionable by a participant.*
|
||||||
|
|
||||||
|
That last one is checkable rather than felt. Everything remaining is either the human's, or deferred with a
|
||||||
|
stated reason, and either side reopens it by adding an item that is theirs.
|
||||||
|
|
||||||
|
**Three states, not two.** An item is `open` / `done` / **`deferred with a reason`**. Three times the honest
|
||||||
|
answer was "mine, and not now" — and only the *reason* distinguishes that from neglect. A protocol with two
|
||||||
|
states forces an agent to lie in one direction or the other.
|
||||||
|
|
||||||
|
**A stall must be detectable.** Silence and completion look identical from outside. Open items plus no
|
||||||
|
document for N minutes is a condition a machine can watch for; silence is not. The human noticed both stalls
|
||||||
|
before either agent did, which is the wrong way round.
|
||||||
|
|
||||||
|
### Ownership, which we did not have and needed
|
||||||
|
|
||||||
|
Late on, both agents independently wrote the *same document* — same filename, same three sections — because
|
||||||
|
one had read the other's notes before deleting them. Pure waste, caught only by diffing the two files.
|
||||||
|
|
||||||
|
Nothing in the protocol said who owned a piece of work. Adding it is cheap: an open item names its owner, and
|
||||||
|
an agent picking up an unowned item claims it in a document before starting.
|
||||||
|
|
||||||
|
### A skeleton to copy
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# NN — <what this is about in one line>
|
||||||
|
|
||||||
|
Commits read: <sha>..<sha>. Answering `<NN-1>`.
|
||||||
|
|
||||||
|
**Verdict / what changed** — one paragraph, file:line.
|
||||||
|
|
||||||
|
## VERIFIED
|
||||||
|
<what was actually run, on what, with output>
|
||||||
|
|
||||||
|
## NOT VERIFIED
|
||||||
|
<stated as prominently as the above>
|
||||||
|
|
||||||
|
## What I am least sure of
|
||||||
|
<your own suspicions, numbered>
|
||||||
|
|
||||||
|
## What I did not do
|
||||||
|
<so nobody assumes it>
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
| item | owner | state |
|
||||||
|
|---|---|---|
|
||||||
|
| … | me / you / the human | open / deferred (reason) |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The review discipline
|
||||||
|
|
||||||
|
"Verify" turned out to mean something more specific than reading a diff. What actually caught defects:
|
||||||
|
|
||||||
|
**Check the enforcement, not the description.** A document says a check is scoped by user; go read the line
|
||||||
|
that compares. Twice the description was right and the code did something else — and the author had read
|
||||||
|
their own description and agreed with it.
|
||||||
|
|
||||||
|
**Run it against a real machine.** Every defect that mattered was found this way, on a first execution. The
|
||||||
|
categories at the top of this report are not a coincidence.
|
||||||
|
|
||||||
|
**A check that has never been seen failing is not evidence.** A verification script was run against a live,
|
||||||
|
fully-provisioned account specifically to watch it fail; it reported 8 of 9 failures, which is what made the
|
||||||
|
later clean result meaningful. Related: a *skipped* test must announce itself, or an unconfigured run reads as
|
||||||
|
a pass.
|
||||||
|
|
||||||
|
**Distrust vacuous passes.** Three separate times something passed because it had not actually looked:
|
||||||
|
a subuid scan on a tree with no subuid-owned files; a search root that did not exist, where every check
|
||||||
|
reports "ok" on finding nothing; and a range scan handed a non-numeric argument. **Any checker whose checks
|
||||||
|
are "look for X, report ok if absent" must refuse to run when its inputs are wrong**, rather than pass.
|
||||||
|
|
||||||
|
**Expect stacked bugs.** Fixing the visible failure reveals the next one underneath. A container failed on a
|
||||||
|
mount-point guard; fixing that revealed an ACL traversal denial. An installer failed on the wrong shell;
|
||||||
|
fixing that would have revealed a root-owned parent directory. Never report "fixed" from a diff — only from a
|
||||||
|
run.
|
||||||
|
|
||||||
|
**Distrust "it is inert today".** Several things were safe only because a gate was up. That is a statement
|
||||||
|
about the present, and the entire purpose of the work was to remove the gate. Review inert code as if it were
|
||||||
|
live, because the commit that makes it live will be reviewed as if it were already correct.
|
||||||
|
|
||||||
|
**Fail closed, and check which way "unknown" resolves.** The most dangerous defect of the night was a resolver
|
||||||
|
that answered "I could not determine whose turn this is" with *the owner's identity*. Any place where an
|
||||||
|
unknown collapses into a privileged default is worth a specific look.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure modes to expect
|
||||||
|
|
||||||
|
Collected from the night, phrased so an agent can pattern-match against them:
|
||||||
|
|
||||||
|
| pattern | what it looked like here |
|
||||||
|
|---|---|
|
||||||
|
| **Author reviews own sentence** | "a wrong answer here must not happen by accident" shipped with that accident |
|
||||||
|
| **Vacuous pass** | checker with a missing search root printing CLEAN |
|
||||||
|
| **Stacked bugs** | PG18 mount guard hiding an ACL traversal denial |
|
||||||
|
| **Inert-today reasoning** | unreachable code reviewed less carefully than reachable code |
|
||||||
|
| **Unknown resolves to privileged** | failed lookup → run as the owner |
|
||||||
|
| **Compiler cannot help** | a parameter changing meaning from email to path, both `string` |
|
||||||
|
| **Guard that cannot fire** | a denylist tested against an object built from an allowlist |
|
||||||
|
| **Side-effect creation** | `install -d` making a parent `root:root` |
|
||||||
|
| **Mode bits vs ACLs** | `chown` severing ownership and leaving access |
|
||||||
|
| **Tail-of-session work** | three of the night's bugs written after hour eight |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git hygiene for two agents on one branch
|
||||||
|
|
||||||
|
Small, and it bit us repeatedly:
|
||||||
|
|
||||||
|
- **Pull before you push, and expect a race.** Both agents pushed within the same minute more than once; one
|
||||||
|
rebase was needed mid-review.
|
||||||
|
- **Merge, verify, *then* delete.** A branch was deleted after an aborted fast-forward — master had moved —
|
||||||
|
and the commits survived only because git had not yet garbage-collected them. Verify the merge landed before
|
||||||
|
removing the only ref to it.
|
||||||
|
- **A doc-only commit still deserves a real message.** These commit messages are the durable record once
|
||||||
|
`COMMS/` is deleted; several findings in this repo now exist *only* in a commit body.
|
||||||
|
- **Say which remote.** See the SHA rule above.
|
||||||
|
|
||||||
|
## The background watcher — launch it exactly this way
|
||||||
|
|
||||||
|
This is the part that was hardest to convey to the second agent, who ended up launching it differently and
|
||||||
|
got something that looked identical and did not work. The mechanism matters more than the script.
|
||||||
|
|
||||||
|
### The requirement, stated so it survives a different harness
|
||||||
|
|
||||||
|
> A **shell process, detached, owned by the agent's harness, that exits when it has something to say** — and
|
||||||
|
> whose exit **re-invokes the agent**.
|
||||||
|
|
||||||
|
Three properties, and dropping any one breaks it in a way that is not obvious from watching it run:
|
||||||
|
|
||||||
|
1. **The waiting happens in the shell, not in the model.** No inference per tick.
|
||||||
|
2. **The harness owns the process**, so its exit is an event the harness delivers to the agent.
|
||||||
|
3. **It exits on detection.** A watcher that notices a change and keeps running has told nobody.
|
||||||
|
|
||||||
|
### The launch
|
||||||
|
|
||||||
|
In Claude Code this is the Bash tool with `run_in_background: true`. Whatever the harness, it must be *that
|
||||||
|
harness's* background mechanism — the one that notifies on completion — and not a shell backgrounding
|
||||||
|
operator.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/repo || exit 1
|
||||||
|
BASE=$(git rev-parse HEAD)
|
||||||
|
echo "watching origin/<branch> from base=$BASE"
|
||||||
|
for i in $(seq 1 2880); do
|
||||||
|
NEW=$(timeout 30 git ls-remote origin <branch> 2>/dev/null | awk '{print $1}')
|
||||||
|
if [ -n "$NEW" ] && [ "$NEW" != "$BASE" ]; then
|
||||||
|
echo "PUSH_DETECTED"; echo "base=$BASE"; echo "new=$NEW"; exit 0
|
||||||
|
fi
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
echo "WATCHER_TIMEOUT no push in ~24h base=$BASE"
|
||||||
|
exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Every line of that is load-bearing:
|
||||||
|
|
||||||
|
| choice | why | what you get instead |
|
||||||
|
|---|---|---|
|
||||||
|
| `git ls-remote` | reads the remote, mutates nothing | `git fetch` moves refs under a working tree that may be mid-edit |
|
||||||
|
| `timeout 30` on the call | a hung network call would freeze the loop silently | a watcher that is alive and blind |
|
||||||
|
| one `echo` at start, then silence | the output enters the agent's context on wake | one line per tick = 2,880 lines to swallow |
|
||||||
|
| `exit 0` on detection | the exit **is** the notification | it notices and nobody hears |
|
||||||
|
| `seq 1 2880` | runaway backstop | a process nobody remembers, polling forever |
|
||||||
|
| `sleep 30` | free, because no model runs | see below |
|
||||||
|
|
||||||
|
### Why 30 seconds is free here and ruinous in the model
|
||||||
|
|
||||||
|
An idle watcher costs **nothing**. Measured: 85 bytes of output over seven minutes, no model inference at
|
||||||
|
all. The agent is suspended between turns; the loop is just a process.
|
||||||
|
|
||||||
|
Cost appears in exactly two places — when the accumulated output enters the context, and the single
|
||||||
|
re-invocation when the process exits. Both happen **once**, on the event.
|
||||||
|
|
||||||
|
A model-driven poll is a different thing wearing the same clothes. There the model wakes each tick and
|
||||||
|
re-reads the entire conversation to decide "nothing yet". At 30-second granularity that is enormous, and
|
||||||
|
there is a second trap: the prompt cache has roughly a five-minute TTL, so any model-side wake spaced beyond
|
||||||
|
that reads the whole context uncached and pays full price. Pushing the waiting *below* the model turns an
|
||||||
|
unaffordable poll into a free one.
|
||||||
|
|
||||||
|
### The four ways to launch it that look right and are not
|
||||||
|
|
||||||
|
**1. `nohup … &` or any shell backgrounding.** The process runs, polls correctly, detects the push, and exits —
|
||||||
|
and **the agent is never told**, because the harness is not tracking it. I did this myself and only noticed
|
||||||
|
because I re-read my own command. It fails silently and looks perfect: a running process, a correct script,
|
||||||
|
and an agent that sits there forever.
|
||||||
|
|
||||||
|
**2. A model-driven interval** — `/loop 30s`, a scheduler, a wake-up timer. Functionally correct, and it pays
|
||||||
|
a full context read per tick to learn nothing. This is the one to warn a new agent about first, because it is
|
||||||
|
the intuitive design and the expense is invisible.
|
||||||
|
|
||||||
|
**3. A loop that does not exit on detection** — printing "found it" and continuing. There is no mechanism by
|
||||||
|
which that reaches the agent. The output file grows and no one reads it.
|
||||||
|
|
||||||
|
**4. Chatty output.** Any per-tick logging is deferred cost: silent while it accumulates, then all of it
|
||||||
|
lands in the context at once on wake.
|
||||||
|
|
||||||
|
### Two operational failures worth pre-empting
|
||||||
|
|
||||||
|
**Self-tripping.** An agent that pushes while its own watcher is live wakes itself. The real cause is
|
||||||
|
starting a new watcher without stopping the old one, so two run concurrently and the stale one fires on your
|
||||||
|
own commit. **Stop the previous watcher before starting the next**, and re-base the new one on the head you
|
||||||
|
just pushed.
|
||||||
|
|
||||||
|
**Silent death.** If the session restarts, the watcher dies, and a dead watcher is indistinguishable from a
|
||||||
|
quiet branch. Twice, pushes landed unnoticed and were found by a manual `git log`. Anything long-running
|
||||||
|
needs a liveness signal of its own, or the eventual replacement of polling with a webhook — the repo is a
|
||||||
|
Gitea instance the platform already runs, and an event delivered is one that cannot be missed by a process
|
||||||
|
that stopped existing.
|
||||||
|
|
||||||
|
## Identity: the gap that made the record unreliable
|
||||||
|
|
||||||
|
Both agents committed from machines configured with the owner's git identity. **Every commit on the branch,
|
||||||
|
by either agent, reads `Author: <the owner>` with a `Co-Authored-By: Claude Opus 5` trailer.**
|
||||||
|
|
||||||
|
The consequence surfaced at the end and was genuinely disorienting: the owner asked which commit an agent had
|
||||||
|
written, and *neither the log nor the agent could answer from the repository*. The only reason one agent knew
|
||||||
|
its own commits was that it had read the SHAs back from its own `git push` output during the session — which
|
||||||
|
does not survive the session.
|
||||||
|
|
||||||
|
`docs/agent-git-identity.md` describes this and is marked *"idea, not implemented"*. It stopped being an idea
|
||||||
|
tonight. Of everything here it is the cheapest to fix and the most corrosive to leave: an audit trail that
|
||||||
|
cannot attribute a line is not an audit trail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session economics, which shape all of the above
|
||||||
|
|
||||||
|
**Idle is free; waking is not.** The watcher costs nothing while it waits. Every wake re-reads the entire
|
||||||
|
conversation, so a late wake in a long session costs far more than an early one, and the cost grows
|
||||||
|
monotonically with the session.
|
||||||
|
|
||||||
|
**This argues against one immortal session.** The durable shape is a *short-lived session per event* — the
|
||||||
|
platform detects a push, spawns an agent with the base SHA and the instruction, it reviews, reports, exits.
|
||||||
|
State lives in the repo, not in an ever-growing transcript. A ten-hour session is possible and was useful, but
|
||||||
|
its last hour cost several times its first.
|
||||||
|
|
||||||
|
**Compaction is the real horizon, not session death.** Where sessions persist, the limit is that the earliest
|
||||||
|
context — usually the most expensive reasoning — degrades to summary first. Anything that must survive belongs
|
||||||
|
in the repo the moment it is understood, not at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Turning this into a convention
|
||||||
|
|
||||||
|
In order, cheapest and most load-bearing first.
|
||||||
|
|
||||||
|
**1. Per-agent git identity.** Both agents commit from machines configured as the owner, so every commit reads
|
||||||
|
`Author: <owner>` with a `Co-Authored-By` trailer, for both of them. The owner asked which commit an agent had
|
||||||
|
written and *neither the log nor the agent could answer from the repository*. An audit trail that cannot
|
||||||
|
attribute a line is not an audit trail, and everything else here assumes attribution works.
|
||||||
|
`docs/agent-git-identity.md` describes the fix and has been marked "idea, not implemented" since 2026-08-10.
|
||||||
|
|
||||||
|
**2. `COMMS/` as a checked convention, not a habit.** The numbering, the parity, the verified/assumed split
|
||||||
|
and the open-item table are all mechanically checkable. A pre-commit hook or a small script that refuses a
|
||||||
|
malformed handoff would have caught the duplicate document and both stalls.
|
||||||
|
|
||||||
|
**3. State-based termination and stall detection.** Open items with owners, in a machine-readable block; the
|
||||||
|
exchange pauses when none is actionable by a participant; a watcher notices open items with no document for N
|
||||||
|
minutes. This is the single biggest quality-of-life gain and it is not hard.
|
||||||
|
|
||||||
|
**4. Event delivery instead of polling.** The repo is a Gitea instance the platform already runs. A webhook
|
||||||
|
removes the watcher entirely — with its self-trips, its bounded lifetime and its silent death — and replaces
|
||||||
|
"did I miss a push" with an event that cannot be missed by a process that stopped existing.
|
||||||
|
|
||||||
|
**5. Ownership on work items**, so two agents cannot independently write the same file.
|
||||||
|
|
||||||
|
**6. A durable-notes rule.** `COMMS/` is deleted at merge. Anything still true afterwards moves to `docs/`
|
||||||
|
*before* the merge, and the merge should refuse if the channel contains unresolved open items.
|
||||||
|
|
||||||
|
## What not to automate
|
||||||
|
|
||||||
|
**The human's arbitration.** Every irreversible decision was the owner's — lifting the chat gates, deleting an
|
||||||
|
account, choosing between two designs, deciding a directory should stop existing. Each was a judgement neither
|
||||||
|
agent should have made alone, and in at least two cases an agent talked the other out of a bad idea using an
|
||||||
|
argument *the human had originally made*.
|
||||||
|
|
||||||
|
`agent-coordination.md` sets the objective as agents coordinating rather than routing through the human. This
|
||||||
|
night supports that for **execution** and contradicts it for **authority**. The human was not a bottleneck in
|
||||||
|
the work — they were the only participant who could say "that is not yours to decide", and the only one who
|
||||||
|
consistently pushed for a real test over more building.
|
||||||
|
|
||||||
|
The distinction worth encoding: agents may coordinate freely on *what is true* and must not decide *what is
|
||||||
|
permitted*.
|
||||||
|
|
||||||
|
## Postscript: the one that worked first time
|
||||||
|
|
||||||
|
Everything above was found by something failing. One thing did not.
|
||||||
|
|
||||||
|
`deprovisionOsAccount` — the function whose failure hands one member another member's home, keys and
|
||||||
|
credentials — ran correctly the first time it ever ran, against a live account with a systemd session, a
|
||||||
|
running Docker stack and a shell parented outside the session cgroup. Ten checks, clean, on the first
|
||||||
|
execution.
|
||||||
|
|
||||||
|
It is also the only piece of work all night that was **specified before it was written, implemented by
|
||||||
|
someone who had not written the spec, and verified by a tool built before the implementation existed**.
|
||||||
|
|
||||||
|
That is the strongest single argument in this document, and it is one data point. Treat it accordingly.
|
||||||
@@ -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,
|
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
|
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
|
## 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:
|
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
|
├── platform/ the application — a git repo
|
||||||
├── capabilities/ what the agent can do — a separate 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
|
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**
|
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
|
— 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.
|
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,
|
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
|
||||||
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
|
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
|
||||||
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
|
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
|
||||||
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
|
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
|
||||||
granted, because they run as the owner's OS user in the owner's home.
|
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
|
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
|
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
||||||
prose, here or elsewhere.
|
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
|
`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
|
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.
|
system but written down nowhere else.
|
||||||
@@ -67,13 +93,13 @@ Commit messages: simple lowercase, no prefixes, explaining *why*.
|
|||||||
|
|
||||||
## Running and checking your work
|
## 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-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
|
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
|
||||||
`pm2 list` shows them; `pm2 logs officer` follows.
|
`pm2 list` shows them; `pm2 logs officer` follows.
|
||||||
|
|
||||||
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
|
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;
|
**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`
|
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
|
- [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
|
`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
|
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
|
**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.
|
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.
|
> which uuid ids would not.
|
||||||
|
|
||||||
- [ ] **`dashboards.id` is a global primary key but ids are `slugify(name)`.**
|
- [ ] **`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
|
`"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
|
`"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
|
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.
|
(see `databases/CLAUDE.md` → "Composite keys") — harmless churn, but read the plan.
|
||||||
|
|
||||||
- [x] **`upsertDashboard`'s UPDATE has no `userId` predicate.**
|
- [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_
|
`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.
|
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.**
|
**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.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_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,56 +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
|
|
||||||
*/
|
|
||||||
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 PORT=5000 with no POSTGRES_URL. __dirname is the repo root — this file sits beside
|
|
||||||
// ecosystem.config.cjs.
|
|
||||||
return { apps: include.map((name) => ({ ...byName.get(name), cwd: __dirname })) };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { defineProfile };
|
|
||||||
+2
-2
@@ -8,7 +8,7 @@
|
|||||||
"src/workspaces/*"
|
"src/workspaces/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22 || v > 22) { console.error('Node 22 required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
||||||
"gen:index": "bun run ./scripts/gen-index.ts",
|
"gen:index": "bun run ./scripts/gen-index.ts",
|
||||||
"predev": "bun run ./scripts/gen-index.ts",
|
"predev": "bun run ./scripts/gen-index.ts",
|
||||||
"dev": "bun --env-file=.env --watch src/server.tsx",
|
"dev": "bun --env-file=.env --watch src/server.tsx",
|
||||||
@@ -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": "{ 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:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||||
"setup": "bash scripts/setup.sh"
|
"setup": "bash scripts/install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
"@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,29 @@
|
|||||||
|
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',
|
||||||
|
|
||||||
|
// Empty is meaningful: this plugin gates nothing of its own and is reachable by anyone who can reach
|
||||||
|
// the platform. A plugin with a surface worth protecting declares a permission here instead.
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* One-time script: add /email to user 2's dock
|
|
||||||
*
|
|
||||||
* Usage: bun run scripts/add-email-dock-user2.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getDockPaths, setDockPaths } from 'officerdb';
|
|
||||||
|
|
||||||
const USER_ID = 2;
|
|
||||||
const DEFAULT_PATHS = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const existing = await getDockPaths(USER_ID);
|
|
||||||
const paths = existing ?? DEFAULT_PATHS;
|
|
||||||
|
|
||||||
if (paths.includes('/email')) {
|
|
||||||
console.log(`[dock] User ${USER_ID} already has /email in dock`);
|
|
||||||
} else {
|
|
||||||
paths.push('/email');
|
|
||||||
await setDockPaths(USER_ID, paths);
|
|
||||||
console.log(`[dock] Added /email to user ${USER_ID}'s dock: ${JSON.stringify(paths)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('[dock] Failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that a deprovisioned account has genuinely released its uid.
|
||||||
|
#
|
||||||
|
# Written as the verification half of `docs/deprovision-os-account.md`, deliberately OUTSIDE the
|
||||||
|
# implementation: if the function under test calls its own checker, the check is a restatement rather than
|
||||||
|
# an audit. This runs against the machine and knows nothing about the code that was supposed to clean it.
|
||||||
|
#
|
||||||
|
# The subuid range must be captured BEFORE the account is deleted, because `userdel` removes the
|
||||||
|
# /etc/subuid entry along with the account — after which there is no way to ask what range it held, and a
|
||||||
|
# check that silently skips that half is the failure mode this whole file exists to prevent.
|
||||||
|
#
|
||||||
|
# ./assert-uid-free.sh --capture green # before: prints "green 1001 165536 65536"
|
||||||
|
# ./assert-uid-free.sh --check green 1001 165536 65536 # after: exits non-zero unless clean
|
||||||
|
#
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
DATA_PATH="${DATA_PATH:-/home/pastilhas/officerdev/data}"
|
||||||
|
SEARCH_ROOTS=("$DATA_PATH" /home)
|
||||||
|
|
||||||
|
usage() { echo "usage: $0 --capture <user> | --check <user> <uid> <subuid_start> <subuid_count>" >&2; exit 2; }
|
||||||
|
|
||||||
|
# ── A search root that does not exist makes this whole script lie ──
|
||||||
|
#
|
||||||
|
# Every check below is "look for X; report ok when nothing is found", so a root that is missing reports
|
||||||
|
# clean without having looked. That is not hypothetical here: this script is documented to run under
|
||||||
|
# `sudo`, and sudo's env_reset DROPS DATA_PATH, so the fallback above is what actually gets used. On a
|
||||||
|
# host where the fallback is wrong, `--check` scans a directory that does not exist, finds nothing, and
|
||||||
|
# prints "CLEAN — uid safe to reissue".
|
||||||
|
#
|
||||||
|
# The ACL check is the one that fails silently and completely, because it is scoped to DATA_PATH alone.
|
||||||
|
# The exact check added to catch the hazard ownership cannot see is the one a missing DATA_PATH disables.
|
||||||
|
#
|
||||||
|
# So: refuse to run rather than pass vacuously. Same posture the subuid section of the spec argues for.
|
||||||
|
require_roots() {
|
||||||
|
local missing=()
|
||||||
|
for root in "${SEARCH_ROOTS[@]}"; do
|
||||||
|
[[ -d "$root" ]] || missing+=("$root")
|
||||||
|
done
|
||||||
|
if (( ${#missing[@]} )); then
|
||||||
|
echo "refusing to check: these search roots do not exist: ${missing[*]}" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "DATA_PATH is currently '$DATA_PATH'. sudo strips it from the environment, so pass it through:" >&2
|
||||||
|
echo " sudo DATA_PATH=/path/to/data $0 --check ..." >&2
|
||||||
|
echo " (or: sudo -E $0 --check ...)" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Every check here reports 'ok' on finding nothing, so a wrong root reports CLEAN without looking." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--capture" ]]; then
|
||||||
|
user="${2:?user required}"
|
||||||
|
uid="$(id -u "$user" 2>/dev/null)" || { echo "no such account: $user" >&2; exit 1; }
|
||||||
|
range="$(awk -F: -v u="$user" '$1==u {print $2" "$3; exit}' /etc/subuid)"
|
||||||
|
[[ -n "$range" ]] || { echo "no /etc/subuid entry for $user — capture it another way or it is unverifiable" >&2; exit 1; }
|
||||||
|
echo "$user $uid $range"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "${1:-}" == "--check" ]] || usage
|
||||||
|
user="${2:?}"; uid="${3:?}"; sub_start="${4:?}"; sub_count="${5:?}"
|
||||||
|
require_roots
|
||||||
|
|
||||||
|
# The range arithmetic has to be numbers. `deprovisionOsAccount` logs '<no-subuid-range>' in this position
|
||||||
|
# when the account had no /etc/subuid entry, and pasting that log line straight in — which is exactly how
|
||||||
|
# it is meant to be used — would otherwise make sub_end empty and turn the range scan into a no-op.
|
||||||
|
[[ "$uid" =~ ^[0-9]+$ && "$sub_start" =~ ^[0-9]+$ && "$sub_count" =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "uid, subuid_start and subuid_count must all be numbers (got: '$uid' '$sub_start' '$sub_count')" >&2
|
||||||
|
echo "an account with no /etc/subuid range has nothing to scan for — verify the uid half by hand" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
sub_end=$(( sub_start + sub_count - 1 ))
|
||||||
|
|
||||||
|
fails=0
|
||||||
|
ok() { printf ' ok %s\n' "$1"; }
|
||||||
|
bad() { printf ' FAIL %s\n' "$1"; fails=$((fails+1)); }
|
||||||
|
|
||||||
|
echo "checking $user (uid $uid, subuids $sub_start-$sub_end)"
|
||||||
|
|
||||||
|
getent passwd "$user" >/dev/null 2>&1 && bad "passwd entry still exists" || ok "no passwd entry"
|
||||||
|
getent passwd "$uid" >/dev/null 2>&1 && bad "uid $uid reassigned or still present" || ok "uid $uid unused"
|
||||||
|
|
||||||
|
grep -q "^$user:" /etc/subuid 2>/dev/null && bad "/etc/subuid entry remains" || ok "no /etc/subuid entry"
|
||||||
|
grep -q "^$user:" /etc/subgid 2>/dev/null && bad "/etc/subgid entry remains" || ok "no /etc/subgid entry"
|
||||||
|
|
||||||
|
[[ -e "/var/lib/systemd/linger/$user" ]] && bad "linger marker remains" || ok "no linger marker"
|
||||||
|
[[ -d "/run/user/$uid" ]] && bad "/run/user/$uid remains" || ok "no runtime directory"
|
||||||
|
|
||||||
|
procs="$(pgrep -u "$uid" 2>/dev/null | wc -l)"
|
||||||
|
[[ "$procs" -eq 0 ]] && ok "no processes" || bad "$procs process(es) still owned by uid $uid"
|
||||||
|
|
||||||
|
# The uid half.
|
||||||
|
owned="$(find "${SEARCH_ROOTS[@]}" -uid "$uid" -print -quit 2>/dev/null)"
|
||||||
|
[[ -z "$owned" ]] && ok "no files owned by uid $uid" || bad "files owned by uid $uid (e.g. $owned)"
|
||||||
|
|
||||||
|
# The subuid half — the one a uid-only check passes straight through. Container processes running as a
|
||||||
|
# non-root user inside their namespace write files owned by a MAPPED id, not by the member's uid, and
|
||||||
|
# `userdel` frees the whole range for reallocation.
|
||||||
|
mapped="$(find "${SEARCH_ROOTS[@]}" -uid +"$((sub_start-1))" ! -uid +"$sub_end" -print -quit 2>/dev/null)"
|
||||||
|
[[ -z "$mapped" ]] && ok "no files in the freed subuid range" || bad "files owned by the freed subuid range (e.g. $mapped)"
|
||||||
|
|
||||||
|
# ACL entries, which ownership checks cannot see. `confineUserTree` grants the member a NAMED entry on their
|
||||||
|
# whole tree — `u:<uid>:rwx` plus a `default:` copy — and `chown` does not remove them: they are xattrs, not
|
||||||
|
# ownership, and they store the uid NUMERICALLY. So a tree reassigned to the service user can still carry
|
||||||
|
# `user:1001:rwx` on every file, and the next account allocated 1001 inherits read/write on all of it.
|
||||||
|
#
|
||||||
|
# `-n` forces numeric output; after `userdel` the uid has no name to resolve to, and relying on the name
|
||||||
|
# would make this check depend on the very passwd entry that is supposed to be gone.
|
||||||
|
#
|
||||||
|
# Scoped to DATA_PATH: member trees live there, and a recursive getfacl over /home would walk the owner's
|
||||||
|
# entire account for no gain.
|
||||||
|
acl_hit="$(getfacl -R -n -p "$DATA_PATH" 2>/dev/null | grep -m1 -E "^(default:)?user:$uid:")"
|
||||||
|
[[ -z "$acl_hit" ]] && ok "no ACL entries naming uid $uid" || bad "ACL entries still grant uid $uid ($acl_hit)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [[ "$fails" -eq 0 ]]; then
|
||||||
|
echo "CLEAN — uid $uid and its subuid range are safe to reissue"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "NOT CLEAN — $fails check(s) failed; do not reissue this uid"
|
||||||
|
exit 1
|
||||||
@@ -131,6 +131,8 @@ fi
|
|||||||
|
|
||||||
# --- Step 7: .env ---
|
# --- Step 7: .env ---
|
||||||
echo "[7/7] Cleaning .env..."
|
echo "[7/7] Cleaning .env..."
|
||||||
|
# A wrong level here is quiet: the sed below simply finds no file, reports "No .env" and leaves the real
|
||||||
|
# VNC_PASSWORD in place. Keep this in step with wherever this script lives.
|
||||||
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env"
|
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env"
|
||||||
if [ -f "$ENV_FILE" ]; then
|
if [ -f "$ENV_FILE" ]; then
|
||||||
sed -i '/^VNC_PASSWORD=/d; /^VNC_PORT=/d' "$ENV_FILE"
|
sed -i '/^VNC_PASSWORD=/d; /^VNC_PORT=/d' "$ENV_FILE"
|
||||||
|
|||||||
+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 template = join(root, 'src/apps/officer-web/index.html');
|
||||||
const output = join(root, 'src/apps/officer-web/index.gen.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');
|
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')) {
|
for (const line of (await Bun.file(envPath).text()).split('\n')) {
|
||||||
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
|
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
|
||||||
if (match) process.env.PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, '');
|
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) {
|
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);
|
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}"
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration script: auth data from JSON files → PostgreSQL
|
|
||||||
*
|
|
||||||
* Migrates:
|
|
||||||
* - users.json → users table
|
|
||||||
* - passkeys.json → passkeys table (email → userId FK)
|
|
||||||
* - token-blacklist.json → token_blacklist table
|
|
||||||
*
|
|
||||||
* Usage: bun run scripts/migrate-auth-to-pg.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { db } from 'officerdb/db';
|
|
||||||
import { users, passkeys, tokenBlacklist } from 'officerdb/schema';
|
|
||||||
|
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
||||||
const AUTH_DIR = join(DATA_PATH, 'auth');
|
|
||||||
|
|
||||||
type OldUser = {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
password: string | null;
|
|
||||||
role: string;
|
|
||||||
status: string;
|
|
||||||
name: string | null;
|
|
||||||
username: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
passwordChangedAt: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OldPasskey = {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
origin: string | null;
|
|
||||||
credentialId: string | null;
|
|
||||||
publicKey: string | null;
|
|
||||||
counter: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OldBlacklistEntry = {
|
|
||||||
jti: string;
|
|
||||||
expiresAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readJson<T>(path: string, fallback: T): Promise<T> {
|
|
||||||
try {
|
|
||||||
const file = Bun.file(path);
|
|
||||||
if (!(await file.exists())) return fallback;
|
|
||||||
return (await file.json()) as T;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log(`[migrate] Reading JSON files from ${AUTH_DIR}`);
|
|
||||||
|
|
||||||
const oldUsers = await readJson<OldUser[]>(join(AUTH_DIR, 'users.json'), []);
|
|
||||||
const oldPasskeys = await readJson<OldPasskey[]>(join(AUTH_DIR, 'passkeys.json'), []);
|
|
||||||
const oldBlacklist = await readJson<OldBlacklistEntry[]>(join(AUTH_DIR, 'token-blacklist.json'), []);
|
|
||||||
|
|
||||||
console.log(`[migrate] Found: ${oldUsers.length} users, ${oldPasskeys.length} passkeys, ${oldBlacklist.length} blacklisted tokens`);
|
|
||||||
|
|
||||||
if (oldUsers.length === 0) {
|
|
||||||
console.log('[migrate] No users to migrate. Done.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build email → userId map for passkey migration
|
|
||||||
const emailToUserId = new Map<string, number>();
|
|
||||||
|
|
||||||
// Migrate users
|
|
||||||
console.log('[migrate] Migrating users...');
|
|
||||||
for (const u of oldUsers) {
|
|
||||||
const [inserted] = await db
|
|
||||||
.insert(users)
|
|
||||||
.values({
|
|
||||||
email: u.email,
|
|
||||||
password: u.password,
|
|
||||||
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
|
|
||||||
name: u.name,
|
|
||||||
username: u.username,
|
|
||||||
avatar: u.avatar,
|
|
||||||
passwordChangedAt: u.passwordChangedAt ? new Date(u.passwordChangedAt) : null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
emailToUserId.set(u.email, inserted!.id);
|
|
||||||
console.log(` [user] ${u.email} (old id=${u.id} → new id=${inserted!.id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate passkeys
|
|
||||||
if (oldPasskeys.length > 0) {
|
|
||||||
console.log('[migrate] Migrating passkeys...');
|
|
||||||
for (const p of oldPasskeys) {
|
|
||||||
const userId = emailToUserId.get(p.email);
|
|
||||||
if (!userId) {
|
|
||||||
console.warn(` [passkey] Skipping passkey for unknown email: ${p.email}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(passkeys).values({
|
|
||||||
userId,
|
|
||||||
origin: p.origin,
|
|
||||||
credentialId: p.credentialId,
|
|
||||||
publicKey: p.publicKey,
|
|
||||||
counter: p.counter,
|
|
||||||
});
|
|
||||||
console.log(` [passkey] ${p.email} / ${p.origin}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate token blacklist
|
|
||||||
if (oldBlacklist.length > 0) {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const active = oldBlacklist.filter((b) => b.expiresAt >= now);
|
|
||||||
console.log(`[migrate] Migrating ${active.length} active blacklisted tokens (${oldBlacklist.length - active.length} expired, skipped)...`);
|
|
||||||
|
|
||||||
for (const b of active) {
|
|
||||||
await db
|
|
||||||
.insert(tokenBlacklist)
|
|
||||||
.values({
|
|
||||||
jti: b.jti,
|
|
||||||
expiresAt: new Date(b.expiresAt * 1000),
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[migrate] Done!');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate().catch((err) => {
|
|
||||||
console.error('[migrate] Failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { openEmailDb, upsertFromRawEml, setSyncMeta } from '../src/servers/sidecar/email/store';
|
|
||||||
|
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
||||||
|
|
||||||
// Find all user directories that have Gmail emails
|
|
||||||
const targetEmail = process.argv[2];
|
|
||||||
|
|
||||||
if (targetEmail) {
|
|
||||||
migrate(targetEmail);
|
|
||||||
} else {
|
|
||||||
const entries = readdirSync(DATA_PATH, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
if (!entry.name.includes('@')) continue;
|
|
||||||
const emailDir = join(DATA_PATH, entry.name, 'Gmail', 'emails');
|
|
||||||
try {
|
|
||||||
const files = readdirSync(emailDir).filter((f) => f.endsWith('.eml'));
|
|
||||||
if (files.length > 0) migrate(entry.name);
|
|
||||||
} catch {
|
|
||||||
// no Gmail dir for this user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrate(userEmail: string): void {
|
|
||||||
console.log(`Migrating ${userEmail}...`);
|
|
||||||
const emailDir = join(DATA_PATH, userEmail, 'Gmail', 'emails');
|
|
||||||
// Obsolete one-off migration (old .eml-file store → SQLite); kept only to compile.
|
|
||||||
const db = openEmailDb(userEmail, userEmail);
|
|
||||||
|
|
||||||
let filenames: string[];
|
|
||||||
try {
|
|
||||||
filenames = readdirSync(emailDir).filter((f) => f.endsWith('.eml'));
|
|
||||||
} catch {
|
|
||||||
console.log(' No .eml files found');
|
|
||||||
db.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIds = new Set<string>();
|
|
||||||
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
|
|
||||||
for (const row of rows) existingIds.add(row.id);
|
|
||||||
|
|
||||||
let added = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
db.exec('BEGIN');
|
|
||||||
try {
|
|
||||||
for (const filename of filenames) {
|
|
||||||
const id = filename.replace(/\.eml$/, '');
|
|
||||||
if (existingIds.has(id)) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(join(emailDir, filename), 'utf-8');
|
|
||||||
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount: userEmail, labels: ['INBOX'] });
|
|
||||||
added++;
|
|
||||||
} catch {
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.exec('COMMIT');
|
|
||||||
} catch (err) {
|
|
||||||
db.exec('ROLLBACK');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` ${filenames.length} .eml files — ${added} added, ${skipped} skipped, ${errors} errors`);
|
|
||||||
|
|
||||||
// Store the latest email date so the next sync only fetches emails after it
|
|
||||||
const row = db.query('SELECT date FROM emails ORDER BY date DESC LIMIT 1').get() as { date: string } | null;
|
|
||||||
if (row?.date) {
|
|
||||||
setSyncMeta(db, 'last_sync_date', row.date);
|
|
||||||
console.log(` Stored last_sync_date: ${row.date}`);
|
|
||||||
}
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* One-time migration: consolidate every agent item into the flat, file-based store
|
|
||||||
* ($OFFICER_ITEMS_DIR) and export the DB-backed `tasks` table to TASK.md files.
|
|
||||||
*
|
|
||||||
* Idempotent — safe to re-run. Run this BEFORE applying the drop-tables DB migration
|
|
||||||
* (it reads the `tasks` table, which still exists until that migration runs).
|
|
||||||
*
|
|
||||||
* Sources, in precedence order (later overwrites earlier on a dirName collision):
|
|
||||||
* - tasks: officer_db.tasks rows (native → global → user)
|
|
||||||
* - skills / tools / processes / extensions: $DATA_PATH/<type> then $DATA_PATH/<email>/<type>
|
|
||||||
* - tools: marketplace registry tools not already present (archive safety)
|
|
||||||
*
|
|
||||||
* Usage: bun run scripts/migrate-items-to-files.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { readdir, cp } from 'node:fs/promises';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { db } from 'officerdb/db';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { itemsDir, ensureItemDirs, DATA_PATH, OFFICER_ITEMS_DIR, type ItemType } from '../src/servers/data-path';
|
|
||||||
import { importTask } from '../src/servers/api/tasks/task-files';
|
|
||||||
|
|
||||||
ensureItemDirs();
|
|
||||||
console.log(`Target store: ${OFFICER_ITEMS_DIR}`);
|
|
||||||
|
|
||||||
async function listSubdirs(dir: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. Tasks: Postgres → TASK.md files ──
|
|
||||||
// Order native → global → user so user/global overwrite native on a dirName collision.
|
|
||||||
const scopeRank = (s: string) => (s === 'user' ? 2 : s === 'global' ? 1 : 0);
|
|
||||||
|
|
||||||
console.log('\n── Tasks (DB → files) ──');
|
|
||||||
let taskRows: Record<string, unknown>[] = [];
|
|
||||||
try {
|
|
||||||
taskRows = (await db.execute(sql.raw('SELECT * FROM tasks'))) as unknown as Record<string, unknown>[];
|
|
||||||
} catch (err) {
|
|
||||||
console.log(` could not read tasks table (already dropped?): ${err instanceof Error ? err.message : err}`);
|
|
||||||
}
|
|
||||||
taskRows.sort((a, b) => scopeRank(String(a.scope)) - scopeRank(String(b.scope)));
|
|
||||||
|
|
||||||
for (const row of taskRows) {
|
|
||||||
const dirName = String(row.dir_name);
|
|
||||||
await importTask(dirName, {
|
|
||||||
name: String(row.name ?? dirName),
|
|
||||||
description: row.description == null ? null : String(row.description),
|
|
||||||
version: Number(row.version) || 1,
|
|
||||||
mode: String(row.mode ?? 'agentic'),
|
|
||||||
language: row.language == null ? null : String(row.language),
|
|
||||||
args: (row.args as string[] | null) ?? null,
|
|
||||||
tags: (row.tags as string[] | null) ?? null,
|
|
||||||
tools: (row.tools as string[] | null) ?? null,
|
|
||||||
skills: (row.skills as string[] | null) ?? null,
|
|
||||||
inputs: row.inputs ?? null,
|
|
||||||
outputs: row.outputs ?? null,
|
|
||||||
dependencies: row.dependencies ?? null,
|
|
||||||
config: row.config ?? null,
|
|
||||||
trigger: row.trigger ?? null,
|
|
||||||
body: row.body == null ? '' : String(row.body),
|
|
||||||
implementation: row.implementation == null ? null : String(row.implementation),
|
|
||||||
});
|
|
||||||
console.log(` ${dirName} (${row.scope})`);
|
|
||||||
}
|
|
||||||
console.log(` ${taskRows.length} task file(s) written`);
|
|
||||||
|
|
||||||
// ── 2. On-disk items → flat store ──
|
|
||||||
const DISK_TYPES: ItemType[] = ['skills', 'tools', 'processes', 'extensions'];
|
|
||||||
|
|
||||||
async function copyItemsFrom(srcTypeDir: string, type: ItemType): Promise<number> {
|
|
||||||
let n = 0;
|
|
||||||
for (const name of await listSubdirs(srcTypeDir)) {
|
|
||||||
await cp(join(srcTypeDir, name), join(itemsDir(type), name), { recursive: true, force: true });
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n── Disk items (DATA_PATH → flat store) ──');
|
|
||||||
const emailDirs = (await listSubdirs(DATA_PATH)).filter((n) => n.includes('@'));
|
|
||||||
|
|
||||||
for (const type of DISK_TYPES) {
|
|
||||||
let n = await copyItemsFrom(join(DATA_PATH, type), type); // global
|
|
||||||
for (const email of emailDirs) n += await copyItemsFrom(join(DATA_PATH, email, type), type); // user (overwrites)
|
|
||||||
console.log(` ${type}: ${n} item(s) copied`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Marketplace registry tools not already present (archive safety) ──
|
|
||||||
const MARKETPLACE_REGISTRY = process.env.MARKETPLACE_REGISTRY ?? resolve(import.meta.dir, '../../marketplace/registry');
|
|
||||||
console.log(`\n── Marketplace registry (${MARKETPLACE_REGISTRY}) ──`);
|
|
||||||
if (existsSync(MARKETPLACE_REGISTRY)) {
|
|
||||||
let n = 0;
|
|
||||||
for (const name of await listSubdirs(join(MARKETPLACE_REGISTRY, 'tools'))) {
|
|
||||||
const target = join(itemsDir('tools'), name);
|
|
||||||
if (existsSync(target)) continue; // don't clobber a synced/user version
|
|
||||||
await cp(join(MARKETPLACE_REGISTRY, 'tools', name), target, { recursive: true });
|
|
||||||
n++;
|
|
||||||
console.log(` tool ${name} (from registry)`);
|
|
||||||
}
|
|
||||||
console.log(` ${n} registry tool(s) added`);
|
|
||||||
console.log(' registry tasks come from the DB export above (native scope) — skipped here');
|
|
||||||
} else {
|
|
||||||
console.log(' registry not found, skipping');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nDone. Verify counts in the UI, then apply the drop-tables DB migration.');
|
|
||||||
process.exit(0);
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* One-time migration: PostgreSQL auth tables → JSON files
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* POSTGRES_URL="postgres://..." bun run scripts/migrate-pg-to-files.ts
|
|
||||||
*
|
|
||||||
* Reads users and passkeys from Postgres, writes JSON files to {DATA_PATH}/auth/.
|
|
||||||
* Safe to run multiple times (overwrites files).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { mkdir } from 'node:fs/promises';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
const POSTGRES_URL = process.env.POSTGRES_URL;
|
|
||||||
if (!POSTGRES_URL) {
|
|
||||||
console.error('POSTGRES_URL env var is required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
||||||
const AUTH_DIR = join(DATA_PATH, 'auth');
|
|
||||||
|
|
||||||
const sql = postgres(POSTGRES_URL);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mkdir(AUTH_DIR, { recursive: true });
|
|
||||||
|
|
||||||
const users = await sql`SELECT id, email, password, role, status, name, username, avatar, password_changed_at FROM users ORDER BY id`;
|
|
||||||
const passkeys = await sql`SELECT id, email, origin, credential_id, public_key, counter FROM passkeys ORDER BY id`;
|
|
||||||
|
|
||||||
const mappedUsers = users.map((u) => ({
|
|
||||||
id: Number(u.id),
|
|
||||||
email: u.email,
|
|
||||||
password: u.password ?? null,
|
|
||||||
role: u.role ?? 'Member',
|
|
||||||
status: u.status ?? 'Unverified',
|
|
||||||
name: u.name ?? null,
|
|
||||||
username: u.username ?? null,
|
|
||||||
avatar: u.avatar ?? null,
|
|
||||||
passwordChangedAt: u.password_changed_at ? Number(u.password_changed_at) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mappedPasskeys = passkeys.map((p) => ({
|
|
||||||
id: Number(p.id),
|
|
||||||
email: p.email,
|
|
||||||
origin: p.origin ?? null,
|
|
||||||
credentialId: p.credential_id ?? null,
|
|
||||||
publicKey: p.public_key ?? null,
|
|
||||||
counter: Number(p.counter ?? 0),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const maxUserId = mappedUsers.reduce((max, u) => Math.max(max, u.id), 0);
|
|
||||||
const maxPasskeyId = mappedPasskeys.reduce((max, p) => Math.max(max, p.id), 0);
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
nextUserId: maxUserId + 1,
|
|
||||||
nextPasskeyId: maxPasskeyId + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const write = (file: string, data: unknown) => Bun.write(join(AUTH_DIR, file), JSON.stringify(data, null, 2));
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
write('users.json', mappedUsers),
|
|
||||||
write('passkeys.json', mappedPasskeys),
|
|
||||||
write('passkey-challenges.json', []),
|
|
||||||
write('token-blacklist.json', []),
|
|
||||||
write('meta.json', meta),
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`Migrated ${mappedUsers.length} users, ${mappedPasskeys.length} passkeys`);
|
|
||||||
console.log(`Files written to ${AUTH_DIR}`);
|
|
||||||
console.log(`meta: nextUserId=${meta.nextUserId}, nextPasskeyId=${meta.nextPasskeyId}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await sql.end();
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration script: server-settings.json → PostgreSQL server_config table
|
|
||||||
*
|
|
||||||
* Usage: bun run scripts/migrate-server-settings-to-pg.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { writeServerSettings } from 'officerdb';
|
|
||||||
|
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
||||||
const settingsPath = join(DATA_PATH, 'server-settings', 'server-settings.json');
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log(`[migrate] Reading ${settingsPath}`);
|
|
||||||
|
|
||||||
const file = Bun.file(settingsPath);
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
console.log('[migrate] No server-settings.json found. Done.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
settings = await file.json();
|
|
||||||
} catch {
|
|
||||||
console.log('[migrate] Could not parse server-settings.json. Done.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(settings);
|
|
||||||
console.log(`[migrate] Found ${keys.length} keys: ${keys.join(', ')}`);
|
|
||||||
|
|
||||||
await writeServerSettings(settings);
|
|
||||||
console.log('[migrate] Written to server_config table.');
|
|
||||||
console.log('[migrate] Done!');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate().catch((err) => {
|
|
||||||
console.error('[migrate] Failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
import type { BrowsedFile } from 'officerdb';
|
import type { BrowsedFile } from 'officerdb';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { db, finishSoulseekBrowse } from 'officerdb';
|
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';
|
import { buildTree } from '../src/servers/sidecar/slskd/browse';
|
||||||
|
|
||||||
const snapshots = await db
|
const snapshots = await db
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* Reset all user data while keeping auth credentials.
|
|
||||||
*
|
|
||||||
* Deletes:
|
|
||||||
* - DB: user_settings, user_state, user_integrations, dock_configs,
|
|
||||||
* chat_sessions (cascades chat_messages), chat_groups,
|
|
||||||
* dashboards, screens, projects,
|
|
||||||
* task_logs, queue_jobs, terminal_containers
|
|
||||||
* - Filesystem: entire $DATA_PATH/<email>/ directory
|
|
||||||
* (home, settings, state, dashboards, chat_sessions, emails.db,
|
|
||||||
* Gmail, skills, tools, tasks, processes, extensions, logs, cache, etc.)
|
|
||||||
* - Queue job files: $DATA_PATH/queue/jobs/*.json owned by user
|
|
||||||
* - Terminal containers map: removes user entry from terminal-containers.json
|
|
||||||
*
|
|
||||||
* Preserves:
|
|
||||||
* - users table row (account, password, role, status)
|
|
||||||
* - passkeys table rows
|
|
||||||
* - passkey_challenges, token_blacklist
|
|
||||||
*
|
|
||||||
* Usage: bun run scripts/reset-user-data.ts <email>
|
|
||||||
* bun run scripts/reset-user-data.ts <email> --yes (skip confirmation)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { rm, readdir, unlink } from 'node:fs/promises';
|
|
||||||
import { db } from 'officerdb/db';
|
|
||||||
import { users } from 'officerdb/schema';
|
|
||||||
import { eq, sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
||||||
|
|
||||||
const email = process.argv[2];
|
|
||||||
const skipConfirm = process.argv.includes('--yes');
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
console.error('Usage: bun run scripts/reset-user-data.ts <email> [--yes]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resolve user ──
|
|
||||||
|
|
||||||
const [user] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.email, email));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error(`User not found: ${email}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nUser: ${user.email} (id: ${user.id})`);
|
|
||||||
console.log(`Data dir: ${join(DATA_PATH, email)}`);
|
|
||||||
console.log('\nThis will delete ALL user data (settings, chats, dashboards, emails, home dir, etc.)');
|
|
||||||
console.log('Auth credentials (account, passkeys) will be preserved.\n');
|
|
||||||
|
|
||||||
if (!skipConfirm) {
|
|
||||||
process.stdout.write('Continue? [y/N] ');
|
|
||||||
const response = await new Promise<string>((resolve) => {
|
|
||||||
process.stdin.once('data', (data) => resolve(data.toString().trim()));
|
|
||||||
});
|
|
||||||
if (response.toLowerCase() !== 'y') {
|
|
||||||
console.log('Aborted.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
// ── Database cleanup ──
|
|
||||||
// All these tables have ON DELETE CASCADE from users, but we don't want to delete the user.
|
|
||||||
// Delete explicitly by user_id.
|
|
||||||
|
|
||||||
console.log('\n── Database ──');
|
|
||||||
|
|
||||||
const tables = [
|
|
||||||
'user_settings',
|
|
||||||
'user_state',
|
|
||||||
'user_integrations',
|
|
||||||
'dock_configs',
|
|
||||||
'chat_sessions', // cascades chat_messages
|
|
||||||
'chat_groups',
|
|
||||||
'dashboards',
|
|
||||||
'screens',
|
|
||||||
'projects',
|
|
||||||
'task_logs',
|
|
||||||
'queue_jobs',
|
|
||||||
'terminal_containers',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
const result = await db.execute(sql.raw(`DELETE FROM ${table} WHERE user_id = ${userId}`));
|
|
||||||
const count = result.length ?? 0;
|
|
||||||
console.log(` ${table}: ${count} rows deleted`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent items (skills, tools, tasks, processes, extensions) are now flat files in
|
|
||||||
// $OFFICER_ITEMS_DIR, shared and not user-owned — intentionally left untouched by a user reset.
|
|
||||||
|
|
||||||
// ── Queue job files ──
|
|
||||||
|
|
||||||
console.log('\n── Queue job files ──');
|
|
||||||
const queueDir = join(DATA_PATH, 'queue', 'jobs');
|
|
||||||
try {
|
|
||||||
const entries = await readdir(queueDir);
|
|
||||||
let deleted = 0;
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.endsWith('.json')) continue;
|
|
||||||
try {
|
|
||||||
const file = Bun.file(join(queueDir, entry));
|
|
||||||
const job = await file.json();
|
|
||||||
if (job.userId === email) {
|
|
||||||
await unlink(join(queueDir, entry));
|
|
||||||
deleted++;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip unreadable files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ${deleted} job files deleted`);
|
|
||||||
} catch {
|
|
||||||
console.log(' queue dir not found, skipping');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Terminal containers map ──
|
|
||||||
|
|
||||||
console.log('\n── Terminal containers ──');
|
|
||||||
const containerMapPath = join(DATA_PATH, 'terminal-containers.json');
|
|
||||||
try {
|
|
||||||
const file = Bun.file(containerMapPath);
|
|
||||||
if (await file.exists()) {
|
|
||||||
const map = await file.json();
|
|
||||||
let changed = false;
|
|
||||||
for (const key of Object.keys(map)) {
|
|
||||||
if (key === email || map[key]?.email === email) {
|
|
||||||
delete map[key];
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
|
|
||||||
console.log(' removed from terminal-containers.json');
|
|
||||||
} else {
|
|
||||||
console.log(' no entry found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.log(' terminal-containers.json not found, skipping');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filesystem ──
|
|
||||||
|
|
||||||
console.log('\n── Filesystem ──');
|
|
||||||
const userDir = join(DATA_PATH, email);
|
|
||||||
try {
|
|
||||||
await rm(userDir, { recursive: true, force: true });
|
|
||||||
console.log(` removed ${userDir}`);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
console.log(` failed to remove ${userDir}: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nDone. User auth preserved, all data wiped.');
|
|
||||||
process.exit(0);
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { ImapFlow } from 'imapflow';
|
|
||||||
import { getUserByEmail, getUserIntegration, getServerIntegration } from 'officerdb';
|
|
||||||
import { openEmailDb, setSyncMeta } from '../src/servers/sidecar/email/store';
|
|
||||||
|
|
||||||
const userEmail = process.argv[2];
|
|
||||||
if (!userEmail) {
|
|
||||||
console.error('Usage: bun run scripts/seed-imap-uids.ts <email>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Load credentials ──
|
|
||||||
|
|
||||||
const dbUser = await getUserByEmail(userEmail);
|
|
||||||
if (!dbUser) throw new Error('User not found');
|
|
||||||
|
|
||||||
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
|
||||||
const config = userGoogle?.config as Record<string, unknown> | undefined;
|
|
||||||
if (!config?.accessToken) throw new Error('No OAuth tokens found');
|
|
||||||
|
|
||||||
// Refresh token if needed
|
|
||||||
let accessToken = config.accessToken as string;
|
|
||||||
const expiresAt = config.expiresAt as number | undefined;
|
|
||||||
if (!expiresAt || expiresAt < Date.now() + 60_000) {
|
|
||||||
console.log('Refreshing expired token...');
|
|
||||||
const serverGoogle = await getServerIntegration('google');
|
|
||||||
const serverConfig = serverGoogle?.config as Record<string, unknown>;
|
|
||||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: serverConfig.clientId as string,
|
|
||||||
client_secret: serverConfig.clientSecret as string,
|
|
||||||
refresh_token: config.refreshToken as string,
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Token refresh failed: ${await res.text()}`);
|
|
||||||
const data = (await res.json()) as { access_token: string };
|
|
||||||
accessToken = data.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connect IMAP ──
|
|
||||||
|
|
||||||
const client = new ImapFlow({
|
|
||||||
host: 'imap.gmail.com',
|
|
||||||
port: 993,
|
|
||||||
secure: true,
|
|
||||||
auth: { user: config.email as string, accessToken },
|
|
||||||
logger: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
console.log('Connected to IMAP');
|
|
||||||
|
|
||||||
const GMAIL_PREFIX_RE = /^\[(?:Gmail|Google Mail)\]\//;
|
|
||||||
const SKIP_SUFFIXES = new Set(['All Mail', 'Trash', 'Spam', 'Bin']);
|
|
||||||
|
|
||||||
const folders = await client.list();
|
|
||||||
const db = openEmailDb(userEmail, config.email as string);
|
|
||||||
|
|
||||||
let seeded = 0;
|
|
||||||
|
|
||||||
for (const folder of folders) {
|
|
||||||
const suffix = folder.path.replace(GMAIL_PREFIX_RE, '');
|
|
||||||
const isGmailFolder = suffix !== folder.path;
|
|
||||||
if (isGmailFolder && SKIP_SUFFIXES.has(suffix)) continue;
|
|
||||||
if (folder.specialUse && ['\\Trash', '\\Junk', '\\All'].includes(folder.specialUse)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await client.status(folder.path, { uidNext: true, uidValidity: true });
|
|
||||||
const lastUid = (status.uidNext ?? 1) - 1;
|
|
||||||
const uidValidity = String(status.uidValidity);
|
|
||||||
|
|
||||||
setSyncMeta(db, `imap_lastuid:${folder.path}`, String(lastUid));
|
|
||||||
setSyncMeta(db, `imap_uidvalidity:${folder.path}`, uidValidity);
|
|
||||||
|
|
||||||
console.log(` ${folder.path}: lastUid=${lastUid}, uidValidity=${uidValidity}`);
|
|
||||||
seeded++;
|
|
||||||
} catch (err) {
|
|
||||||
console.log(` ${folder.path}: skipped (${err instanceof Error ? err.message : err})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
await client.logout();
|
|
||||||
|
|
||||||
console.log(`\nSeeded ${seeded} folders. Next sync will only fetch new messages.`);
|
|
||||||
process.exit(0);
|
|
||||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
|||||||
# capture an Xorg server, NOT a Wayland compositor — so we install the full GNOME desktop but force GDM
|
# capture an Xorg server, NOT a Wayland compositor — so we install the full GNOME desktop but force GDM
|
||||||
# onto the Xorg session (WaylandEnable=false). Auto-login is enabled so a user session owns :0 for the
|
# onto the Xorg session (WaylandEnable=false). Auto-login is enabled so a user session owns :0 for the
|
||||||
# mirror to attach to. Switching the display manager takes effect on the next reboot.
|
# mirror to attach to. Switching the display manager takes effect on the next reboot.
|
||||||
# Usage: ./scripts/setup-desktop.sh
|
# Usage: ./scripts/setup/setup-desktop.sh
|
||||||
|
|
||||||
echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ==="
|
echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ==="
|
||||||
echo ""
|
echo ""
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
# Outputs parseable key=value lines to stdout; all prompts go to stderr.
|
# Outputs parseable key=value lines to stdout; all prompts go to stderr.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash scripts/setup-dockers.sh
|
# bash scripts/setup/setup-dockers.sh
|
||||||
# eval "$(bash scripts/setup-dockers.sh)"
|
# eval "$(bash scripts/setup/setup-dockers.sh)"
|
||||||
#
|
#
|
||||||
# Environment overrides:
|
# Environment overrides:
|
||||||
# SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none")
|
# SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none")
|
||||||
# SETUP_DOCKER_NETWORK="services" — docker network name
|
# SETUP_DOCKER_NETWORK="services" — docker network name
|
||||||
|
# SETUP_NPM_BIND="100.64.0.8" — host address Nginx Proxy Manager publishes on. Defaults to this
|
||||||
|
# node's Tailscale IPv4; set it explicitly to bind somewhere else.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -47,6 +49,44 @@ prompt_value() {
|
|||||||
# ─── docker network ─────────────────────────────────────────────────────────
|
# ─── docker network ─────────────────────────────────────────────────────────
|
||||||
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
|
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
|
||||||
|
|
||||||
|
# ─── Nginx Proxy Manager bind address ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# NPM is the only service here that ever published on 0.0.0.0, and a published Docker port is not
|
||||||
|
# behind the firewall: Docker writes its DNAT rules directly into the nat table, which UFW's INPUT
|
||||||
|
# chain never sees. `ufw default deny incoming` does not cover 80/443/81 — that is what the host's
|
||||||
|
# ufw-docker-rules.conf exists to patch, and patching a rule is weaker than never opening the socket.
|
||||||
|
#
|
||||||
|
# So bind to the tailnet address instead. The kernel then refuses the socket on every other interface
|
||||||
|
# and the firewall stops being load-bearing for this. The address is read at run time rather than
|
||||||
|
# passed in, because by the time this script runs the host provisioning has already done `tailscale up`.
|
||||||
|
resolve_npm_bind() {
|
||||||
|
if [[ -n "${SETUP_NPM_BIND:-}" ]]; then
|
||||||
|
echo "$SETUP_NPM_BIND"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local ip
|
||||||
|
ip=$(tailscale ip -4 2>/dev/null | head -1)
|
||||||
|
# 100.64.0.0/10 — the CGNAT range both Tailscale and Headscale allocate from. Anything outside it
|
||||||
|
# means `tailscale ip` answered with something unexpected, and a bind address is not a value to
|
||||||
|
# guess at: the whole point is that it is NOT reachable from the internet.
|
||||||
|
if [[ "$ip" =~ ^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\. ]]; then
|
||||||
|
echo "$ip"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
NPM_BIND="$(resolve_npm_bind)"
|
||||||
|
|
||||||
|
# Binding to an address that belongs to another service's interface makes that service a boot-order
|
||||||
|
# dependency: if tailscaled has not brought tailscale0 up yet, the container cannot get its socket and
|
||||||
|
# Docker falls back on the restart policy to retry. That converges, but only if the tailnet comes up
|
||||||
|
# at all on its own.
|
||||||
|
if [[ -n "$NPM_BIND" ]] && ! systemctl is-enabled --quiet tailscaled 2>/dev/null; then
|
||||||
|
warn "tailscaled is not enabled at boot — NPM binds $NPM_BIND, which will not exist after a reboot"
|
||||||
|
warn "until the tailnet is up. Fix with: sudo systemctl enable tailscaled"
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure network exists
|
# Ensure network exists
|
||||||
if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then
|
if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then
|
||||||
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
|
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
|
||||||
@@ -94,6 +134,14 @@ MAILHOG_SELECTED=false
|
|||||||
for svc in $SERVICES; do
|
for svc in $SERVICES; do
|
||||||
case "$svc" in
|
case "$svc" in
|
||||||
1)
|
1)
|
||||||
|
if [[ -z "$NPM_BIND" ]]; then
|
||||||
|
fail "Nginx Proxy Manager selected, but no Tailscale IPv4 was found on this host."
|
||||||
|
echo " Bring the tailnet up first (the host provisioning does this), or choose the" >&2
|
||||||
|
echo " address deliberately: SETUP_NPM_BIND=<ip> bash scripts/setup/setup-dockers.sh" >&2
|
||||||
|
echo " Publishing it on 0.0.0.0 is not offered — Docker bypasses UFW, so that would put" >&2
|
||||||
|
echo " 80/443/81 on every interface the host has." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
COMPOSE_SERVICES+=("nginx-proxy-manager")
|
COMPOSE_SERVICES+=("nginx-proxy-manager")
|
||||||
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
||||||
nginx-proxy-manager:
|
nginx-proxy-manager:
|
||||||
@@ -101,9 +149,9 @@ for svc in $SERVICES; do
|
|||||||
container_name: nginx-proxy-manager
|
container_name: nginx-proxy-manager
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "$NPM_BIND:80:80"
|
||||||
- "443:443"
|
- "$NPM_BIND:443:443"
|
||||||
- "81:81"
|
- "$NPM_BIND:81:81"
|
||||||
volumes:
|
volumes:
|
||||||
- ./npm_data:/data
|
- ./npm_data:/data
|
||||||
- ./npm_letsencrypt:/etc/letsencrypt
|
- ./npm_letsencrypt:/etc/letsencrypt
|
||||||
@@ -238,6 +286,10 @@ fi
|
|||||||
# ─── output parseable values to stdout ───────────────────────────────────────
|
# ─── output parseable values to stdout ───────────────────────────────────────
|
||||||
echo "COMPOSE_DIR=$COMPOSE_DIR"
|
echo "COMPOSE_DIR=$COMPOSE_DIR"
|
||||||
|
|
||||||
|
if [[ " ${COMPOSE_SERVICES[*]} " == *" nginx-proxy-manager "* ]]; then
|
||||||
|
echo "NPM_BIND=$NPM_BIND"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$PG_PASSWORD" ]]; then
|
if [[ -n "$PG_PASSWORD" ]]; then
|
||||||
echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}"
|
echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}"
|
||||||
fi
|
fi
|
||||||
Executable
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Officer — host dependencies for the optional, sidecar-backed features.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/setup/setup-sidecars.sh
|
||||||
|
#
|
||||||
|
# WHAT THIS IS
|
||||||
|
# Everything here was part of setup.sh and is not any more. setup.sh installs what the app needs to
|
||||||
|
# serve itself; this installs what a handful of *optional* features need on the host, and it is never
|
||||||
|
# invoked by setup.sh — running it is a deliberate act.
|
||||||
|
#
|
||||||
|
# The sections keep the numbering they had in setup.sh so the two files can be read against each
|
||||||
|
# other:
|
||||||
|
#
|
||||||
|
# 1 (was 8) Rust
|
||||||
|
# 2 (was 9) PulseAudio + audio dev headers
|
||||||
|
# 3 (was 10) cliamp
|
||||||
|
# 4 (was 13) yt-dlp
|
||||||
|
# 5 (was 17) Remote desktop (delegates to setup-desktop.sh)
|
||||||
|
#
|
||||||
|
# There is no `light`/`full` profile here. In setup.sh these sections were the ones `light` skipped,
|
||||||
|
# so gating them again would only mean "run this script and have it do nothing" — running it at all
|
||||||
|
# IS the opt-in.
|
||||||
|
#
|
||||||
|
# ORDER MATTERS: section 3 needs Go, which setup.sh installs. Run setup.sh first.
|
||||||
|
# Section 5 rewrites GRUB and switches the display manager — it takes effect on the next reboot.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||||
|
warn() { echo -e " ${YELLOW}!${NC} $1"; }
|
||||||
|
fail() { echo -e " ${RED}✗${NC} $1"; }
|
||||||
|
skip() { echo -e " - $1 (already installed)"; }
|
||||||
|
|
||||||
|
has() { command -v "$1" &>/dev/null; }
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# setup.sh installed Go and rustup into the user's home and exported them for its own run only. A
|
||||||
|
# fresh shell has neither on PATH, which would make `has go` false (silently skipping the cliamp
|
||||||
|
# build) and `has rustc` false (re-running rustup over an existing toolchain). Put them back.
|
||||||
|
export PATH="$HOME/.local/go/bin:$HOME/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
# ─── detect package manager ────────────────────────────────────────────────────
|
||||||
|
if has apt; then
|
||||||
|
PM=apt
|
||||||
|
elif has pacman; then
|
||||||
|
PM=pacman
|
||||||
|
elif has brew; then
|
||||||
|
PM=brew
|
||||||
|
else
|
||||||
|
fail "No supported package manager found (apt, pacman, brew)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_pkg() {
|
||||||
|
case $PM in
|
||||||
|
apt) sudo apt install -y "$@" ;;
|
||||||
|
pacman) sudo pacman -S --noconfirm "$@" ;;
|
||||||
|
brew) brew install "$@" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════"
|
||||||
|
echo " Officer — optional host dependencies ($PM)"
|
||||||
|
echo "═══════════════════════════════════════════"
|
||||||
|
|
||||||
|
# ─── 1. Rust (was setup.sh section 8) ─────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "── Rust ──"
|
||||||
|
|
||||||
|
if has rustc && has cargo; then
|
||||||
|
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
|
||||||
|
else
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 2. PulseAudio (headless audio for cliamp) (was section 9) ────────────────
|
||||||
|
echo ""
|
||||||
|
echo "── PulseAudio (headless audio) ──"
|
||||||
|
|
||||||
|
PULSE_PKGS=()
|
||||||
|
|
||||||
|
if has pulseaudio; then skip "pulseaudio"; else
|
||||||
|
case $PM in
|
||||||
|
apt) PULSE_PKGS+=(pulseaudio) ;;
|
||||||
|
pacman) PULSE_PKGS+=(pulseaudio) ;;
|
||||||
|
brew) warn "PulseAudio: brew install pulseaudio (cliamp audio won't work without it)" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# pulseaudio-utils provides parec and pactl
|
||||||
|
if has parec && has pactl; then skip "pulseaudio-utils (parec, pactl)"; else
|
||||||
|
case $PM in
|
||||||
|
apt) PULSE_PKGS+=(pulseaudio-utils) ;;
|
||||||
|
pacman) ;; # included in pulseaudio package
|
||||||
|
brew) ;; # included in pulseaudio formula
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ALSA dev headers (needed to compile cliamp's Go audio library)
|
||||||
|
case $PM in
|
||||||
|
apt)
|
||||||
|
if dpkg -s libasound2-dev &>/dev/null 2>&1; then skip "libasound2-dev"; else PULSE_PKGS+=(libasound2-dev); fi
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
if pacman -Qi alsa-lib &>/dev/null 2>&1; then skip "alsa-lib"; else PULSE_PKGS+=(alsa-lib); fi
|
||||||
|
;;
|
||||||
|
brew) ;; # not needed on macOS
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Vorbis/OGG/FLAC dev headers (needed by cliamp's Go dependencies)
|
||||||
|
case $PM in
|
||||||
|
apt)
|
||||||
|
for pkg in libvorbis-dev libogg-dev libflac-dev; do
|
||||||
|
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
for pkg in libvorbis libogg flac; do
|
||||||
|
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
brew) ;; # not needed on macOS
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
|
||||||
|
install_pkg "${PULSE_PKGS[@]}"
|
||||||
|
ok "Installed: ${PULSE_PKGS[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 3. cliamp (music player) (was section 10) ────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "── cliamp ──"
|
||||||
|
|
||||||
|
# Export GOPATH (not just PATH) so `go install` lands in GOPATH_BIN — even when Go was already present
|
||||||
|
# this run and install_go (which sets GOPATH) never ran. Otherwise go uses its default ~/go/bin and the
|
||||||
|
# check below wrongly reports a build failure.
|
||||||
|
export GOPATH="${GOPATH:-$HOME/.local/go-path}"
|
||||||
|
GOPATH_BIN="$GOPATH/bin"
|
||||||
|
export PATH="$GOPATH_BIN:$PATH"
|
||||||
|
|
||||||
|
if has cliamp; then
|
||||||
|
skip "cliamp ($(command -v cliamp))"
|
||||||
|
else
|
||||||
|
if ! has go; then
|
||||||
|
warn "Go not installed — skipping cliamp build (run setup.sh first)"
|
||||||
|
else
|
||||||
|
echo " Building cliamp from source..."
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
git clone --depth=1 https://github.com/bjarneo/cliamp.git "$TMPDIR/cliamp"
|
||||||
|
(cd "$TMPDIR/cliamp" && go install .)
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
if [ -f "$GOPATH_BIN/cliamp" ]; then
|
||||||
|
ok "cliamp installed at $GOPATH_BIN/cliamp"
|
||||||
|
else
|
||||||
|
warn "cliamp build failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 4. yt-dlp (video/audio download) (was section 13) ────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "── yt-dlp ──"
|
||||||
|
|
||||||
|
# Always install/upgrade via pip to get the latest version (apt repos are outdated).
|
||||||
|
# Remove apt version first if present, then install via pip to /usr/local/bin.
|
||||||
|
if has pip3; then
|
||||||
|
# Remove outdated apt version if installed
|
||||||
|
case $PM in
|
||||||
|
apt)
|
||||||
|
if dpkg -s yt-dlp &>/dev/null 2>&1; then
|
||||||
|
echo " Removing outdated apt version..."
|
||||||
|
sudo apt remove -y yt-dlp > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo " Installing/upgrading yt-dlp via pip..."
|
||||||
|
sudo pip3 install --break-system-packages --upgrade yt-dlp 2>/dev/null
|
||||||
|
if has yt-dlp; then ok "yt-dlp $(yt-dlp --version) installed"; else warn "yt-dlp pip install failed"; fi
|
||||||
|
else
|
||||||
|
case $PM in
|
||||||
|
apt) install_pkg yt-dlp 2>/dev/null && ok "yt-dlp installed (apt — may be outdated)" || warn "yt-dlp not available" ;;
|
||||||
|
pacman) install_pkg yt-dlp && ok "yt-dlp installed" ;;
|
||||||
|
brew) install_pkg yt-dlp && ok "yt-dlp installed" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 5. remote desktop (Ubuntu Desktop + VNC) (was section 17) ────────────────
|
||||||
|
echo ""
|
||||||
|
echo "── Remote Desktop (Ubuntu Desktop + VNC) ──"
|
||||||
|
|
||||||
|
# No "already installed" guard here on purpose. This used to skip on `dpkg -s ubuntu-desktop`, which
|
||||||
|
# treats one package being present as proof the whole remote desktop is configured — and those are very
|
||||||
|
# different things. A host can have ubuntu-desktop and still be missing every part that makes the mirror
|
||||||
|
# work: GDM auto-login, the forced Xorg session, the captured EDID and its kernel command line, the
|
||||||
|
# login-time mode setter. That was not hypothetical; it was this machine on 2026-08-02, where the guard
|
||||||
|
# reported "skip" while five of setup-desktop.sh's steps had never run and /desktop could not survive a
|
||||||
|
# reboot. setup-desktop.sh is idempotent throughout — every step either no-ops or is individually
|
||||||
|
# guarded — so letting it run each time converges a partially configured host instead of trusting a
|
||||||
|
# proxy for state it never actually checked.
|
||||||
|
#
|
||||||
|
# This is by far the most expensive section — it pulls the whole ubuntu-desktop meta-package, rewrites
|
||||||
|
# /etc/default/grub and switches the display manager.
|
||||||
|
case $PM in
|
||||||
|
apt)
|
||||||
|
bash "$SCRIPT_DIR/setup-desktop.sh"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ─── verification ─────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════"
|
||||||
|
echo " Verification"
|
||||||
|
echo "═══════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check() {
|
||||||
|
if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Rust:"
|
||||||
|
check rustc
|
||||||
|
check cargo
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Audio (cliamp):"
|
||||||
|
check pulseaudio
|
||||||
|
check parec
|
||||||
|
check pactl
|
||||||
|
check cliamp
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Download:"
|
||||||
|
check yt-dlp
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════"
|
||||||
|
echo " Done"
|
||||||
|
echo "═══════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo "Notes:"
|
||||||
|
echo " • PulseAudio null sink starts automatically with the server"
|
||||||
|
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
|
||||||
|
echo " • Make sure ~/.local/go-path/bin is in your PATH for Go-installed tools (cliamp)"
|
||||||
|
echo " • REBOOT to switch into the GNOME-on-Xorg session the remote desktop mirrors"
|
||||||
|
echo ""
|
||||||
Executable
+1179
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@
|
|||||||
# Run once on a fresh Ubuntu/Debian host before launching the server.
|
# Run once on a fresh Ubuntu/Debian host before launching the server.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash scripts/setup.sh # full server install
|
# bash scripts/setup/setup.sh # full server install
|
||||||
# OFFICER_PROFILE=light bash scripts/setup.sh # light install
|
# OFFICER_PROFILE=light bash scripts/setup/setup.sh # light install
|
||||||
#
|
#
|
||||||
# PROFILES
|
# PROFILES
|
||||||
# full Everything: the self-hosted estate, the remote desktop, the music/audio stack, the shell
|
# full Everything: the self-hosted estate, the remote desktop, the music/audio stack, the shell
|
||||||
@@ -13,13 +13,24 @@
|
|||||||
# Claude/opencode chat — on a Linux host. Installs only what those need: node, bun, ffmpeg,
|
# Claude/opencode chat — on a Linux host. Installs only what those need: node, bun, ffmpeg,
|
||||||
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
||||||
#
|
#
|
||||||
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust,
|
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, and Go.
|
||||||
# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and
|
# Of the Docker services only Postgres is brought up.
|
||||||
# the remote desktop. Of the Docker services only Postgres is brought up.
|
|
||||||
#
|
#
|
||||||
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
||||||
# are not running report themselves unavailable rather than disappearing. A profile changes
|
# are not running report themselves unavailable rather than disappearing. A profile changes
|
||||||
# which processes start, not which code ships.
|
# which processes start, not which code ships.
|
||||||
|
#
|
||||||
|
# NOT INSTALLED HERE — and the gaps in the section numbers are where these used to be
|
||||||
|
# Moved to scripts/setup/setup-sidecars.sh, which nothing below invokes; run it deliberately, and
|
||||||
|
# only after this script: 8 Rust, 9 PulseAudio, 10 cliamp, 13 yt-dlp, 17 remote desktop.
|
||||||
|
#
|
||||||
|
# Removed outright, because the host provisioning already installs them and two installers racing
|
||||||
|
# for the same binaries is worse than one: 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit),
|
||||||
|
# 14 npm globals (the ~/.local npm prefix, Claude Code, pm2).
|
||||||
|
#
|
||||||
|
# That makes node, npm, pm2 and the agent CLIs PREREQUISITES of this script rather than products of
|
||||||
|
# it. Section 19 warns and skips rather than failing if pm2 is absent, so a host that never ran the
|
||||||
|
# provisioning will finish "successfully" with nothing listening — check the verification block.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -48,7 +59,10 @@ is_light() { [ "$OFFICER_PROFILE" = "light" ]; }
|
|||||||
if is_light; then ECOSYSTEM_FILE="ecosystem.light.config.cjs"; else ECOSYSTEM_FILE="ecosystem.config.cjs"; fi
|
if is_light; then ECOSYSTEM_FILE="ecosystem.light.config.cjs"; else ECOSYSTEM_FILE="ecosystem.config.cjs"; fi
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
# ../.. — this lives in scripts/setup/, so the repo root is two levels up, not one. Nothing here fails
|
||||||
|
# loudly if that is wrong: PROJECT_DIR is where .env is written, where `bun install` and `db:push` run and
|
||||||
|
# where pm2 is pointed, so an off-by-one level silently sets up scripts/ instead of the repo.
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
# Resolve the real user's home even when running under sudo
|
# Resolve the real user's home even when running under sudo
|
||||||
if [[ -n "${SUDO_USER:-}" ]]; then
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||||
@@ -135,7 +149,7 @@ if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp)
|
# pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp, in setup-sidecars.sh)
|
||||||
if has pkg-config; then skip "pkg-config"; else
|
if has pkg-config; then skip "pkg-config"; else
|
||||||
case $PM in
|
case $PM in
|
||||||
apt) CORE_PKGS+=(pkg-config) ;;
|
apt) CORE_PKGS+=(pkg-config) ;;
|
||||||
@@ -515,8 +529,9 @@ fi
|
|||||||
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
|
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
|
||||||
# one on exactly the installs most likely to have members.
|
# one on exactly the installs most likely to have members.
|
||||||
#
|
#
|
||||||
# One static binary and one config file. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips
|
# One static binary and one config file, which is why it survived the cull that removed the rest of the
|
||||||
# them: those are host comforts, and the shell template treats each as optional.
|
# terminal tooling: oh-my-zsh, eza and lazygit are host comforts the provisioning installs, and the shell
|
||||||
|
# template treats each as optional. Starship it does not — the prompt would visibly degrade.
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Prompt (starship) ──"
|
echo "── Prompt (starship) ──"
|
||||||
|
|
||||||
@@ -529,9 +544,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
||||||
# every run, silently — the nvim step below already gets this right by guarding on the config's
|
# every run, silently. Converge when there is nothing to lose, keep what the user wrote when there is.
|
||||||
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
|
|
||||||
# wrote when there is.
|
|
||||||
mkdir -p "$HOME/.config"
|
mkdir -p "$HOME/.config"
|
||||||
STARSHIP_DEST="$HOME/.config/starship.toml"
|
STARSHIP_DEST="$HOME/.config/starship.toml"
|
||||||
if [ ! -f "$STARSHIP_DEST" ]; then
|
if [ ! -f "$STARSHIP_DEST" ]; then
|
||||||
@@ -540,17 +553,15 @@ if [ ! -f "$STARSHIP_DEST" ]; then
|
|||||||
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
||||||
skip "starship config"
|
skip "starship config"
|
||||||
else
|
else
|
||||||
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
|
warn "starship config kept — yours differs (cp scripts/setup/starship.toml ~/.config/ to take this one)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
|
# Go is a host comfort rather than anything the app needs to serve a file browser, a terminal and a
|
||||||
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
|
# chat, so `light` skips it. It is the only section left in this block — 8-13 were removed or moved.
|
||||||
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
|
|
||||||
# terminal and a chat.
|
|
||||||
if is_light; then
|
if is_light; then
|
||||||
echo ""
|
echo ""
|
||||||
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp"
|
omit "Go"
|
||||||
else
|
else
|
||||||
|
|
||||||
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -604,279 +615,18 @@ else
|
|||||||
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi
|
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── 8. Rust ──────────────────────────────────────────────────────────────────
|
# 8 Rust, 9 PulseAudio, 10 cliamp and 13 yt-dlp are in setup-sidecars.sh.
|
||||||
echo ""
|
# 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit) and 14 npm globals are gone entirely — the host
|
||||||
echo "── Rust ──"
|
# provisioning owns node, npm, pm2, Claude Code, Neovim and the shell, and this script duplicating
|
||||||
|
# them meant two installers racing for the same binaries.
|
||||||
|
|
||||||
if has rustc && has cargo; then
|
fi # end of the light-profile skip, which is now section 7 alone
|
||||||
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
|
|
||||||
else
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
|
||||||
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 9. PulseAudio (headless audio for cliamp) ────────────────────────────────
|
# Kept from the removed section 14: nothing here installs into ~/.local/bin any more, but section 19
|
||||||
echo ""
|
# still asks `has pm2` and the agent still resolves `claude` off PATH. A host that installed either
|
||||||
echo "── PulseAudio (headless audio) ──"
|
# user-locally would otherwise look like it has neither.
|
||||||
|
|
||||||
PULSE_PKGS=()
|
|
||||||
|
|
||||||
if has pulseaudio; then skip "pulseaudio"; else
|
|
||||||
case $PM in
|
|
||||||
apt) PULSE_PKGS+=(pulseaudio) ;;
|
|
||||||
pacman) PULSE_PKGS+=(pulseaudio) ;;
|
|
||||||
brew) warn "PulseAudio: brew install pulseaudio (cliamp audio won't work without it)" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# pulseaudio-utils provides parec and pactl
|
|
||||||
if has parec && has pactl; then skip "pulseaudio-utils (parec, pactl)"; else
|
|
||||||
case $PM in
|
|
||||||
apt) PULSE_PKGS+=(pulseaudio-utils) ;;
|
|
||||||
pacman) ;; # included in pulseaudio package
|
|
||||||
brew) ;; # included in pulseaudio formula
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ALSA dev headers (needed to compile cliamp's Go audio library)
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
if dpkg -s libasound2-dev &>/dev/null 2>&1; then skip "libasound2-dev"; else PULSE_PKGS+=(libasound2-dev); fi
|
|
||||||
;;
|
|
||||||
pacman)
|
|
||||||
if pacman -Qi alsa-lib &>/dev/null 2>&1; then skip "alsa-lib"; else PULSE_PKGS+=(alsa-lib); fi
|
|
||||||
;;
|
|
||||||
brew) ;; # not needed on macOS
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Vorbis/OGG/FLAC dev headers (needed by cliamp's Go dependencies)
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
for pkg in libvorbis-dev libogg-dev libflac-dev; do
|
|
||||||
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
|
|
||||||
done
|
|
||||||
;;
|
|
||||||
pacman)
|
|
||||||
for pkg in libvorbis libogg flac; do
|
|
||||||
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
|
|
||||||
done
|
|
||||||
;;
|
|
||||||
brew) ;; # not needed on macOS
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
|
|
||||||
install_pkg "${PULSE_PKGS[@]}"
|
|
||||||
ok "Installed: ${PULSE_PKGS[*]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 10. cliamp (music player) ────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── cliamp ──"
|
|
||||||
|
|
||||||
# Export GOPATH (not just PATH) so `go install` lands in GOPATH_BIN — even when Go was already present
|
|
||||||
# this run and install_go (which sets GOPATH) never ran. Otherwise go uses its default ~/go/bin and the
|
|
||||||
# check below wrongly reports a build failure.
|
|
||||||
export GOPATH="${GOPATH:-$HOME/.local/go-path}"
|
|
||||||
GOPATH_BIN="$GOPATH/bin"
|
|
||||||
export PATH="$GOPATH_BIN:$PATH"
|
|
||||||
|
|
||||||
if has cliamp; then
|
|
||||||
skip "cliamp ($(command -v cliamp))"
|
|
||||||
else
|
|
||||||
if ! has go; then
|
|
||||||
warn "Go not installed — skipping cliamp build"
|
|
||||||
else
|
|
||||||
echo " Building cliamp from source..."
|
|
||||||
TMPDIR=$(mktemp -d)
|
|
||||||
git clone --depth=1 https://github.com/bjarneo/cliamp.git "$TMPDIR/cliamp"
|
|
||||||
(cd "$TMPDIR/cliamp" && go install .)
|
|
||||||
rm -rf "$TMPDIR"
|
|
||||||
if [ -f "$GOPATH_BIN/cliamp" ]; then
|
|
||||||
ok "cliamp installed at $GOPATH_BIN/cliamp"
|
|
||||||
else
|
|
||||||
warn "cliamp build failed"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 11. Neovim ──────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── Neovim ──"
|
|
||||||
|
|
||||||
if has nvim; then
|
|
||||||
skip "neovim ($(nvim --version 2>/dev/null | head -1))"
|
|
||||||
else
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
echo " Installing Neovim from GitHub releases..."
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case $ARCH in
|
|
||||||
x86_64) NVIM_ARCH=x86_64 ;;
|
|
||||||
aarch64) NVIM_ARCH=aarch64 ;;
|
|
||||||
*) NVIM_ARCH=x86_64 ;;
|
|
||||||
esac
|
|
||||||
curl -fsSL "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" -o /tmp/nvim.tar.gz
|
|
||||||
sudo tar -C /opt -xzf /tmp/nvim.tar.gz
|
|
||||||
sudo ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim
|
|
||||||
rm /tmp/nvim.tar.gz
|
|
||||||
;;
|
|
||||||
pacman) install_pkg neovim ;;
|
|
||||||
brew) install_pkg neovim ;;
|
|
||||||
esac
|
|
||||||
if has nvim; then ok "neovim installed"; else warn "neovim install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# LazyVim starter config
|
|
||||||
if [ -d "$HOME/.config/nvim" ]; then
|
|
||||||
skip "nvim config (already exists at ~/.config/nvim)"
|
|
||||||
else
|
|
||||||
echo " Installing LazyVim starter config..."
|
|
||||||
git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim"
|
|
||||||
rm -rf "$HOME/.config/nvim/.git"
|
|
||||||
ok "LazyVim starter installed at ~/.config/nvim"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 12. Terminal tools (starship is section 6b, outside the light skip) ─────
|
|
||||||
echo ""
|
|
||||||
echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──"
|
|
||||||
|
|
||||||
|
|
||||||
# Oh-My-Zsh
|
|
||||||
if [ -d "$HOME/.oh-my-zsh" ]; then
|
|
||||||
skip "oh-my-zsh (already at ~/.oh-my-zsh)"
|
|
||||||
else
|
|
||||||
git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh"
|
|
||||||
ok "oh-my-zsh installed at ~/.oh-my-zsh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# eza
|
|
||||||
if has eza; then
|
|
||||||
skip "eza"
|
|
||||||
else
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
echo " Fetching latest eza version..."
|
|
||||||
EZA_VERSION=$(curl -fsSL "https://api.github.com/repos/eza-community/eza/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
|
|
||||||
if [ -z "$EZA_VERSION" ]; then warn "Could not fetch eza version — skipping"; else
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case $ARCH in
|
|
||||||
x86_64) EZA_ARCH=x86_64 ;;
|
|
||||||
aarch64) EZA_ARCH=aarch64 ;;
|
|
||||||
*) EZA_ARCH=x86_64 ;;
|
|
||||||
esac
|
|
||||||
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz
|
|
||||||
tar -xzf /tmp/eza.tar.gz -C /tmp
|
|
||||||
sudo mv /tmp/eza /usr/local/bin/eza
|
|
||||||
sudo chmod +x /usr/local/bin/eza
|
|
||||||
rm -f /tmp/eza.tar.gz
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
pacman) install_pkg eza ;;
|
|
||||||
brew) install_pkg eza ;;
|
|
||||||
esac
|
|
||||||
if has eza; then ok "eza installed"; else warn "eza install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# lazygit
|
|
||||||
if has lazygit; then
|
|
||||||
skip "lazygit"
|
|
||||||
else
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
echo " Fetching latest lazygit version..."
|
|
||||||
LAZYGIT_VERSION=$(curl -fsSL "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
|
|
||||||
if [ -z "$LAZYGIT_VERSION" ]; then warn "Could not fetch lazygit version — skipping"; else
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case $ARCH in
|
|
||||||
x86_64) LG_ARCH=x86_64 ;;
|
|
||||||
aarch64) LG_ARCH=arm64 ;;
|
|
||||||
*) LG_ARCH=x86_64 ;;
|
|
||||||
esac
|
|
||||||
curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LG_ARCH}.tar.gz" -o /tmp/lazygit.tar.gz
|
|
||||||
tar -xzf /tmp/lazygit.tar.gz -C /tmp
|
|
||||||
sudo mv /tmp/lazygit /usr/local/bin/lazygit
|
|
||||||
sudo chmod +x /usr/local/bin/lazygit
|
|
||||||
rm -f /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
pacman) install_pkg lazygit ;;
|
|
||||||
brew) install_pkg lazygit ;;
|
|
||||||
esac
|
|
||||||
if has lazygit; then ok "lazygit installed"; else warn "lazygit install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 13. yt-dlp (optional — video/audio download) ────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── yt-dlp (optional) ──"
|
|
||||||
|
|
||||||
# Always install/upgrade via pip to get the latest version (apt repos are outdated).
|
|
||||||
# Remove apt version first if present, then install via pip to /usr/local/bin.
|
|
||||||
if has pip3; then
|
|
||||||
# Remove outdated apt version if installed
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
if dpkg -s yt-dlp &>/dev/null 2>&1; then
|
|
||||||
echo " Removing outdated apt version..."
|
|
||||||
sudo apt remove -y yt-dlp > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
echo " Installing/upgrading yt-dlp via pip..."
|
|
||||||
sudo pip3 install --break-system-packages --upgrade yt-dlp 2>/dev/null
|
|
||||||
if has yt-dlp; then ok "yt-dlp $(yt-dlp --version) installed"; else warn "yt-dlp pip install failed"; fi
|
|
||||||
else
|
|
||||||
case $PM in
|
|
||||||
apt) install_pkg yt-dlp 2>/dev/null && ok "yt-dlp installed (apt — may be outdated)" || warn "yt-dlp not available" ;;
|
|
||||||
pacman) install_pkg yt-dlp && ok "yt-dlp installed" ;;
|
|
||||||
brew) install_pkg yt-dlp && ok "yt-dlp installed" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
fi # end of the light-profile skip for sections 7-13
|
|
||||||
|
|
||||||
# ─── 14. npm global packages (user-local) ───────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "── npm global packages (user-local) ──"
|
|
||||||
|
|
||||||
# Ensure ~/.local/bin is in PATH for this session
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
if ! has npm; then
|
|
||||||
warn "npm not found — skipping global package installs"
|
|
||||||
else
|
|
||||||
# Set npm prefix to user-local so no sudo is needed for installs/updates
|
|
||||||
echo " Configuring npm global prefix to ~/.local..."
|
|
||||||
npm config set prefix "$HOME/.local"
|
|
||||||
ok "npm prefix set to $HOME/.local"
|
|
||||||
|
|
||||||
# Claude Code (uses Anthropic's own installer for auto-update support)
|
|
||||||
if has claude; then
|
|
||||||
skip "claude (claude-code)"
|
|
||||||
else
|
|
||||||
echo " Installing claude-code via Anthropic installer..."
|
|
||||||
curl -fsSL https://claude.ai/install.sh | bash # bash, not sh: a piped script ignores its shebang and install.sh is bash
|
|
||||||
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# No /usr/local/bin/claude symlink. That existed because the sidecar hardcoded that path, which in
|
|
||||||
# turn came from the bwrap-sandboxed architecture — the jail ro-bound /usr and could not see the
|
|
||||||
# installer's real target in ~/.local/bin. The sandbox is gone and claude-manager.ts now resolves the
|
|
||||||
# CLI itself: $CLAUDE_BIN, then PATH, then ~/.local/bin/claude, /usr/local/bin/claude and
|
|
||||||
# /opt/homebrew/bin/claude. The installer above puts it in ~/.local/bin, which is both on PATH and the
|
|
||||||
# first candidate, so the symlink was satisfying a requirement that no longer exists — at the cost of
|
|
||||||
# a sudo-owned link into /usr/local/bin, a directory macOS does not even ship.
|
|
||||||
|
|
||||||
# pm2 (process manager)
|
|
||||||
if has pm2; then
|
|
||||||
skip "pm2"
|
|
||||||
else
|
|
||||||
echo " Installing pm2..."
|
|
||||||
npm install -g pm2
|
|
||||||
if has pm2; then ok "pm2 installed"; else warn "pm2 install failed"; fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 15. bun install (project dependencies) ──────────────────────────────────
|
# ─── 15. bun install (project dependencies) ──────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Project dependencies ──"
|
echo "── Project dependencies ──"
|
||||||
@@ -998,34 +748,7 @@ ENVFILE
|
|||||||
ok ".env written to $PROJECT_DIR/.env"
|
ok ".env written to $PROJECT_DIR/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── 17. remote desktop (Ubuntu Desktop + VNC) ───────────────────────────────
|
# 17 remote desktop is in setup-sidecars.sh.
|
||||||
echo ""
|
|
||||||
echo "── Remote Desktop (Ubuntu Desktop + VNC) ──"
|
|
||||||
|
|
||||||
# No "already installed" guard here on purpose. This used to skip on `dpkg -s ubuntu-desktop`, which
|
|
||||||
# treats one package being present as proof the whole remote desktop is configured — and those are very
|
|
||||||
# different things. A host can have ubuntu-desktop and still be missing every part that makes the mirror
|
|
||||||
# work: GDM auto-login, the forced Xorg session, the captured EDID and its kernel command line, the
|
|
||||||
# login-time mode setter. That was not hypothetical; it was this machine on 2026-08-02, where the guard
|
|
||||||
# reported "skip" while five of setup-desktop.sh's steps had never run and /desktop could not survive a
|
|
||||||
# reboot. setup-desktop.sh is idempotent throughout — every step either no-ops or is individually
|
|
||||||
# guarded — so letting it run each time converges a partially configured host instead of trusting a
|
|
||||||
# proxy for state it never actually checked.
|
|
||||||
# The light profile does not run officer-vnc, so there is nothing to mirror. This is the single most
|
|
||||||
# expensive section — it pulls the whole ubuntu-desktop meta-package — and the one most clearly outside
|
|
||||||
# "file browser, terminal, chat".
|
|
||||||
if is_light; then
|
|
||||||
omit "remote desktop (ubuntu-desktop, GDM, x11vnc, Brave)"
|
|
||||||
else
|
|
||||||
case $PM in
|
|
||||||
apt)
|
|
||||||
bash "$SCRIPT_DIR/setup-desktop.sh"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 18. project initialization ──────────────────────────────────────────────
|
# ─── 18. project initialization ──────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1113,17 +836,13 @@ check gcc
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Dev tools:"
|
echo "Dev tools:"
|
||||||
# Only the ones the light profile actually installs are checked under it — reporting Go and cliamp as
|
# Only what this script still installs is checked. Reporting Go as NOT FOUND on a light install that
|
||||||
# NOT FOUND on an install that deliberately skipped them makes a clean run look broken.
|
# deliberately skipped it makes a clean run look broken; so does checking for nvim, lazygit and eza,
|
||||||
|
# which this script no longer owns at all.
|
||||||
if ! is_light; then
|
if ! is_light; then
|
||||||
check go
|
check go
|
||||||
check rustc
|
|
||||||
check cargo
|
|
||||||
check nvim
|
|
||||||
check starship
|
|
||||||
check lazygit
|
|
||||||
check eza
|
|
||||||
fi
|
fi
|
||||||
|
check starship
|
||||||
check zsh
|
check zsh
|
||||||
check rg
|
check rg
|
||||||
check fd
|
check fd
|
||||||
@@ -1134,21 +853,15 @@ check tree
|
|||||||
check btop
|
check btop
|
||||||
check sqlite3
|
check sqlite3
|
||||||
|
|
||||||
if ! is_light; then
|
# Neither of these is installed here any more — they come from the host provisioning. They are still
|
||||||
echo ""
|
# checked because section 19 and every chat turn depend on them, and "NOT FOUND" here is the only
|
||||||
echo "Audio (cliamp):"
|
# warning you get before the services silently do not start.
|
||||||
check pulseaudio
|
|
||||||
check parec
|
|
||||||
check pactl
|
|
||||||
check cliamp
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "AI agents:"
|
echo "AI agents (from host provisioning):"
|
||||||
check claude
|
check claude
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Process manager:"
|
echo "Process manager (from host provisioning):"
|
||||||
check pm2
|
check pm2
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1158,7 +871,6 @@ check 7z
|
|||||||
check unrar
|
check unrar
|
||||||
check pgrep
|
check pgrep
|
||||||
check fuser
|
check fuser
|
||||||
if ! is_light; then check yt-dlp; fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══════════════════════════════════════════"
|
echo "═══════════════════════════════════════════"
|
||||||
@@ -1186,8 +898,9 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Notes:"
|
echo "Notes:"
|
||||||
echo " • PulseAudio null sink starts automatically with the server"
|
|
||||||
echo " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
|
echo " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
|
||||||
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
|
|
||||||
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
|
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
|
||||||
|
echo " • node, npm, pm2 and the agent CLIs come from the host provisioning, not from here"
|
||||||
|
echo " • Rust, PulseAudio, cliamp, yt-dlp and the remote desktop are NOT installed by this script:"
|
||||||
|
echo " run 'bash scripts/setup/setup-sidecars.sh' if you want them"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Officer — macOS laptop setup.
|
# Officer — macOS laptop setup.
|
||||||
#
|
#
|
||||||
# The barebones counterpart to scripts/setup.sh (which targets an Ubuntu/Debian server and is left
|
# The barebones counterpart to scripts/setup/setup.sh (which targets an Ubuntu/Debian server and is left
|
||||||
# alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat,
|
# alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat,
|
||||||
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
|
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
|
||||||
# no sudoers grant, no power-management changes.
|
# no sudoers grant, no power-management changes.
|
||||||
#
|
#
|
||||||
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively:
|
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively:
|
||||||
#
|
#
|
||||||
# SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup_mac_light.sh
|
# SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup/setup_mac_light.sh
|
||||||
#
|
#
|
||||||
# SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
|
# SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
|
||||||
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI
|
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
# This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note
|
# This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note
|
||||||
# that Homebrew's own installer does ask for an administrator password on a fresh Mac.
|
# that Homebrew's own installer does ask for an administrator password on a fresh Mac.
|
||||||
#
|
#
|
||||||
# Usage: bash scripts/setup_mac_light.sh
|
# Usage: bash scripts/setup/setup_mac_light.sh
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -48,7 +48,10 @@ FAILURES=()
|
|||||||
note_failure() { FAILURES+=("$1"); fail "$1"; }
|
note_failure() { FAILURES+=("$1"); fail "$1"; }
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
# ../.. — this lives in scripts/setup/. See the note in setup.sh: PROJECT_DIR is where .env is written
|
||||||
|
# and where bun install, gen:index, db:push and pm2 are pointed, and none of them fails loudly on the
|
||||||
|
# wrong directory.
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
PG_FORMULA="postgresql@18"
|
PG_FORMULA="postgresql@18"
|
||||||
PG_DATABASE="officer_dev"
|
PG_DATABASE="officer_dev"
|
||||||
@@ -103,7 +106,7 @@ echo "════════════════════════
|
|||||||
step "Preflight"
|
step "Preflight"
|
||||||
|
|
||||||
if [ "$(uname -s)" != "Darwin" ]; then
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
fail "This script is macOS-only. On Linux use scripts/setup.sh."
|
fail "This script is macOS-only. On Linux use scripts/setup/setup.sh."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -532,5 +535,5 @@ echo " • Not run on macOS: VNC desktop, email sync, music indexer, cliamp aud
|
|||||||
echo " that front a container or an external service — vault, slskd, headscale, transmission,"
|
echo " that front a container or an external service — vault, slskd, headscale, transmission,"
|
||||||
echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.light.config.cjs."
|
echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.light.config.cjs."
|
||||||
echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to"
|
echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to"
|
||||||
echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup_mac_light.sh"
|
echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup/setup_mac_light.sh"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — shared foundation
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file
|
||||||
|
# declares state and functions and must never install, write or restart
|
||||||
|
# anything. Sourcing it has to be safe at any point, including from a step that
|
||||||
|
# is only being read for its variables.
|
||||||
|
#
|
||||||
|
# The one thing it expects from its caller, because they are facts about the
|
||||||
|
# entry point rather than about this library:
|
||||||
|
#
|
||||||
|
# SCRIPT_DIR directory of the script being run
|
||||||
|
# PROGRESS_FILE where completed step names are recorded
|
||||||
|
#
|
||||||
|
# Everything else below is owned here.
|
||||||
|
|
||||||
|
# Guard against being sourced twice — steps will eventually source this
|
||||||
|
# directly so they can be run on their own, and re-running it would reset
|
||||||
|
# SUMMARY and lose everything recorded so far.
|
||||||
|
[[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_BASE_LOADED=1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Shared state
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SUMMARY=() # what was done, printed at the end
|
||||||
|
ERRORS=() # non-fatal failures, printed at the end
|
||||||
|
CURRENT_STEP=""
|
||||||
|
SKIP_STEP=false
|
||||||
|
|
||||||
|
# What machine this is. Filled in by detect_os() before any step runs; every step
|
||||||
|
# after that branches on these rather than assuming apt on x86_64.
|
||||||
|
OS="" # os-release ID: ubuntu | debian | arch | fedora | macos | …
|
||||||
|
OS_NAME="" # pretty name, for the banner
|
||||||
|
OS_VERSION="" # version id; empty on rolling releases
|
||||||
|
PM="" # apt | pacman | dnf | brew
|
||||||
|
ARCH="" # amd64 | arm64, normalised — upstream tarballs disagree on spelling
|
||||||
|
IS_WSL=false
|
||||||
|
|
||||||
|
# What this box is FOR. Asked once in pre-flight and consulted by the steps
|
||||||
|
# afterwards, because several of them have a different right answer per role and
|
||||||
|
# no way to work it out on their own:
|
||||||
|
#
|
||||||
|
# homelab a machine you physically control on a network you own
|
||||||
|
# vps rented, public IP, someone else's DHCP and console
|
||||||
|
# dev a laptop or desktop you sit at
|
||||||
|
#
|
||||||
|
# Set MACHINE_ROLE in the environment to answer it ahead of time — hence the
|
||||||
|
# :- default rather than a plain assignment, which would wipe what the caller
|
||||||
|
# passed in before ask_machine_role ever looked at it.
|
||||||
|
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Output
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||||
|
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
||||||
|
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
||||||
|
fail() {
|
||||||
|
echo -e " ${RED}FAIL${NC}: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Steps and resume
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# A step announces itself, and is skipped when its name is already in the
|
||||||
|
# progress file. step_ok records it. The pattern at each call site is:
|
||||||
|
#
|
||||||
|
# step "Name"
|
||||||
|
# if ! skip; then
|
||||||
|
# …
|
||||||
|
# step_ok
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Remembering the answers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Pre-flight asks four things — role, account, where Officer goes — and every
|
||||||
|
# section needs them. Asking again on every run made a resumed run re-answer
|
||||||
|
# questions it had already been told, and made --only unusable: four questions to
|
||||||
|
# reach one section.
|
||||||
|
#
|
||||||
|
# Saved beside the progress file, and loaded before anything is asked. The
|
||||||
|
# environment still wins, so SETUP_USERNAME=x on the command line overrides what
|
||||||
|
# was saved.
|
||||||
|
|
||||||
|
ANSWERS_FILE="${ANSWERS_FILE:-}"
|
||||||
|
|
||||||
|
save_answers() {
|
||||||
|
[[ -n "$ANSWERS_FILE" ]] || return 0
|
||||||
|
cat >"$ANSWERS_FILE" <<EOF
|
||||||
|
# Written by machine-setup. Delete this to be asked again.
|
||||||
|
MACHINE_ROLE=${MACHINE_ROLE}
|
||||||
|
SETUP_USERNAME=${USERNAME}
|
||||||
|
OFFICER_ROOT=${OFFICER_ROOT}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$ANSWERS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Loaded as assignments, not sourced as a script: this file sits beside the
|
||||||
|
# script and is read by a root run, so it should not be able to execute anything.
|
||||||
|
load_answers() {
|
||||||
|
[[ -n "$ANSWERS_FILE" && -r "$ANSWERS_FILE" ]] || return 0
|
||||||
|
local key value
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
||||||
|
[[ -n "$value" ]] || continue
|
||||||
|
# The environment wins over what was saved.
|
||||||
|
#
|
||||||
|
# Written as if/then rather than `[[ … ]] && assign`.
|
||||||
|
#
|
||||||
|
# That form returns non-zero when the test is false. Harmless on its own —
|
||||||
|
# `set -e` exempts the left side of an && list — but here it is the last
|
||||||
|
# thing the case runs, the case is the last thing the loop body runs, and the
|
||||||
|
# loop is the last thing THE FUNCTION runs. So load_answers returned
|
||||||
|
# non-zero, and calling a function that returns non-zero is a plain command
|
||||||
|
# failure, which does end the script.
|
||||||
|
#
|
||||||
|
# It needed the answers file to exist AND the variables to be set already, so
|
||||||
|
# it only appeared when running with env overrides. The trap reported "Step:
|
||||||
|
# unknown" at a line inside this library, before pre-flight had run.
|
||||||
|
#
|
||||||
|
# The general rule this is an instance of: a function whose last statement
|
||||||
|
# can return non-zero fails when it is called, however innocuous the
|
||||||
|
# statement looks.
|
||||||
|
case "$key" in
|
||||||
|
MACHINE_ROLE) if [[ -z "${MACHINE_ROLE:-}" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||||
|
SETUP_USERNAME) if [[ -z "${SETUP_USERNAME:-}" ]]; then SETUP_USERNAME="$value"; fi ;;
|
||||||
|
OFFICER_ROOT) if [[ -z "${OFFICER_ROOT:-}" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||||
|
esac
|
||||||
|
done <"$ANSWERS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set by --only. When it is set, every step whose name does not match is passed
|
||||||
|
# over in silence, and the one that does matches runs regardless of the progress
|
||||||
|
# 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
|
||||||
|
SKIP_STEP=false
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}── $1 ──${NC}"
|
||||||
|
else
|
||||||
|
SKIP_STEP=true
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
||||||
|
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
||||||
|
SKIP_STEP=true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
SKIP_STEP=false
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}── $1 ──${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
skip() { [[ "$SKIP_STEP" == true ]]; }
|
||||||
|
|
||||||
|
step_ok() {
|
||||||
|
# A single step run on its own is not progress through the script, and
|
||||||
|
# recording it would make the next full run skip it.
|
||||||
|
[[ -n "$ONLY_STEP" ]] && return 0
|
||||||
|
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try a command, log error but don't exit
|
||||||
|
try() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
if "$@" 2>&1; then
|
||||||
|
ok "$label"
|
||||||
|
else
|
||||||
|
warn "$label — failed (non-critical, continuing)"
|
||||||
|
ERRORS+=("$label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Input
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
prompt_value() {
|
||||||
|
local varname="$1" message="$2" default="$3"
|
||||||
|
# If env var already set, use it silently
|
||||||
|
if [[ -n "${!varname:-}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local input
|
||||||
|
if [[ -n "$default" ]]; then
|
||||||
|
read -rp "$message [$default]: " input
|
||||||
|
eval "$varname=\"\${input:-$default}\""
|
||||||
|
else
|
||||||
|
read -rp "$message: " input
|
||||||
|
eval "$varname=\"\$input\""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show long output a screen at a time.
|
||||||
|
#
|
||||||
|
# Only when there is a terminal to page on: with output redirected or piped —
|
||||||
|
# a transcript, a log, the test harness — it has to come through whole, and a
|
||||||
|
# pager would either block or mangle it. `more` rather than `less` because it
|
||||||
|
# exits at the end of the file instead of sitting there waiting to be quit,
|
||||||
|
# which is what you want for something you asked to read once.
|
||||||
|
page() {
|
||||||
|
if [[ -t 1 ]] && command -v more &>/dev/null; then
|
||||||
|
more
|
||||||
|
else
|
||||||
|
cat
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ask before acting. Every section that changes the machine goes through this, so
|
||||||
|
# a run is a sequence of things you agreed to rather than a wall of output you
|
||||||
|
# read afterwards to find out what happened.
|
||||||
|
#
|
||||||
|
# Enter means yes — unlike the machine-role question, which has no default. These
|
||||||
|
# are "do the thing you already asked for", and making twenty of them require a
|
||||||
|
# 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
|
||||||
|
# already asked for" and Enter should mean yes; a few are genuine extras, where
|
||||||
|
# defaulting to yes would have people agreeing to them by reflex.
|
||||||
|
local default="${2:-y}"
|
||||||
|
# Third is the name of a function that explains the question. Where one is
|
||||||
|
# given, `?` becomes an answer — so the explanation is available to whoever
|
||||||
|
# wants it without being in the way of whoever does not.
|
||||||
|
local help_fn="${3:-}"
|
||||||
|
local answer prompt
|
||||||
|
|
||||||
|
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
||||||
|
|
||||||
|
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
||||||
|
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# EOF is not a yes. Without this an unattended run without ASSUME_YES would
|
||||||
|
# spin here forever.
|
||||||
|
if ! read -rp " ${message} ${prompt}: " answer; then
|
||||||
|
echo ""
|
||||||
|
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
||||||
|
fi
|
||||||
|
[[ -z "$answer" ]] && answer="$default"
|
||||||
|
case "$answer" in
|
||||||
|
y | Y | yes | Yes) return 0 ;;
|
||||||
|
n | N | no | No) return 1 ;;
|
||||||
|
"?")
|
||||||
|
if [[ -n "$help_fn" ]]; then
|
||||||
|
echo ""
|
||||||
|
"$help_fn" | page
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
warn "Answer y or n."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Which account this machine is being set up for.
|
||||||
|
#
|
||||||
|
# Asked at the top because two later questions default off it — where Officer is
|
||||||
|
# installed, and where the disk ballast goes — so it has to be settled before
|
||||||
|
# either is put to the user.
|
||||||
|
#
|
||||||
|
# Defaults to whoever invoked sudo. On a re-run, or on a machine that is already
|
||||||
|
# somebody's, that is the answer every time, and typing it again is a chance to
|
||||||
|
# typo it into creating a second account.
|
||||||
|
#
|
||||||
|
# SETUP_USERNAME in the environment answers it ahead of time. Deliberately not
|
||||||
|
# USERNAME: that name is set by some login environments, and a variable this
|
||||||
|
# script silently obeys should not be one that might already be in the
|
||||||
|
# environment for unrelated reasons.
|
||||||
|
ask_username() {
|
||||||
|
local default="${SUDO_USER:-}" answer
|
||||||
|
|
||||||
|
# root invoked the script directly rather than through sudo. It is never the
|
||||||
|
# account being set up, so there is nothing to suggest.
|
||||||
|
[[ "$default" == "root" ]] && default=""
|
||||||
|
|
||||||
|
if [[ -n "${SETUP_USERNAME:-}" ]]; then
|
||||||
|
answer="$SETUP_USERNAME"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
info "Which account is this machine for?"
|
||||||
|
echo " The account you log in and work as, day to day. It will be created"
|
||||||
|
echo " if it does not exist."
|
||||||
|
echo ""
|
||||||
|
warn "Strongly advised: use a normal account, not root."
|
||||||
|
echo " Working as root means everything runs with no safety net. A typo in"
|
||||||
|
echo " a path deletes instead of refusing, anything you run has the whole"
|
||||||
|
echo " machine, and nothing distinguishes you from a process that got out"
|
||||||
|
echo " of hand. sudo gives you the same power when you ask for it, and"
|
||||||
|
echo " only then — which is why root is not accepted as an answer here."
|
||||||
|
|
||||||
|
# Whether this was started FROM a root session, which usually means root is
|
||||||
|
# how they log in. That is exactly the situation the advice above is for, and
|
||||||
|
# the one where general advice is easiest to assume is aimed at somebody else.
|
||||||
|
#
|
||||||
|
# Two ways to be in it, and the second is the one that hides: no SUDO_USER at
|
||||||
|
# all, or a SUDO_USER that is itself uid 0. Some providers ship an image whose
|
||||||
|
# default account is uid 0 under an ordinary-looking name, so `sudo` from it
|
||||||
|
# sets SUDO_USER to something that looks like a normal user and is not.
|
||||||
|
local invoker_uid=""
|
||||||
|
[[ -n "${SUDO_USER:-}" ]] && invoker_uid="$(id -u "$SUDO_USER" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -z "${SUDO_USER:-}" || "$invoker_uid" == "0" ]]; then
|
||||||
|
echo ""
|
||||||
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||||
|
warn "You are running this from '${SUDO_USER}', which is uid 0 — the root account."
|
||||||
|
else
|
||||||
|
warn "You are running this as root directly, not through sudo."
|
||||||
|
fi
|
||||||
|
echo " If that is how you normally log into this machine, now is the"
|
||||||
|
echo " moment to make an account and stop doing that."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
while [[ -z "${answer:-}" ]]; do
|
||||||
|
if ! read -rp " Username${default:+ [$default]}: " answer; then
|
||||||
|
echo ""
|
||||||
|
fail "No answer. Set SETUP_USERNAME=<name> to answer this ahead of time."
|
||||||
|
fi
|
||||||
|
answer="${answer:-$default}"
|
||||||
|
[[ -z "$answer" ]] && warn "There is no default here — type a username."
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The portable shape of a Linux account name. Worth checking rather than
|
||||||
|
# letting adduser refuse it later, because by then several questions have been
|
||||||
|
# answered against a name that was never going to work.
|
||||||
|
[[ "$answer" =~ ^[a-z_][a-z0-9_-]*\$?$ && ${#answer} -le 32 ]] ||
|
||||||
|
fail "'${answer}' is not a usable Linux username — lower case, starting with a letter or underscore."
|
||||||
|
# By uid, not by name. "root" is a label — what makes an account root is uid 0,
|
||||||
|
# and some providers ship an image whose default login is uid 0 under a
|
||||||
|
# friendlier name. Refusing only the string would let exactly that case through,
|
||||||
|
# which is the one worth catching.
|
||||||
|
local answer_uid
|
||||||
|
answer_uid="$(id -u "$answer" 2>/dev/null || true)"
|
||||||
|
if [[ "$answer_uid" == "0" ]]; then
|
||||||
|
if [[ "$answer" == "root" ]]; then
|
||||||
|
fail "root is not the account to set up here — see the warning above."
|
||||||
|
fi
|
||||||
|
fail "'${answer}' is uid 0 — the root account under another name, and not what to set up here."
|
||||||
|
fi
|
||||||
|
|
||||||
|
USERNAME="$answer"
|
||||||
|
|
||||||
|
# Looked up, not assumed. The original built "/home/$USERNAME", which is merely
|
||||||
|
# the usual answer — an account created with a different home, or one whose home
|
||||||
|
# was moved, would have every later step writing to a directory that is not
|
||||||
|
# theirs.
|
||||||
|
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||||
|
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Where Officer will live.
|
||||||
|
#
|
||||||
|
# Asked in pre-flight with the rest of the questions rather than at the point it
|
||||||
|
# is first needed, because it decides the shape of several later steps — the
|
||||||
|
# directory the repository is cloned into, where DATA_PATH sits beside it, and
|
||||||
|
# which filesystem the app store's containers bind-mount out of. Answering it
|
||||||
|
# once at the start also means the run can be described before it begins.
|
||||||
|
#
|
||||||
|
# One directory holding four, per docs/sidecar-app-store.md:
|
||||||
|
#
|
||||||
|
# <root>/platform/ the app
|
||||||
|
# <root>/data/ DATA_PATH
|
||||||
|
# <root>/dockers/ services the app store provisioned
|
||||||
|
# <root>/capabilities/ the file-based item store
|
||||||
|
#
|
||||||
|
# OFFICER_ROOT in the environment answers it ahead of time.
|
||||||
|
ask_officer_root() {
|
||||||
|
local default="${USER_HOME}/officerdev" answer
|
||||||
|
|
||||||
|
if [[ -n "${OFFICER_ROOT:-}" ]]; then
|
||||||
|
answer="$OFFICER_ROOT"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
info "Where should Officer be installed?"
|
||||||
|
echo " One directory holding the app, its data, the item store and any"
|
||||||
|
echo " containers the app store provisions — so it can be moved, backed"
|
||||||
|
echo " up or deleted as a unit."
|
||||||
|
echo ""
|
||||||
|
if ! read -rp " Path [${default}]: " answer; then
|
||||||
|
echo ""
|
||||||
|
fail "No answer. Set OFFICER_ROOT=<path> to answer this ahead of time."
|
||||||
|
fi
|
||||||
|
answer="${answer:-$default}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A leading ~ arrives as a literal when it comes from a read or an environment
|
||||||
|
# variable — nothing expands it there — and would create a directory named "~".
|
||||||
|
answer="${answer/#\~/$USER_HOME}"
|
||||||
|
|
||||||
|
[[ "$answer" == /* ]] || fail "That needs to be an absolute path, starting with / — got '${answer}'"
|
||||||
|
|
||||||
|
OFFICER_ROOT="${answer%/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The account's PRIMARY GROUP, asked of the system rather than assumed to be
|
||||||
|
# named after the user.
|
||||||
|
#
|
||||||
|
# Debian and Ubuntu create a group per user, so "pastilhas:pastilhas" is right on
|
||||||
|
# most machines — but not on one where the account came from LDAP, or was made
|
||||||
|
# with `useradd -g users`, or is a cloud image with a shared group. There
|
||||||
|
# `chown user:user` fails with "invalid group" and `install -g user` refuses,
|
||||||
|
# both of which abort the step.
|
||||||
|
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||||
|
|
||||||
|
# Run a block as the created user (login shell, inherits HOME)
|
||||||
|
as_user() {
|
||||||
|
sudo -u "$USERNAME" -i bash -c "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# sudoers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Grant an account passwordless sudo, safely.
|
||||||
|
#
|
||||||
|
# A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo
|
||||||
|
# to repair it, so on a remote machine that is unrecoverable short of a rescue
|
||||||
|
# console. The same is true of one with loose permissions: sudo refuses to read
|
||||||
|
# its own configuration and every sudo on the box fails.
|
||||||
|
#
|
||||||
|
# The original wrote the file into /etc/sudoers.d first and validated it after,
|
||||||
|
# with a chmod later still. Both of those leave a window where a broken or
|
||||||
|
# world-readable sudoers file is live. This validates a temp file first and then
|
||||||
|
# places it with its mode in a single install(1) — so what lands in /etc is
|
||||||
|
# already known good and already 0440.
|
||||||
|
grant_passwordless_sudo() {
|
||||||
|
# Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands
|
||||||
|
# $a before it has been assigned, so b comes out with the name missing — which
|
||||||
|
# here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd,
|
||||||
|
# each one silently overwriting the last, and has_passwordless_sudo never
|
||||||
|
# finding the file it was looking for.
|
||||||
|
local user="$1"
|
||||||
|
local dest="/etc/sudoers.d/99-${user}-nopasswd"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
|
||||||
|
[[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username"
|
||||||
|
|
||||||
|
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
|
||||||
|
|
||||||
|
if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
fail "visudo rejected the sudoers entry for '${user}' — not installing it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0440 -o root -g root "$tmp" "$dest"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
has_passwordless_sudo() {
|
||||||
|
local user="$1"
|
||||||
|
[[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] ||
|
||||||
|
grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Operating system detection
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Read one key out of /etc/os-release without leaking the rest of it into this
|
||||||
|
# script. That file defines NAME, VERSION and ID — all generic enough to collide
|
||||||
|
# with something here — so it is sourced in a subshell and only the one value
|
||||||
|
# asked for comes back.
|
||||||
|
os_release() {
|
||||||
|
[[ -r /etc/os-release ]] || return 1
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
(
|
||||||
|
. /etc/os-release 2>/dev/null
|
||||||
|
printf '%s' "${!1:-}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Identify the machine, or refuse to guess.
|
||||||
|
#
|
||||||
|
# /etc/os-release rather than probing for a binary: a box can have more than one
|
||||||
|
# package manager on PATH (a Homebrew install on Linux, a leftover apt on a
|
||||||
|
# converted box), and only os-release can say which distribution the machine
|
||||||
|
# actually IS, or give a version worth reporting.
|
||||||
|
#
|
||||||
|
# ID_LIKE is the fallback so derivatives resolve without being listed by name —
|
||||||
|
# Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below.
|
||||||
|
detect_os() {
|
||||||
|
local kernel like
|
||||||
|
kernel="$(uname -s)"
|
||||||
|
|
||||||
|
case "$kernel" in
|
||||||
|
Darwin)
|
||||||
|
OS="macos"
|
||||||
|
OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)"
|
||||||
|
OS_NAME="macOS ${OS_VERSION}"
|
||||||
|
PM="brew"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
OS="$(os_release ID || true)"
|
||||||
|
OS_NAME="$(os_release PRETTY_NAME || true)"
|
||||||
|
OS_VERSION="$(os_release VERSION_ID || true)"
|
||||||
|
like="$(os_release ID_LIKE || true)"
|
||||||
|
|
||||||
|
case "$OS" in
|
||||||
|
ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;;
|
||||||
|
arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;;
|
||||||
|
fedora | rhel | centos | rocky | almalinux) PM="dnf" ;;
|
||||||
|
*)
|
||||||
|
case " $like " in
|
||||||
|
*" debian "* | *" ubuntu "*) PM="apt" ;;
|
||||||
|
*" arch "*) PM="pacman" ;;
|
||||||
|
*" fedora "* | *" rhel "*) PM="dnf" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# WSL reports itself as Linux, but has no real systemd session: masking
|
||||||
|
# sleep targets, restarting logind and anything touching the boot path
|
||||||
|
# either fail or silently do nothing. Worth knowing before those steps run.
|
||||||
|
if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi
|
||||||
|
;;
|
||||||
|
MINGW* | MSYS* | CYGWIN*)
|
||||||
|
fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Unrecognised kernel '$kernel' — cannot tell what this machine is."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Normalised once here because upstream projects spell it differently:
|
||||||
|
# Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64.
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64 | amd64) ARCH="amd64" ;;
|
||||||
|
aarch64 | arm64) ARCH="arm64" ;;
|
||||||
|
*) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)."
|
||||||
|
[[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Machine role
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# The interface packets actually leave by, which is not always the first one up.
|
||||||
|
default_iface() {
|
||||||
|
ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ask what this machine is, unless the environment already said.
|
||||||
|
#
|
||||||
|
# Asked in pre-flight rather than at the point of use so that the run knows its
|
||||||
|
# own shape before it starts: the steps that care are spread from swap through to
|
||||||
|
# the firewall, and being asked "is this a VPS?" for the fourth time halfway down
|
||||||
|
# a provisioning run is how people start answering without reading.
|
||||||
|
#
|
||||||
|
# NO DEFAULT, deliberately, and it is the only question in the script like that.
|
||||||
|
# A guessed default is right often enough to be trusted and wrong in exactly the
|
||||||
|
# case that costs the most: pinning a static IP on a rented box, or leaving the
|
||||||
|
# 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 ;;
|
||||||
|
*) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "What is this machine? Several later steps depend on the answer."
|
||||||
|
echo " [1] homelab — yours, on a network you control"
|
||||||
|
echo " [2] vps — rented, public IP, provider's DHCP and console"
|
||||||
|
echo " [3] dev — a laptop or desktop you sit at"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local choice
|
||||||
|
while [[ -z "$MACHINE_ROLE" ]]; do
|
||||||
|
# A failed read means EOF, not a wrong answer — without this the loop would
|
||||||
|
# spin forever when stdin is closed, which is how an unattended run hangs.
|
||||||
|
if ! read -rp " Which one? (1/2/3): " choice; then
|
||||||
|
fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time."
|
||||||
|
fi
|
||||||
|
case "$choice" in
|
||||||
|
1 | homelab) MACHINE_ROLE=homelab ;;
|
||||||
|
2 | vps) MACHINE_ROLE=vps ;;
|
||||||
|
3 | dev) MACHINE_ROLE=dev ;;
|
||||||
|
"") warn "There is no default here — pick 1, 2 or 3." ;;
|
||||||
|
*) warn "Not one of the options: '$choice'" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convenience for the steps that branch on it.
|
||||||
|
is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; }
|
||||||
|
is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — the development environment
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_DEV_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_DEV_LOADED=1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# git
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Read and written as the account, not as root. `git config --global` writes to
|
||||||
|
# $HOME/.gitconfig, so running it under sudo without -H would write root's.
|
||||||
|
#
|
||||||
|
# ── Why this asks before touching an existing identity ──
|
||||||
|
#
|
||||||
|
# The original set all four values unconditionally on every run. Re-running it on
|
||||||
|
# a machine somebody already uses replaces the name and email they had with
|
||||||
|
# whatever is typed — and prompt_value accepts an empty answer, so pressing
|
||||||
|
# Enter twice wrote `user.name = ""`. An empty name is worse than none at all:
|
||||||
|
# unset makes git refuse to commit and say why, empty makes it commit with a
|
||||||
|
# blank author and never mention it.
|
||||||
|
#
|
||||||
|
# ── And why it is worth being careful about here in particular ──
|
||||||
|
#
|
||||||
|
# docs/agent-git-identity.md: every agent Officer runs commits AS THE OWNER,
|
||||||
|
# because it runs as the owner. So this is not only the human's identity — it is
|
||||||
|
# what `git log` will attribute every agent commit on this machine to.
|
||||||
|
|
||||||
|
# Run from / rather than wherever the script was launched.
|
||||||
|
#
|
||||||
|
# `git config --global` reads and writes $HOME/.gitconfig and needs no repository
|
||||||
|
# — but git still stats the working directory on the way, looking for one. The
|
||||||
|
# script is typically launched from somewhere under the invoking user's home,
|
||||||
|
# which is 0750, so the target account cannot stat it and every call dies with
|
||||||
|
#
|
||||||
|
# fatal: failed to stat '<cwd>': Permission denied
|
||||||
|
#
|
||||||
|
# Found because the writes failed silently: the section reported "written" while
|
||||||
|
# nothing had been. Both wrappers now run in a subshell from /, which every
|
||||||
|
# account can stat, and their exit status is checked by the caller.
|
||||||
|
# `git config --get` exits NON-ZERO when the key is simply unset, and
|
||||||
|
# `VAR="$(git_get …)"` propagates that under `set -e`. So on a machine where git
|
||||||
|
# has never been configured — the fresh machine this script exists for — reading
|
||||||
|
# the current value aborted the run before the section had printed anything.
|
||||||
|
# Missing a value is an answer here, not a failure.
|
||||||
|
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
|
||||||
|
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
|
||||||
|
|
||||||
|
# Is there anything configured at all?
|
||||||
|
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
|
||||||
|
|
||||||
|
# Ask for a value that must not be empty. The original's prompt accepted empty
|
||||||
|
# and wrote it; this re-asks.
|
||||||
|
ask_required() {
|
||||||
|
local __var="$1" message="$2" default="$3" answer=""
|
||||||
|
while [[ -z "$answer" ]]; do
|
||||||
|
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||||
|
echo ""
|
||||||
|
fail "No answer."
|
||||||
|
fi
|
||||||
|
answer="${answer:-$default}"
|
||||||
|
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
||||||
|
done
|
||||||
|
printf -v "$__var" '%s' "$answer"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Shell
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ── One starship config, not two ──
|
||||||
|
#
|
||||||
|
# The platform deploys scripts/setup/starship.toml into every member's home
|
||||||
|
# (os-user-shell.ts), and the comment there calls it "the prompt config the
|
||||||
|
# owner's own install uses — one file, both audiences". That was not true: the
|
||||||
|
# original machine script wrote a DIFFERENT config inline, so the owner got one
|
||||||
|
# prompt and every member got another. This deploys the same file the platform
|
||||||
|
# does, which makes the comment true rather than aspirational.
|
||||||
|
#
|
||||||
|
# It lives one directory up because it is shared with the platform, not owned by
|
||||||
|
# this script.
|
||||||
|
STARSHIP_SRC="${STARSHIP_SRC:-$SCRIPT_DIR/../starship.toml}"
|
||||||
|
|
||||||
|
user_login_shell() { getent passwd "$USERNAME" | cut -d: -f7; }
|
||||||
|
|
||||||
|
oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
|
||||||
|
|
||||||
|
install_oh_my_zsh() {
|
||||||
|
# The installer refuses to run unattended over an existing install, so this is
|
||||||
|
# only ever called when there is none.
|
||||||
|
#
|
||||||
|
# ── `|| true` is what makes this non-fatal, NOT the `return 0` below ──
|
||||||
|
#
|
||||||
|
# It used to be `return 0` alone, with a comment claiming the function returned
|
||||||
|
# zero whatever happened. It did not. Under `set -e` a failing command inside a
|
||||||
|
# function aborts the SHELL at that line when the function is called plainly —
|
||||||
|
# `return 0` is never reached. So a machine where this curl or the installer
|
||||||
|
# failed died here, silently, because the output is redirected: the run just
|
||||||
|
# stopped after apt finished installing zsh, with nothing said. Observed on a
|
||||||
|
# fresh Hetzner VPS, 2026-08-14.
|
||||||
|
sudo -H -u "$USERNAME" sh -c \
|
||||||
|
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
|
||||||
|
true
|
||||||
|
# Belt and braces: `|| true` above already makes the last command succeed, and
|
||||||
|
# this states the contract for anyone adding a line beneath it.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# `chsh` is what actually changes the login shell. Asked separately from
|
||||||
|
# installing zsh, because having a shell available and being handed it at every
|
||||||
|
# login are different decisions.
|
||||||
|
# Reports whether chsh worked, rather than swallowing it. The same `set -e` trap as
|
||||||
|
# install_oh_my_zsh applies — a bare `chsh` that fails kills the run at this line —
|
||||||
|
# but here the answer matters: the caller announces the new login shell, and `|| true`
|
||||||
|
# would have it announce one that was never set. So the status comes back and the
|
||||||
|
# CALLER guards the call, which is also what keeps set -e out of it.
|
||||||
|
set_login_shell() {
|
||||||
|
local shell="$1"
|
||||||
|
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
||||||
|
chsh -s "$shell" "$USERNAME" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Neovim
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# From the upstream tarball rather than the distribution, which ships Neovim
|
||||||
|
# years behind — Ubuntu 24.04 has 0.9 where upstream is on 0.12, and LazyVim
|
||||||
|
# requires 0.9+ with most plugins wanting newer.
|
||||||
|
#
|
||||||
|
# The asset names are x86_64 and arm64. The original mapped aarch64 to
|
||||||
|
# "aarch64", which is not a name Neovim publishes: on an arm machine it
|
||||||
|
# downloaded a 404 and tar failed on the HTML error page.
|
||||||
|
nvim_asset() {
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) echo x86_64 ;;
|
||||||
|
arm64) echo arm64 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
nvim_installed_version() { nvim --version 2>/dev/null | awk 'NR == 1 { print $2 }'; }
|
||||||
|
|
||||||
|
nvim_latest_version() {
|
||||||
|
curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest 2>/dev/null |
|
||||||
|
jq -r '.tag_name // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Downloaded to /tmp, not to whatever directory the script was launched from —
|
||||||
|
# the original used `curl -LO`, which drops the tarball beside the script and
|
||||||
|
# leaves it there if tar fails.
|
||||||
|
#
|
||||||
|
# The old install is removed only after the download has succeeded, so a failed
|
||||||
|
# fetch leaves the working copy alone.
|
||||||
|
nvim_install() {
|
||||||
|
local asset tarball dest
|
||||||
|
asset="$(nvim_asset)"
|
||||||
|
tarball="/tmp/nvim-linux-${asset}.tar.gz"
|
||||||
|
dest="/opt/nvim-linux-${asset}"
|
||||||
|
|
||||||
|
curl -fsSL -o "$tarball" \
|
||||||
|
"https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${asset}.tar.gz" || return 1
|
||||||
|
|
||||||
|
# A 404 comes back as an HTML page, and tar's failure on it is unhelpful.
|
||||||
|
# Checking here names the real problem.
|
||||||
|
tar -tzf "$tarball" >/dev/null 2>&1 || {
|
||||||
|
rm -f "$tarball"
|
||||||
|
warn "the download is not a tarball — the release asset may have been renamed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf "$dest"
|
||||||
|
tar -C /opt -xzf "$tarball"
|
||||||
|
rm -f "$tarball"
|
||||||
|
ln -sf "${dest}/bin/nvim" /usr/local/bin/nvim
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clone a Neovim config into the account's ~/.config/nvim.
|
||||||
|
#
|
||||||
|
# From `cd /` for the same reason git config does: the script's working directory
|
||||||
|
# is usually under the invoking user's home at 0750, which the target account
|
||||||
|
# cannot stat, and git fails there before it does anything useful.
|
||||||
|
nvim_clone_config() {
|
||||||
|
local repo="$1" dest="${USER_HOME}/.config/nvim"
|
||||||
|
|
||||||
|
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
|
||||||
|
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
|
||||||
|
|
||||||
|
# The starter is a template, not something to track. Left in place for a
|
||||||
|
# config of the user's own, which they will want to keep pulling.
|
||||||
|
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# JavaScript runtimes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Three of these are not optional, and it is worth being precise about why,
|
||||||
|
# because "we run on Bun" suggests Node could go and it cannot:
|
||||||
|
#
|
||||||
|
# node pm2 is a Node application (#!/usr/bin/env node), and pm2 supervises
|
||||||
|
# every process here. officer-pty imports node-pty, a native addon with
|
||||||
|
# no Linux prebuild — it compiles against the installed Node on every
|
||||||
|
# machine. Either one alone makes Node load-bearing.
|
||||||
|
# bun the platform itself and nineteen of the twenty pm2 apps.
|
||||||
|
# pm2 the process manager the ecosystem files are written for.
|
||||||
|
#
|
||||||
|
# Deno is not. Nothing in the platform imports it — checked across the whole
|
||||||
|
# tree — and it is offered only because it was in the original script and
|
||||||
|
# somebody may still want it.
|
||||||
|
|
||||||
|
# The current LTS major, asked of nodejs.org rather than hardcoded. The original
|
||||||
|
# pinned setup_22.x, which ages into "the version we happened to pick" the moment
|
||||||
|
# a new LTS lands.
|
||||||
|
node_lts_major() {
|
||||||
|
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
|
||||||
|
jq -r '[.[] | select(.lts != false)][0].version // empty' | sed 's/^v//; s/\..*//'
|
||||||
|
}
|
||||||
|
|
||||||
|
node_lts_label() {
|
||||||
|
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
|
||||||
|
jq -r '[.[] | select(.lts != false)][0] | "\(.version) (\(.lts))" // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
local major="$1"
|
||||||
|
# NodeSource publishes one setup script per major. Checked before it is piped
|
||||||
|
# into a shell, because a 404 page piped to bash is a confusing way to fail.
|
||||||
|
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
|
||||||
|
warn "NodeSource has no setup script for Node ${major}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${major}.x" | bash - >/dev/null 2>&1
|
||||||
|
pkg_install_now nodejs
|
||||||
|
# Global installs land in /usr/local rather than in a path only root can write,
|
||||||
|
# so `npm i -g` works the same for the owner and for root.
|
||||||
|
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Present anywhere: on PATH for this root shell, or in the account's own
|
||||||
|
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
||||||
|
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
||||||
|
|
||||||
|
# Asked of whichever copy exists. Before the symlink is made, root's PATH has no
|
||||||
|
# bun at all, so `bun --version` reports nothing on a machine that plainly has it.
|
||||||
|
bun_version() {
|
||||||
|
if command -v bun &>/dev/null; then
|
||||||
|
bun --version
|
||||||
|
elif [[ -x "${USER_HOME}/.bun/bin/bun" ]]; then
|
||||||
|
"${USER_HOME}/.bun/bin/bun" --version
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The system-wide link, ensured on every run rather than only after an install.
|
||||||
|
#
|
||||||
|
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
||||||
|
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
|
||||||
|
# then fails to start on reboot while working perfectly when started by hand. A
|
||||||
|
# machine that already had bun before this script ran would never get the link if
|
||||||
|
# it were only made as part of installing.
|
||||||
|
#
|
||||||
|
# Safe across upgrades: a symlink resolves by path, and `bun upgrade` replaces
|
||||||
|
# the file at $BUN_INSTALL/bin/bun rather than moving it. The link only breaks if
|
||||||
|
# the home directory goes, which breaks bun anyway.
|
||||||
|
ensure_bun_symlink() {
|
||||||
|
local bin="${USER_HOME}/.bun/bin/bun"
|
||||||
|
[[ -x "$bin" ]] || return 1
|
||||||
|
[[ "$(readlink -f /usr/local/bin/bun 2>/dev/null)" == "$(readlink -f "$bin")" ]] && return 1
|
||||||
|
ln -sf "$bin" /usr/local/bin/bun
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installed as the account, then symlinked system-wide. pm2 started at boot by
|
||||||
|
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
||||||
|
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
||||||
|
# started by hand, which is a miserable thing to debug.
|
||||||
|
install_bun() {
|
||||||
|
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
|
||||||
|
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
pm2_installed() { command -v pm2 &>/dev/null; }
|
||||||
|
install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
|
||||||
|
install_deno() {
|
||||||
|
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
|
||||||
|
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Agent CLIs
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Claude Code goes in through Anthropic's own installer rather than npm, matching
|
||||||
|
# what the platform does for members (os-user-claude.ts) and chosen there for the
|
||||||
|
# auto-update the npm package does not do.
|
||||||
|
#
|
||||||
|
# Two things that installer insists on, both of which a naive port gets wrong:
|
||||||
|
#
|
||||||
|
# It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
|
||||||
|
# with SUDO_USER set, because everything it installs goes under $HOME and under
|
||||||
|
# sudo that is root's home. So it must run AS the account, not as root.
|
||||||
|
#
|
||||||
|
# It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash.
|
||||||
|
# `| sh` fails on a dash-based /bin/sh, which is Ubuntu's.
|
||||||
|
#
|
||||||
|
# Both are recorded in os-user-claude.ts too, which found them first.
|
||||||
|
|
||||||
|
CLAUDE_INSTALL_URL="https://claude.ai/install.sh"
|
||||||
|
OPENCODE_INSTALL_URL="https://opencode.ai/install"
|
||||||
|
|
||||||
|
# Where each installer actually puts its binary. They disagree, and the platform
|
||||||
|
# depends on the difference:
|
||||||
|
#
|
||||||
|
# claude ~/.local/bin/claude — claude-manager.ts tries Bun.which then
|
||||||
|
# that exact path
|
||||||
|
# opencode ~/.opencode/bin/opencode — sidecar/opencode/index.ts:22 hardcodes
|
||||||
|
# join(homedir(), '.opencode', 'bin', …)
|
||||||
|
#
|
||||||
|
# Looking for opencode in ~/.local/bin, as an earlier version of this did,
|
||||||
|
# reports a perfectly good install as missing and then installs it again.
|
||||||
|
agent_bin() {
|
||||||
|
case "$1" in
|
||||||
|
claude) echo "${USER_HOME}/.local/bin/claude" ;;
|
||||||
|
opencode) echo "${USER_HOME}/.opencode/bin/opencode" ;;
|
||||||
|
*) echo "${USER_HOME}/.local/bin/$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The directories those live in, for the account's PATH.
|
||||||
|
agent_bin_dirs() { echo "${USER_HOME}/.local/bin" "${USER_HOME}/.opencode/bin"; }
|
||||||
|
|
||||||
|
agent_installed() { [[ -x "$(agent_bin "$1")" ]] || command -v "$1" &>/dev/null; }
|
||||||
|
|
||||||
|
# Which copy answers, so the run can say where it came from. Claude installed
|
||||||
|
# from npm sits in /usr/local/lib/node_modules and does NOT auto-update, which is
|
||||||
|
# the whole reason the platform prefers Anthropic's installer.
|
||||||
|
agent_path() {
|
||||||
|
local name="$1" bin
|
||||||
|
bin="$(agent_bin "$name")"
|
||||||
|
[[ -x "$bin" ]] && {
|
||||||
|
echo "$bin"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command -v "$name" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" == */node_modules/* ]]; }
|
||||||
|
|
||||||
|
agent_version() {
|
||||||
|
local bin
|
||||||
|
bin="$(agent_path "$1")"
|
||||||
|
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_claude_code() {
|
||||||
|
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||||
|
[[ -x "$(agent_bin claude)" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
install_opencode() {
|
||||||
|
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||||
|
[[ -x "$(agent_bin opencode)" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
install_pi() { npm install -g @mariozechner/pi-coding-agent >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Default editor
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# One preference, two mechanisms, and both are needed:
|
||||||
|
#
|
||||||
|
# EDITOR / VISUAL what the account's own shell hands to git, crontab -e,
|
||||||
|
# systemctl edit and anything else that opens an editor
|
||||||
|
# update-alternatives the system-wide `editor` command, which is what root and
|
||||||
|
# `sudoedit` use — an account's shell config cannot reach
|
||||||
|
# those
|
||||||
|
#
|
||||||
|
# This is the setting core.editor was deliberately left out in favour of: set it
|
||||||
|
# here and git follows, along with everything else.
|
||||||
|
|
||||||
|
editor_candidates() {
|
||||||
|
local e
|
||||||
|
for e in nvim vim nano; do command -v "$e" &>/dev/null && echo "$e"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
|
||||||
|
# assignment from a function that exits non-zero aborts the run under `set -e`.
|
||||||
|
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
|
||||||
|
|
||||||
|
set_system_editor() {
|
||||||
|
local editor="$1" path
|
||||||
|
path="$(command -v "$editor")" || return 1
|
||||||
|
# Only where the alternatives system is in use. Absent on non-Debian systems,
|
||||||
|
# where there is nothing to set.
|
||||||
|
command -v update-alternatives &>/dev/null || return 0
|
||||||
|
update-alternatives --install /usr/bin/editor editor "$path" 100 >/dev/null 2>&1
|
||||||
|
update-alternatives --set editor "$path" >/dev/null 2>&1
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — using the whole disk
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
#
|
||||||
|
# ── The problem this exists for ──
|
||||||
|
#
|
||||||
|
# Ubuntu Server's installer, left on its defaults, creates an LVM logical volume
|
||||||
|
# at a fixed size and leaves the rest of the disk as free extents in the volume
|
||||||
|
# group. On a 2TB drive you get a root filesystem of around 100GB and no
|
||||||
|
# indication anything is wrong: `lsblk` shows the whole disk, `df` shows 100G,
|
||||||
|
# and the two are never seen side by side until the day it fills.
|
||||||
|
#
|
||||||
|
# The same shape turns up two other ways:
|
||||||
|
#
|
||||||
|
# a virtual disk grown at the hypervisor or provider, where the partition still
|
||||||
|
# ends where it used to
|
||||||
|
#
|
||||||
|
# a partition that was resized without the filesystem inside it being told
|
||||||
|
#
|
||||||
|
# Three layers, and any one of them can be the short one:
|
||||||
|
#
|
||||||
|
# disk the physical or virtual device
|
||||||
|
# container the partition, or the logical volume
|
||||||
|
# filesystem what df reports
|
||||||
|
#
|
||||||
|
# So all three are measured and reported together. Seeing them in one place is
|
||||||
|
# most of the value; the fix is usually two commands once you know which layer is
|
||||||
|
# short.
|
||||||
|
#
|
||||||
|
# ── Only ever grows ──
|
||||||
|
#
|
||||||
|
# Nothing here shrinks anything, and nothing here creates or deletes a partition.
|
||||||
|
# ext4, xfs and btrfs all grow while mounted, so there is no unmount and no
|
||||||
|
# reboot, and a failure part-way leaves a smaller filesystem on a larger
|
||||||
|
# container — which is exactly the state it started in.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_DISK_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_DISK_LOADED=1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# What is where
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
root_device() { findmnt -no SOURCE / 2>/dev/null; }
|
||||||
|
root_fstype() { findmnt -no FSTYPE / 2>/dev/null; }
|
||||||
|
|
||||||
|
# Size of a block device in bytes.
|
||||||
|
dev_bytes() { lsblk -bndo SIZE "$1" 2>/dev/null || echo 0; }
|
||||||
|
|
||||||
|
# Bytes, formatted the way df and lsblk format them.
|
||||||
|
human_bytes() { numfmt --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || echo "${1}B"; }
|
||||||
|
|
||||||
|
# Is the root filesystem on a logical volume?
|
||||||
|
root_is_lvm() { [[ "$(lsblk -ndo TYPE "$(root_device)" 2>/dev/null)" == "lvm" ]]; }
|
||||||
|
|
||||||
|
# The whole disk a device ultimately sits on: /dev/sda1 -> /dev/sda, and through
|
||||||
|
# LVM as well, since PKNAME walks one level at a time.
|
||||||
|
parent_disk() {
|
||||||
|
local dev="$1" name
|
||||||
|
while true; do
|
||||||
|
name="$(lsblk -ndo PKNAME "$dev" 2>/dev/null)"
|
||||||
|
[[ -z "$name" ]] && break
|
||||||
|
dev="/dev/${name}"
|
||||||
|
done
|
||||||
|
echo "$dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The partition immediately below a device — for LVM, the one holding the PV.
|
||||||
|
backing_partition() {
|
||||||
|
local dev="$1" name
|
||||||
|
while [[ "$(lsblk -ndo TYPE "$dev" 2>/dev/null)" != "part" ]]; do
|
||||||
|
name="$(lsblk -ndo PKNAME "$dev" 2>/dev/null)"
|
||||||
|
[[ -z "$name" ]] && return 1
|
||||||
|
dev="/dev/${name}"
|
||||||
|
done
|
||||||
|
echo "$dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Split /dev/sda1 into "/dev/sda 1" — growpart wants them as separate arguments.
|
||||||
|
# The digits come off the end because that is where a partition number is, on
|
||||||
|
# /dev/sda1 and /dev/nvme0n1p2 alike.
|
||||||
|
partition_parts() {
|
||||||
|
local part="$1" num disk
|
||||||
|
num="${part##*[!0-9]}"
|
||||||
|
disk="${part%"$num"}"
|
||||||
|
disk="${disk%p}" # nvme0n1p2 -> nvme0n1
|
||||||
|
echo "$disk $num"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Sizes of the three layers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# What the filesystem itself believes it is, which is the number df reports and
|
||||||
|
# the only one of the three that is asked of the filesystem rather than the
|
||||||
|
# kernel's block layer.
|
||||||
|
fs_bytes() {
|
||||||
|
local dev="$1"
|
||||||
|
case "$(root_fstype)" in
|
||||||
|
ext2 | ext3 | ext4)
|
||||||
|
local count size
|
||||||
|
count="$(tune2fs -l "$dev" 2>/dev/null | awk -F: '/^Block count:/ { gsub(/ /, "", $2); print $2 }')"
|
||||||
|
size="$(tune2fs -l "$dev" 2>/dev/null | awk -F: '/^Block size:/ { gsub(/ /, "", $2); print $2 }')"
|
||||||
|
[[ -n "$count" && -n "$size" ]] && echo $((count * size)) || echo 0
|
||||||
|
;;
|
||||||
|
xfs | btrfs)
|
||||||
|
# Both report through the mount rather than the device.
|
||||||
|
echo $(($(findmnt -bno SIZE / 2>/dev/null || echo 0)))
|
||||||
|
;;
|
||||||
|
*) echo 0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unallocated extents in the volume group behind root. This is the Ubuntu
|
||||||
|
# installer case, and the one that is invisible without asking LVM directly.
|
||||||
|
vg_free_bytes() {
|
||||||
|
local vg
|
||||||
|
command -v vgs &>/dev/null || {
|
||||||
|
echo 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vg="$(lvs --noheadings -o vg_name "$(root_device)" 2>/dev/null | tr -d ' ')"
|
||||||
|
[[ -z "$vg" ]] && {
|
||||||
|
echo 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vgs --noheadings --nosuffix --units b -o vg_free "$vg" 2>/dev/null | tr -d ' ' || echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Can anything be reclaimed?
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# growpart answers this better than arithmetic on sector counts: it exits 0 when
|
||||||
|
# it would change something and 1 with NOCHANGE when the partition already
|
||||||
|
# reaches the end of the disk. Needs cloud-guest-utils, which is not installed by
|
||||||
|
# default on every image.
|
||||||
|
partition_can_grow() {
|
||||||
|
local part="$1" disk num
|
||||||
|
command -v growpart &>/dev/null || return 1
|
||||||
|
read -r disk num <<<"$(partition_parts "$part")"
|
||||||
|
growpart --dry-run "$disk" "$num" &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_growpart() {
|
||||||
|
command -v growpart &>/dev/null && return 0
|
||||||
|
info " installing cloud-guest-utils, which provides growpart"
|
||||||
|
pkg_install_now cloud-guest-utils
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Growing
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
grow_partition() {
|
||||||
|
local part="$1" disk num
|
||||||
|
read -r disk num <<<"$(partition_parts "$part")"
|
||||||
|
growpart "$disk" "$num"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tell LVM the partition under the physical volume got bigger.
|
||||||
|
grow_pv() { pvresize "$1"; }
|
||||||
|
|
||||||
|
# Take every free extent in the volume group.
|
||||||
|
grow_lv() { lvextend -l +100%FREE "$(root_device)"; }
|
||||||
|
|
||||||
|
# Grow the filesystem into whatever room it now has. All three do this online, so
|
||||||
|
# the root filesystem is grown while it is mounted and in use.
|
||||||
|
grow_fs() {
|
||||||
|
case "$(root_fstype)" in
|
||||||
|
ext2 | ext3 | ext4) resize2fs "$(root_device)" ;;
|
||||||
|
xfs) xfs_growfs / ;;
|
||||||
|
btrfs) btrfs filesystem resize max / ;;
|
||||||
|
*)
|
||||||
|
warn "do not know how to grow a $(root_fstype) filesystem"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — Docker
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_DOCKER_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_DOCKER_LOADED=1
|
||||||
|
|
||||||
|
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
|
||||||
|
|
||||||
|
docker_is_installed() { command -v docker &>/dev/null; }
|
||||||
|
|
||||||
|
# The daemon, not just the binary. `docker --version` answers from the client
|
||||||
|
# alone and says nothing about whether there is anything to talk to.
|
||||||
|
docker_daemon_ok() { docker info &>/dev/null; }
|
||||||
|
|
||||||
|
user_in_docker_group() { id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx docker; }
|
||||||
|
|
||||||
|
docker_rootless_installed() { [[ -S "/run/user/$(id -u "$USERNAME" 2>/dev/null)/docker.sock" ]]; }
|
||||||
|
|
||||||
|
# The codename Docker's repository is actually published under.
|
||||||
|
#
|
||||||
|
# `lsb_release -cs` is what the original used, and it is wrong on every
|
||||||
|
# derivative: Mint reports "vanessa", Pop reports its own, and Docker publishes
|
||||||
|
# neither — so `apt update` fails on a repository that does not exist. os-release
|
||||||
|
# carries UBUNTU_CODENAME on exactly those systems for exactly this reason, so it
|
||||||
|
# is preferred and VERSION_CODENAME is the fallback.
|
||||||
|
docker_repo_codename() {
|
||||||
|
local c
|
||||||
|
c="$(os_release UBUNTU_CODENAME || true)"
|
||||||
|
[[ -z "$c" ]] && c="$(os_release VERSION_CODENAME || true)"
|
||||||
|
echo "$c"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Which upstream to point at. A derivative is Ubuntu or Debian as far as Docker
|
||||||
|
# is concerned, and ID_LIKE is how it says which.
|
||||||
|
docker_repo_distro() {
|
||||||
|
case "$OS" in
|
||||||
|
ubuntu | debian) echo "$OS" ;;
|
||||||
|
*)
|
||||||
|
case " $(os_release ID_LIKE || true) " in
|
||||||
|
*" ubuntu "*) echo ubuntu ;;
|
||||||
|
*) echo debian ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
|
||||||
|
[[ -n "$codename" ]] || {
|
||||||
|
warn "could not work out this release's codename — cannot add the Docker repository"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL "https://download.docker.com/linux/${distro}/gpg" |
|
||||||
|
gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${distro} ${codename} stable" \
|
||||||
|
>/etc/apt/sources.list.d/docker.list
|
||||||
|
|
||||||
|
pkg_refresh >/dev/null
|
||||||
|
|
||||||
|
# ── The rootless prerequisites go in HERE, not in the rootless branch ──
|
||||||
|
#
|
||||||
|
# They used to be installed only when the owner picked "[2] rootless Docker for
|
||||||
|
# me" in section 22. But the OWNER's choice is not the only one that matters:
|
||||||
|
# every Developer account the platform provisions gets its own rootless daemon,
|
||||||
|
# whatever the owner picked for themselves. So on a machine where the owner chose
|
||||||
|
# the docker group, the host never got these and every member's daemon failed
|
||||||
|
# with `rootless Docker needs these packages on the host: uidmap`.
|
||||||
|
#
|
||||||
|
# `src/servers/os-user-docker.ts` → checkDockerPrerequisites is the authority on
|
||||||
|
# this list, and it wants both:
|
||||||
|
#
|
||||||
|
# uidmap /usr/bin/newuidmap, /usr/bin/newgidmap
|
||||||
|
# docker-ce-rootless-extras /usr/bin/dockerd-rootless-setuptool.sh
|
||||||
|
#
|
||||||
|
# docker-ce only RECOMMENDS rootless-extras. That is installed by default, so it
|
||||||
|
# is usually there by luck — and is not on a host configured with
|
||||||
|
# --no-install-recommends. Named explicitly so it does not depend on that.
|
||||||
|
#
|
||||||
|
# dbus-user-session is what lets a member's systemd --user survive without a
|
||||||
|
# login session, which is how the daemon stays up.
|
||||||
|
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
||||||
|
docker-ce-rootless-extras uidmap dbus-user-session
|
||||||
|
}
|
||||||
|
|
||||||
|
# A shared network so containers from different compose files can reach each
|
||||||
|
# other by name. Harmless if it is already there.
|
||||||
|
ensure_docker_network() {
|
||||||
|
docker network inspect "$DOCKER_NETWORK" &>/dev/null && return 0
|
||||||
|
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Rootless, for the owner ──
|
||||||
|
#
|
||||||
|
# Works, and does not work with Officer's app store as it stands. Both are true
|
||||||
|
# and the second is the one nobody would find out until a container failed to
|
||||||
|
# provision, so it is stated at the prompt rather than left here.
|
||||||
|
#
|
||||||
|
# The app store spawns `docker` with no environment of its own —
|
||||||
|
# app-store/compose.ts, app-store/preflight.ts, api/system-monitor — so it talks
|
||||||
|
# to whatever socket the `officer` pm2 process's environment points at. That is
|
||||||
|
# /var/run/docker.sock unless DOCKER_HOST says otherwise, and nothing sets
|
||||||
|
# DOCKER_HOST for the owner: os-user-docker.ts sets it only for member commands.
|
||||||
|
#
|
||||||
|
# pm2 started at boot by systemd has no session either, so exporting it in a
|
||||||
|
# shell rc does not reach the process that matters.
|
||||||
|
install_docker_rootless() {
|
||||||
|
local uid
|
||||||
|
uid="$(id -u "$USERNAME")"
|
||||||
|
|
||||||
|
# Without lingering, the user manager stops when the last session ends and
|
||||||
|
# takes the daemon with it. Officer's shells are not login sessions.
|
||||||
|
loginctl enable-linger "$USERNAME" >/dev/null 2>&1
|
||||||
|
|
||||||
|
sudo -u "$USERNAME" \
|
||||||
|
XDG_RUNTIME_DIR="/run/user/${uid}" \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||||
|
PATH="/usr/bin:/usr/sbin:/bin:/sbin" \
|
||||||
|
dockerd-rootless-setuptool.sh install >/dev/null 2>&1 || return 1
|
||||||
|
|
||||||
|
sudo -u "$USERNAME" \
|
||||||
|
XDG_RUNTIME_DIR="/run/user/${uid}" \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||||
|
systemctl --user enable --now docker >/dev/null 2>&1
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — writing files into somebody's home
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
#
|
||||||
|
# ── The rule ──
|
||||||
|
#
|
||||||
|
# A setup script may create a config file. It may not silently replace one the
|
||||||
|
# user wrote. The original did the second: `cp .tmux.conf $USER_HOME/` on every
|
||||||
|
# run, over whatever was there, and five separate `cat >>` into .zshrc with no
|
||||||
|
# guard — so a second pass duplicated the starship init, the nvim PATH, bun, deno
|
||||||
|
# and the aliases.
|
||||||
|
#
|
||||||
|
# Both of those are the same mistake in different shapes: writing without looking
|
||||||
|
# first. The two helpers here are the two safe shapes.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_FILES_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_FILES_LOADED=1
|
||||||
|
|
||||||
|
# Put a config file in place, asking before it replaces one the user has.
|
||||||
|
#
|
||||||
|
# Three outcomes, and the caller can tell them apart by the return code:
|
||||||
|
#
|
||||||
|
# 0 installed — there was nothing there, or the user chose to replace
|
||||||
|
# 1 identical — already exactly this, nothing done
|
||||||
|
# 2 kept — the user chose to keep theirs
|
||||||
|
#
|
||||||
|
# When the file exists and differs, this ASKS rather than deciding. Silently
|
||||||
|
# keeping theirs is safe but unhelpful — they never learn that a newer version
|
||||||
|
# exists — and silently replacing it is how a setup script eats somebody's
|
||||||
|
#configuration. So: keep, replace, or show the difference first, and a replaced file is
|
||||||
|
# always kept beside the new one.
|
||||||
|
#
|
||||||
|
# NOTE for callers: 1 and 2 are outcomes, not failures — but they are still
|
||||||
|
# non-zero, so calling this as a plain command under `set -e` ends the script
|
||||||
|
# before the result can be read. Always capture it:
|
||||||
|
#
|
||||||
|
# install_config "$src" "$dest" "$user" && rc=0 || rc=$?
|
||||||
|
install_config() {
|
||||||
|
local src="$1" dest="$2" owner="$3" answer
|
||||||
|
|
||||||
|
if [[ ! -f "$dest" ]]; then
|
||||||
|
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||||
|
# Recorded here rather than at the call site: "which files did it write" is
|
||||||
|
# the question a reviewer asks first, and a per-section report would drift
|
||||||
|
# from what this function actually did.
|
||||||
|
declare -F report_changed >/dev/null && report_changed "wrote ${dest} (0644, owner ${owner}) — did not exist"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if cmp -s "$src" "$dest"; then
|
||||||
|
declare -F report_kept >/dev/null && report_kept "${dest} already identical to the shipped version — not touched"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
warn "${dest} already exists here, and differs from the one this script ships."
|
||||||
|
|
||||||
|
# Never replace a file the user has without being told to. An unattended run
|
||||||
|
# answers "keep", because the alternative is destroying configuration nobody
|
||||||
|
# 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
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
echo " [1] keep yours — nothing changes"
|
||||||
|
echo " [2] use ours — yours is kept as ${dest}.before-machine-setup"
|
||||||
|
echo " [3] show me the difference first"
|
||||||
|
echo ""
|
||||||
|
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)
|
||||||
|
echo ""
|
||||||
|
# yours on the left, ours on the right: - is what you would lose,
|
||||||
|
# + is what you would gain.
|
||||||
|
diff -u --label "yours: ${dest}" --label "ours: ${src}" "$dest" "$src" | page
|
||||||
|
echo ""
|
||||||
|
;;
|
||||||
|
*) warn "Pick 1, 2 or 3." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Append a block to a file exactly once.
|
||||||
|
#
|
||||||
|
# The block is wrapped in markers naming what it is, so a second run recognises
|
||||||
|
# its own work instead of adding it again — and so a human reading the file can
|
||||||
|
# see which lines came from here and delete them as a unit.
|
||||||
|
#
|
||||||
|
# append_once ~/.zshrc bun <<'EOF'
|
||||||
|
# export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
# EOF
|
||||||
|
#
|
||||||
|
# Returns 0 if it wrote, 1 if the block was already there.
|
||||||
|
#
|
||||||
|
# One limitation, and it bites the author rather than the user: RENAMING a marker
|
||||||
|
# orphans the block that used the old name. append_once only recognises the name
|
||||||
|
# it is given, so the previous block stays in the file doing whatever it did.
|
||||||
|
# Changing a block's CONTENT has the same shape — the marker is found, so the new
|
||||||
|
# content is never written. Both need the old block removed by hand.
|
||||||
|
append_once() {
|
||||||
|
local file="$1" name="$2"
|
||||||
|
local begin="# >>> machine-setup: ${name} >>>"
|
||||||
|
local end="# <<< machine-setup: ${name} <<<"
|
||||||
|
|
||||||
|
if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "$begin"
|
||||||
|
cat
|
||||||
|
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" ]]
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — network configuration
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_NETWORK_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_NETWORK_LOADED=1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DNS
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ── What is actually being changed here ──
|
||||||
|
#
|
||||||
|
# On a machine running systemd-resolved there are two layers, and only one of
|
||||||
|
# them is ours to set:
|
||||||
|
#
|
||||||
|
# per-link what DHCP handed each interface, and what Tailscale installs on
|
||||||
|
# its own. These answer for that link's domains — the provider's
|
||||||
|
# internal names, and the tailnet — and are NOT touched here.
|
||||||
|
# Overriding them is how private networking quietly stops resolving.
|
||||||
|
#
|
||||||
|
# global the resolver used when no link claims the query. This is what the
|
||||||
|
# step sets.
|
||||||
|
#
|
||||||
|
# So this changes where public lookups go, and leaves the machine's own networks
|
||||||
|
# resolving exactly as they did.
|
||||||
|
#
|
||||||
|
# ── Drop-in, and note the sort order ──
|
||||||
|
#
|
||||||
|
# systemd reads drop-ins in lexical order and the LAST value wins, so 99- is what
|
||||||
|
# overrides. That is the opposite of sshd, three files away in this same
|
||||||
|
# directory, where the FIRST value wins and the drop-in has to sort early. Worth
|
||||||
|
# stating because getting it backwards fails silently in both directions.
|
||||||
|
#
|
||||||
|
# The original rewrote /etc/systemd/resolved.conf wholesale, which discards
|
||||||
|
# anything else in it — DNSSEC, DNSOverTLS, Domains, Cache — without mentioning
|
||||||
|
# that it had.
|
||||||
|
|
||||||
|
RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/99-machine-setup.conf
|
||||||
|
|
||||||
|
resolved_is_active() { systemctl is-active --quiet systemd-resolved 2>/dev/null; }
|
||||||
|
|
||||||
|
# The global resolvers in force, space separated, or empty if none are set.
|
||||||
|
dns_current_global() {
|
||||||
|
if resolved_is_active; then
|
||||||
|
resolvectl status 2>/dev/null | awk '/^ *DNS Servers:/ { $1 = ""; $2 = ""; print; exit }' | xargs
|
||||||
|
else
|
||||||
|
awk '/^nameserver/ { printf "%s ", $2 }' /etc/resolv.conf 2>/dev/null | xargs
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# What each interface was handed. Printed, never changed — the point is to show
|
||||||
|
# that this step is not touching them.
|
||||||
|
dns_per_link() {
|
||||||
|
resolved_is_active || return 0
|
||||||
|
resolvectl status 2>/dev/null |
|
||||||
|
awk '/^Link [0-9]+ \(/ { link = $3; gsub(/[()]/, "", link) }
|
||||||
|
/^ *DNS Servers:/ && link { $1 = ""; $2 = ""; printf "%s:%s\n", link, $0; link = "" }'
|
||||||
|
}
|
||||||
|
|
||||||
|
dns_set_global() {
|
||||||
|
local primary="$1" fallback="$2"
|
||||||
|
|
||||||
|
if resolved_is_active; then
|
||||||
|
install -d -m 0755 "$(dirname "$RESOLVED_DROPIN")"
|
||||||
|
cat >"$RESOLVED_DROPIN" <<EOF
|
||||||
|
# Written by machine-setup. 99- so it sorts last: systemd drop-ins are
|
||||||
|
# last-value-wins. Only the GLOBAL resolvers are set here — per-link DNS from
|
||||||
|
# DHCP and from Tailscale is left alone, so internal names keep resolving.
|
||||||
|
[Resolve]
|
||||||
|
DNS=${primary}
|
||||||
|
FallbackDNS=${fallback}
|
||||||
|
EOF
|
||||||
|
chmod 644 "$RESOLVED_DROPIN"
|
||||||
|
|
||||||
|
# resolv.conf has to point at the stub for any of this to be consulted. A
|
||||||
|
# machine where something replaced the symlink with a static file bypasses
|
||||||
|
# resolved entirely, and the drop-in would have no effect at all.
|
||||||
|
local target
|
||||||
|
target="$(readlink -f /etc/resolv.conf 2>/dev/null || true)"
|
||||||
|
if [[ "$target" != /run/systemd/resolve/*resolv.conf ]]; then
|
||||||
|
cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true
|
||||||
|
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl restart systemd-resolved
|
||||||
|
else
|
||||||
|
# No resolved: write resolv.conf directly, and say plainly that anything
|
||||||
|
# managing the interface may put its own back.
|
||||||
|
cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true
|
||||||
|
if lsattr /etc/resolv.conf 2>/dev/null | cut -c1-20 | grep -q i; then
|
||||||
|
chattr -i /etc/resolv.conf
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "# Written by machine-setup."
|
||||||
|
local ns
|
||||||
|
for ns in $primary $fallback; do echo "nameserver ${ns}"; done
|
||||||
|
} >/etc/resolv.conf
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Does name resolution actually work now? Asked after the change rather than
|
||||||
|
# assumed, because a resolver that does not answer is the one failure that makes
|
||||||
|
# everything after it look broken for unrelated reasons.
|
||||||
|
dns_works() { getent hosts one.one.one.one >/dev/null 2>&1 || getent hosts example.com >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The address this machine gets
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ── Why a fresh Ubuntu box takes a new IP on every reboot ──
|
||||||
|
#
|
||||||
|
# Not a router fault, and not something a static IP is the right answer to.
|
||||||
|
# systemd-networkd's ClientIdentifier defaults to `duid` — an RFC 4361 client ID
|
||||||
|
# built from an IAID and a DUID — so the machine introduces itself to DHCP by
|
||||||
|
# that, and `networkctl status` shows it as "DHCP4 Client ID: IAID:0x…/DUID".
|
||||||
|
#
|
||||||
|
# Consumer routers key their leases and their reservations on the MAC address.
|
||||||
|
# The two never match, so the router does not recognise the machine as a client
|
||||||
|
# it has seen before and hands out the next free address instead. A reservation
|
||||||
|
# pinned to the MAC never takes effect, which is the part that makes it look like
|
||||||
|
# the router is broken.
|
||||||
|
#
|
||||||
|
# `dhcp-identifier: mac` in netplan sets ClientIdentifier=mac, and the router then
|
||||||
|
# sees what it expects. DHCP keeps working, the reservation starts being honoured,
|
||||||
|
# and nothing is pinned on the machine itself — which is why this is offered ahead
|
||||||
|
# of a static address rather than beside it.
|
||||||
|
|
||||||
|
NETPLAN_DHCP_ID=/etc/netplan/99-machine-setup-dhcp-identifier.yaml
|
||||||
|
NETPLAN_STATIC=/etc/netplan/99-machine-setup-static.yaml
|
||||||
|
|
||||||
|
# What the machine is sending as its DHCP identity: "mac", "duid", or empty when
|
||||||
|
# the link is not on DHCP at all.
|
||||||
|
dhcp_client_identifier() {
|
||||||
|
local iface="$1"
|
||||||
|
local id
|
||||||
|
# Everything after the FIRST colon, not field 2 of a colon split: the value is
|
||||||
|
# itself "IAID:0x…/DUID", so splitting on colons yields "IAID" and the DUID
|
||||||
|
# test silently answers backwards.
|
||||||
|
id="$(networkctl status "$iface" 2>/dev/null | awk '/DHCP4 Client ID/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }')"
|
||||||
|
[[ -z "$id" ]] && return 0
|
||||||
|
if [[ "$id" == *DUID* ]]; then echo duid; else echo mac; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Already asked for by some netplan file?
|
||||||
|
dhcp_identifier_is_mac() { grep -rqs "dhcp-identifier:[[:space:]]*mac" /etc/netplan/ 2>/dev/null; }
|
||||||
|
|
||||||
|
iface_ipv4() { ip -4 addr show "$1" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+' | head -1; }
|
||||||
|
iface_gateway() { ip route | awk '/^default/ { print $3; exit }'; }
|
||||||
|
iface_is_dhcp() { networkctl status "$1" 2>/dev/null | grep -q "DHCP4"; }
|
||||||
|
|
||||||
|
# Ask for MAC-based identity, as its own netplan file.
|
||||||
|
#
|
||||||
|
# Netplan reads /etc/netplan in lexical order and merges, so a 99- file adds this
|
||||||
|
# one key to whatever the installer or cloud-init already wrote, without this
|
||||||
|
# script having to parse and rewrite their YAML.
|
||||||
|
set_dhcp_identifier_mac() {
|
||||||
|
local iface="$1"
|
||||||
|
cat >"$NETPLAN_DHCP_ID" <<EOF
|
||||||
|
# Written by machine-setup.
|
||||||
|
#
|
||||||
|
# Identify to DHCP by MAC rather than by DUID, so the router recognises this
|
||||||
|
# machine across reboots and any reservation pinned to its MAC is honoured.
|
||||||
|
# Merged with whatever else is in /etc/netplan; 99- so it is read last.
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
ethernets:
|
||||||
|
${iface}:
|
||||||
|
dhcp-identifier: mac
|
||||||
|
EOF
|
||||||
|
chmod 600 "$NETPLAN_DHCP_ID"
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Freeze the current lease into a static address.
|
||||||
|
write_static_netplan() {
|
||||||
|
local iface="$1" cidr="$2" gateway="$3"
|
||||||
|
cat >"$NETPLAN_STATIC" <<EOF
|
||||||
|
# Written by machine-setup. Delete this file and run 'netplan apply' to go back
|
||||||
|
# to DHCP.
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
ethernets:
|
||||||
|
${iface}:
|
||||||
|
dhcp4: false
|
||||||
|
addresses:
|
||||||
|
- ${cidr}
|
||||||
|
routes:
|
||||||
|
- to: default
|
||||||
|
via: ${gateway}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$NETPLAN_STATIC"
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
netplan_check() { netplan generate 2>&1; }
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Firewall
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Last in the run, for the reason the original gave: enabling a firewall is the
|
||||||
|
# one step that can cut the connection it is being run over. Everything else
|
||||||
|
# should be done and working first.
|
||||||
|
#
|
||||||
|
# ── The bug in the shipped Docker rules ──
|
||||||
|
#
|
||||||
|
# ufw-docker-rules.conf hardcodes eth0. Docker publishes ports by writing its own
|
||||||
|
# iptables rules, which bypass ufw entirely — DOCKER-USER is the hook that lets
|
||||||
|
# ufw have a say. But every rule in that file names eth0, so on a machine with
|
||||||
|
# predictable interface names (ens18, enp1s0, and most VPS images) they match
|
||||||
|
# nothing, the final DROP never fires, and every published container port is open
|
||||||
|
# to the internet while `ufw status` says active. A firewall that reports itself
|
||||||
|
# working and is not is worse than none.
|
||||||
|
|
||||||
|
UFW_AFTER_RULES=/etc/ufw/after.rules
|
||||||
|
|
||||||
|
ufw_is_active() { ufw status 2>/dev/null | grep -q "^Status: active"; }
|
||||||
|
ufw_allows_ssh() { ufw status 2>/dev/null | grep -qiE "^(22/tcp|OpenSSH)"; }
|
||||||
|
ufw_has_rule() { ufw status 2>/dev/null | grep -qF "$1"; }
|
||||||
|
ufw_docker_rules_applied() { grep -q "DOCKER-USER" "$UFW_AFTER_RULES" 2>/dev/null; }
|
||||||
|
|
||||||
|
# The shipped rules, with eth0 replaced by the interface this machine actually
|
||||||
|
# uses. Appended once — the DOCKER-USER marker is the guard.
|
||||||
|
apply_ufw_docker_rules() {
|
||||||
|
local src="$1" iface
|
||||||
|
iface="$(default_iface)"
|
||||||
|
[[ -n "$iface" ]] || return 1
|
||||||
|
[[ -r "$src" ]] || return 1
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Appended by machine-setup. Interface substituted for the one this"
|
||||||
|
echo "# machine actually uses; the shipped file hardcodes eth0."
|
||||||
|
sed "s/-i eth0/-i ${iface}/g" "$src"
|
||||||
|
} >>"$UFW_AFTER_RULES"
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — distro packages
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
|
||||||
|
#
|
||||||
|
# ── The rule: install what is missing, never touch what is there ──
|
||||||
|
#
|
||||||
|
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
|
||||||
|
# the repository has a newer one. On a machine somebody already uses, that can
|
||||||
|
# move a version they chose deliberately, and the setup script is the last thing
|
||||||
|
# that should be doing that behind their back.
|
||||||
|
#
|
||||||
|
# So every install here goes through pkg_install, which queries the package
|
||||||
|
# database first, installs only the subset that is genuinely absent, and prints
|
||||||
|
# both lists before doing it. A package already present is never named on a
|
||||||
|
# command line at all.
|
||||||
|
#
|
||||||
|
# ── Why per-package-manager lists rather than a translation table ──
|
||||||
|
#
|
||||||
|
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
|
||||||
|
# and some packages are not a package elsewhere at all: apt-transport-https,
|
||||||
|
# lsb-release and software-properties-common are apt concepts. A canonical-name
|
||||||
|
# table with per-manager overrides hides both of those behind indirection. A
|
||||||
|
# plain `case $PM` says what each system actually gets, in one place, and matches
|
||||||
|
# the shape scripts/setup-old/setup.sh already used.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_PACKAGES_LOADED=1
|
||||||
|
|
||||||
|
# What the last pkg_install/tools_install actually put on the machine, as opposed
|
||||||
|
# to what it was asked for. Read by the caller to write an honest summary line:
|
||||||
|
# without it every section reports its whole list as installed, including the
|
||||||
|
# packages it deliberately left alone.
|
||||||
|
LAST_INSTALLED=()
|
||||||
|
LAST_KEPT=()
|
||||||
|
LAST_SKIPPED=()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The sections
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Core: what this script itself would break without, plus the command-line tools
|
||||||
|
# that make a machine worth sitting at.
|
||||||
|
#
|
||||||
|
# The first six are load-bearing and each is used by a later step — curl fetches
|
||||||
|
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
|
||||||
|
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
|
||||||
|
# archive, and ca-certificates is what makes any of the fetching work. The rest
|
||||||
|
# are the environment: nothing calls them, they are here because a box you use
|
||||||
|
# should have them.
|
||||||
|
#
|
||||||
|
# Four entries earn a note.
|
||||||
|
#
|
||||||
|
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
|
||||||
|
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
|
||||||
|
# darwin and win32 ONLY, so on Linux its install script always falls through to
|
||||||
|
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
|
||||||
|
# the failure surfaces as a broken terminal sidecar rather than as a missing
|
||||||
|
# package. build-essential below is the other half of the same requirement.
|
||||||
|
#
|
||||||
|
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
|
||||||
|
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
|
||||||
|
# has no equivalent at all, so it is not a name to translate. Installing the
|
||||||
|
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
|
||||||
|
# is what the apt-daily timers read, and on this host no package owns that file.
|
||||||
|
# The section makes sure it is there.
|
||||||
|
#
|
||||||
|
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
|
||||||
|
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
|
||||||
|
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
|
||||||
|
# moment it installs, an address failing to log in five times in ten minutes is
|
||||||
|
# blocked for ten, including yours. That is the point of it and it is worth
|
||||||
|
# having by default, but it is why it belongs in this comment rather than being
|
||||||
|
# thought of as one more binary. An existing install with its own jails is
|
||||||
|
# untouched, because pkg_install never names a package that is already there.
|
||||||
|
#
|
||||||
|
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
|
||||||
|
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
|
||||||
|
# distribution's default alongside it. It stays in core because anything that
|
||||||
|
# compiles a native module needs it, but it is the one to move out first if that
|
||||||
|
# ever bites.
|
||||||
|
pkgs_core() {
|
||||||
|
case "$PM" in
|
||||||
|
apt)
|
||||||
|
# apt-transport-https and lsb-release are not tools — they are what lets a
|
||||||
|
# later step add the Docker repository. They have no counterpart on the
|
||||||
|
# other systems.
|
||||||
|
#
|
||||||
|
# software-properties-common is still here and is no longer needed by
|
||||||
|
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
|
||||||
|
# only caller until that was removed on 2026-08-14 (Docker writes its own
|
||||||
|
# sources.list.d entry by hand). Left in deliberately rather than dropped
|
||||||
|
# in the same change — it is one small package, and pulling it is a
|
||||||
|
# separate decision from removing the tool that wanted it.
|
||||||
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
|
apt-transport-https lsb-release software-properties-common \
|
||||||
|
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
|
fail2ban unattended-upgrades
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
|
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||||
|
fail2ban
|
||||||
|
;;
|
||||||
|
dnf)
|
||||||
|
echo curl ca-certificates gnupg2 git jq unzip \
|
||||||
|
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
|
fail2ban
|
||||||
|
;;
|
||||||
|
brew)
|
||||||
|
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||||
|
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Querying
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Is this package installed right now?
|
||||||
|
#
|
||||||
|
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
|
||||||
|
# package that was removed but left its config behind — that state would be read
|
||||||
|
# as "present" and the package would never be reinstalled.
|
||||||
|
pkg_is_installed() {
|
||||||
|
case "$PM" in
|
||||||
|
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
|
||||||
|
pacman) pacman -Qi "$1" &>/dev/null ;;
|
||||||
|
dnf) rpm -q "$1" &>/dev/null ;;
|
||||||
|
brew) brew list --formula "$1" &>/dev/null ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Acting
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Refresh the package index.
|
||||||
|
#
|
||||||
|
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
|
||||||
|
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
|
||||||
|
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
|
||||||
|
# rather than at each call site, because forgetting one turns an unattended run
|
||||||
|
# into one that is silently waiting for a keypress.
|
||||||
|
pkg_refresh() {
|
||||||
|
case "$PM" in
|
||||||
|
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
|
||||||
|
pacman) pacman -Sy --noconfirm ;;
|
||||||
|
dnf) dnf makecache ;;
|
||||||
|
brew) brew update ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# What an upgrade would actually move, one package name per line.
|
||||||
|
#
|
||||||
|
# Asked before the upgrade runs so the section can name what it is about to
|
||||||
|
# change rather than asking to be trusted. Needs a refreshed index to be
|
||||||
|
# accurate, which is why pkg_refresh runs first.
|
||||||
|
#
|
||||||
|
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
|
||||||
|
# which is the same calculation the real run does — as opposed to
|
||||||
|
# `apt list --upgradable`, which also lists packages that are held back and
|
||||||
|
# would not actually move.
|
||||||
|
pkg_upgradable() {
|
||||||
|
case "$PM" in
|
||||||
|
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
|
||||||
|
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
|
||||||
|
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
|
||||||
|
brew) brew outdated --quiet 2>/dev/null ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upgrade everything already installed. Separate from pkg_install on purpose:
|
||||||
|
# this one DOES move versions, so it is a deliberate step rather than something
|
||||||
|
# that happens as a side effect of installing a tool.
|
||||||
|
pkg_upgrade_all() {
|
||||||
|
case "$PM" in
|
||||||
|
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
|
||||||
|
pacman) pacman -Su --noconfirm ;;
|
||||||
|
dnf) dnf upgrade -y ;;
|
||||||
|
brew) brew upgrade ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The raw install, with no presence check. Use pkg_install instead.
|
||||||
|
pkg_install_now() {
|
||||||
|
case "$PM" in
|
||||||
|
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
|
||||||
|
pacman) pacman -S --noconfirm --needed "$@" ;;
|
||||||
|
dnf) dnf install -y "$@" ;;
|
||||||
|
brew) brew install "$@" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Announce a section, then install only what is absent from it.
|
||||||
|
#
|
||||||
|
# pkg_install "Core packages" $(pkgs_core)
|
||||||
|
#
|
||||||
|
# Prints both lists before touching anything, so the run says what it is about to
|
||||||
|
# do to this machine and what it is deliberately leaving alone. Returns 0 when
|
||||||
|
# there was nothing to do.
|
||||||
|
pkg_install() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local pkg
|
||||||
|
local -a missing=() present=()
|
||||||
|
LAST_SKIPPED=()
|
||||||
|
for pkg in "$@"; do
|
||||||
|
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
|
||||||
|
done
|
||||||
|
|
||||||
|
LAST_INSTALLED=("${missing[@]}")
|
||||||
|
LAST_KEPT=("${present[@]}")
|
||||||
|
|
||||||
|
announce_plan "$label" present missing || {
|
||||||
|
# Declining is a fact a reviewer wants: it explains a package being absent
|
||||||
|
# later without having to guess whether the script failed or was refused.
|
||||||
|
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg_install_now "${missing[@]}"; then
|
||||||
|
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
|
||||||
|
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
|
||||||
|
else
|
||||||
|
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print what a section is about to do and ask permission for it.
|
||||||
|
#
|
||||||
|
# Takes the NAMES of the two arrays rather than their contents, because a list
|
||||||
|
# passed by value cannot be told apart from an empty one once it has been through
|
||||||
|
# word splitting.
|
||||||
|
#
|
||||||
|
# Returns non-zero when there is nothing to do, or when the answer was no — in
|
||||||
|
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
|
||||||
|
# refusal so the summary does not claim work that never happened.
|
||||||
|
announce_plan() {
|
||||||
|
local label="$1"
|
||||||
|
local -n _present="$2"
|
||||||
|
local -n _missing="$3"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "${label} — installs what is missing, keeps what you already have"
|
||||||
|
((${#_present[@]})) && echo " already here: ${_present[*]}"
|
||||||
|
|
||||||
|
if ((${#_missing[@]} == 0)); then
|
||||||
|
echo " to install: nothing, all present"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " to install: ${_missing[*]}"
|
||||||
|
if ! confirm "Proceed?"; then
|
||||||
|
warn "skipped by request"
|
||||||
|
LAST_INSTALLED=()
|
||||||
|
LAST_SKIPPED=("${_missing[@]}")
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# One summary line describing what a section actually did, from LAST_INSTALLED
|
||||||
|
# and LAST_KEPT. Call straight after pkg_install or tools_install.
|
||||||
|
summarise_last() {
|
||||||
|
local label="$1"
|
||||||
|
if ((${#LAST_SKIPPED[@]})); then
|
||||||
|
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
|
||||||
|
elif ((${#LAST_INSTALLED[@]} == 0)); then
|
||||||
|
SUMMARY+=("$label: already present, nothing installed")
|
||||||
|
elif ((${#LAST_KEPT[@]} == 0)); then
|
||||||
|
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
|
||||||
|
else
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — ssh keys and ssh hardening
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
#
|
||||||
|
# ── Why the original's hardening did not work, and could not be seen not to ──
|
||||||
|
#
|
||||||
|
# It sed'd /etc/ssh/sshd_config directly. Two things make that wrong on a modern
|
||||||
|
# Ubuntu, and both fail silently:
|
||||||
|
#
|
||||||
|
# Ubuntu's sshd_config has `Include /etc/ssh/sshd_config.d/*.conf` on line 12,
|
||||||
|
# and sshd takes the FIRST value it obtains for a keyword — not the last. Cloud
|
||||||
|
# images ship 50-cloud-init.conf containing `PasswordAuthentication yes`, which
|
||||||
|
# is read before anything further down the main file. So the sed edits a line
|
||||||
|
# sshd never reaches, the script reports "SSH hardened", and password login is
|
||||||
|
# still on.
|
||||||
|
#
|
||||||
|
# It also sed'd ChallengeResponseAuthentication, which OpenSSH renamed to
|
||||||
|
# KbdInteractiveAuthentication in 8.7. On 24.04 the old name appears nowhere in
|
||||||
|
# the file, so that substitution matched nothing at all.
|
||||||
|
#
|
||||||
|
# So the settings go in a drop-in named to sort FIRST — 01- beats 50-cloud-init —
|
||||||
|
# which is the only placement that actually wins under first-value-wins.
|
||||||
|
#
|
||||||
|
# ── And the reason it is dangerous ──
|
||||||
|
#
|
||||||
|
# Step 8 of the original could warn-and-skip (no ssh-keys.zip, or an unrecognised
|
||||||
|
# menu choice, since its case had no default arm) and still mark itself done.
|
||||||
|
# Step 9 then disabled password authentication and root login regardless. No key,
|
||||||
|
# no password, no root: locked out at the next disconnect, on a machine that may
|
||||||
|
# be in a datacentre. Nothing here disables password authentication without first
|
||||||
|
# confirming a usable key is in place.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_SSH_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_SSH_LOADED=1
|
||||||
|
|
||||||
|
SSHD_DROPIN=/etc/ssh/sshd_config.d/01-machine-setup.conf
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Keys
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
user_ssh_dir() { echo "${USER_HOME}/.ssh"; }
|
||||||
|
user_authorized_keys() { echo "${USER_HOME}/.ssh/authorized_keys"; }
|
||||||
|
|
||||||
|
# How many usable keys the account can log in with.
|
||||||
|
#
|
||||||
|
# Counted by asking ssh-keygen to parse the file rather than by counting lines:
|
||||||
|
# comments, blanks and a half-pasted key all look like lines, and "there is a
|
||||||
|
# file" is not the same fact as "there is a key that works".
|
||||||
|
authorized_key_count() {
|
||||||
|
local file
|
||||||
|
file="$(user_authorized_keys)"
|
||||||
|
[[ -r "$file" ]] || return 0
|
||||||
|
ssh-keygen -l -f "$file" 2>/dev/null | grep -c . || true
|
||||||
|
}
|
||||||
|
|
||||||
|
has_authorized_key() { (($(authorized_key_count) > 0)); }
|
||||||
|
|
||||||
|
# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd
|
||||||
|
# ignores an authorized_keys file that is group- or world-writable, and does so
|
||||||
|
# silently from the client's point of view — the login just fails.
|
||||||
|
fix_ssh_permissions() {
|
||||||
|
local dir
|
||||||
|
dir="$(user_ssh_dir)"
|
||||||
|
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||||
|
chmod 700 "$dir"
|
||||||
|
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
|
||||||
|
find "$dir" -maxdepth 1 -type f -name 'id_*' ! -name '*.pub' -exec chmod 600 {} +
|
||||||
|
chown -R "${USERNAME}:$(user_group)" "$dir"
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add a public key, once. Appending blindly is how authorized_keys ends up with
|
||||||
|
# the same key four times after four runs.
|
||||||
|
add_authorized_key() {
|
||||||
|
local key="$1" file
|
||||||
|
file="$(user_authorized_keys)"
|
||||||
|
|
||||||
|
# Validated before it is stored. A truncated paste or a private key pasted by
|
||||||
|
# mistake would otherwise sit there looking like a key and never work.
|
||||||
|
if ! ssh-keygen -l -f /dev/stdin <<<"$key" >/dev/null 2>&1; then
|
||||||
|
warn "that does not parse as an ssh public key — nothing added"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(user_ssh_dir)"
|
||||||
|
touch "$file"
|
||||||
|
|
||||||
|
# Compare on the key body, not the whole line: the trailing comment differs
|
||||||
|
# between machines and is not part of the identity.
|
||||||
|
local body
|
||||||
|
body="$(awk '{print $2}' <<<"$key")"
|
||||||
|
if [[ -n "$body" ]] && grep -qF "$body" "$file" 2>/dev/null; then
|
||||||
|
info " that key is already authorised"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$key" >>"$file"
|
||||||
|
fix_ssh_permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate a keypair for the account and authorise it.
|
||||||
|
generate_user_key() {
|
||||||
|
local comment="$1" key
|
||||||
|
key="$(user_ssh_dir)/id_ed25519"
|
||||||
|
|
||||||
|
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(user_ssh_dir)"
|
||||||
|
sudo -u "$USERNAME" ssh-keygen -t ed25519 -C "$comment" -f "$key" -N "" >/dev/null
|
||||||
|
add_authorized_key "$(cat "${key}.pub")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Hardening
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# What sshd actually resolves a setting to, across the main file and every
|
||||||
|
# drop-in. The only honest way to report the current state: reading the config
|
||||||
|
# files tells you what is written, not what wins.
|
||||||
|
sshd_effective() { sshd -T 2>/dev/null | awk -v k="${1,,}" 'tolower($1) == k { print $2; exit }'; }
|
||||||
|
|
||||||
|
# Write the drop-in, verify it, and only then reload.
|
||||||
|
#
|
||||||
|
# Returns non-zero without touching the running daemon if the result would not
|
||||||
|
# parse — the alternative is a config that sshd refuses, at which point it will
|
||||||
|
# not come back after a restart and the machine has no ssh at all.
|
||||||
|
harden_sshd() {
|
||||||
|
local backup=""
|
||||||
|
|
||||||
|
[[ -f "$SSHD_DROPIN" ]] && backup="$(mktemp)" && cp "$SSHD_DROPIN" "$backup"
|
||||||
|
|
||||||
|
install -d -m 0755 /etc/ssh/sshd_config.d
|
||||||
|
cat >"$SSHD_DROPIN" <<'EOF'
|
||||||
|
# Written by machine-setup.
|
||||||
|
#
|
||||||
|
# Named 01- deliberately: sshd uses the FIRST value it obtains for a keyword, and
|
||||||
|
# Ubuntu includes this directory from the top of sshd_config. A file sorting
|
||||||
|
# after 50-cloud-init.conf would be read too late to override it.
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
PermitRootLogin no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
EOF
|
||||||
|
chmod 644 "$SSHD_DROPIN"
|
||||||
|
|
||||||
|
if ! sshd -t 2>/dev/null; then
|
||||||
|
warn "sshd rejected the new configuration — reverting, nothing changed"
|
||||||
|
if [[ -n "$backup" ]]; then cp "$backup" "$SSHD_DROPIN"; else rm -f "$SSHD_DROPIN"; fi
|
||||||
|
[[ -n "$backup" ]] && rm -f "$backup"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
[[ -n "$backup" ]] && rm -f "$backup"
|
||||||
|
|
||||||
|
# Reload rather than restart: existing sessions keep their sshd, so the
|
||||||
|
# connection this is being run over is not the thing being experimented on.
|
||||||
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || systemctl restart ssh
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — system configuration
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files. Locale, and the system-level
|
||||||
|
# settings that follow it.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_SYSTEM_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_SYSTEM_LOADED=1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Locale
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Two separate facts, and the original only handled one of them:
|
||||||
|
#
|
||||||
|
# what a new login shell is told to use — LANG in /etc/default/locale
|
||||||
|
# whether that locale actually exists — whether it has been generated
|
||||||
|
#
|
||||||
|
# Setting LANG to a locale that was never generated is the state that produces
|
||||||
|
# "setlocale: LC_ALL: cannot change locale" on every ssh login and every perl
|
||||||
|
# invocation. Both are checked, so the step can say which one is missing.
|
||||||
|
|
||||||
|
# What a new login shell will be handed, or empty if nothing is configured.
|
||||||
|
locale_current() {
|
||||||
|
if [[ -r /etc/default/locale ]]; then
|
||||||
|
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/default/locale
|
||||||
|
elif [[ -r /etc/locale.conf ]]; then
|
||||||
|
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/locale.conf
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Has this locale actually been built?
|
||||||
|
#
|
||||||
|
# `locale -a` prints en_US.utf8 where the configuration spells it en_US.UTF-8,
|
||||||
|
# so both sides are folded to lower case with the dashes removed before
|
||||||
|
# comparing. A literal match here would report a perfectly good locale missing.
|
||||||
|
locale_is_generated() {
|
||||||
|
local want="${1,,}"
|
||||||
|
want="${want//-/}"
|
||||||
|
locale -a 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -d '-' | grep -qx "$want"
|
||||||
|
}
|
||||||
|
|
||||||
|
locale_set() {
|
||||||
|
local want="$1"
|
||||||
|
local escaped="${want//./\\.}"
|
||||||
|
|
||||||
|
case "$PM" in
|
||||||
|
apt)
|
||||||
|
# locale-gen comes from the `locales` package, which minimal images and
|
||||||
|
# most cloud base images do not ship. Without this the step fails with
|
||||||
|
# "locale-gen: command not found" halfway through.
|
||||||
|
if ! pkg_is_installed locales; then
|
||||||
|
info " installing locales, which provides locale-gen"
|
||||||
|
pkg_install_now locales
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Uncomment it if it is there commented out, add it if it is absent.
|
||||||
|
# Editing the file rather than passing the name to locale-gen is what makes
|
||||||
|
# it survive: a locale generated by argument alone is lost the next time
|
||||||
|
# anything regenerates from /etc/locale.gen.
|
||||||
|
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
|
||||||
|
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
|
||||||
|
elif ! grep -qE "^${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
|
||||||
|
# The charset is the part after the dot: en_US.UTF-8 -> UTF-8
|
||||||
|
echo "${want} ${want##*.}" >>/etc/locale.gen
|
||||||
|
fi
|
||||||
|
|
||||||
|
locale-gen
|
||||||
|
update-locale LANG="$want"
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
|
||||||
|
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
|
||||||
|
fi
|
||||||
|
locale-gen
|
||||||
|
echo "LANG=${want}" >/etc/locale.conf
|
||||||
|
;;
|
||||||
|
dnf)
|
||||||
|
# No locale.gen here — the locales come prebuilt in langpack packages.
|
||||||
|
pkg_install_now "glibc-langpack-${want%%_*}"
|
||||||
|
localectl set-locale "LANG=${want}"
|
||||||
|
;;
|
||||||
|
brew)
|
||||||
|
warn "macOS has no system locale to set — it is per-user, from the terminal's settings"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Swap
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SWAPFILE=/swapfile
|
||||||
|
|
||||||
|
# Rounded to nearest, not floored: a 4 GiB swapfile is 4194300 kB, which floors
|
||||||
|
# to 3 and reads as though a gigabyte went missing. Same for RAM, where 3.7 GiB
|
||||||
|
# reporting as "3G" makes the sizing tiers look wrong.
|
||||||
|
kb_to_gb_rounded() { echo $((($1 + 524288) / 1048576)); }
|
||||||
|
|
||||||
|
# Total active swap in GiB, 0 if there is none.
|
||||||
|
#
|
||||||
|
# From /proc/meminfo rather than by grepping swapon's output for a slash, which
|
||||||
|
# is what the original did to spot a swap FILE — that test reports no swap at all
|
||||||
|
# on a machine using zram or a swap partition, and the step would then add a
|
||||||
|
# swapfile beside perfectly good swap.
|
||||||
|
swap_active_gb() { kb_to_gb_rounded "$(awk '/^SwapTotal:/ { print $2 }' /proc/meminfo)"; }
|
||||||
|
|
||||||
|
ram_gb() { kb_to_gb_rounded "$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)"; }
|
||||||
|
|
||||||
|
# Free space on the filesystem that would hold the swapfile, in GiB. Floored
|
||||||
|
# rather than rounded, deliberately: this one decides how much to allocate, and
|
||||||
|
# rounding up invents space that is not there.
|
||||||
|
disk_free_gb() { echo $(($(df -Pk "$(dirname "$SWAPFILE")" | awk 'NR == 2 { print $4 }') / 1024 / 1024)); }
|
||||||
|
|
||||||
|
# How much swap this machine should have.
|
||||||
|
#
|
||||||
|
# The tiers are the original's. What is new is that the answer is capped by what
|
||||||
|
# is actually on the disk — the original would try to fallocate 8G on a VPS with
|
||||||
|
# 4G free, fail, and take the run down with it.
|
||||||
|
swap_recommended_gb() {
|
||||||
|
local ram size
|
||||||
|
ram="$(ram_gb)"
|
||||||
|
if ((ram <= 2)); then
|
||||||
|
size=2
|
||||||
|
elif ((ram <= 8)); then
|
||||||
|
size=4
|
||||||
|
else
|
||||||
|
size=8
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Leave a few gigabytes behind. A swapfile that fills the disk is a worse
|
||||||
|
# problem than no swapfile.
|
||||||
|
local room=$(($(disk_free_gb) - 5))
|
||||||
|
((room < size)) && size="$room"
|
||||||
|
((size < 1)) && size=0
|
||||||
|
echo "$size"
|
||||||
|
}
|
||||||
|
|
||||||
|
# How eagerly the kernel swaps, by role.
|
||||||
|
#
|
||||||
|
# 10 on a server: swapping is the emergency valve, not a routine, and the cost of
|
||||||
|
# a page fault on a request path is latency somebody is waiting for. A desktop is
|
||||||
|
# the opposite case — swapping out an application nobody has touched in an hour
|
||||||
|
# is exactly what you want — so dev keeps the kernel default of 60.
|
||||||
|
swappiness_for_role() { if is_server; then echo 10; else echo 60; fi; }
|
||||||
|
|
||||||
|
swap_create() {
|
||||||
|
local gb="$1"
|
||||||
|
|
||||||
|
# fallocate is instant but produces a file some filesystems refuse to swap on
|
||||||
|
# (btrfs without the right attributes, zfs at all). dd is slow and always
|
||||||
|
# works, so it is the fallback rather than the default.
|
||||||
|
if ! fallocate -l "${gb}G" "$SWAPFILE" 2>/dev/null; then
|
||||||
|
info " fallocate is not usable here — writing the file with dd, which is slower"
|
||||||
|
dd if=/dev/zero of="$SWAPFILE" bs=1M count=$((gb * 1024)) status=none
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod 600 "$SWAPFILE"
|
||||||
|
mkswap "$SWAPFILE" >/dev/null
|
||||||
|
swapon "$SWAPFILE"
|
||||||
|
|
||||||
|
grep -qs "^${SWAPFILE}[[:space:]]" /etc/fstab || echo "${SWAPFILE} none swap sw 0 0" >>/etc/fstab
|
||||||
|
}
|
||||||
|
|
||||||
|
# Written as a drop-in rather than by rewriting /etc/sysctl.conf in place. The
|
||||||
|
# original sed'd that file, which means the setting is tangled up with whatever
|
||||||
|
# else lives there and is invisible to anyone looking for what this script did.
|
||||||
|
swappiness_set() {
|
||||||
|
echo "vm.swappiness=$1" >/etc/sysctl.d/99-machine-setup-swappiness.conf
|
||||||
|
sysctl -q -w "vm.swappiness=$1"
|
||||||
|
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||||
|
# function that ends on a failing command is fatal under `set -e` when it
|
||||||
|
# is called as a plain command — which would abort the remaining sections
|
||||||
|
# over something the run could simply report. The caller checks the outcome.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Emergency disk ballast
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The same idea as swap, one layer down. Swap is the valve for memory pressure;
|
||||||
|
# this is the valve for disk pressure.
|
||||||
|
#
|
||||||
|
# A junk file holding no data, sized at 10% of free disk. Its only job is to be
|
||||||
|
# deleted when the filesystem is about to fill, buying enough headroom to log in
|
||||||
|
# and clean up properly instead of meeting a wedged box — Docker, journald and
|
||||||
|
# postgres all misbehave badly at 100% full, and some of them do not recover on
|
||||||
|
# their own.
|
||||||
|
#
|
||||||
|
# A one-shot valve: once spent, it has to be recreated.
|
||||||
|
#
|
||||||
|
# ── Moved out of the user's home ──
|
||||||
|
#
|
||||||
|
# The original put the checker in $USER_HOME/.local/bin and ran it from a root
|
||||||
|
# cron. A root cron executing a script inside a directory its owner can write is
|
||||||
|
# a privilege escalation waiting to be noticed — moot on a box where that user
|
||||||
|
# already has passwordless sudo, but wrong, and not something to carry forward.
|
||||||
|
# Both the script and the file now live in root-owned system paths.
|
||||||
|
|
||||||
|
# Where it goes is asked rather than decided. A ballast only protects the
|
||||||
|
# filesystem it is ON — the checker measures its own directory — so the choice is
|
||||||
|
# also a choice of which mount is being protected. Defaults to the user's home,
|
||||||
|
# which on most machines is the same filesystem as / and is the easiest place to
|
||||||
|
# find it again months later.
|
||||||
|
BALLAST_FILE=""
|
||||||
|
BALLAST_CHECKER=/usr/local/sbin/emergency-disk-check
|
||||||
|
BALLAST_CRON=/etc/cron.d/emergency-disk-check
|
||||||
|
BALLAST_THRESHOLD=10
|
||||||
|
BALLAST_NAME=emergency-disk-ballast.bin
|
||||||
|
|
||||||
|
ballast_exists() { [[ -n "$BALLAST_FILE" && -f "$BALLAST_FILE" ]]; }
|
||||||
|
|
||||||
|
ballast_size_human() { du -h "$BALLAST_FILE" 2>/dev/null | cut -f1; }
|
||||||
|
|
||||||
|
# The nearest directory that exists, walking up. A path being chosen for the
|
||||||
|
# ballast does not mean anything has created it yet, and df cannot measure a
|
||||||
|
# directory that is not there.
|
||||||
|
existing_ancestor() {
|
||||||
|
local dir="$1"
|
||||||
|
while [[ ! -d "$dir" && "$dir" != "/" ]]; do dir="$(dirname "$dir")"; done
|
||||||
|
echo "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Free space in KiB on whichever filesystem would hold this path.
|
||||||
|
ballast_free_kb() { df -Pk "$(existing_ancestor "$1")" | awk 'NR == 2 { print $4 }'; }
|
||||||
|
|
||||||
|
ballast_create() {
|
||||||
|
local mb="$1"
|
||||||
|
mkdir -p "$(dirname "$BALLAST_FILE")"
|
||||||
|
|
||||||
|
# fallocate reserves real blocks. A sparse file made with truncate would
|
||||||
|
# reserve nothing and free nothing when deleted, which is the entire point.
|
||||||
|
if ! fallocate -l "${mb}M" "$BALLAST_FILE" 2>/dev/null; then
|
||||||
|
info " fallocate is not usable here — writing with dd, which is slower"
|
||||||
|
dd if=/dev/zero of="$BALLAST_FILE" bs=1M count="$mb" status=none
|
||||||
|
fi
|
||||||
|
chmod 600 "$BALLAST_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
ballast_install_checker() {
|
||||||
|
mkdir -p "$(dirname "$BALLAST_FILE")"
|
||||||
|
cat >"$BALLAST_CHECKER" <<CHECKER
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Emergency disk ballast checker. Installed by machine-setup.
|
||||||
|
#
|
||||||
|
# Deletes the pre-allocated ballast file when free space falls below the
|
||||||
|
# threshold, buying headroom to log in and clean up. Run with --status to see
|
||||||
|
# where things stand without changing anything.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BALLAST="${BALLAST_FILE}"
|
||||||
|
THRESHOLD=${BALLAST_THRESHOLD}
|
||||||
|
TAG="emergency-disk"
|
||||||
|
|
||||||
|
# Walk up to a directory that exists. The ballast's own directory is gone if
|
||||||
|
# somebody cleaned up after the valve was spent, and df failing under
|
||||||
|
# \`set -e\` would make cron mail an error every ten minutes.
|
||||||
|
MOUNT_DIR="\$(dirname "\$BALLAST")"
|
||||||
|
while [[ ! -d "\$MOUNT_DIR" && "\$MOUNT_DIR" != "/" ]]; do MOUNT_DIR="\$(dirname "\$MOUNT_DIR")"; done
|
||||||
|
USE_PCT="\$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { gsub(/%/, "", \$5); print \$5 }')"
|
||||||
|
FREE_PCT=\$((100 - USE_PCT))
|
||||||
|
|
||||||
|
if [[ "\${1:-}" == "--status" ]]; then
|
||||||
|
echo "Mount: \$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { print \$6 }')"
|
||||||
|
echo "Free: \${FREE_PCT}% (threshold: \${THRESHOLD}%)"
|
||||||
|
if [[ -f "\$BALLAST" ]]; then
|
||||||
|
echo "Ballast: present, \$(du -h "\$BALLAST" | cut -f1) — \$BALLAST"
|
||||||
|
else
|
||||||
|
echo "Ballast: ABSENT (already spent) — \$BALLAST"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Everything urgent goes through here, so there is one place to add a second
|
||||||
|
# channel later. Today it is syslog only, which means the message is in the
|
||||||
|
# journal and nowhere else — nobody finds out until they go looking, which is
|
||||||
|
# exactly the wrong moment. Push, mail or Officer's own notify sidecar hook in
|
||||||
|
# here.
|
||||||
|
notify() {
|
||||||
|
logger -t "\$TAG" -p user.crit "\$1"
|
||||||
|
# A copy on stderr as well, so a human running this by hand sees it.
|
||||||
|
echo "\$1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
((FREE_PCT < THRESHOLD)) || exit 0
|
||||||
|
|
||||||
|
if [[ -f "\$BALLAST" ]]; then
|
||||||
|
FREED="\$(du -h "\$BALLAST" | cut -f1)"
|
||||||
|
rm -f "\$BALLAST"
|
||||||
|
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — deleted ballast, reclaimed \${FREED}. CLEAN UP NOW: this valve is spent."
|
||||||
|
else
|
||||||
|
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — ballast already spent, no headroom left to reclaim."
|
||||||
|
fi
|
||||||
|
CHECKER
|
||||||
|
|
||||||
|
chown root:root "$BALLAST_CHECKER"
|
||||||
|
chmod 755 "$BALLAST_CHECKER"
|
||||||
|
|
||||||
|
cat >"$BALLAST_CRON" <<CRON
|
||||||
|
# Emergency disk ballast — deletes the ballast file if free space drops below ${BALLAST_THRESHOLD}%.
|
||||||
|
# Installed by machine-setup. Check status: ${BALLAST_CHECKER} --status
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
*/10 * * * * root ${BALLAST_CHECKER}
|
||||||
|
CRON
|
||||||
|
chmod 644 "$BALLAST_CRON"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# earlyoom
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# What happens when swap runs out too.
|
||||||
|
#
|
||||||
|
# The kernel's own OOM killer waits until allocation genuinely fails, and by then
|
||||||
|
# the machine has usually spent minutes thrashing — unresponsive, ssh refusing to
|
||||||
|
# connect, nothing to do but reset it. earlyoom watches free memory and kills the
|
||||||
|
# largest consumer while there is still enough left to stay reachable.
|
||||||
|
|
||||||
|
earlyoom_is_active() { systemctl is-active --quiet earlyoom 2>/dev/null; }
|
||||||
|
|
||||||
|
earlyoom_install() {
|
||||||
|
pkg_is_installed earlyoom || pkg_install_now earlyoom
|
||||||
|
systemctl enable --now earlyoom >/dev/null 2>&1
|
||||||
|
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||||
|
# function that ends on a failing command is fatal under `set -e` when it
|
||||||
|
# is called as a plain command — which would abort the remaining sections
|
||||||
|
# over something the run could simply report. The caller checks the outcome.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Resource limits
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# inotify watches: how many files one user can have the kernel watching. The
|
||||||
|
# stock limit is small enough that one file watcher walking
|
||||||
|
# node_modules. Every watcher on the machine draws from the same pool.
|
||||||
|
#
|
||||||
|
# The failure is silent, which is what makes it worth setting in advance: nothing
|
||||||
|
# errors, the watcher simply stops noticing changes. Hot reload goes quiet, a
|
||||||
|
# build stops rebuilding, and the reason is never on screen.
|
||||||
|
#
|
||||||
|
# Mostly a development concern, but not exclusively — anything running `bun
|
||||||
|
# --watch` or serving a file browser is a watcher too.
|
||||||
|
|
||||||
|
INOTIFY_WATCHES=524288
|
||||||
|
INOTIFY_INSTANCES=1024
|
||||||
|
|
||||||
|
inotify_current_watches() { sysctl -n fs.inotify.max_user_watches 2>/dev/null || echo 0; }
|
||||||
|
|
||||||
|
inotify_raise() {
|
||||||
|
cat >/etc/sysctl.d/99-machine-setup-inotify.conf <<EOF
|
||||||
|
# Raised by machine-setup: the 8192 default is exhausted by file watchers, and
|
||||||
|
# the failure is silent — the watcher stops noticing changes without an error.
|
||||||
|
fs.inotify.max_user_watches=${INOTIFY_WATCHES}
|
||||||
|
fs.inotify.max_user_instances=${INOTIFY_INSTANCES}
|
||||||
|
EOF
|
||||||
|
sysctl -q -w "fs.inotify.max_user_watches=${INOTIFY_WATCHES}"
|
||||||
|
sysctl -q -w "fs.inotify.max_user_instances=${INOTIFY_INSTANCES}"
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Sleep and suspend
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# A server that suspends is a server that is off. The machine stops answering,
|
||||||
|
# and on a box with no keyboard attached there is nothing to wake it — which is
|
||||||
|
# the whole failure: it looks like a crash, and the only fix is physical.
|
||||||
|
#
|
||||||
|
# Two independent mechanisms, and both have to be dealt with:
|
||||||
|
#
|
||||||
|
# the sleep targets what suspend/hibernate hang off. Masking them means
|
||||||
|
# nothing can trigger a sleep, including a stray
|
||||||
|
# `systemctl suspend`
|
||||||
|
# logind's handlers what closing a lid, pressing the power button or going
|
||||||
|
# idle DO. These are what a desktop image sets, and they
|
||||||
|
# act before anything reaches a target
|
||||||
|
#
|
||||||
|
# Written as a drop-in rather than by editing logind.conf in place, so what this
|
||||||
|
# script set is one file that can be read or deleted on its own.
|
||||||
|
|
||||||
|
SLEEP_TARGETS=(sleep.target suspend.target hibernate.target hybrid-sleep.target)
|
||||||
|
LOGIND_DROPIN=/etc/systemd/logind.conf.d/99-machine-setup.conf
|
||||||
|
|
||||||
|
# The value actually in force for a logind setting, or empty for the default.
|
||||||
|
# Drop-ins override the main file and later ones override earlier, so the last
|
||||||
|
# match wins — reading only logind.conf would miss a setting made by a drop-in
|
||||||
|
# and report the machine as unconfigured when it is not.
|
||||||
|
logind_effective() {
|
||||||
|
local key="$1"
|
||||||
|
{
|
||||||
|
[[ -r /etc/systemd/logind.conf ]] && grep -hE "^${key}=" /etc/systemd/logind.conf
|
||||||
|
for f in /etc/systemd/logind.conf.d/*.conf; do
|
||||||
|
[[ -r "$f" ]] && grep -hE "^${key}=" "$f"
|
||||||
|
done
|
||||||
|
} 2>/dev/null | tail -1 | cut -d= -f2-
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep_targets_masked() {
|
||||||
|
local t
|
||||||
|
for t in "${SLEEP_TARGETS[@]}"; do
|
||||||
|
[[ "$(systemctl is-enabled "$t" 2>/dev/null)" == "masked" ]] || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# What the machine should be set to. RuntimeDirectorySize is deliberately NOT
|
||||||
|
# here: the original set it to 10% alongside these, which is both unrelated to
|
||||||
|
# sleeping — it is the size of /run — and systemd's own default, so the line
|
||||||
|
# never did anything.
|
||||||
|
logind_wanted() {
|
||||||
|
cat <<'EOF'
|
||||||
|
HandleLidSwitch=ignore
|
||||||
|
HandleLidSwitchExternalPower=ignore
|
||||||
|
HandleLidSwitchDocked=ignore
|
||||||
|
HandlePowerKey=ignore
|
||||||
|
IdleAction=none
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Is every wanted setting already in force?
|
||||||
|
logind_is_configured() {
|
||||||
|
local line key value
|
||||||
|
while IFS= read -r line; do
|
||||||
|
key="${line%%=*}"
|
||||||
|
value="${line#*=}"
|
||||||
|
[[ "$(logind_effective "$key")" == "$value" ]] || return 1
|
||||||
|
done < <(logind_wanted)
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_sleep() {
|
||||||
|
systemctl mask "${SLEEP_TARGETS[@]}" >/dev/null 2>&1
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOGIND_DROPIN")"
|
||||||
|
{
|
||||||
|
echo "# Written by machine-setup: this machine is a server and must not sleep."
|
||||||
|
echo "[Login]"
|
||||||
|
logind_wanted
|
||||||
|
} >"$LOGIND_DROPIN"
|
||||||
|
|
||||||
|
# Only restart when something actually changed — a needless restart of logind
|
||||||
|
# disturbs live sessions, and this step runs on every pass.
|
||||||
|
systemctl restart systemd-logind
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Boot hang
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# systemd-networkd-wait-online blocks boot until the network is up. Where
|
||||||
|
# systemd-networkd actually manages the network — a server or cloud image, via
|
||||||
|
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
|
||||||
|
#
|
||||||
|
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
|
||||||
|
# but the wait-online unit is still enabled and waits for a link that will never
|
||||||
|
# be configured. It gives up after its full timeout, on every boot.
|
||||||
|
#
|
||||||
|
# So the fix is masking one unit, and only on the second stack. Do NOT
|
||||||
|
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
|
||||||
|
# networkd-managed box that brings it up with no network on the next boot, no
|
||||||
|
# ssh, and nothing but the provider's rescue console.
|
||||||
|
|
||||||
|
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
|
||||||
|
|
||||||
|
network_manager_name() {
|
||||||
|
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
|
||||||
|
echo "NetworkManager"
|
||||||
|
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
|
||||||
|
echo "systemd-networkd"
|
||||||
|
else
|
||||||
|
echo "neither — unclear"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
|
||||||
|
# Anything else, including "cannot tell", is left alone.
|
||||||
|
wait_online_is_spurious() {
|
||||||
|
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
|
||||||
|
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# What that unit actually cost on this boot, straight from systemd's own
|
||||||
|
# accounting. Worth printing rather than asking somebody whether boot "feels
|
||||||
|
# slow": the answer is either 14ms or two minutes, and there is no arguing with
|
||||||
|
# it. Empty when the unit did not run.
|
||||||
|
wait_online_boot_time() {
|
||||||
|
systemd-analyze blame 2>/dev/null | awk -v u="$WAIT_ONLINE_UNIT" '$NF == u { $NF = ""; sub(/[[:space:]]+$/, ""); print; exit }'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns 0 whatever happens — see swappiness_set for why an optional step must
|
||||||
|
# not be able to abort the run.
|
||||||
|
mask_wait_online() {
|
||||||
|
systemctl mask --now "$WAIT_ONLINE_UNIT" >/dev/null 2>&1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Timezone
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# The shortlist offered at the prompt. Any zone name can be typed instead, so
|
||||||
|
# this is a convenience rather than a limit.
|
||||||
|
TZ_OPTIONS=(UTC Europe/Lisbon Europe/London Europe/Berlin Europe/Stockholm US/Eastern US/Pacific Asia/Tokyo)
|
||||||
|
|
||||||
|
# What the machine is set to now.
|
||||||
|
#
|
||||||
|
# Three sources because they disagree about availability rather than about the
|
||||||
|
# answer: timedatectl is absent without systemd (containers, WSL), /etc/timezone
|
||||||
|
# is Debian-specific, and the /etc/localtime symlink is the one thing that is
|
||||||
|
# always true when any of them are.
|
||||||
|
timezone_current() {
|
||||||
|
if command -v timedatectl &>/dev/null && timedatectl show -p Timezone --value 2>/dev/null | grep -q .; then
|
||||||
|
timedatectl show -p Timezone --value 2>/dev/null
|
||||||
|
elif [[ -r /etc/timezone ]]; then
|
||||||
|
tr -d '[:space:]' </etc/timezone
|
||||||
|
elif [[ -L /etc/localtime ]]; then
|
||||||
|
readlink -f /etc/localtime | sed 's|.*/zoneinfo/||'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Checked against the zoneinfo database before it is used. `timedatectl
|
||||||
|
# set-timezone` on a name that does not exist fails, and under `set -e` that
|
||||||
|
# takes the whole run down over a typo.
|
||||||
|
timezone_is_valid() { [[ -f "/usr/share/zoneinfo/$1" ]]; }
|
||||||
|
|
||||||
|
timezone_set() {
|
||||||
|
local tz="$1"
|
||||||
|
case "$PM" in
|
||||||
|
brew) systemsetup -settimezone "$tz" >/dev/null ;;
|
||||||
|
*)
|
||||||
|
# timedatectl where there is a systemd to talk to; the files directly
|
||||||
|
# otherwise, which is the same thing it would have written.
|
||||||
|
if command -v timedatectl &>/dev/null && [[ "$IS_WSL" != true ]]; then
|
||||||
|
timedatectl set-timezone "$tz"
|
||||||
|
else
|
||||||
|
ln -sf "/usr/share/zoneinfo/${tz}" /etc/localtime
|
||||||
|
echo "$tz" >/etc/timezone
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — Tailscale
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
#
|
||||||
|
# ── Why this runs early ──
|
||||||
|
#
|
||||||
|
# It is a second way into the machine. The section that can lock you out is SSH
|
||||||
|
# hardening, and everything after this one can break networking in some smaller
|
||||||
|
# way; having the tailnet up first means a mistake is recoverable rather than a
|
||||||
|
# trip to a rescue console.
|
||||||
|
#
|
||||||
|
# ── Why it matters to Officer specifically ──
|
||||||
|
#
|
||||||
|
# The platform's CLAUDE.md is explicit: the perimeter IS the tailnet. Origin
|
||||||
|
# checking was removed outright on 2026-08-13 because the tailnet stands in its
|
||||||
|
# place, so a valid token plus the tailnet IS the lock — not one layer of two.
|
||||||
|
# An Officer install with no tailnet is missing the half the design assumes.
|
||||||
|
#
|
||||||
|
# ── Why the original hung ──
|
||||||
|
#
|
||||||
|
# It passed --authkey unconditionally, and its prompt accepted an empty answer.
|
||||||
|
# `tailscale up --authkey ""` falls back to interactive login: it prints a URL and
|
||||||
|
# blocks, with no timeout, forever. Nothing here passes an empty key, every call
|
||||||
|
# has a timeout, and the state is read before anything is run.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_TAILSCALE_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_TAILSCALE_LOADED=1
|
||||||
|
|
||||||
|
TS_EXIT_SYSCTL=/etc/sysctl.d/99-tailscale-exit.conf
|
||||||
|
TS_DISPATCHER=/etc/networkd-dispatcher/routable.d/50-tailscale-exit
|
||||||
|
|
||||||
|
# Printed only when asked for. The section leads with the question rather than
|
||||||
|
# with ten lines of explanation: somebody who runs Tailscale already does not need
|
||||||
|
# to be told what it is, and somebody who does not can type ?.
|
||||||
|
tailscale_help() {
|
||||||
|
echo " Tailscale is a private network between your own machines, over"
|
||||||
|
echo " WireGuard. Every device you enrol gets a stable 100.x address and"
|
||||||
|
echo " can reach every other, wherever they are — through NAT, across"
|
||||||
|
echo " providers, without either end having a public address."
|
||||||
|
echo ""
|
||||||
|
echo " Nothing is published to the open internet to make that work: no"
|
||||||
|
echo " port forwarding, no exposed ports, no holes in the firewall."
|
||||||
|
echo ""
|
||||||
|
echo " For Officer it is not a convenience. The platform is built assuming"
|
||||||
|
echo " the tailnet IS the perimeter, and there is no origin checking behind"
|
||||||
|
echo " it — a valid token plus the tailnet is the whole lock. Without the"
|
||||||
|
echo " tailnet you are running with half of it missing."
|
||||||
|
echo ""
|
||||||
|
echo " It is installed at this point in the run, before anything that can"
|
||||||
|
echo " lock you out of the machine, so there is always a second way in."
|
||||||
|
}
|
||||||
|
|
||||||
|
# The menu itself, in a function because it is shown twice — once to ask, and
|
||||||
|
# again after ? has printed the long answer, so the reader is not dropped back at
|
||||||
|
# a bare prompt having forgotten what the options were.
|
||||||
|
tailscale_network_menu() {
|
||||||
|
info "Which network should this machine join?"
|
||||||
|
echo ""
|
||||||
|
echo " [1] set up your own network — offscale"
|
||||||
|
echo " Your own coordination server. The protocol on the wire is"
|
||||||
|
echo " Tailscale's and the encryption is WireGuard's; offscale changes"
|
||||||
|
echo " neither — it runs headscale's open-source code. What changes is"
|
||||||
|
echo " the work: managed from an app rather than a terminal, and"
|
||||||
|
echo " enrolling a device is a link and a tap."
|
||||||
|
echo " offscale — just like headscale, and just like Tailscale's own"
|
||||||
|
echo " service — needs to run on a publicly reachable server of its"
|
||||||
|
echo " own. A small VPS is enough. Not this machine, not behind a home"
|
||||||
|
echo " router: every device that joins has to find it, including phones"
|
||||||
|
echo " on mobile data. The only difference from option 3 is who runs"
|
||||||
|
echo " that server."
|
||||||
|
echo " Follow that setup through first, then come back here with its"
|
||||||
|
echo " address and a key."
|
||||||
|
echo " https://officer.dev/infrastructure/offscale.html#install"
|
||||||
|
echo ""
|
||||||
|
echo " [2] use a network you already run — headscale or offscale"
|
||||||
|
echo " You already have a coordination server somewhere. Point this"
|
||||||
|
echo " machine at it and it joins that network alongside the rest."
|
||||||
|
echo ""
|
||||||
|
echo " [3] the easy route — tailscale.com"
|
||||||
|
echo " Tailscale runs the coordination for you. Nothing to host and"
|
||||||
|
echo " nothing to maintain, free for personal use; the trade is that"
|
||||||
|
echo " the list of your machines lives with them."
|
||||||
|
echo ""
|
||||||
|
echo " [4] no private network at all"
|
||||||
|
echo " This machine is reached over the open internet, or not at all."
|
||||||
|
echo " Everything the tailnet was doing becomes yours to do."
|
||||||
|
echo ""
|
||||||
|
echo " [?] what are tailscale, headscale and offscale?"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# The long answer, printed when somebody types ?. Covers all three names,
|
||||||
|
# because the menu offers all three and two of them are not words anyone outside
|
||||||
|
# this project would know.
|
||||||
|
tailscale_networks_help() {
|
||||||
|
echo " Tailscale, headscale and offscale are three answers to one question:"
|
||||||
|
echo " who keeps the list of your machines and hands out the keys they use"
|
||||||
|
echo " to find each other."
|
||||||
|
echo ""
|
||||||
|
echo " The network itself is the same in all three cases. Machines talk"
|
||||||
|
echo " directly to each other over WireGuard, encrypted end to end. What"
|
||||||
|
echo " differs is only the coordination server — the thing that knows which"
|
||||||
|
echo " machines are yours. It never carries your traffic."
|
||||||
|
echo ""
|
||||||
|
echo " TAILSCALE"
|
||||||
|
echo " The company's own coordination server. Nothing to run, nothing to"
|
||||||
|
echo " maintain, free for personal use. You sign in with an existing"
|
||||||
|
echo " identity and your machines appear in their admin console."
|
||||||
|
echo " The trade is that the list of your machines lives with them."
|
||||||
|
echo ""
|
||||||
|
echo " HEADSCALE"
|
||||||
|
echo " An open-source coordination server you run yourself. The same"
|
||||||
|
echo " Tailscale clients connect to it, so the machines behave identically;"
|
||||||
|
echo " the difference is that nobody else holds the list. The cost is that"
|
||||||
|
echo " it is now a service you host, and it needs to be reachable."
|
||||||
|
echo ""
|
||||||
|
echo " OFFSCALE"
|
||||||
|
echo " Our own distribution of headscale, which is to say: headscale. The"
|
||||||
|
echo " protocol on the wire is Tailscale's and the encryption is"
|
||||||
|
echo " WireGuard's, and offscale changes neither — it runs the same"
|
||||||
|
echo " open-source project. A machine on an offscale network behaves"
|
||||||
|
echo " exactly as it would on either of the other two. There is no offscale"
|
||||||
|
echo " protocol to be locked into, because there is no offscale protocol."
|
||||||
|
echo ""
|
||||||
|
echo " Clients: stock Tailscale on computers. On iPhone, iPad and Android"
|
||||||
|
echo " there is our own app — the Tailscale client, our branding, and one"
|
||||||
|
echo " real difference: it takes an invite from the server directly. That"
|
||||||
|
echo " is the part of running headscale people give up at, because the"
|
||||||
|
echo " official app has to be talked into using a server that is not"
|
||||||
|
echo " Tailscale's. Desktop apps of our own are not there yet; on a"
|
||||||
|
echo " computer you point the official client at your own server."
|
||||||
|
echo ""
|
||||||
|
# Where it runs matters more than how it installs, and is the thing people
|
||||||
|
# get wrong: a coordination server at home is unreachable from exactly the
|
||||||
|
# devices a private network exists to reach.
|
||||||
|
echo " Where it runs: on a publicly reachable server of its own — a small"
|
||||||
|
echo " VPS is enough. Not on this machine, and not behind a home router."
|
||||||
|
echo " Every device that joins has to find it, including phones on mobile"
|
||||||
|
echo " data and laptops in other buildings, so it needs an address that"
|
||||||
|
echo " resolves from anywhere."
|
||||||
|
echo ""
|
||||||
|
echo " This is not something offscale asks for and the others do not. It"
|
||||||
|
echo " is true of headscale, and it is true of Tailscale — their"
|
||||||
|
echo " coordination server is publicly reachable too, they simply run it"
|
||||||
|
echo " for you. That is the whole of the difference between choosing"
|
||||||
|
echo " option 3 and choosing to host it yourself."
|
||||||
|
echo ""
|
||||||
|
echo " What it does that plain headscale does not:"
|
||||||
|
echo " · installs in one command on that server, certificates included"
|
||||||
|
echo " · health, logs, restarts and access policies from the app,"
|
||||||
|
echo " instead of a config file and a CLI"
|
||||||
|
echo " · enrolling a device is a link and a tap — the key is minted"
|
||||||
|
echo " and handed over for you"
|
||||||
|
echo " · several networks at once, and services reachable across them"
|
||||||
|
echo ""
|
||||||
|
echo " https://officer.dev/infrastructure/offscale.html"
|
||||||
|
echo ""
|
||||||
|
echo " FOR OFFICER"
|
||||||
|
echo " Whichever you pick, the tailnet is what Officer treats as its"
|
||||||
|
echo " perimeter, and it is not one layer of two — there is no origin"
|
||||||
|
echo " checking behind it. Installed at this point in the run,"
|
||||||
|
echo " before anything that can lock you out, so there is always a second"
|
||||||
|
echo " way in."
|
||||||
|
echo ""
|
||||||
|
echo " Officer also administers it. Its Headscale app talks to headscale"
|
||||||
|
echo " and offscale servers alike: register as many as you run, see which"
|
||||||
|
echo " are actually up — each is probed, not remembered — and switch"
|
||||||
|
echo " between them. On whichever is active you get the nodes, the users,"
|
||||||
|
echo " the pre-auth keys, the invites and the ACL policy, with an"
|
||||||
|
echo " assistant for writing it, plus a console and diagnostics. So the"
|
||||||
|
echo " server this section sets up is managed from the same place as"
|
||||||
|
echo " everything else on this machine, rather than over ssh and a CLI."
|
||||||
|
}
|
||||||
|
|
||||||
|
tailscale_is_installed() { command -v tailscale &>/dev/null; }
|
||||||
|
|
||||||
|
# NeedsLogin, Running, Stopped, NoState… Read before acting, because the original's
|
||||||
|
# failure was running `up` blindly against a node that was already up.
|
||||||
|
tailscale_state() {
|
||||||
|
tailscale status --json 2>/dev/null | awk -F'"' '/"BackendState"/ { print $4; exit }'
|
||||||
|
}
|
||||||
|
|
||||||
|
tailscale_ip() { tailscale ip -4 2>/dev/null | head -1; }
|
||||||
|
|
||||||
|
# Which control plane this node is talking to. Empty means Tailscale's own.
|
||||||
|
tailscale_control_url() {
|
||||||
|
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
||||||
|
}
|
||||||
|
|
||||||
|
# The official install.sh is a Linux package-manager script. macOS gets the same
|
||||||
|
# daemon wrapped in a GUI app, and the cask is the version with a CLI at
|
||||||
|
# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build
|
||||||
|
# is sandboxed and ships no usable `tailscale` binary, which is the difference
|
||||||
|
# that matters to a script.
|
||||||
|
tailscale_install() {
|
||||||
|
if [[ "${OS:-}" == "macos" ]]; then
|
||||||
|
brew install --cask tailscale
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tailscale's own coordination server, spelled out.
|
||||||
|
#
|
||||||
|
# Passed explicitly even when it is the default, because `tailscale up` with no
|
||||||
|
# --login-server keeps whatever ControlURL is already stored. On a node already
|
||||||
|
# pointed at a self-hosted server, choosing "the easy route" would otherwise
|
||||||
|
# leave it exactly where it was — no error, no message, wrong answer.
|
||||||
|
TS_DEFAULT_CONTROL_URL="https://controlplane.tailscale.com"
|
||||||
|
|
||||||
|
# Moving a node between coordination servers is not something `up` will do while
|
||||||
|
# it is logged in to one. Logging out first is the documented way, and doing it
|
||||||
|
# unasked would be worse than saying so.
|
||||||
|
# What choosing "no private network" actually hands you, said before it is
|
||||||
|
# chosen rather than discovered afterwards.
|
||||||
|
tailscale_none_warning() {
|
||||||
|
echo " Without a tailnet, everything it was doing becomes yours:"
|
||||||
|
echo ""
|
||||||
|
echo " · Anything you want to reach remotely has to be published to the"
|
||||||
|
echo " open internet deliberately, and kept closed otherwise."
|
||||||
|
echo " · TLS certificates are yours to obtain and to keep renewed."
|
||||||
|
echo " · Every exposed service needs its own authentication, because"
|
||||||
|
echo " there is no longer a network boundary in front of it."
|
||||||
|
echo " · This machine will be found. Anything listening on a public"
|
||||||
|
echo " address is scanned within minutes and attacked continuously."
|
||||||
|
echo ""
|
||||||
|
echo " For Officer specifically, this removes a layer that cannot be put"
|
||||||
|
echo " back from a setting:"
|
||||||
|
echo ""
|
||||||
|
echo " There is no origin checking in the platform. It was removed"
|
||||||
|
echo " because the tailnet is the perimeter, so a valid token plus the"
|
||||||
|
echo " tailnet is the entire lock. With no tailnet, the token is the"
|
||||||
|
echo " only thing left. Put an HTTPS reverse proxy in front of the"
|
||||||
|
echo " platform and restrict who can reach it at the network layer."
|
||||||
|
}
|
||||||
|
|
||||||
|
tailscale_needs_logout() {
|
||||||
|
local current="$1" target="$2"
|
||||||
|
[[ -n "$current" && -n "$target" && "$current" != "$target" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Routing has to be on before this machine can forward anyone else's packets,
|
||||||
|
# whether as an exit node or as a subnet router. Written as a drop-in so it is
|
||||||
|
# visible as this script's doing.
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
cat >"$TS_EXIT_SYSCTL" <<'EOF'
|
||||||
|
# Written by machine-setup: required to forward traffic for other tailnet nodes,
|
||||||
|
# as an exit node or as a subnet router.
|
||||||
|
net.ipv4.ip_forward = 1
|
||||||
|
net.ipv6.conf.all.forwarding = 1
|
||||||
|
EOF
|
||||||
|
sysctl --system >/dev/null 2>&1
|
||||||
|
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||||
|
# function that ends on a failing command is fatal under `set -e` when it
|
||||||
|
# is called as a plain command — which would abort the remaining sections
|
||||||
|
# over something the run could simply report. The caller checks the outcome.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# UDP GRO forwarding, which Tailscale documents as roughly doubling throughput on
|
||||||
|
# a node that forwards for others. Applied on every routable event rather than
|
||||||
|
# once, because the settings are per-interface and do not survive the link going
|
||||||
|
# down and back up.
|
||||||
|
install_exit_node_tuning() {
|
||||||
|
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$TS_DISPATCHER")"
|
||||||
|
cat >"$TS_DISPATCHER" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Written by machine-setup. NIC offload settings for a Tailscale exit node or
|
||||||
|
# subnet router — Tailscale's own recommendation for forwarding throughput.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
IF="${IFACE:-}"
|
||||||
|
if [[ -z "${IF}" ]]; then
|
||||||
|
IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "${IF}" ]] || exit 0
|
||||||
|
command -v ethtool >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true
|
||||||
|
ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true
|
||||||
|
ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 755 "$TS_DISPATCHER"
|
||||||
|
systemctl enable --now networkd-dispatcher >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# And once now, for the interface that is already up.
|
||||||
|
IFACE="$(default_iface)" bash "$TS_DISPATCHER" >/dev/null 2>&1 || true
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# The LAN this machine sits on, as a CIDR — the useful default for a subnet
|
||||||
|
# router, and the number nobody remembers offhand.
|
||||||
|
lan_cidr() {
|
||||||
|
local iface
|
||||||
|
iface="$(default_iface)"
|
||||||
|
ip -4 route show dev "$iface" 2>/dev/null |
|
||||||
|
awk '$1 ~ /\// && $1 !~ /^default/ { print $1; exit }'
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# machine-setup — command-line tools that do not come from the distribution
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only, like the other lib/ files.
|
||||||
|
#
|
||||||
|
# These four were buried inside "System Update & Essentials", after the package
|
||||||
|
# install and with no announcement, so a run appeared to be installing system
|
||||||
|
# packages and then started downloading tarballs and printing a shell tutorial.
|
||||||
|
# They are their own concern: upstream binaries, fetched from upstream, on their
|
||||||
|
# own release cadence.
|
||||||
|
#
|
||||||
|
# Each one is checked before it is fetched. The original re-ran every installer
|
||||||
|
# on every run — which is how a machine that already had starship got it
|
||||||
|
# reinstalled, along with its "add this to your ~/.zshrc" instructions, which we
|
||||||
|
# do not want because this script writes the shell config itself.
|
||||||
|
|
||||||
|
[[ -n "${MACHINE_SETUP_TOOLS_LOADED:-}" ]] && return 0
|
||||||
|
MACHINE_SETUP_TOOLS_LOADED=1
|
||||||
|
|
||||||
|
# The set installed on every machine, in the order they are fetched.
|
||||||
|
#
|
||||||
|
# fastfetch was here until 2026-08-14 and was removed after it stopped a real
|
||||||
|
# install. It is the only one of these with no source but a third-party PPA on
|
||||||
|
# Ubuntu 24.04 and older, and the failure was in the half that was not guarded:
|
||||||
|
# a PPA that ADDS cleanly but carries no package for the running codename gets
|
||||||
|
# past the `|| skip` and dies on the install instead. A neofetch clone is not
|
||||||
|
# worth a branch in a script that has to survive on machines nobody has seen.
|
||||||
|
tools_default() { echo lazydocker lazygit starship; }
|
||||||
|
|
||||||
|
# The command that proves a tool is already here. Same as the tool name for all
|
||||||
|
# three today, but kept as a mapping because that is not a rule — a package and
|
||||||
|
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
||||||
|
# indirection.
|
||||||
|
tool_command() {
|
||||||
|
case "$1" in
|
||||||
|
lazydocker) echo lazydocker ;;
|
||||||
|
lazygit) echo lazygit ;;
|
||||||
|
starship) echo starship ;;
|
||||||
|
*) echo "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The installers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tool_install_lazydocker() {
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
|
||||||
|
DIR=/usr/local/bin bash
|
||||||
|
}
|
||||||
|
|
||||||
|
# The one that was actually broken on arm64: the asset name was hardcoded to
|
||||||
|
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
|
||||||
|
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
|
||||||
|
# two spellings ARCH uses, hence the mapping.
|
||||||
|
tool_install_lazygit() {
|
||||||
|
local version asset url
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) asset="x86_64" ;;
|
||||||
|
arm64) asset="arm64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
version="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | jq -r '.tag_name')"
|
||||||
|
# Strip only the leading v. The original used `tr -d 'v'`, which deletes every
|
||||||
|
# v in the string and would mangle any tag that had one anywhere else.
|
||||||
|
version="${version#v}"
|
||||||
|
[[ -n "$version" ]] || {
|
||||||
|
warn "could not read the latest lazygit version — skipping"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
url="https://github.com/jesseduffield/lazygit/releases/download/v${version}/lazygit_${version}_Linux_${asset}.tar.gz"
|
||||||
|
curl -fsSLo /tmp/lazygit.tar.gz "$url"
|
||||||
|
tar -C /usr/local/bin -xzf /tmp/lazygit.tar.gz lazygit
|
||||||
|
rm -f /tmp/lazygit.tar.gz
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quiet on purpose. The installer ends by printing how to add starship to bash,
|
||||||
|
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
|
||||||
|
# already writes the zsh config itself. Errors still come through.
|
||||||
|
tool_install_starship() {
|
||||||
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Acting
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Announce the section, then fetch only what is absent — same contract and same
|
||||||
|
# output shape as pkg_install, so the two read alike in a transcript.
|
||||||
|
tools_install() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local tool
|
||||||
|
local -a missing=() present=()
|
||||||
|
LAST_SKIPPED=()
|
||||||
|
for tool in "$@"; do
|
||||||
|
if tool_is_installed "$tool"; then present+=("$tool"); else missing+=("$tool"); fi
|
||||||
|
done
|
||||||
|
|
||||||
|
LAST_INSTALLED=("${missing[@]}")
|
||||||
|
LAST_KEPT=("${present[@]}")
|
||||||
|
|
||||||
|
announce_plan "$label" present missing || return 0
|
||||||
|
|
||||||
|
for tool in "${missing[@]}"; do
|
||||||
|
info " installing ${tool}..."
|
||||||
|
"tool_install_${tool}"
|
||||||
|
done
|
||||||
|
}
|
||||||
Executable
+2600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
# UFW Docker compatibility rules
|
||||||
|
# Append these to /etc/ufw/after.rules (after the existing COMMIT)
|
||||||
|
# Blocks all external access to Docker-published ports except:
|
||||||
|
# - Trusted IPs (add your own)
|
||||||
|
# - Explicitly allowed public ports (80, 443)
|
||||||
|
# - Docker internal and loopback traffic
|
||||||
|
|
||||||
|
*filter
|
||||||
|
:DOCKER-USER - [0:0]
|
||||||
|
|
||||||
|
# Allow established/related
|
||||||
|
-A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
|
||||||
|
|
||||||
|
# Allow loopback
|
||||||
|
-A DOCKER-USER -i lo -j RETURN
|
||||||
|
|
||||||
|
# Allow Docker internal networks
|
||||||
|
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
|
||||||
|
|
||||||
|
# Allow trusted external sources (add more lines as needed)
|
||||||
|
# -A DOCKER-USER -s <TRUSTED_IP> -j RETURN
|
||||||
|
|
||||||
|
# Allow public ports
|
||||||
|
-A DOCKER-USER -i eth0 -p tcp --dport 80 -j RETURN
|
||||||
|
-A DOCKER-USER -i eth0 -p tcp --dport 443 -j RETURN
|
||||||
|
|
||||||
|
# Drop everything else from external
|
||||||
|
-A DOCKER-USER -i eth0 -j DROP
|
||||||
|
|
||||||
|
# Return for non-external traffic
|
||||||
|
-A DOCKER-USER -j RETURN
|
||||||
|
|
||||||
|
COMMIT
|
||||||
Executable
+965
@@ -0,0 +1,965 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — the platform, on a machine that is already provisioned
|
||||||
|
#
|
||||||
|
# The second half of the install. machine-setup/ brings a blank box up to a
|
||||||
|
# usable machine; this puts Officer on top of it.
|
||||||
|
#
|
||||||
|
# Run as root: sudo scripts/setup/officer-setup.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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)
|
||||||
|
ONLY_STEP="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--only=*)
|
||||||
|
ONLY_STEP="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
# Set before lib/repo.sh is sourced below, which reads it as
|
||||||
|
# `${OFFICER_REPO:-<default>}` — so this wins and an absent flag still defaults.
|
||||||
|
--repo)
|
||||||
|
[[ -n "${2:-}" ]] || {
|
||||||
|
echo "--repo needs a URL" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
OFFICER_REPO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--repo=*)
|
||||||
|
OFFICER_REPO="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--unattended | -y)
|
||||||
|
export UNATTENDED=1 ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-l | --list)
|
||||||
|
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
|
||||||
|
echo ""
|
||||||
|
echo " --only <step> run one step; --list names them"
|
||||||
|
echo " --unattended take the default for every question that has one (-y)"
|
||||||
|
echo " --repo <url> clone from here instead of the default, which is a"
|
||||||
|
echo " private Gitea over SSH and only authenticates on a"
|
||||||
|
echo " machine whose key it already knows. Same as exporting"
|
||||||
|
echo " OFFICER_REPO. Ignored once the repo is checked out."
|
||||||
|
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"
|
||||||
|
# shellcheck source=officer-setup/lib/repo.sh
|
||||||
|
source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
|
||||||
|
# shellcheck source=officer-setup/lib/layout.sh
|
||||||
|
source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
|
||||||
|
# shellcheck source=officer-setup/lib/postgres.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
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. Pre-flight
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
||||||
|
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||||
|
|
||||||
|
# ── Privileges: asked for, not demanded ──
|
||||||
|
#
|
||||||
|
# Run this as YOURSELF. It needs root on Linux, so it asks through sudo and
|
||||||
|
# re-executes itself rather than making you type it. Variables are passed to sudo
|
||||||
|
# by name rather than with -E, because `env_reset` is the sudoers default and
|
||||||
|
# strips the environment — which is how DATA_PATH was lost once already.
|
||||||
|
#
|
||||||
|
# macOS never escalates: Homebrew refuses to run as root, and the account running
|
||||||
|
# this IS the owner, so there is nothing to chown and nothing to drop to.
|
||||||
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||||
|
if [[ "$EUID" -eq 0 ]]; then
|
||||||
|
fail "Do not run this with sudo on macOS — run it as yourself."
|
||||||
|
fi
|
||||||
|
elif [[ "$EUID" -ne 0 ]]; then
|
||||||
|
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
|
||||||
|
echo ""
|
||||||
|
echo " This needs administrator rights. You will be asked for your password."
|
||||||
|
echo ""
|
||||||
|
exec sudo \
|
||||||
|
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||||
|
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||||
|
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||||
|
REPORT_FILE="${REPORT_FILE:-}" \
|
||||||
|
UNATTENDED="${UNATTENDED:-}" \
|
||||||
|
ASSUME_YES="${ASSUME_YES:-}" \
|
||||||
|
OFFICER_REPO="${OFFICER_REPO:-}" \
|
||||||
|
bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap report_flush EXIT
|
||||||
|
|
||||||
|
# ── what machine-setup already established ──
|
||||||
|
echo ""
|
||||||
|
if load_machine_answers; then
|
||||||
|
info "Read from machine-setup: ${MACHINE_ANSWERS}"
|
||||||
|
else
|
||||||
|
warn "machine-setup has not run on this machine"
|
||||||
|
echo " That is fine if you provisioned it another way — the questions it"
|
||||||
|
echo " would have answered are asked below instead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── the account ──
|
||||||
|
#
|
||||||
|
# A remembered answer can go stale: the account it names may have been renamed or
|
||||||
|
# removed since machine-setup ran. That is a reason to ask again, not a reason to
|
||||||
|
# stop — so the remembered value is checked before it is trusted, and a bad one
|
||||||
|
# is reported and replaced rather than ending the run.
|
||||||
|
if [[ -n "$USERNAME" ]] && ! owner_exists; then
|
||||||
|
warn "the remembered account '${USERNAME}' does not exist on this machine any more"
|
||||||
|
USERNAME=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ -z "$USERNAME" ]] || ! owner_exists; do
|
||||||
|
echo ""
|
||||||
|
info "Which account owns this Officer install?"
|
||||||
|
echo " Its files, its node_modules and its pm2 process list all belong to"
|
||||||
|
echo " this account rather than to root."
|
||||||
|
echo ""
|
||||||
|
ask_required USERNAME "Username" "${SUDO_USER:-}"
|
||||||
|
owner_exists || warn "There is no account called '${USERNAME}' on this machine."
|
||||||
|
done
|
||||||
|
|
||||||
|
resolve_user_home
|
||||||
|
|
||||||
|
# ── where it goes ──
|
||||||
|
if [[ -z "$OFFICER_ROOT" ]]; then
|
||||||
|
echo ""
|
||||||
|
info "Where should Officer be installed?"
|
||||||
|
echo " One directory holding the app, its data, the item store and any"
|
||||||
|
echo " containers the app store provisions."
|
||||||
|
echo ""
|
||||||
|
ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev"
|
||||||
|
fi
|
||||||
|
OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}"
|
||||||
|
[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'"
|
||||||
|
OFFICER_ROOT="${OFFICER_ROOT%/}"
|
||||||
|
|
||||||
|
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
|
||||||
|
# in, after a repository has been cloned and a database started, is a worse way
|
||||||
|
# to learn it.
|
||||||
|
echo ""
|
||||||
|
info "What Officer needs from this machine"
|
||||||
|
|
||||||
|
mapfile -t MISSING < <(missing_tools)
|
||||||
|
mapfile -t MISSING_OPT < <(missing_optional_tools)
|
||||||
|
|
||||||
|
for t in "${REQUIRED_TOOLS[@]}"; do
|
||||||
|
if command -v "$t" &>/dev/null; then
|
||||||
|
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
|
||||||
|
else
|
||||||
|
printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for t in "${OPTIONAL_TOOLS[@]}"; do
|
||||||
|
if command -v "$t" &>/dev/null; then
|
||||||
|
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
|
||||||
|
else
|
||||||
|
printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((${#MISSING[@]} > 0)); then
|
||||||
|
echo ""
|
||||||
|
fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((${#MISSING_OPT[@]} > 0)); then
|
||||||
|
echo ""
|
||||||
|
warn "No Docker. Postgres will have to be one you already run, and the app"
|
||||||
|
echo " store cannot provision anything until Docker is installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$PROGRESS_FILE" ]]; then
|
||||||
|
echo ""
|
||||||
|
info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped"
|
||||||
|
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo " This can be stopped at any point and run again later. Completed"
|
||||||
|
echo " steps are remembered and skipped."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. Layout
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Before the repository, because the repository is cloned into it.
|
||||||
|
|
||||||
|
report_section "Layout"
|
||||||
|
step "Layout"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Layout — everything Officer owns, under one root"
|
||||||
|
echo " ${OFFICER_ROOT}/"
|
||||||
|
echo " platform/ the app"
|
||||||
|
echo " data/ managed homes, attachments, job logs"
|
||||||
|
echo " dockers/ anything the app store provisions"
|
||||||
|
echo " capabilities/ skills, tools, tasks, processes"
|
||||||
|
echo ""
|
||||||
|
echo " Nothing here is configurable. The original asked separately for the"
|
||||||
|
echo " data directory and the item store, which were two answers that had"
|
||||||
|
echo " to agree with each other. One root now, and the rest follows."
|
||||||
|
echo ""
|
||||||
|
echo " To put data/ on a bigger volume later, symlink it — that is a"
|
||||||
|
echo " decision about storage rather than about how Officer is laid out."
|
||||||
|
|
||||||
|
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
|
||||||
|
if ((${#WRONG_OWNER[@]} > 0)); then
|
||||||
|
echo ""
|
||||||
|
warn "these exist but do not belong to ${USERNAME}:"
|
||||||
|
printf ' %s\n' "${WRONG_OWNER[@]}"
|
||||||
|
echo " Everything that writes into them runs as ${USERNAME} — the platform"
|
||||||
|
echo " under pm2, the app store's compose files, the item store the agent"
|
||||||
|
echo " authors into. Left as they are, those writes fail in a way that"
|
||||||
|
echo " reads as a bug in the platform."
|
||||||
|
if confirm "Give them to ${USERNAME}?"; then
|
||||||
|
for d in "${WRONG_OWNER[@]}"; do chown -R "${USERNAME}:$(user_group)" "$d"; done
|
||||||
|
ok "ownership corrected"
|
||||||
|
SUMMARY+=("Layout: ownership corrected on ${#WRONG_OWNER[@]} directory(ies)")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
create_layout
|
||||||
|
ok "layout in place under ${OFFICER_ROOT}"
|
||||||
|
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. Repository
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Repository"
|
||||||
|
step "Repository"
|
||||||
|
if ! skip; then
|
||||||
|
PLATFORM_DIR="$(platform_dir)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Repository — where the platform's code lives"
|
||||||
|
echo " path: ${PLATFORM_DIR}"
|
||||||
|
|
||||||
|
if repo_exists; then
|
||||||
|
echo " remote: $(repo_remote)"
|
||||||
|
echo " branch: $(repo_branch)"
|
||||||
|
echo " working: $(repo_is_dirty && echo 'has uncommitted changes' || echo 'clean')"
|
||||||
|
|
||||||
|
# Reported, never silently corrected. Repointing somebody's remote is a
|
||||||
|
# decision about where their work goes, and this script is not entitled to
|
||||||
|
# make it quietly.
|
||||||
|
if [[ -n "$(repo_remote)" && "$(repo_remote)" != "$OFFICER_REPO" ]]; then
|
||||||
|
echo ""
|
||||||
|
warn "this checkout points somewhere other than ${OFFICER_REPO}"
|
||||||
|
echo " Left alone. To move it:"
|
||||||
|
echo " git -C ${PLATFORM_DIR} remote set-url origin ${OFFICER_REPO}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if repo_is_dirty; then
|
||||||
|
echo ""
|
||||||
|
echo " not pulling — there are uncommitted changes here, and a pull"
|
||||||
|
echo " would either fail or bury them"
|
||||||
|
SUMMARY+=("Repository: present at ${PLATFORM_DIR}, left alone (uncommitted changes)")
|
||||||
|
elif confirm "Pull the latest changes?"; then
|
||||||
|
if pull_repo; then
|
||||||
|
ok "up to date on $(repo_branch)"
|
||||||
|
SUMMARY+=("Repository: pulled, on $(repo_branch)")
|
||||||
|
else
|
||||||
|
# --ff-only, so this means the branch has diverged rather than that the
|
||||||
|
# network failed. Saying which matters.
|
||||||
|
warn "could not fast-forward — the local branch has diverged from the remote"
|
||||||
|
ERRORS+=("Repository: pull refused, branch diverged")
|
||||||
|
SUMMARY+=("Repository: present, pull refused (diverged)")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
SUMMARY+=("Repository: present at ${PLATFORM_DIR}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo " nothing there yet"
|
||||||
|
echo ""
|
||||||
|
info "Clone from ${OFFICER_REPO}?"
|
||||||
|
echo " Cloned as ${USERNAME}, not as root — a repository owned by root is"
|
||||||
|
echo " one you cannot pull, commit in, or install into."
|
||||||
|
|
||||||
|
CLONE_URL="$OFFICER_REPO"
|
||||||
|
|
||||||
|
if confirm "Clone it now?"; then
|
||||||
|
if clone_repo "$CLONE_URL"; then
|
||||||
|
ok "cloned to ${PLATFORM_DIR} on $(repo_branch)"
|
||||||
|
SUMMARY+=("Repository: cloned from ${CLONE_URL}")
|
||||||
|
else
|
||||||
|
# GIT_TERMINAL_PROMPT=0 in clone_repo means this is a real failure rather
|
||||||
|
# than a prompt nobody answered.
|
||||||
|
fail "could not clone ${CLONE_URL} — nothing below can run without it."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "Nothing below can run without the repository."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. Dependencies
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Dependencies"
|
||||||
|
step "Dependencies"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Dependencies — bun install, as ${USERNAME}"
|
||||||
|
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
|
||||||
|
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
|
||||||
|
echo ""
|
||||||
|
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
|
||||||
|
echo " so a package.json that disagrees with it fails rather than quietly"
|
||||||
|
echo " picking newer versions. That friction is deliberate."
|
||||||
|
echo ""
|
||||||
|
echo " node-pty has no Linux prebuild, so this compiles it from source"
|
||||||
|
echo " every time — which is what build-essential and python3 are for."
|
||||||
|
|
||||||
|
if deps_installed && node_pty_built; then
|
||||||
|
ok "already installed, and node-pty is built"
|
||||||
|
SUMMARY+=("Dependencies: already installed")
|
||||||
|
elif confirm "Install them?"; then
|
||||||
|
if install_deps; then
|
||||||
|
if node_pty_built; then
|
||||||
|
ok "installed, node-pty built"
|
||||||
|
SUMMARY+=("Dependencies: installed")
|
||||||
|
else
|
||||||
|
# The install can succeed while the native module does not get built —
|
||||||
|
# bun skips a dependency's lifecycle scripts unless it trusts the
|
||||||
|
# package. Worth naming, because the symptom is a terminal that never
|
||||||
|
# comes up rather than an install error.
|
||||||
|
warn "installed, but node-pty has no built module at node_modules/node-pty/build/Release/"
|
||||||
|
echo " The terminal sidecar cannot start without it. Try:"
|
||||||
|
echo " cd $(platform_dir) && bun install --force"
|
||||||
|
ERRORS+=("Dependencies: node-pty not built")
|
||||||
|
SUMMARY+=("Dependencies: installed, node-pty NOT built")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "bun install failed"
|
||||||
|
echo " If it complained about the lockfile, package.json and bun.lock"
|
||||||
|
echo " disagree — that is the frozen lockfile doing its job, and it"
|
||||||
|
echo " wants a human to look at the diff."
|
||||||
|
ERRORS+=("Dependencies: bun install failed")
|
||||||
|
SUMMARY+=("Dependencies: FAILED")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "skipped by request"
|
||||||
|
SUMMARY+=("Dependencies: SKIPPED by request")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. Database
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# POSTGRES_URL is set here and written by the environment section below.
|
||||||
|
|
||||||
|
report_section "Database"
|
||||||
|
step "Database"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Database — Postgres, the only one Officer has"
|
||||||
|
echo " It holds the account, passkeys, settings, dashboards, email"
|
||||||
|
echo " accounts and the job queue. Nothing else in the platform is a"
|
||||||
|
echo " database."
|
||||||
|
echo ""
|
||||||
|
# One network for everything Officer provisions. Created before the compose
|
||||||
|
# file references it, since it is declared external there.
|
||||||
|
if ensure_docker_network; then
|
||||||
|
ok "docker network '${OFFICER_NETWORK}' created"
|
||||||
|
SUMMARY+=("Docker network: ${OFFICER_NETWORK} created")
|
||||||
|
elif docker_network_exists; 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')"
|
||||||
|
|
||||||
|
POSTGRES_URL=""
|
||||||
|
|
||||||
|
# An existing compose file means this ran before. Reuse its password rather
|
||||||
|
# than minting a new one, which would leave the container and the URL
|
||||||
|
# disagreeing about the credential.
|
||||||
|
if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then
|
||||||
|
POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")"
|
||||||
|
echo ""
|
||||||
|
echo " already provisioned here — reusing the password from $(pg_env_file)"
|
||||||
|
pg_container_running || {
|
||||||
|
info " starting it"
|
||||||
|
pg_compose_up >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
if pg_wait_ready; then
|
||||||
|
ok "postgres answering on 127.0.0.1:${PG_PORT}"
|
||||||
|
SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}")
|
||||||
|
else
|
||||||
|
warn "the container is not answering — check: docker logs ${PG_CONTAINER}"
|
||||||
|
ERRORS+=("Database: provisioned but not answering")
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
info "Which Postgres should Officer use?"
|
||||||
|
echo ""
|
||||||
|
echo " [1] provision one here"
|
||||||
|
echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only."
|
||||||
|
echo " Docker publishes ports by writing iptables rules beneath ufw,"
|
||||||
|
echo " so a database published to every interface is reachable from"
|
||||||
|
echo " the internet whatever the firewall says. Loopback is all the"
|
||||||
|
echo " platform needs — it runs on this machine."
|
||||||
|
echo ""
|
||||||
|
echo " [2] use one you already run"
|
||||||
|
echo " Give the connection URL. Nothing is provisioned."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
DB_PICK=""
|
||||||
|
while [[ -z "$DB_PICK" ]]; do
|
||||||
|
if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then
|
||||||
|
echo ""
|
||||||
|
fail "No answer."
|
||||||
|
fi
|
||||||
|
case "${DB_CHOICE:-1}" in
|
||||||
|
1)
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
warn "Docker is not installed, so there is nothing to provision into."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if pg_port_in_use; then
|
||||||
|
warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind"
|
||||||
|
echo " If that is a Postgres you already run, pick 2 and give its URL."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
DB_PICK=provision
|
||||||
|
;;
|
||||||
|
2) DB_PICK=existing ;;
|
||||||
|
*) warn "Pick 1 or 2." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$DB_PICK" == provision ]]; then
|
||||||
|
PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
|
||||||
|
write_pg_compose "$PG_PASSWORD"
|
||||||
|
ok "compose written to $(pg_compose_file)"
|
||||||
|
if pg_compose_up && pg_wait_ready; then
|
||||||
|
POSTGRES_URL="$(pg_url "$PG_PASSWORD")"
|
||||||
|
ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'"
|
||||||
|
SUMMARY+=("Database: provisioned at $(pg_service_dir)")
|
||||||
|
else
|
||||||
|
warn "the container did not come up — check: docker logs ${PG_CONTAINER}"
|
||||||
|
ERRORS+=("Database: container did not start")
|
||||||
|
SUMMARY+=("Database: provisioning FAILED")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer"
|
||||||
|
if pg_url_works "$POSTGRES_URL"; then
|
||||||
|
ok "reachable"
|
||||||
|
SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…")
|
||||||
|
else
|
||||||
|
# Not fatal. The URL may be right and the database not started yet, and
|
||||||
|
# refusing to continue over that would be worse than saying so.
|
||||||
|
warn "could not connect with that URL"
|
||||||
|
echo " Kept anyway — check it before running the schema step."
|
||||||
|
ERRORS+=("Database: the given URL did not answer")
|
||||||
|
SUMMARY+=("Database: existing URL kept, did not answer")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 6. Environment
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Environment"
|
||||||
|
step "Environment"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Environment — $(env_file)"
|
||||||
|
|
||||||
|
# Read back before anything is asked; existing values become the defaults.
|
||||||
|
ENV_PORT="$(env_get PORT)"
|
||||||
|
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
|
||||||
|
|
||||||
|
if env_exists; then
|
||||||
|
echo " exists — its values are the defaults below"
|
||||||
|
else
|
||||||
|
echo " does not exist yet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── what is asked ──
|
||||||
|
echo ""
|
||||||
|
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " PUBLIC_URL is where Officer is reached from a browser. It is the one"
|
||||||
|
echo " thing this machine cannot work out for itself, and three things need"
|
||||||
|
echo " it: the OpenGraph tags baked into the page by 'bun gen:index', the"
|
||||||
|
echo " host the task API hands to scripts, and the CalDAV profile an iPhone"
|
||||||
|
echo " installs — that last one requires https."
|
||||||
|
echo ""
|
||||||
|
echo " Defaulting to this machine's tailnet address, not localhost: the"
|
||||||
|
echo " tailnet is where Officer is actually reached from, and localhost"
|
||||||
|
echo " works from here and nowhere else."
|
||||||
|
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-$(default_public_url "$ENV_PORT")}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " to write:"
|
||||||
|
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"
|
||||||
|
echo " parent of the repo, so data/, capabilities/ and dockers/ follow from"
|
||||||
|
echo " ${OFFICER_ROOT} without anything having to agree with anything."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
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
|
||||||
|
warn "skipped by request"
|
||||||
|
SUMMARY+=("Environment: SKIPPED by request")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 7. Secrets
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# 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 ""
|
||||||
|
|
||||||
|
report_mark_complete
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — shared foundation
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Sourced by officer-setup.sh before anything runs. DEFINITIONS ONLY, the same
|
||||||
|
# rule machine-setup/lib holds to: nothing here installs, writes or restarts.
|
||||||
|
#
|
||||||
|
# ── Why this is a separate script from machine-setup ──
|
||||||
|
#
|
||||||
|
# They answer different questions. machine-setup asks what a MACHINE should be —
|
||||||
|
# users, ssh, firewall, runtimes — and is worth running on a box that will never
|
||||||
|
# see Officer. This one puts Officer on a machine that is already ready, and
|
||||||
|
# assumes nothing about how it got that way.
|
||||||
|
#
|
||||||
|
# The split also means the failure modes stay apart: a broken firewall rule and a
|
||||||
|
# failed database migration are not the same kind of problem and should not be
|
||||||
|
# in the same run.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_BASE_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_BASE_LOADED=1
|
||||||
|
|
||||||
|
SUMMARY=()
|
||||||
|
ERRORS=()
|
||||||
|
CURRENT_STEP=""
|
||||||
|
SKIP_STEP=false
|
||||||
|
|
||||||
|
USERNAME="${SETUP_USERNAME:-}"
|
||||||
|
USER_HOME=""
|
||||||
|
OFFICER_ROOT="${OFFICER_ROOT:-}"
|
||||||
|
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||||
|
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
||||||
|
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
||||||
|
fail() {
|
||||||
|
echo -e " ${RED}FAIL${NC}: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ONLY_STEP="${ONLY_STEP:-}"
|
||||||
|
|
||||||
|
step() {
|
||||||
|
CURRENT_STEP="$1"
|
||||||
|
if [[ -n "$ONLY_STEP" ]]; then
|
||||||
|
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||||
|
SKIP_STEP=false
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}── $1 ──${NC}"
|
||||||
|
else
|
||||||
|
SKIP_STEP=true
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
||||||
|
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
||||||
|
SKIP_STEP=true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
SKIP_STEP=false
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}── $1 ──${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
skip() { [[ "$SKIP_STEP" == true ]]; }
|
||||||
|
|
||||||
|
step_ok() {
|
||||||
|
[[ -n "$ONLY_STEP" ]] && return 0
|
||||||
|
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
page() {
|
||||||
|
if [[ -t 1 ]] && command -v more &>/dev/null; then more; else cat; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
local message="${1:-Proceed?}" default="${2:-y}" help_fn="${3:-}" answer prompt
|
||||||
|
|
||||||
|
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
||||||
|
|
||||||
|
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
||||||
|
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if ! read -rp " ${message} ${prompt}: " answer; then
|
||||||
|
echo ""
|
||||||
|
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
||||||
|
fi
|
||||||
|
[[ -z "$answer" ]] && answer="$default"
|
||||||
|
case "$answer" in
|
||||||
|
y | Y | yes | Yes) return 0 ;;
|
||||||
|
n | N | no | No) return 1 ;;
|
||||||
|
"?")
|
||||||
|
if [[ -n "$help_fn" ]]; then
|
||||||
|
echo ""
|
||||||
|
"$help_fn" | page
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
warn "Answer y or n."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
fail "No answer."
|
||||||
|
fi
|
||||||
|
answer="${answer:-$default}"
|
||||||
|
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
||||||
|
done
|
||||||
|
printf -v "$__var" '%s' "$answer"
|
||||||
|
}
|
||||||
|
|
||||||
|
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||||
|
|
||||||
|
# Run something as the account that owns the install. Officer's files, its
|
||||||
|
# node_modules and its pm2 process list all belong to that account, not to root —
|
||||||
|
# a repository cloned as root is one the owner cannot pull.
|
||||||
|
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
|
||||||
@@ -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"; }
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — the environment file
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# ── No secrets are written here ──
|
||||||
|
#
|
||||||
|
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
|
||||||
|
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
|
||||||
|
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
|
||||||
|
#
|
||||||
|
# So this file holds no credential except POSTGRES_URL, which is a connection
|
||||||
|
# string to a database bound to loopback.
|
||||||
|
#
|
||||||
|
# ── Derived, not asked ──
|
||||||
|
#
|
||||||
|
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing
|
||||||
|
# replaces them. The platform derives the install root as the parent of its own
|
||||||
|
# working directory, so data/, capabilities/ and dockers/ follow from the layout
|
||||||
|
# on disk, and the owner's home comes from the OS. They were three environment
|
||||||
|
# variables that had to agree with each other and with the directory tree.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_ENV_LOADED=1
|
||||||
|
|
||||||
|
env_file() { echo "$(platform_dir)/.env"; }
|
||||||
|
|
||||||
|
env_exists() { [[ -f "$(env_file)" ]]; }
|
||||||
|
|
||||||
|
# One value out of an existing .env, without sourcing it — the file holds
|
||||||
|
# secrets and arbitrary shell would run as root.
|
||||||
|
env_get() {
|
||||||
|
[[ -r "$(env_file)" ]] || return 0
|
||||||
|
awk -F= -v k="$1" '
|
||||||
|
$1 == k {
|
||||||
|
v = substr($0, index($0, "=") + 1)
|
||||||
|
gsub(/^"|"$/, "", v)
|
||||||
|
print v
|
||||||
|
exit
|
||||||
|
}' "$(env_file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_env() {
|
||||||
|
local dest
|
||||||
|
dest="$(env_file)"
|
||||||
|
|
||||||
|
[[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup"
|
||||||
|
|
||||||
|
# Restrictive from the moment it exists rather than chmod'd afterwards, so the
|
||||||
|
# secrets are never briefly world-readable. Restored straight after: umask is
|
||||||
|
# not scoped to a function, and leaving it at 077 would quietly make every file
|
||||||
|
# a later section creates owner-only.
|
||||||
|
local prior_umask
|
||||||
|
prior_umask="$(umask)"
|
||||||
|
umask 077
|
||||||
|
cat >"$dest" <<ENVF
|
||||||
|
# Written by officer-setup.
|
||||||
|
#
|
||||||
|
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
|
||||||
|
# holds the token-signing secret and the database credential.
|
||||||
|
|
||||||
|
PORT="${ENV_PORT}"
|
||||||
|
|
||||||
|
# Where Officer is reached from a browser. Not derivable — see the section.
|
||||||
|
PUBLIC_URL="${ENV_PUBLIC_URL}"
|
||||||
|
|
||||||
|
POSTGRES_URL="${POSTGRES_URL}"
|
||||||
|
|
||||||
|
ENVF
|
||||||
|
|
||||||
|
umask "$prior_umask"
|
||||||
|
|
||||||
|
chown "${USERNAME}:$(user_group)" "$dest"
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — the install layout
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# ── One root, and nothing configurable underneath it ──
|
||||||
|
#
|
||||||
|
# $OFFICER_ROOT/
|
||||||
|
# platform/ the app — the git checkout
|
||||||
|
# data/ DATA_PATH: managed homes, attachments, job logs
|
||||||
|
# dockers/ services the app store provisioned
|
||||||
|
# capabilities/ the file-based item store — skills, tools, tasks, processes
|
||||||
|
#
|
||||||
|
# The original asked separately for DATA_PATH and for OFFICER_ITEMS_DIR, and left
|
||||||
|
# the app store's directory implicit. Three answers that had to agree with each
|
||||||
|
# other, given by somebody with no reason to know they had to.
|
||||||
|
#
|
||||||
|
# Now one question — where the root goes — and the rest follows. Anybody who wants
|
||||||
|
# data/ on a bigger volume can symlink it; that is a decision about storage, not
|
||||||
|
# about how Officer is laid out, and it does not need a prompt in a setup script.
|
||||||
|
#
|
||||||
|
# This is also what the code already assumes. app-store/paths.ts derives
|
||||||
|
# OFFICER_ROOT as dirname(DATA_PATH) and DOCKERS_DIR as OFFICER_ROOT/dockers, so
|
||||||
|
# setting DATA_PATH to <root>/data is the whole of what makes the layout correct.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_LAYOUT_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_LAYOUT_LOADED=1
|
||||||
|
|
||||||
|
layout_data_dir() { echo "${OFFICER_ROOT}/data"; }
|
||||||
|
layout_dockers_dir() { echo "${OFFICER_ROOT}/dockers"; }
|
||||||
|
layout_items_dir() { echo "${OFFICER_ROOT}/capabilities"; }
|
||||||
|
|
||||||
|
layout_dirs() {
|
||||||
|
echo "$OFFICER_ROOT"
|
||||||
|
echo "$(layout_data_dir)"
|
||||||
|
echo "$(layout_dockers_dir)"
|
||||||
|
echo "$(layout_items_dir)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Created owned by the account, because everything that writes into them runs as
|
||||||
|
# the account: the platform under pm2, the app store's compose files, the item
|
||||||
|
# store the agent authors into.
|
||||||
|
create_layout() {
|
||||||
|
local dir
|
||||||
|
while read -r dir; do
|
||||||
|
[[ -d "$dir" ]] || install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||||
|
done < <(layout_dirs)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# A directory that exists but belongs to somebody else is the failure this
|
||||||
|
# reports: it happens when an earlier run, or a hand-made directory, was created
|
||||||
|
# as root, and everything written into it afterwards fails in a way that reads as
|
||||||
|
# a permissions bug in the platform.
|
||||||
|
layout_wrong_owner() {
|
||||||
|
local dir
|
||||||
|
while read -r dir; do
|
||||||
|
[[ -d "$dir" ]] || continue
|
||||||
|
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
|
||||||
|
done < <(layout_dirs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — Postgres
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# ── Only Postgres ──
|
||||||
|
#
|
||||||
|
# The original offered five containers. Of those, Redis and SearXNG are not
|
||||||
|
# referenced anywhere in the platform — no import, no environment variable, no
|
||||||
|
# mention — and Nginx Proxy Manager is a deployment choice rather than something
|
||||||
|
# a setup script should pick. Mailhog is a development convenience and is offered
|
||||||
|
# separately.
|
||||||
|
#
|
||||||
|
# Postgres is the only one Officer cannot run without: it is the single database,
|
||||||
|
# holding the account, passkeys, settings, dashboards, email accounts and the
|
||||||
|
# queue.
|
||||||
|
#
|
||||||
|
# ── Where it goes ──
|
||||||
|
#
|
||||||
|
# $OFFICER_ROOT/dockers/postgres/, which is the same convention the app store
|
||||||
|
# uses for anything it provisions: one directory per service, the compose file
|
||||||
|
# inside it, and RELATIVE bind mounts so the data sits beside the compose file
|
||||||
|
# where both a human and the platform can find it.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_POSTGRES_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_POSTGRES_LOADED=1
|
||||||
|
|
||||||
|
# One network for everything Officer provisions, so containers can reach each
|
||||||
|
# other by name. Postgres needs nothing from it today — the platform is a host
|
||||||
|
# process and reaches it over loopback — but a reverse proxy in front of the web
|
||||||
|
# UI, or any app-store service that talks to another, does. Creating it now means
|
||||||
|
# the later ones do not have to migrate onto it.
|
||||||
|
OFFICER_NETWORK="${OFFICER_NETWORK:-officerdev}"
|
||||||
|
|
||||||
|
PG_IMAGE="${PG_IMAGE:-postgres:18-alpine}"
|
||||||
|
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
|
||||||
|
docker network create "$OFFICER_NETWORK" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_service_dir() { echo "${OFFICER_ROOT}/dockers/postgres"; }
|
||||||
|
pg_compose_file() { echo "$(pg_service_dir)/docker-compose.yaml"; }
|
||||||
|
pg_env_file() { echo "$(pg_service_dir)/.env"; }
|
||||||
|
|
||||||
|
pg_compose_exists() { [[ -f "$(pg_compose_file)" ]]; }
|
||||||
|
pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q .; }
|
||||||
|
|
||||||
|
# Is something already answering on the port? A Postgres the user runs their own
|
||||||
|
# way is a perfectly good answer, and finding out by failing to bind is not.
|
||||||
|
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
|
||||||
|
|
||||||
|
# Bound to loopback, deliberately, and the reason is worth keeping next to the
|
||||||
|
# line it explains.
|
||||||
|
#
|
||||||
|
# Publishing a port makes Docker write its own DNAT and ACCEPT rules into
|
||||||
|
# iptables, and those are evaluated BEFORE ufw sees the packet. So `ports:
|
||||||
|
# "5432:5432"` is reachable from the internet while `ufw status` reports
|
||||||
|
# everything denied. Binding to 127.0.0.1 sidesteps it entirely: the DNAT rule
|
||||||
|
# only matches traffic arriving on loopback.
|
||||||
|
#
|
||||||
|
# Loopback is not the whole story, though, and the password is not decoration.
|
||||||
|
# Every account ON this machine can open 127.0.0.1:5432 — including the per-user
|
||||||
|
# Linux accounts Officer gives its members. What stops them is that they cannot
|
||||||
|
# authenticate. The password is the boundary between the platform and anyone
|
||||||
|
# with a login here, which is why it is random and why both files holding it are
|
||||||
|
# 0600.
|
||||||
|
write_pg_compose() {
|
||||||
|
local password="$1" dir
|
||||||
|
dir="$(pg_service_dir)"
|
||||||
|
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||||
|
|
||||||
|
cat >"$(pg_compose_file)" <<COMPOSE
|
||||||
|
# Written by officer-setup. Officer's database.
|
||||||
|
#
|
||||||
|
# The port is bound to 127.0.0.1 on purpose. Docker publishes ports by writing
|
||||||
|
# iptables rules beneath ufw, so "5432:5432" would be reachable from the internet
|
||||||
|
# whatever the firewall reports. The platform runs on this machine, so loopback
|
||||||
|
# is all it needs.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: ${PG_IMAGE}
|
||||||
|
container_name: ${PG_CONTAINER}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PG_PORT}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${PG_DATABASE}
|
||||||
|
PGDATA: /var/lib/postgresql/data
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/lib/postgresql/data
|
||||||
|
- ./dumps:/dumps
|
||||||
|
networks:
|
||||||
|
- ${OFFICER_NETWORK}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
networks:
|
||||||
|
${OFFICER_NETWORK}:
|
||||||
|
external: true
|
||||||
|
COMPOSE
|
||||||
|
|
||||||
|
# The password lives beside the compose file rather than inside it, so the
|
||||||
|
# compose file can be read, copied or committed without carrying a credential.
|
||||||
|
umask 077
|
||||||
|
cat >"$(pg_env_file)" <<ENVF
|
||||||
|
# Written by officer-setup. Read by docker compose from this directory.
|
||||||
|
POSTGRES_PASSWORD=${password}
|
||||||
|
ENVF
|
||||||
|
chown "${USERNAME}:$(user_group)" "$(pg_compose_file)" "$(pg_env_file)"
|
||||||
|
chmod 600 "$(pg_env_file)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_password_from_env_file() {
|
||||||
|
[[ -r "$(pg_env_file)" ]] || return 1
|
||||||
|
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
|
||||||
|
|
||||||
|
# Wait for it to answer, rather than assuming `up -d` means ready. Postgres
|
||||||
|
# initialises its data directory on first start, which takes several seconds, and
|
||||||
|
# everything after this — db:push especially — fails confusingly against a
|
||||||
|
# database that is still starting.
|
||||||
|
pg_wait_ready() {
|
||||||
|
local tries="${1:-30}"
|
||||||
|
while ((tries-- > 0)); do
|
||||||
|
docker exec "$PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1 && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_url() { echo "postgresql://postgres:${1}@127.0.0.1:${PG_PORT}/${PG_DATABASE}"; }
|
||||||
|
|
||||||
|
# Does this URL actually answer? Asked of any URL, provisioned or given, because
|
||||||
|
# a database nobody can reach is the failure that makes every later section look
|
||||||
|
# broken for its own reasons.
|
||||||
|
pg_url_works() {
|
||||||
|
local url="$1"
|
||||||
|
as_owner "docker run --rm --network host ${PG_IMAGE} psql '${url}' -c 'select 1' >/dev/null 2>&1" /
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — is this machine ready
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# ── Inherited, not re-asked ──
|
||||||
|
#
|
||||||
|
# machine-setup saves the account, the Officer path and the machine role beside
|
||||||
|
# itself. This reads the same file, so a normal run — machine-setup, then this —
|
||||||
|
# asks nothing at all. It only prompts on a machine where machine-setup never
|
||||||
|
# ran, which is a supported case rather than an error: somebody may have
|
||||||
|
# provisioned the box their own way.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_PREFLIGHT_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_PREFLIGHT_LOADED=1
|
||||||
|
|
||||||
|
# Where machine-setup keeps what it was told. Beside this script, one directory
|
||||||
|
# across.
|
||||||
|
MACHINE_ANSWERS="${MACHINE_ANSWERS:-${SCRIPT_DIR}/machine-setup/.setup-answers}"
|
||||||
|
|
||||||
|
# Read as assignments rather than sourced: the file is read by a root run and
|
||||||
|
# sourcing it would make it executable content.
|
||||||
|
load_machine_answers() {
|
||||||
|
[[ -r "$MACHINE_ANSWERS" ]] || return 1
|
||||||
|
local key value
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
||||||
|
[[ -n "$value" ]] || continue
|
||||||
|
case "$key" in
|
||||||
|
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||||
|
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
|
||||||
|
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||||
|
esac
|
||||||
|
done <"$MACHINE_ANSWERS"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# What Officer needs to already be here, and what installs it.
|
||||||
|
#
|
||||||
|
# Checked together and reported together: finding out about a missing bun three
|
||||||
|
# sections in, after the repository has been cloned and a database started, is a
|
||||||
|
# worse way to learn it than being told at the start.
|
||||||
|
REQUIRED_TOOLS=(git node bun pm2)
|
||||||
|
OPTIONAL_TOOLS=(docker)
|
||||||
|
|
||||||
|
missing_tools() {
|
||||||
|
local t
|
||||||
|
for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_optional_tools() {
|
||||||
|
local t
|
||||||
|
for t in "${OPTIONAL_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_why() {
|
||||||
|
case "$1" in
|
||||||
|
git) echo "to clone and update the platform" ;;
|
||||||
|
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
||||||
|
bun) echo "the platform itself and nineteen of the twenty processes" ;;
|
||||||
|
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
||||||
|
docker) echo "Postgres, and anything the app store provisions" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The account has to exist before anything is written to its home.
|
||||||
|
owner_exists() { id "$USERNAME" &>/dev/null; }
|
||||||
|
|
||||||
|
resolve_user_home() {
|
||||||
|
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||||
|
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — the repository
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# ── Cloned as the owner, never as root ──
|
||||||
|
#
|
||||||
|
# A repository cloned by root is one the owner cannot pull, cannot commit in, and
|
||||||
|
# whose node_modules they cannot write. Every git operation here runs as the
|
||||||
|
# account, from a directory that account can stat.
|
||||||
|
|
||||||
|
[[ -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"; }
|
||||||
|
|
||||||
|
repo_exists() { [[ -d "$(platform_dir)/.git" ]]; }
|
||||||
|
|
||||||
|
repo_remote() { (cd "$(platform_dir)" 2>/dev/null && git remote get-url origin 2>/dev/null) || true; }
|
||||||
|
repo_branch() { (cd "$(platform_dir)" 2>/dev/null && git branch --show-current 2>/dev/null) || true; }
|
||||||
|
repo_is_dirty() { [[ -n "$(cd "$(platform_dir)" 2>/dev/null && git status --porcelain 2>/dev/null)" ]]; }
|
||||||
|
|
||||||
|
# Split an ssh:// URL into host and port, for the reachability check below.
|
||||||
|
repo_ssh_host() { sed -E 's|^ssh://[^@]*@([^:/]+).*|\1|' <<<"$1"; }
|
||||||
|
repo_ssh_port() { sed -nE 's|^ssh://[^@]*@[^:]+:([0-9]+)/.*|\1|p' <<<"$1"; }
|
||||||
|
|
||||||
|
# Can this account actually clone it?
|
||||||
|
#
|
||||||
|
# `git ls-remote` is the real question — not "does the host answer" but "can this
|
||||||
|
# account read this repository". Both prompts are disabled, because neither fails
|
||||||
|
# cleanly on its own: over https git asks for a username nobody is there to type,
|
||||||
|
# and over ssh it asks for a password or stops on host-key verification. With
|
||||||
|
# both off, an unreachable or unreadable repository is an immediate non-zero
|
||||||
|
# instead of a hang.
|
||||||
|
repo_reachable() {
|
||||||
|
as_owner "GIT_TERMINAL_PROMPT=0 \
|
||||||
|
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8' \
|
||||||
|
timeout 20 git ls-remote '$1' >/dev/null 2>&1" /
|
||||||
|
}
|
||||||
|
|
||||||
|
# The https form of the same repository, for a machine with no key.
|
||||||
|
repo_https_url() {
|
||||||
|
sed -E 's|^ssh://[^@]*@([^:/]+)(:[0-9]+)?/|https://\1/|' <<<"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_repo() {
|
||||||
|
local url="$1" dest
|
||||||
|
dest="$(platform_dir)"
|
||||||
|
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$OFFICER_ROOT"
|
||||||
|
as_owner "GIT_TERMINAL_PROMPT=0 git clone '${url}' '${dest}'" /
|
||||||
|
}
|
||||||
|
|
||||||
|
pull_repo() { as_owner "git -C '$(platform_dir)' pull --ff-only" /; }
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Dependencies
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ── The lockfile is frozen, and that is the point ──
|
||||||
|
#
|
||||||
|
# bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
|
||||||
|
# bun.lock and nothing else. A package.json that disagrees with the lockfile is a
|
||||||
|
# hard failure rather than a quiet resolution — which is deliberate: the friction
|
||||||
|
# exists so that an unexplained lockfile change shows up in a diff. See the
|
||||||
|
# supply-chain note in CLAUDE.md.
|
||||||
|
#
|
||||||
|
# So a failure here is usually one of two things, and they need different
|
||||||
|
# answers: the lockfile genuinely disagrees with package.json, or node-pty failed
|
||||||
|
# to build. Both are reported as such rather than as "install failed".
|
||||||
|
|
||||||
|
deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
|
||||||
|
|
||||||
|
# node-pty has no Linux prebuild, so `bun install` compiles it every time. This is
|
||||||
|
# the artefact that proves it worked, and its absence is why the terminal sidecar
|
||||||
|
# would not start.
|
||||||
|
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
|
||||||
|
|
||||||
|
# Auto-install TPM if missing
|
||||||
|
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
|
||||||
|
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
|
||||||
|
|
||||||
|
# Plugin list
|
||||||
|
set -g @plugin 'tmux-plugins/tpm'
|
||||||
|
|
||||||
|
# remap prefix from 'C-b' to 'C-a'
|
||||||
|
unbind C-b
|
||||||
|
set-option -g prefix C-a
|
||||||
|
bind-key C-a send-prefix
|
||||||
|
|
||||||
|
set -g base-index 1
|
||||||
|
|
||||||
|
# split panes using | and -
|
||||||
|
unbind '"'
|
||||||
|
unbind %
|
||||||
|
bind | split-window -h
|
||||||
|
bind - split-window -v
|
||||||
|
|
||||||
|
# reload config file (change file location to your the tmux.conf you want to use)
|
||||||
|
unbind r
|
||||||
|
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||||
|
|
||||||
|
# switch panes using Alt-arrow without prefix
|
||||||
|
bind -n M-Left select-pane -L
|
||||||
|
bind -n M-Right select-pane -R
|
||||||
|
bind -n M-Up select-pane -U
|
||||||
|
bind -n M-Down select-pane -D
|
||||||
|
# switch panes using Alt-HJKL without prefix
|
||||||
|
bind -n M-h select-pane -L
|
||||||
|
bind -n M-l select-pane -R
|
||||||
|
bind -n M-k select-pane -U
|
||||||
|
bind -n M-j select-pane -D
|
||||||
|
|
||||||
|
# Enable mouse control (clickable windows, panes, resizable panes)
|
||||||
|
|
||||||
|
# don't rename windows automatically
|
||||||
|
set-option -g allow-rename off
|
||||||
|
|
||||||
|
######################
|
||||||
|
### DESIGN CHANGES ###
|
||||||
|
######################
|
||||||
|
|
||||||
|
# loud or quiet?
|
||||||
|
set -g visual-activity off
|
||||||
|
set -g visual-bell off
|
||||||
|
set -g visual-silence off
|
||||||
|
setw -g monitor-activity off
|
||||||
|
set -g bell-action none
|
||||||
|
|
||||||
|
# modes
|
||||||
|
setw -g clock-mode-colour colour12
|
||||||
|
setw -g mode-style 'fg=colour1 bg=colour18 bold'
|
||||||
|
|
||||||
|
# panes
|
||||||
|
set -g pane-border-style 'fg=colour19 bg=colour0'
|
||||||
|
set -g pane-active-border-style 'bg=colour0 fg=colour9'
|
||||||
|
|
||||||
|
# statusbar
|
||||||
|
set -g status-position bottom
|
||||||
|
set -g status-justify left
|
||||||
|
set -g status-style 'bg=colour2 fg=colour23'
|
||||||
|
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
|
||||||
|
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
|
||||||
|
# set -g status-left-length 20
|
||||||
|
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
|
||||||
|
set -g status-right-length 50
|
||||||
|
set -g status-left-length 20
|
||||||
|
|
||||||
|
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
|
||||||
|
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
|
||||||
|
|
||||||
|
setw -g window-status-style 'fg=colour9 bg=colour18'
|
||||||
|
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
|
||||||
|
|
||||||
|
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
|
||||||
|
# ...existing code...
|
||||||
|
# messages
|
||||||
|
set -g message-style 'fg=#ffffff bg=red bold'
|
||||||
|
# Change the font color for the exit pane confirmation message
|
||||||
|
set -g message-command-style 'fg=#ffffff bg=red bold'
|
||||||
|
|
||||||
|
# ...existing code...
|
||||||
|
|
||||||
|
# messages
|
||||||
|
# set -g message-style 'fg=colour232 bg=colour16 bold'
|
||||||
|
|
||||||
|
##########################
|
||||||
|
### END DESIGN CHANGES ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
##########################
|
||||||
|
### EASY MOUSE SCROLL ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
set -g mouse on
|
||||||
|
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
|
||||||
@@ -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.
|
||||||
@@ -91,8 +91,6 @@ export function App() {
|
|||||||
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
|
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
|
||||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||||
<Route path="/processes/:dirName" 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" element={<Dashboard.JobsPage />} />
|
||||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Card } from '@/components/Card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
|
import { randomId } from 'helpers/random-id';
|
||||||
|
|
||||||
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||||
|
|
||||||
@@ -571,7 +572,7 @@ export const PipelineJobDetail = () => {
|
|||||||
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
||||||
const text = msg.text || streamBuffers.current.get(key) || '';
|
const text = msg.text || streamBuffers.current.get(key) || '';
|
||||||
if (text) {
|
if (text) {
|
||||||
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||||
}
|
}
|
||||||
streamBuffers.current.delete(key);
|
streamBuffers.current.delete(key);
|
||||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||||
@@ -583,7 +584,7 @@ export const PipelineJobDetail = () => {
|
|||||||
// Flush any streaming text before the tool call
|
// Flush any streaming text before the tool call
|
||||||
flushStreamBuffer(key);
|
flushStreamBuffer(key);
|
||||||
appendOutput(key, {
|
appendOutput(key, {
|
||||||
id: crypto.randomUUID(),
|
id: randomId(),
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
toolCallId: msg.toolCallId,
|
toolCallId: msg.toolCallId,
|
||||||
toolName: msg.toolName,
|
toolName: msg.toolName,
|
||||||
@@ -630,7 +631,7 @@ export const PipelineJobDetail = () => {
|
|||||||
const flushStreamBuffer = useCallback((key: string) => {
|
const flushStreamBuffer = useCallback((key: string) => {
|
||||||
const text = streamBuffers.current.get(key);
|
const text = streamBuffers.current.get(key);
|
||||||
if (text) {
|
if (text) {
|
||||||
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
appendOutput(key, { id: randomId(), type: 'text', text });
|
||||||
streamBuffers.current.delete(key);
|
streamBuffers.current.delete(key);
|
||||||
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Code,
|
Code,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ScrollText,
|
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Monitor,
|
Monitor,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -170,12 +169,15 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
||||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
{ 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: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
{ 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
|
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||||
// things that disappears when uninstalled.
|
// things that disappears when uninstalled.
|
||||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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
|
// 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.
|
// instead of a session everything shares.
|
||||||
@@ -39,7 +40,7 @@ const formatDate = (value: string | null) =>
|
|||||||
|
|
||||||
const copy = async (text: string) => {
|
const copy = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await copyToClipboard(text);
|
||||||
toast.success('Key copied');
|
toast.success('Key copied');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not copy — select and copy manually');
|
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { copyToClipboard } from 'helpers/clipboard';
|
||||||
|
|
||||||
type RelayToken = {
|
type RelayToken = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -46,7 +47,7 @@ export const BrowserRelay = () => {
|
|||||||
|
|
||||||
const handleCopy = async (value: string, field: string) => {
|
const handleCopy = async (value: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await copyToClipboard(value);
|
||||||
setCopiedField(field);
|
setCopiedField(field);
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
setTimeout(() => setCopiedField(null), 2000);
|
setTimeout(() => setCopiedField(null), 2000);
|
||||||
|
|||||||
+2
-1
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { copyToClipboard } from 'helpers/clipboard';
|
||||||
|
|
||||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
// 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) => {
|
const copy = async (text: string, what: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await copyToClipboard(text);
|
||||||
toast.success(`${what} copied`);
|
toast.success(`${what} copied`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not copy — select and copy manually');
|
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 { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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.
|
// 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) => {
|
const copy = (value: string, what: string) => {
|
||||||
void navigator.clipboard.writeText(value);
|
void copyToClipboard(value);
|
||||||
toast.success(`${what} copied`);
|
toast.success(`${what} copied`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,7 +284,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
|||||||
size="icon"
|
size="icon"
|
||||||
disabled={!form.password}
|
disabled={!form.password}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(form.password);
|
void copyToClipboard(form.password);
|
||||||
toast.success('Password copied');
|
toast.success('Password copied');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
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 { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { CreateUserForm } from './CreateUserForm';
|
import { CreateUserForm } from './CreateUserForm';
|
||||||
|
import { copyToClipboard } from 'helpers/clipboard';
|
||||||
|
|
||||||
type ManagedUser = {
|
type ManagedUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -32,8 +33,6 @@ type ManagedUser = {
|
|||||||
|
|
||||||
type UsersResponse = {
|
type UsersResponse = {
|
||||||
users: ManagedUser[];
|
users: ManagedUser[];
|
||||||
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
|
|
||||||
osUsersEnabled: boolean;
|
|
||||||
/** Every role, for displaying the owner's own value. */
|
/** Every role, for displaying the owner's own value. */
|
||||||
roles: string[];
|
roles: string[];
|
||||||
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
|
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
|
||||||
@@ -48,6 +47,9 @@ export const UsersSection = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | 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>({
|
const { data, isLoading, isError } = useQuery<UsersResponse>({
|
||||||
queryKey: USERS_KEY,
|
queryKey: USERS_KEY,
|
||||||
@@ -107,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) => {
|
const remove = async (user: ManagedUser) => {
|
||||||
setPendingId(user.id);
|
setPendingId(user.id);
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
@@ -183,7 +206,7 @@ export const UsersSection = () => {
|
|||||||
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
|
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
|
||||||
anyone who has one (retry after fixing a host problem, or replace their key) — the
|
anyone who has one (retry after fixing a host problem, or replace their key) — the
|
||||||
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
|
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
|
||||||
{data.osUsersEnabled && !user.isOwner && (
|
{!user.isOwner && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -211,7 +234,7 @@ export const UsersSection = () => {
|
|||||||
aria-label={`Copy ${user.email}'s SSH public key`}
|
aria-label={`Copy ${user.email}'s SSH public key`}
|
||||||
title="Copy their SSH public key (add it to their Gitea account)"
|
title="Copy their SSH public key (add it to their Gitea account)"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(user.osSshPublicKey!);
|
void copyToClipboard(user.osSshPublicKey!);
|
||||||
toast.success('Public key copied');
|
toast.success('Public key copied');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -219,6 +242,22 @@ export const UsersSection = () => {
|
|||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -256,6 +295,69 @@ export const UsersSection = () => {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,7 +6,6 @@ export * from './Processes';
|
|||||||
export * from './CapabilityPage';
|
export * from './CapabilityPage';
|
||||||
export * from './Settings';
|
export * from './Settings';
|
||||||
export * from './Skills';
|
export * from './Skills';
|
||||||
export * from './TaskLogs';
|
|
||||||
export * from './Tasks';
|
export * from './Tasks';
|
||||||
|
|
||||||
export * from './Files';
|
export * from './Files';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
|
|||||||
import type { PageTitleOverride } from 'officerdev';
|
import type { PageTitleOverride } from 'officerdev';
|
||||||
import { usePageTitleOverride } from 'officerdev';
|
import { usePageTitleOverride } from 'officerdev';
|
||||||
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
||||||
|
import { randomId } from 'helpers/random-id';
|
||||||
|
|
||||||
type TitleRule = { match: (p: string) => boolean; title: string };
|
type TitleRule = { match: (p: string) => boolean; title: string };
|
||||||
|
|
||||||
@@ -34,7 +35,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
||||||
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
|
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
|
||||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
{ 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('/tasks'), title: 'Tasks' },
|
||||||
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
||||||
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
|
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
|
||||||
@@ -139,7 +139,7 @@ function claimTabIdentity(): void {
|
|||||||
|
|
||||||
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
|
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
|
||||||
function newTabId(): string {
|
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.
|
// Once per document, before React reads the stored name.
|
||||||
|
|||||||
+30
-10
@@ -10,17 +10,37 @@ describing a different codebase.
|
|||||||
src/databases/officer_db/
|
src/databases/officer_db/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── db.ts # the connection
|
│ ├── db.ts # the connection
|
||||||
│ ├── index.ts # public surface: re-exports queries, schema and drizzle helpers
|
│ ├── 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)
|
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||||
│ └── schema/
|
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||||
│ ├── index.ts # re-exports all schema files
|
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||||
│ └── *.ts # table definitions, grouped by domain
|
│ └── <feature>/
|
||||||
└── package.json # exports "." and "./types"
|
│ ├── 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`,
|
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||||
`headscale`, `music`, `operations`, `pipeline-jobs`, `server`, `soulseek`, `user-data`, `vault`,
|
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||||
`wallet`.
|
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
|
## Schema changes use `push`, not migrations
|
||||||
|
|
||||||
@@ -114,8 +134,8 @@ Organise `types.ts` by domain with section comments, mirroring the schema files.
|
|||||||
|
|
||||||
## Queries
|
## Queries
|
||||||
|
|
||||||
Hand-written, one file per domain under `src/queries/`, importing tables from `../schema` and types from
|
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||||
`../types`:
|
types from `../types`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ try {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: './src/schema/index.ts',
|
schema: './src/schema.ts',
|
||||||
out: './migrations',
|
out: './migrations',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./types": "./src/types.ts",
|
"./types": "./src/types.ts",
|
||||||
"./db": "./src/db.ts",
|
"./db": "./src/db.ts",
|
||||||
"./schema": "./src/schema/index.ts"
|
"./schema": "./src/schema.ts",
|
||||||
|
"./secret-store": "./src/secret-store.ts",
|
||||||
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "drizzle-kit generate --config=drizzle.config.ts",
|
"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 { randomUUID } from 'crypto';
|
||||||
import { and, asc, eq } from 'drizzle-orm';
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { agentPanels } from '../schema';
|
import { agentPanels } from './schema';
|
||||||
import type { AgentPanelRow } from '../schema/agent-panels';
|
import type { AgentPanelRow } from './schema';
|
||||||
|
|
||||||
export type AgentPanel = AgentPanelRow;
|
export type AgentPanel = AgentPanelRow;
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
|
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
|
* 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 { eq, and, isNull, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { apiKeys, users } from '../schema';
|
import { apiKeys } from './schema';
|
||||||
|
import { users } from '../auth/schema';
|
||||||
import type { ApiKeySelect } from '../types';
|
import type { ApiKeySelect } from '../types';
|
||||||
|
|
||||||
// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is
|
// 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 { 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.
|
// 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 { eq } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
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
|
// 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.
|
// 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 { eq, and, lt, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
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';
|
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
|
||||||
|
|
||||||
// ── Users ──
|
// ── Users ──
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user