Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d000cedf2f | ||
|
|
336e718463 | ||
|
|
fe0012635a | ||
|
|
547662842b | ||
|
|
eb1fd8c31a | ||
|
|
d85f089817 | ||
|
|
11710f283a | ||
|
|
b6feca8350 | ||
|
|
1d95ad3d1b | ||
|
|
e881015df5 | ||
|
|
3862558b92 | ||
|
|
977e30e782 | ||
|
|
edbe446b34 | ||
|
|
e36c6bb431 | ||
|
|
4d14e11f6c | ||
|
|
4c33ef7206 | ||
|
|
6c13e0d8f6 | ||
|
|
cbfe376a42 | ||
|
|
64f3de59fb | ||
|
|
081920c61f | ||
|
|
fb0286e1a9 | ||
|
|
d6f01862fe | ||
|
|
bf7e919593 | ||
|
|
2ac58e2007 | ||
|
|
9f15de3448 | ||
|
|
3ca7f5f331 | ||
|
|
2273941e71 | ||
|
|
41cdd1e63a | ||
|
|
7b32f5bc2f | ||
|
|
cc1eab7794 | ||
|
|
8826c2847e | ||
|
|
236a3a5481 | ||
|
|
d7d64cd6d6 | ||
|
|
cd209483e3 | ||
|
|
77f1284925 | ||
|
|
74b4c7908a | ||
|
|
f1cfc0042f | ||
|
|
88c9e96895 | ||
|
|
e5fe966308 | ||
|
|
60ab531294 | ||
|
|
f33e7474b0 | ||
|
|
bedc420d4d | ||
|
|
9b56e04b5e | ||
|
|
ea87ffed04 | ||
|
|
3cca07187e | ||
|
|
b5aa2e0387 | ||
|
|
0da15d78a1 | ||
|
|
e2faad40a3 | ||
|
|
61a3ae720f | ||
|
|
085afe7604 | ||
|
|
62cbf510cb | ||
|
|
b075f1f882 | ||
|
|
7280d13b93 | ||
|
|
fbb6d6c78c | ||
|
|
3cb39662b5 | ||
|
|
ffca309a77 | ||
|
|
1eecc400a7 | ||
|
|
595dd082a7 | ||
|
|
f9cd0a798a | ||
|
|
288b4bf683 | ||
|
|
68f2c55ecf | ||
|
|
4cebae1c85 | ||
|
|
9d6c196848 | ||
|
|
8207824a81 | ||
|
|
1ccb21e67f | ||
|
|
9864fb1a49 |
+17
-19
@@ -1,29 +1,27 @@
|
|||||||
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
||||||
PORT=9000
|
PORT=9000
|
||||||
BROWSER_RELAY_PORT=18792
|
|
||||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||||
|
|
||||||
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
|
# Where Officer is reached from a browser — the one value the machine cannot derive. Read by
|
||||||
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
|
# `bun gen:index` (OpenGraph tags, which need an absolute URL), the task API host, and the CalDAV iOS
|
||||||
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
|
# profile builder, which additionally requires https.
|
||||||
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
|
|
||||||
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
|
|
||||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
|
||||||
|
|
||||||
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
|
|
||||||
# encrypting every secret column in Postgres: Headscale admin API keys, app-store service
|
|
||||||
# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the
|
|
||||||
# owner passphrase that seals it.
|
|
||||||
#
|
#
|
||||||
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
|
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
|
||||||
# passphrase opens the inner envelope and this is the outer one.
|
PUBLIC_URL=http://localhost:9000
|
||||||
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
|
||||||
|
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
|
||||||
|
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
|
||||||
|
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
|
||||||
|
# per purpose, created on first use. See docs/secret-store.md.
|
||||||
|
#
|
||||||
|
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
|
||||||
|
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
|
||||||
|
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
|
||||||
|
#
|
||||||
|
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
|
||||||
|
# unreadable, and for the wallet seed that is unrecoverable.
|
||||||
|
|
||||||
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
||||||
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
|
|
||||||
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
|
|
||||||
# PUBLIC_URL=https://officer.example.com
|
|
||||||
|
|
||||||
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||||
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
||||||
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
||||||
|
|||||||
@@ -58,3 +58,8 @@ public/plugins/
|
|||||||
|
|
||||||
# Written by officer-setup.sh; per-machine.
|
# Written by officer-setup.sh; per-machine.
|
||||||
scripts/setup/officer-setup/.setup-progress
|
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
|
||||||
@@ -112,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
|
||||||
|
|
||||||
@@ -163,11 +186,13 @@ valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`
|
|||||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
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
|
||||||
@@ -194,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# What breaks over plain http
|
||||||
|
|
||||||
|
**Audited 2026-08-13**, after `crypto.randomUUID` took the chat page down at the end of every turn.
|
||||||
|
|
||||||
|
Officer is reached at `http://officer-dev:9000` — a tailnet address, so **neither https nor
|
||||||
|
localhost**, and therefore not a [secure context]. A set of browser APIs are unavailable there by
|
||||||
|
specification, not by policy, and there is no flag that changes it.
|
||||||
|
|
||||||
|
The failure mode is what makes this worth a document. Two of the three shapes below are silent:
|
||||||
|
|
||||||
|
| shape | what a user sees |
|
||||||
|
| --- | --- |
|
||||||
|
| `crypto.randomUUID()` | `TypeError` — and if it is inside a `useState` initialiser, the whole tree unmounts |
|
||||||
|
| `navigator.clipboard.writeText()` | `TypeError`, killing the click handler |
|
||||||
|
| `navigator.clipboard?.writeText()` | **nothing at all** — the button reports success and copies nothing |
|
||||||
|
|
||||||
|
The optional-chained one is the worst: indistinguishable from working until somebody pastes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
### `crypto.randomUUID` — 18 call sites
|
||||||
|
Secure-context only. `crypto.getRandomValues` is **not** — it lives on `Crypto` rather than
|
||||||
|
`SubtleCrypto` — so `helpers/random-id.ts` builds the same v4 UUID from the same CSPRNG when
|
||||||
|
`randomUUID` is absent. Same entropy, same version and variant bits.
|
||||||
|
|
||||||
|
### `navigator.clipboard.writeText` — 20 call sites across 18 files
|
||||||
|
Secure-context only. `helpers/clipboard.ts` falls back to `document.execCommand('copy')` over an
|
||||||
|
off-screen textarea, which predates the secure-context rule and works on any origin. Deprecated and
|
||||||
|
working beats modern and absent.
|
||||||
|
|
||||||
|
One call site carried the comment *"Officer is always behind HTTPS"*. It was not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cannot be fixed this way
|
||||||
|
|
||||||
|
### `navigator.clipboard.read()` — pasting a file in the file browser
|
||||||
|
No fallback exists. `document.execCommand('paste')` was never permitted from script, so on an
|
||||||
|
insecure origin there is no way to pull clipboard contents on demand — only a real paste event the
|
||||||
|
user initiates, which is a different interaction. Now guarded by `canReadClipboard()` and refuses
|
||||||
|
with an explanation instead of throwing.
|
||||||
|
|
||||||
|
### `getUserMedia` — audio recording, 4 files
|
||||||
|
`apps/Chat/useAudioRecording.ts`, `apps/FileBrowser/.../DictateDialog.tsx`,
|
||||||
|
`apps/QrTransfer/Receiver.tsx`, and a test. Requires a secure context and cannot be polyfilled — the
|
||||||
|
browser will not hand out a microphone or camera over http.
|
||||||
|
|
||||||
|
**Being removed** rather than guarded: the owner uses an external dictation app. Note `QrTransfer`
|
||||||
|
uses it for the CAMERA rather than a microphone, so removing "audio" does not cover it — that one
|
||||||
|
needs its own decision.
|
||||||
|
|
||||||
|
### `navigator.credentials` — passkeys
|
||||||
|
WebAuthn is secure-context only. `helpers/passkeys.ts` exists and cannot work over http, whatever is
|
||||||
|
done to it. Not currently reachable, so nothing is broken today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checked and clear
|
||||||
|
|
||||||
|
- **`crypto.subtle`** — not used anywhere in the frontend. This was the one worth confirming, since
|
||||||
|
it would have had no cheap fallback.
|
||||||
|
- **`Notification`** — the six matches are type names, not the browser API. Nothing calls
|
||||||
|
`new Notification` or `requestPermission`.
|
||||||
|
- **Service workers, WebUSB, WebSerial, WebBluetooth, Payment Request, Wake Lock, Storage Manager,
|
||||||
|
`SharedArrayBuffer`** — not used.
|
||||||
|
- **`navigator.geolocation`** (`widgets/Weather`) — secure-context only, but already guarded with
|
||||||
|
`if (!navigator.geolocation) return;`, so it degrades rather than throws. The widget simply cannot
|
||||||
|
locate you over http.
|
||||||
|
- **`navigator.share`** (`Headscale/InvitesView`) — already guarded with a `typeof` check, and its
|
||||||
|
comment notes it is absent on desktop browsers anyway.
|
||||||
|
- **WebSockets, IndexedDB, localStorage, EventSource** — no secure-context restriction. Chat,
|
||||||
|
terminal and the sidecar transports are unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The alternative
|
||||||
|
|
||||||
|
All of this disappears with a certificate, and `tailscale cert` issues a real one for the MagicDNS
|
||||||
|
name in about one command — no public DNS, no port 80 challenge, no renewal to remember. Worth
|
||||||
|
knowing that the choice here was "make it work over http", not "http is the only option".
|
||||||
|
|
||||||
|
[secure context]: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Testing the installer in containers
|
||||||
|
|
||||||
|
**2026-08-13.** First pass. Ubuntu 24.04, Debian 12, Arch, Fedora 41.
|
||||||
|
|
||||||
|
## What passed
|
||||||
|
|
||||||
|
**OS and package-manager detection is correct on all four.**
|
||||||
|
|
||||||
|
| image | `OS` | `PM` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ubuntu:24.04 | `ubuntu` | `apt` |
|
||||||
|
| debian:12 | `debian` | `apt` |
|
||||||
|
| archlinux | `arch` | `pacman` |
|
||||||
|
| fedora:41 | `fedora` | `dnf` |
|
||||||
|
|
||||||
|
**`--help` and argument handling work unprivileged** in a clean container, before any escalation.
|
||||||
|
|
||||||
|
**The install report is written**, end to end, in a container that had never seen this code. That is
|
||||||
|
task 1's mechanism confirmed outside the machine it was written on.
|
||||||
|
|
||||||
|
**Refusing beats hanging.** With no answer available the run stopped with
|
||||||
|
`FAIL: No answer. Set ASSUME_YES=1 to run without prompts.` rather than blocking forever on a prompt
|
||||||
|
nobody could see. That is the behaviour an unattended run needs, and it already exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it found
|
||||||
|
|
||||||
|
### 1. `--only` does not isolate a step
|
||||||
|
|
||||||
|
Running `--only "Core utils"` still **created a user account**, because `ask_username` and the
|
||||||
|
account creation happen in the preamble, above the step framework. Everything before the first
|
||||||
|
`step` call runs on every invocation.
|
||||||
|
|
||||||
|
Defensible — every step needs to know who it is installing for — but it means `--only` is not the
|
||||||
|
surgical tool it appears to be, and a first-time reader will assume it is. Either the preamble
|
||||||
|
becomes lazy, or `--only` says plainly what it will still do.
|
||||||
|
|
||||||
|
### 2. `.setup-answers` travels with a copy of the tree
|
||||||
|
|
||||||
|
It lives at `scripts/setup/machine-setup/.setup-answers`, is correctly gitignored, and is `0600`
|
||||||
|
root-owned. But it is **inside the repository directory**, so `cp -r` or a tarball of the tree
|
||||||
|
carries it — which is exactly what happened here: a container that had never run setup came up
|
||||||
|
already knowing the username `pastilhas` and created that account.
|
||||||
|
|
||||||
|
Not a leak (username and install path, nothing secret). It is a surprise, and surprises in an
|
||||||
|
installer are the expensive kind. Worth moving outside the repo, next to the progress file.
|
||||||
|
|
||||||
|
### 3. `adduser` leaks its own prompts
|
||||||
|
|
||||||
|
```
|
||||||
|
Use of uninitialized value $answer in pattern match (m//) at /usr/sbin/adduser line 848.
|
||||||
|
Try again? [y/N]
|
||||||
|
```
|
||||||
|
|
||||||
|
The account-creation path reaches an interactive `adduser` question the script does not answer.
|
||||||
|
Harmless here because the run stopped anyway, but on a real unattended install this is a hang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage this cannot reach
|
||||||
|
|
||||||
|
Containers have no init by default, so **`systemctl`, netplan, ufw and the sshd drop-ins were not
|
||||||
|
exercised**. Those sections can only be verified as "wrote the right file", not "the service came
|
||||||
|
up". Running privileged containers with systemd would close most of that gap and is the obvious next
|
||||||
|
step.
|
||||||
|
|
||||||
|
**Docker-in-Docker** was not attempted, so the Docker section and Postgres provisioning are
|
||||||
|
untested. Mounting the host socket would test the section's logic while telling us nothing about the
|
||||||
|
install path.
|
||||||
|
|
||||||
|
**macOS is untestable here entirely.** The 17 skipped sections, the Homebrew paths, the Xcode
|
||||||
|
command line tools step and the refusal-to-run-as-root are all reasoned from documentation and
|
||||||
|
unverified by execution.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# The install page, and the scripts behind it
|
||||||
|
|
||||||
|
**Status: for discussion, 2026-08-13.** Nothing here is built. It exists so tomorrow's conversation
|
||||||
|
is about real branches rather than sketched ones — every question below is one the scripts already
|
||||||
|
ask today.
|
||||||
|
|
||||||
|
## The shape agreed
|
||||||
|
|
||||||
|
- One **source** — the interactive scripts as they are.
|
||||||
|
- A **build script** that compiles them into single files, because `curl | bash` cannot fetch libs.
|
||||||
|
- The build emits **one script per leaf** of the question tree, not one script with pre-seeded
|
||||||
|
answers. A person auditing before running reads only their own path.
|
||||||
|
- Verification is of the **generator**, once: anyone regenerates the leaves from source and diffs
|
||||||
|
them against what is published. One thing to trust rather than N.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The questions that actually exist
|
||||||
|
|
||||||
|
Forty-seven prompts across the two scripts. Almost none of them should become a branch — the
|
||||||
|
distinction that matters is:
|
||||||
|
|
||||||
|
**A branch** changes which *code* runs. Removing it makes a script genuinely shorter.
|
||||||
|
|
||||||
|
**A value** changes a *string*. Removing it makes a script no shorter — it just moves the answer
|
||||||
|
from a prompt to a constant.
|
||||||
|
|
||||||
|
**A consent** is a yes/no about doing a step at all. These are the interesting middle: pre-answering
|
||||||
|
one lets the build delete the section entirely.
|
||||||
|
|
||||||
|
### Branches — these change what code exists
|
||||||
|
|
||||||
|
| question | answers | what it eliminates |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| operating system | macOS · Debian/Ubuntu · Arch · Fedora | 17 of 26 machine-setup sections on macOS; the whole `case $PM` ladder collapses to one arm |
|
||||||
|
| machine role | homelab · vps · dev | swap, ballast, earlyoom, sleep/suspend, boot-hang, static addressing — each is role-gated today |
|
||||||
|
| tailnet | already connected · set one up · none | the entire Tailscale section, its four sub-options and the offscale explanation |
|
||||||
|
| which half | machine + officer · officer only · machine only | one of the two scripts disappears |
|
||||||
|
|
||||||
|
### Consents — pre-answering deletes a section
|
||||||
|
|
||||||
|
Docker · fail2ban · unattended-upgrades · Neovim · agent CLIs · shell config · firewall · SSH
|
||||||
|
hardening · DNS · swap · ballast · earlyoom · inotify · boot-on-start.
|
||||||
|
|
||||||
|
Fourteen sections that a leaf script can simply not contain.
|
||||||
|
|
||||||
|
### Values — never a branch
|
||||||
|
|
||||||
|
Username · install path · git name and email · port · public URL · Postgres connection · timezone ·
|
||||||
|
locale · LAN CIDR · swap size · swappiness.
|
||||||
|
|
||||||
|
These stay as prompts even in a generated script, or arrive as environment variables. Baking them
|
||||||
|
into a published file would mean publishing somebody's hostname.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where this collides with `--unattended`
|
||||||
|
|
||||||
|
`--unattended` and a generated leaf are the *same mechanism seen twice*: both are "answer these in
|
||||||
|
advance". The difference is only whether the answer is baked in at build time or supplied at run
|
||||||
|
time.
|
||||||
|
|
||||||
|
Worth deciding tomorrow whether a leaf script is literally `base.sh --unattended` with a header of
|
||||||
|
constants, or whether the build truly strips the dead branches. The second is what makes it
|
||||||
|
auditable-by-being-short; the first is what makes it maintainable. **They are not the same artifact,
|
||||||
|
and the whole plan rests on which one we mean.**
|
||||||
|
|
||||||
|
One thing that already exists and should be preserved either way: with no tty, `install_config`
|
||||||
|
keeps the user's file rather than replacing it. Every unattended answer needs to be conservative in
|
||||||
|
that same way, and that is a property of each prompt, not of the flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The combinatorics
|
||||||
|
|
||||||
|
4 OS × 3 roles × 3 tailnet states = **36 leaves** before any consent is considered, and consents
|
||||||
|
multiply it past anything anyone would publish.
|
||||||
|
|
||||||
|
So the tree the install page walks cannot be the full product. Two ways out, to choose between:
|
||||||
|
|
||||||
|
1. **Publish a few opinionated leaves** — "Ubuntu VPS, new tailnet", "macOS dev machine", "Ubuntu
|
||||||
|
homelab, existing tailnet" — and send everything else to the full interactive script.
|
||||||
|
2. **Generate on demand** — the page composes the leaf when the questions are answered. Stronger, but
|
||||||
|
the artifact is no longer a static file anyone can diff against the repo, which costs the
|
||||||
|
verification property the whole design was for.
|
||||||
|
|
||||||
|
My inclination is (1), because (2) quietly trades away the thing that made per-leaf scripts worth
|
||||||
|
building. But it is a real trade and it is yours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open, for tomorrow
|
||||||
|
|
||||||
|
- Does a leaf strip dead code, or set constants and call the base?
|
||||||
|
- How many leaves get published, and what happens to the rest?
|
||||||
|
- Does the install page show the script before running it? It should — that is the moment auditing
|
||||||
|
is cheap and nobody will do it afterwards.
|
||||||
|
- The report from `install-report.md` names a script commit. A generated leaf needs to name the
|
||||||
|
source commit it was generated from, or the report cannot be checked against anything.
|
||||||
@@ -327,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
|
|||||||
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
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.
|
||||||
|
|||||||
+43
-17
@@ -1,11 +1,19 @@
|
|||||||
# The secret store
|
# The secret store
|
||||||
|
|
||||||
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
|
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
|
||||||
current code was checked against the tree on that date; the file:line references are live.
|
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
|
||||||
|
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
|
||||||
|
yet.
|
||||||
|
|
||||||
A small SQLite database, created during setup, holding every encryption and signing key the platform
|
A small SQLite database holding every encryption and signing key the platform uses. It replaced
|
||||||
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
|
`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing
|
||||||
instead of inventing its own.
|
its own.
|
||||||
|
|
||||||
|
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
|
||||||
|
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
|
||||||
|
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
|
||||||
|
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
|
||||||
|
`headscale` — and every other purpose is created when its plugin first asks.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,7 +77,18 @@ both to still exist. That is a table with `id, purpose, key, created_at, retired
|
|||||||
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
|
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.
|
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
|
||||||
|
|
||||||
### 2. It is NOT encrypted at rest, for now
|
### 2. It is NOT encrypted at rest — and that decision changed shape
|
||||||
|
|
||||||
|
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
|
||||||
|
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
|
||||||
|
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
|
||||||
|
|
||||||
|
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
|
||||||
|
|
||||||
|
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
|
||||||
|
finding is still true and still the reason whole-file encryption is not on the table.
|
||||||
|
|
||||||
|
#### The original note
|
||||||
|
|
||||||
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||||||
|
|
||||||
@@ -94,25 +113,30 @@ trade and it is written down here so nobody later assumes the file is opaque.
|
|||||||
|
|
||||||
### 3. Where the file goes
|
### 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
|
**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
|
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.
|
problem this design exists to avoid.
|
||||||
|
|
||||||
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
|
The setup script says so out loud when it creates the store, because "back this up, but not next to the
|
||||||
documented loudly enough that a backup script excludes it deliberately.
|
other thing you back up" is not a rule anyone infers.
|
||||||
|
|
||||||
### 4. One secret remains outside
|
### 4. ~~One secret remains outside~~ — none does
|
||||||
|
|
||||||
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
|
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
|
||||||
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
|
|
||||||
two processes that need it.**
|
|
||||||
|
|
||||||
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
|
The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N
|
||||||
one value — or comes from a file read on demand.
|
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
|
### 5. What moves in
|
||||||
|
|
||||||
- `VAULT_STORE_KEY` — the at-rest key for everything in the table above.
|
- `VAULT_STORE_KEY` — **split into one key per purpose**, rather than moved. See the status note at the
|
||||||
|
top: the table above is seven unrelated things, and one key for all of them meant one leak opened all
|
||||||
|
of them.
|
||||||
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
|
- `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.
|
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.
|
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
|
||||||
@@ -208,8 +232,10 @@ wallet table and it cannot be interrupted safely, which argues for something tha
|
|||||||
## What this does not change
|
## What this does not change
|
||||||
|
|
||||||
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
||||||
- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from
|
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
|
||||||
changes, so no caller is touched.
|
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
|
- 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.
|
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ does and does not protect against.
|
|||||||
|
|
||||||
Authoritative for the crypto design. The code is `src/servers/sidecar/wallet/keys.ts` (sealing,
|
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/setup.sh`, which installs only what these
|
|
||||||
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
|
|
||||||
//
|
|
||||||
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
|
|
||||||
// for the two checks that make a drifted profile fail loudly instead of silently starting less than it
|
|
||||||
// claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs
|
|
||||||
// and every profile follows.
|
|
||||||
//
|
|
||||||
// The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent
|
|
||||||
// report themselves unavailable rather than disappearing. A profile decides which processes start, not
|
|
||||||
// which code ships.
|
|
||||||
//
|
|
||||||
// Start with: pm2 startOrRestart ecosystem.light.config.cjs
|
|
||||||
|
|
||||||
const { defineProfile } = require('./ecosystem.profile.cjs');
|
|
||||||
|
|
||||||
module.exports = defineProfile({
|
|
||||||
file: 'ecosystem.light.config.cjs',
|
|
||||||
|
|
||||||
include: [
|
|
||||||
'officer', // the app: SPA, /api, websockets
|
|
||||||
'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream
|
|
||||||
'officer-agent', // spawns `claude` — chat is dead without it
|
|
||||||
'officer-opencode', // the alternative agent
|
|
||||||
'officer-pty', // the terminal
|
|
||||||
],
|
|
||||||
|
|
||||||
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
|
|
||||||
// A light install simply is not running the thing behind it.
|
|
||||||
excluded: {
|
|
||||||
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
|
|
||||||
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
|
|
||||||
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
|
|
||||||
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
|
|
||||||
// makes "is Gitea here" one question with one answer instead of two that disagree.
|
|
||||||
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
|
||||||
'officer-vnc': 'no desktop to mirror on a light install',
|
|
||||||
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
|
|
||||||
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
|
|
||||||
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
|
|
||||||
'officer-slskd': 'supervises the slskd daemon',
|
|
||||||
'officer-headscale': 'fronts a headscale server',
|
|
||||||
'officer-transmission': 'fronts a transmission daemon',
|
|
||||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
|
||||||
'officer-jellyfin': 'fronts a Jellyfin container',
|
|
||||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
|
||||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
|
||||||
'officer-caldav': 'supervises Radicale, which the light profile does not install',
|
|
||||||
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
|
|
||||||
'officer-wallet': 'holds seed and node credentials',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// macOS light profile — the same process set as the Linux light profile, on a laptop.
|
|
||||||
//
|
|
||||||
// Paired with scripts/setup/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
|
|
||||||
// chat; nothing else.
|
|
||||||
//
|
|
||||||
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
|
|
||||||
// file specifically: written on 2026-07-28 as a hand-copied process list, it was broken within days by
|
|
||||||
// two changes it could not see. It ran `officer-claude` against the Anthropic proxy's entry point
|
|
||||||
// while the process that actually spawns `claude` was never started, and it pointed at a pty sidecar
|
|
||||||
// that had moved. Both failures were silent — the processes simply did not come up. See
|
|
||||||
// ecosystem.profile.cjs for the checks that now make that loud.
|
|
||||||
//
|
|
||||||
// WHY THIS IS SEPARATE FROM ecosystem.light.config.cjs, given both currently run the same five apps:
|
|
||||||
// the exclusions mean different things. On macOS officer-vnc cannot run — there is no Xorg to mirror.
|
|
||||||
// On a Linux light install it could run perfectly well; you have chosen not to. Those diverge as soon
|
|
||||||
// as one profile gains something the other cannot have, and collapsing them would lose the reason.
|
|
||||||
//
|
|
||||||
// Start with: pm2 startOrRestart ecosystem.mac.light.config.cjs
|
|
||||||
|
|
||||||
const { defineProfile } = require('./ecosystem.profile.cjs');
|
|
||||||
|
|
||||||
module.exports = defineProfile({
|
|
||||||
file: 'ecosystem.mac.light.config.cjs',
|
|
||||||
|
|
||||||
include: [
|
|
||||||
'officer', // the app: SPA, /api, websockets
|
|
||||||
'officer-anthropic-proxy', // holds the Anthropic credential, forwards to api.anthropic.com
|
|
||||||
// Spawns `claude`. Reads the proxy secret from disk, so it needs no ordering against the proxy
|
|
||||||
// above: if the secret is not written yet it warns and re-reads before the next spawn.
|
|
||||||
'officer-agent',
|
|
||||||
'officer-opencode', // the alternative agent
|
|
||||||
// The terminal. Runs under node rather than bun — node-pty binds a native addon built against
|
|
||||||
// node's ABI. That detail lives in ecosystem.config.cjs, not here.
|
|
||||||
'officer-pty',
|
|
||||||
],
|
|
||||||
|
|
||||||
excluded: {
|
|
||||||
// Cannot run on macOS at all.
|
|
||||||
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
|
|
||||||
|
|
||||||
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
|
|
||||||
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
|
|
||||||
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
|
|
||||||
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
|
|
||||||
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
|
||||||
|
|
||||||
// Would run, but needs something setup_mac_light.sh deliberately does not install.
|
|
||||||
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
|
|
||||||
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
|
|
||||||
'officer-music': 'the ffprobe indexer works, but a full ~/Music index is expensive to start by default',
|
|
||||||
|
|
||||||
// Fronts a container or daemon a laptop is not running.
|
|
||||||
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
|
|
||||||
'officer-slskd': 'supervises the slskd daemon',
|
|
||||||
'officer-headscale': 'fronts a headscale server',
|
|
||||||
'officer-transmission': 'fronts a transmission daemon',
|
|
||||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
|
||||||
'officer-jellyfin': 'fronts a Jellyfin container',
|
|
||||||
|
|
||||||
// Needs an owner-configured external service.
|
|
||||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
|
||||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
|
||||||
|
|
||||||
// Deliberate, for what it holds or who feeds it.
|
|
||||||
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
|
|
||||||
'officer-wallet': 'holds seed and node credentials; not on a laptop',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs,
|
|
||||||
// ecosystem.mac.light.config.cjs).
|
|
||||||
//
|
|
||||||
// A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how
|
|
||||||
// a process is launched — `script` and `args` are read from the host file at load — because a
|
|
||||||
// hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and
|
|
||||||
// within days was starting a sidecar that had been split in two and pointing at a pty entry point that
|
|
||||||
// had moved. Neither failure said anything; the processes simply did not come up.
|
|
||||||
//
|
|
||||||
// So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a
|
|
||||||
// profile only decides which of them to run.
|
|
||||||
//
|
|
||||||
// Two consistency checks, both of which turn a silent breakage into a loud one at load:
|
|
||||||
// 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed
|
|
||||||
// 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which
|
|
||||||
// must be classified deliberately rather than defaulting to absent because nobody noticed
|
|
||||||
//
|
|
||||||
// The second is the one that matters over time. Without it, every sidecar added to the host silently
|
|
||||||
// stays out of every profile, and the profiles quietly stop meaning what their comments claim.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} spec
|
|
||||||
* @param {string} spec.file this profile's filename, for error messages
|
|
||||||
* @param {string[]} spec.include app names to run, in start order
|
|
||||||
* @param {Record<string,string>} spec.excluded app name → why it is not in this profile
|
|
||||||
*/
|
|
||||||
// The directory holding the platform's package.json, found by walking up from this file. Independent of
|
|
||||||
// where in the tree this config is kept, and of where pm2 was invoked from.
|
|
||||||
function repoRoot() {
|
|
||||||
const { existsSync, readFileSync } = require('node:fs');
|
|
||||||
const { dirname, join } = require('node:path');
|
|
||||||
let dir = __dirname;
|
|
||||||
for (;;) {
|
|
||||||
const manifest = join(dir, 'package.json');
|
|
||||||
if (existsSync(manifest)) {
|
|
||||||
try {
|
|
||||||
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir;
|
|
||||||
} catch {
|
|
||||||
// Unparseable is not ours; keep walking.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const up = dirname(dir);
|
|
||||||
if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname);
|
|
||||||
dir = up;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defineProfile({ file, include, excluded }) {
|
|
||||||
const full = require('./ecosystem.config.cjs');
|
|
||||||
const byName = new Map(full.apps.map((app) => [app.name, app]));
|
|
||||||
|
|
||||||
const missing = include.filter((name) => !byName.has(name));
|
|
||||||
if (missing.length) {
|
|
||||||
throw new Error(
|
|
||||||
`${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` +
|
|
||||||
`removed. Update this profile's include list.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unclassified = full.apps
|
|
||||||
.map((app) => app.name)
|
|
||||||
.filter((name) => !include.includes(name) && !(name in excluded));
|
|
||||||
if (unclassified.length) {
|
|
||||||
throw new Error(
|
|
||||||
`${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` +
|
|
||||||
`excluded here. Add it to the include list, or to the excluded map with a reason.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does
|
|
||||||
// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls
|
|
||||||
// back to the default PORT with no POSTGRES_URL.
|
|
||||||
//
|
|
||||||
// It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of
|
|
||||||
// the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not
|
|
||||||
// fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it.
|
|
||||||
//
|
|
||||||
// This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside
|
|
||||||
// ecosystem.config.cjs". That stopped being true the moment these files were moved into
|
|
||||||
// ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is
|
|
||||||
// true wherever this file ends up living.
|
|
||||||
return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { defineProfile };
|
|
||||||
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
"format": "{ 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/officer-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",
|
||||||
|
|||||||
+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}"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -150,8 +150,54 @@ load_answers() {
|
|||||||
# file — the point of asking for a single step is to run that step.
|
# file — the point of asking for a single step is to run that step.
|
||||||
ONLY_STEP="${ONLY_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() {
|
step() {
|
||||||
CURRENT_STEP="$1"
|
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 [[ -n "$ONLY_STEP" ]]; then
|
||||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||||
@@ -239,6 +285,37 @@ page() {
|
|||||||
# deliberate keystroke would train people to hold the y key down.
|
# deliberate keystroke would train people to hold the y key down.
|
||||||
#
|
#
|
||||||
# ASSUME_YES=1 answers all of them, for an unattended run.
|
# 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() {
|
confirm() {
|
||||||
local message="${1:-Proceed?}"
|
local message="${1:-Proceed?}"
|
||||||
# Second argument flips the default. Most questions here are "do the thing you
|
# Second argument flips the default. Most questions here are "do the thing you
|
||||||
@@ -585,6 +662,15 @@ default_iface() {
|
|||||||
# firewall open on one. Every branch downstream is about what this machine is
|
# 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.
|
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
|
||||||
ask_machine_role() {
|
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
|
if [[ -n "$MACHINE_ROLE" ]]; then
|
||||||
case "$MACHINE_ROLE" in
|
case "$MACHINE_ROLE" in
|
||||||
homelab | vps | dev) return ;;
|
homelab | vps | dev) return ;;
|
||||||
|
|||||||
@@ -92,27 +92,36 @@ oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
|
|||||||
install_oh_my_zsh() {
|
install_oh_my_zsh() {
|
||||||
# The installer refuses to run unattended over an existing install, so this is
|
# The installer refuses to run unattended over an existing install, so this is
|
||||||
# only ever called when there is none.
|
# 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 \
|
sudo -H -u "$USERNAME" sh -c \
|
||||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1
|
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
|
||||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
true
|
||||||
# function that ends on a failing command is fatal under `set -e` when it
|
# Belt and braces: `|| true` above already makes the last command succeed, and
|
||||||
# is called as a plain command — which would abort the remaining sections
|
# this states the contract for anyone adding a line beneath it.
|
||||||
# over something the run could simply report. The caller checks the outcome.
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# `chsh` is what actually changes the login shell. Asked separately from
|
# `chsh` is what actually changes the login shell. Asked separately from
|
||||||
# installing zsh, because having a shell available and being handed it at every
|
# installing zsh, because having a shell available and being handed it at every
|
||||||
# login are different decisions.
|
# 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() {
|
set_login_shell() {
|
||||||
local shell="$1"
|
local shell="$1"
|
||||||
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
||||||
chsh -s "$shell" "$USERNAME"
|
chsh -s "$shell" "$USERNAME" >/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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ docker_repo_distro() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_docker_engine() {
|
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
|
local distro codename
|
||||||
distro="$(docker_repo_distro)"
|
distro="$(docker_repo_distro)"
|
||||||
codename="$(docker_repo_codename)"
|
codename="$(docker_repo_codename)"
|
||||||
@@ -67,7 +72,30 @@ install_docker_engine() {
|
|||||||
>/etc/apt/sources.list.d/docker.list
|
>/etc/apt/sources.list.d/docker.list
|
||||||
|
|
||||||
pkg_refresh >/dev/null
|
pkg_refresh >/dev/null
|
||||||
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
|
||||||
|
# ── The rootless prerequisites go in HERE, not in the rootless branch ──
|
||||||
|
#
|
||||||
|
# They used to be installed only when the owner picked "[2] rootless Docker for
|
||||||
|
# me" in section 22. But the OWNER's choice is not the only one that matters:
|
||||||
|
# every Developer account the platform provisions gets its own rootless daemon,
|
||||||
|
# whatever the owner picked for themselves. So on a machine where the owner chose
|
||||||
|
# the docker group, the host never got these and every member's daemon failed
|
||||||
|
# with `rootless Docker needs these packages on the host: uidmap`.
|
||||||
|
#
|
||||||
|
# `src/servers/os-user-docker.ts` → checkDockerPrerequisites is the authority on
|
||||||
|
# this list, and it wants both:
|
||||||
|
#
|
||||||
|
# uidmap /usr/bin/newuidmap, /usr/bin/newgidmap
|
||||||
|
# docker-ce-rootless-extras /usr/bin/dockerd-rootless-setuptool.sh
|
||||||
|
#
|
||||||
|
# docker-ce only RECOMMENDS rootless-extras. That is installed by default, so it
|
||||||
|
# is usually there by luck — and is not on a host configured with
|
||||||
|
# --no-install-recommends. Named explicitly so it does not depend on that.
|
||||||
|
#
|
||||||
|
# dbus-user-session is what lets a member's systemd --user survive without a
|
||||||
|
# login session, which is how the daemon stays up.
|
||||||
|
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
||||||
|
docker-ce-rootless-extras uidmap dbus-user-session
|
||||||
}
|
}
|
||||||
|
|
||||||
# A shared network so containers from different compose files can reach each
|
# A shared network so containers from different compose files can reach each
|
||||||
|
|||||||
@@ -43,10 +43,17 @@ install_config() {
|
|||||||
|
|
||||||
if [[ ! -f "$dest" ]]; then
|
if [[ ! -f "$dest" ]]; then
|
||||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cmp -s "$src" "$dest" && return 1
|
if cmp -s "$src" "$dest"; then
|
||||||
|
declare -F report_kept >/dev/null && report_kept "${dest} already identical to the shipped version — not touched"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
warn "${dest} already exists here, and differs from the one this script ships."
|
warn "${dest} already exists here, and differs from the one this script ships."
|
||||||
@@ -56,6 +63,7 @@ install_config() {
|
|||||||
# was present to defend.
|
# was present to defend.
|
||||||
if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then
|
if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then
|
||||||
echo " keeping yours (nothing was asked, so nothing is replaced)"
|
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
|
return 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -67,17 +75,20 @@ install_config() {
|
|||||||
if ! read -rp " Which one? (1/2/3) [1]: " answer; then
|
if ! read -rp " Which one? (1/2/3) [1]: " answer; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " keeping yours"
|
echo " keeping yours"
|
||||||
|
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — no answer available"
|
||||||
return 2
|
return 2
|
||||||
fi
|
fi
|
||||||
case "${answer:-1}" in
|
case "${answer:-1}" in
|
||||||
1)
|
1)
|
||||||
echo " keeping yours"
|
echo " keeping yours"
|
||||||
|
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT by choice"
|
||||||
return 2
|
return 2
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
cp -a "$dest" "${dest}.before-machine-setup"
|
cp -a "$dest" "${dest}.before-machine-setup"
|
||||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||||
ok "replaced — yours is at ${dest}.before-machine-setup"
|
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
|
return 0
|
||||||
;;
|
;;
|
||||||
3)
|
3)
|
||||||
@@ -125,3 +136,38 @@ append_once() {
|
|||||||
echo "$end"
|
echo "$end"
|
||||||
} >>"$file"
|
} >>"$file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Where tmux actually reads its config
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# tmux 3.1 added an XDG location and it takes PRECEDENCE. Verified on 3.4 by
|
||||||
|
# creating both and asking tmux which marker it ended up with:
|
||||||
|
#
|
||||||
|
# both present -> ~/.config/tmux/tmux.conf
|
||||||
|
# only ~/.tmux.conf -> ~/.tmux.conf
|
||||||
|
# only the XDG one -> the XDG one
|
||||||
|
#
|
||||||
|
# So installing to ~/.tmux.conf on a machine that has the XDG file writes a file
|
||||||
|
# tmux will never read, and the script would report success having changed
|
||||||
|
# nothing anybody can see. That is the failure this exists to prevent.
|
||||||
|
#
|
||||||
|
# Rules, in order:
|
||||||
|
# 1. an existing XDG config wins -> that is their real config, target it
|
||||||
|
# 2. an existing ~/.tmux.conf -> target it, since it is what tmux reads
|
||||||
|
# 3. neither -> ~/.tmux.conf, the path every guide names
|
||||||
|
tmux_config_target() {
|
||||||
|
local home="$1"
|
||||||
|
local xdg="${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf"
|
||||||
|
if [[ -f "$xdg" ]]; then
|
||||||
|
echo "$xdg"
|
||||||
|
else
|
||||||
|
echo "$home/.tmux.conf"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when a ~/.tmux.conf would be shadowed by an XDG config that already exists.
|
||||||
|
tmux_dot_conf_is_shadowed() {
|
||||||
|
local home="$1"
|
||||||
|
[[ -f "${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf" && -f "$home/.tmux.conf" ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,28 +84,35 @@ LAST_SKIPPED=()
|
|||||||
pkgs_core() {
|
pkgs_core() {
|
||||||
case "$PM" in
|
case "$PM" in
|
||||||
apt)
|
apt)
|
||||||
# apt-transport-https, lsb-release and software-properties-common are not
|
# apt-transport-https and lsb-release are not tools — they are what lets a
|
||||||
# tools — they are what lets later steps add the Docker repository and the
|
# later step add the Docker repository. They have no counterpart on the
|
||||||
# fastfetch PPA. They have no counterpart on the other systems.
|
# 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 \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
apt-transport-https lsb-release software-properties-common \
|
apt-transport-https lsb-release software-properties-common \
|
||||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \
|
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
fail2ban unattended-upgrades
|
fail2ban unattended-upgrades
|
||||||
;;
|
;;
|
||||||
pacman)
|
pacman)
|
||||||
echo curl ca-certificates gnupg git jq unzip \
|
echo curl ca-certificates gnupg git jq unzip \
|
||||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \
|
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||||
fail2ban
|
fail2ban
|
||||||
;;
|
;;
|
||||||
dnf)
|
dnf)
|
||||||
echo curl ca-certificates gnupg2 git jq unzip \
|
echo curl ca-certificates gnupg2 git jq unzip \
|
||||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \
|
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||||
fail2ban
|
fail2ban
|
||||||
;;
|
;;
|
||||||
brew)
|
brew)
|
||||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||||
# the Xcode command line tools, which is not a formula.
|
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||||
echo gnupg git jq wget btop htop tree tmux ripgrep fd
|
echo gnupg git jq wget btop htop tree ripgrep fd eza
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -211,8 +218,20 @@ pkg_install() {
|
|||||||
LAST_INSTALLED=("${missing[@]}")
|
LAST_INSTALLED=("${missing[@]}")
|
||||||
LAST_KEPT=("${present[@]}")
|
LAST_KEPT=("${present[@]}")
|
||||||
|
|
||||||
announce_plan "$label" present missing || return 0
|
announce_plan "$label" present missing || {
|
||||||
pkg_install_now "${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.
|
# Print what a section is about to do and ask permission for it.
|
||||||
@@ -262,3 +281,23 @@ summarise_last() {
|
|||||||
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
|
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The Xcode command line tools
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# macOS's build-essential, and not installable as a formula. It matters here for
|
||||||
|
# one specific reason: node-pty ships no prebuilt binary for any platform, so
|
||||||
|
# `bun install` always falls through to node-gyp and needs a working compiler.
|
||||||
|
# Without this the platform install fails deep inside a dependency tree with an
|
||||||
|
# error that names neither Xcode nor node-pty.
|
||||||
|
#
|
||||||
|
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
|
||||||
|
# not block until the download finishes. So this asks, and then says to come back,
|
||||||
|
# rather than pretending to have waited.
|
||||||
|
|
||||||
|
xcode_clt_installed() { xcode-select -p &>/dev/null; }
|
||||||
|
|
||||||
|
xcode_clt_install() {
|
||||||
|
xcode-select --install 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,7 +190,18 @@ tailscale_control_url() {
|
|||||||
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; }
|
# The official install.sh is a Linux package-manager script. macOS gets the same
|
||||||
|
# daemon wrapped in a GUI app, and the cask is the version with a CLI at
|
||||||
|
# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build
|
||||||
|
# is sandboxed and ships no usable `tailscale` binary, which is the difference
|
||||||
|
# that matters to a script.
|
||||||
|
tailscale_install() {
|
||||||
|
if [[ "${OS:-}" == "macos" ]]; then
|
||||||
|
brew install --cask tailscale
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
}
|
||||||
|
|
||||||
# Tailscale's own coordination server, spelled out.
|
# Tailscale's own coordination server, spelled out.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -20,10 +20,17 @@
|
|||||||
MACHINE_SETUP_TOOLS_LOADED=1
|
MACHINE_SETUP_TOOLS_LOADED=1
|
||||||
|
|
||||||
# The set installed on every machine, in the order they are fetched.
|
# The set installed on every machine, in the order they are fetched.
|
||||||
tools_default() { echo lazydocker lazygit starship fastfetch; }
|
#
|
||||||
|
# fastfetch was here until 2026-08-14 and was removed after it stopped a real
|
||||||
|
# install. It is the only one of these with no source but a third-party PPA on
|
||||||
|
# Ubuntu 24.04 and older, and the failure was in the half that was not guarded:
|
||||||
|
# a PPA that ADDS cleanly but carries no package for the running codename gets
|
||||||
|
# past the `|| skip` and dies on the install instead. A neofetch clone is not
|
||||||
|
# worth a branch in a script that has to survive on machines nobody has seen.
|
||||||
|
tools_default() { echo lazydocker lazygit starship; }
|
||||||
|
|
||||||
# The command that proves a tool is already here. Same as the tool name for all
|
# The command that proves a tool is already here. Same as the tool name for all
|
||||||
# four today, but kept as a mapping because that is not a rule — a package and
|
# three today, but kept as a mapping because that is not a rule — a package and
|
||||||
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
||||||
# indirection.
|
# indirection.
|
||||||
tool_command() {
|
tool_command() {
|
||||||
@@ -31,7 +38,6 @@ tool_command() {
|
|||||||
lazydocker) echo lazydocker ;;
|
lazydocker) echo lazydocker ;;
|
||||||
lazygit) echo lazygit ;;
|
lazygit) echo lazygit ;;
|
||||||
starship) echo starship ;;
|
starship) echo starship ;;
|
||||||
fastfetch) echo fastfetch ;;
|
|
||||||
*) echo "$1" ;;
|
*) echo "$1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -80,23 +86,6 @@ tool_install_starship() {
|
|||||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# A distribution package everywhere, but not always one the distribution ships:
|
|
||||||
# Ubuntu only picked fastfetch up in 24.10, so on noble and older the PPA is the
|
|
||||||
# only source. Checked rather than assumed, so the PPA stops being added the
|
|
||||||
# moment the archive has it.
|
|
||||||
tool_install_fastfetch() {
|
|
||||||
if [[ "$PM" == "apt" ]] && ! apt-cache policy fastfetch 2>/dev/null | grep -q 'Candidate: [0-9]'; then
|
|
||||||
info " fastfetch is not in this release's archive — adding the upstream PPA"
|
|
||||||
add-apt-repository -y ppa:zhangsongcui3371/fastfetch >/dev/null 2>&1 ||
|
|
||||||
{
|
|
||||||
warn "could not add the fastfetch PPA — skipping"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
pkg_refresh >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
pkg_install_now fastfetch
|
|
||||||
}
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Acting
|
# Acting
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ ANSWERS_FILE="$SCRIPT_DIR/.setup-answers"
|
|||||||
# still runs, because every section needs what it establishes — the system, the
|
# still runs, because every section needs what it establishes — the system, the
|
||||||
# role, the account and its home.
|
# role, the account and its home.
|
||||||
ONLY_STEP=""
|
ONLY_STEP=""
|
||||||
|
|
||||||
|
# Kept before the loop consumes them: this script re-executes itself through sudo
|
||||||
|
# below and was passing `"$@"`, which `shift` had already emptied — so `--only` and
|
||||||
|
# `--reask` silently stopped existing the moment it escalated.
|
||||||
|
ORIGINAL_ARGS=(${@+"$@"})
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--only)
|
--only)
|
||||||
@@ -32,12 +38,25 @@ while [[ $# -gt 0 ]]; do
|
|||||||
RE_ASK=1
|
RE_ASK=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
# Every question that HAS a default answers itself; the ones with none still ask.
|
||||||
|
# ASSUME_YES drives confirm(), UNATTENDED drives the numbered menus and the
|
||||||
|
# free-text prompts that carry a default.
|
||||||
|
--unattended | -y)
|
||||||
|
export UNATTENDED=1 ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-l | --list)
|
-l | --list)
|
||||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
-h | --help)
|
-h | --help)
|
||||||
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list]"
|
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list] [--unattended]"
|
||||||
|
echo ""
|
||||||
|
echo " --unattended take the default for every question that has one (-y)."
|
||||||
|
echo " Still asks the ones with no possible default: the"
|
||||||
|
echo " username, the Tailscale control plane / login server /"
|
||||||
|
echo " auth key, an SSH public key when the account has none,"
|
||||||
|
echo " and the git identity."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||||
@@ -48,6 +67,7 @@ done
|
|||||||
# lib/ so a step can eventually be read — or run — on its own without dragging the
|
# lib/ so a step can eventually be read — or run — on its own without dragging the
|
||||||
# whole script in. Definitions only; nothing in there acts.
|
# whole script in. Definitions only; nothing in there acts.
|
||||||
# shellcheck source=lib/base.sh
|
# shellcheck source=lib/base.sh
|
||||||
|
source "$SCRIPT_DIR/../report.sh"
|
||||||
source "$SCRIPT_DIR/lib/base.sh"
|
source "$SCRIPT_DIR/lib/base.sh"
|
||||||
# shellcheck source=lib/packages.sh
|
# shellcheck source=lib/packages.sh
|
||||||
source "$SCRIPT_DIR/lib/packages.sh"
|
source "$SCRIPT_DIR/lib/packages.sh"
|
||||||
@@ -87,6 +107,7 @@ echo -e "${BOLD}╚════════════════════
|
|||||||
[[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE"
|
[[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE"
|
||||||
load_answers
|
load_answers
|
||||||
|
|
||||||
|
trap report_flush EXIT
|
||||||
detect_os
|
detect_os
|
||||||
echo ""
|
echo ""
|
||||||
info "Machine: ${OS_NAME} (${ARCH})"
|
info "Machine: ${OS_NAME} (${ARCH})"
|
||||||
@@ -119,15 +140,58 @@ else
|
|||||||
echo " so coming back costs nothing."
|
echo " so coming back costs nothing."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$EUID" -ne 0 ]]; then
|
# ── root on Linux, NOT root on macOS ──
|
||||||
fail "Please run as root: sudo ./machine-setup.sh"
|
#
|
||||||
|
# The two are opposites and it is not a preference. On Linux nearly every section
|
||||||
|
# needs root — apt, systemd units, useradd, netplan, ufw. On macOS Homebrew
|
||||||
|
# REFUSES to run as root and says so; running the whole script under sudo there
|
||||||
|
# would fail at the first `brew install` having already asked for a password.
|
||||||
|
#
|
||||||
|
# It works out because the macOS path skips everything that needed root in the
|
||||||
|
# first place (see MACOS_SKIP in lib/base.sh). What is left — brew, the Xcode
|
||||||
|
# command line tools, the agent CLIs, bun — is all per-user by design.
|
||||||
|
# ── Privileges: asked for, not demanded ──
|
||||||
|
#
|
||||||
|
# Run this as YOURSELF. Linux needs root for apt, systemd units, useradd, netplan
|
||||||
|
# and ufw, so it asks through sudo and re-executes itself rather than making you
|
||||||
|
# type it. Variables go to sudo by name rather than with -E: `env_reset` is the
|
||||||
|
# sudoers default and strips the environment, which is how DATA_PATH was lost
|
||||||
|
# once already.
|
||||||
|
#
|
||||||
|
# macOS never escalates — Homebrew refuses to run as root, and the sections that
|
||||||
|
# needed root are the ones the macOS path skips.
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
if [[ "$EUID" -eq 0 ]]; then
|
||||||
|
fail "Do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
|
||||||
|
fi
|
||||||
|
elif [[ "$EUID" -ne 0 ]]; then
|
||||||
|
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
|
||||||
|
echo ""
|
||||||
|
echo " This needs administrator rights. You will be asked for your password."
|
||||||
|
echo ""
|
||||||
|
exec sudo \
|
||||||
|
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||||
|
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||||
|
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||||
|
REPORT_FILE="${REPORT_FILE:-}" \
|
||||||
|
UNATTENDED="${UNATTENDED:-}" \
|
||||||
|
ASSUME_YES="${ASSUME_YES:-}" \
|
||||||
|
bash "$SCRIPT_DIR/machine-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ask_username
|
# On macOS the account running the script IS the account, and there is nothing to
|
||||||
if id "$USERNAME" &>/dev/null; then
|
# create — the User account step is skipped entirely.
|
||||||
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
USERNAME="$(id -un)"
|
||||||
|
USER_HOME="$HOME"
|
||||||
|
info "Account: ${USERNAME} (you — macOS creates no accounts here)"
|
||||||
else
|
else
|
||||||
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
|
ask_username
|
||||||
|
if id "$USERNAME" &>/dev/null; then
|
||||||
|
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
|
||||||
|
else
|
||||||
|
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ask_officer_root
|
ask_officer_root
|
||||||
@@ -140,9 +204,9 @@ fi
|
|||||||
save_answers
|
save_answers
|
||||||
|
|
||||||
# Always, and outside any step: everything below reads this index — core utils,
|
# Always, and outside any step: everything below reads this index — core utils,
|
||||||
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
|
# the Docker repo — and `step` skips a step whose name is already in the progress
|
||||||
# already in the progress file. With the refresh inside one of those, a resumed
|
# file. With the refresh inside one of those, a resumed run installed against
|
||||||
# run installed against whatever the index happened to say hours or days ago.
|
# whatever the index happened to say hours or days ago.
|
||||||
echo ""
|
echo ""
|
||||||
info "Refreshing the package index..."
|
info "Refreshing the package index..."
|
||||||
pkg_refresh >/dev/null
|
pkg_refresh >/dev/null
|
||||||
@@ -374,6 +438,34 @@ fi
|
|||||||
# What the distribution provides: the six this script would break without, and
|
# What the distribution provides: the six this script would break without, and
|
||||||
# the command-line tools that make a machine worth sitting at.
|
# the command-line tools that make a machine worth sitting at.
|
||||||
|
|
||||||
|
# macOS's compilers, before anything that might need to build a native module.
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
step "Xcode command line tools"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
if xcode_clt_installed; then
|
||||||
|
ok "already installed ($(xcode-select -p))"
|
||||||
|
SUMMARY+=("Xcode CLT: already installed")
|
||||||
|
else
|
||||||
|
info "Xcode command line tools — macOS's compilers"
|
||||||
|
echo " Needed because node-pty ships no prebuilt binary and compiles"
|
||||||
|
echo " from source on every machine, so 'bun install' cannot finish"
|
||||||
|
echo " without a compiler."
|
||||||
|
echo ""
|
||||||
|
if confirm "Start the install?"; then
|
||||||
|
xcode_clt_install
|
||||||
|
warn "a macOS dialogue has opened — finish it there, then re-run this step"
|
||||||
|
echo " ./machine-setup.sh --only 'Xcode command line tools'"
|
||||||
|
SUMMARY+=("Xcode CLT: install started in a GUI dialogue — finish it, then re-run")
|
||||||
|
else
|
||||||
|
warn "skipped — 'bun install' will fail on node-pty without it"
|
||||||
|
SUMMARY+=("Xcode CLT: SKIPPED by request")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
step "Core utils"
|
step "Core utils"
|
||||||
if ! skip; then
|
if ! skip; then
|
||||||
# shellcheck disable=SC2046 # word splitting is how the list is passed
|
# shellcheck disable=SC2046 # word splitting is how the list is passed
|
||||||
@@ -690,10 +782,9 @@ if ! skip; then
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
while [[ -z "${TIMEZONE:-}" ]]; do
|
while [[ -z "${TIMEZONE:-}" ]]; do
|
||||||
if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then
|
# Unattended keeps the current zone, which is what Enter does here. TIMEZONE=<zone>
|
||||||
echo ""
|
# in the environment answers it ahead of time and skips this block entirely.
|
||||||
fail "No answer. Set TIMEZONE=<zone> to answer this ahead of time."
|
menu_answer TZ_CHOICE " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: "
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$TZ_CHOICE" ]]; then
|
if [[ -z "$TZ_CHOICE" ]]; then
|
||||||
TIMEZONE="$CURRENT_TZ"
|
TIMEZONE="$CURRENT_TZ"
|
||||||
@@ -847,10 +938,7 @@ elif ! skip; then
|
|||||||
|
|
||||||
BALLAST_FILE=""
|
BALLAST_FILE=""
|
||||||
while [[ -z "$BALLAST_FILE" ]]; do
|
while [[ -z "$BALLAST_FILE" ]]; do
|
||||||
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then
|
menu_answer BALLAST_WHERE " Which one? (1/2/3) [1]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
case "${BALLAST_WHERE:-1}" in
|
case "${BALLAST_WHERE:-1}" in
|
||||||
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
|
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
|
||||||
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
|
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
|
||||||
@@ -890,10 +978,7 @@ elif ! skip; then
|
|||||||
|
|
||||||
BALLAST_PCT=""
|
BALLAST_PCT=""
|
||||||
while [[ -z "$BALLAST_PCT" ]]; do
|
while [[ -z "$BALLAST_PCT" ]]; do
|
||||||
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then
|
menu_answer BALLAST_SIZE_CHOICE " Which one? (1/2/3) [2]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
case "${BALLAST_SIZE_CHOICE:-2}" in
|
case "${BALLAST_SIZE_CHOICE:-2}" in
|
||||||
1) BALLAST_PCT=5 ;;
|
1) BALLAST_PCT=5 ;;
|
||||||
2) BALLAST_PCT=10 ;;
|
2) BALLAST_PCT=10 ;;
|
||||||
@@ -1268,10 +1353,7 @@ if ! skip; then
|
|||||||
DNS_FALLBACK=""
|
DNS_FALLBACK=""
|
||||||
DNS_CHOSEN=""
|
DNS_CHOSEN=""
|
||||||
while [[ -z "$DNS_CHOSEN" ]]; do
|
while [[ -z "$DNS_CHOSEN" ]]; do
|
||||||
if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then
|
menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
case "${DNS_CHOICE:-1}" in
|
case "${DNS_CHOICE:-1}" in
|
||||||
1) DNS_CHOSEN="keep" ;;
|
1) DNS_CHOSEN="keep" ;;
|
||||||
2)
|
2)
|
||||||
@@ -1378,10 +1460,7 @@ elif ! skip; then
|
|||||||
|
|
||||||
NET_CHOICE=""
|
NET_CHOICE=""
|
||||||
while [[ -z "$NET_CHOICE" ]]; do
|
while [[ -z "$NET_CHOICE" ]]; do
|
||||||
if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then
|
menu_answer NET_ANSWER " Which one? (1/2/3) [1]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
case "${NET_ANSWER:-1}" in
|
case "${NET_ANSWER:-1}" in
|
||||||
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
|
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
|
||||||
*) warn "Pick 1, 2 or 3." ;;
|
*) warn "Pick 1, 2 or 3." ;;
|
||||||
@@ -1644,12 +1723,47 @@ if ! skip; then
|
|||||||
info "Docker — containers, and how ${USERNAME} is allowed to talk to them"
|
info "Docker — containers, and how ${USERNAME} is allowed to talk to them"
|
||||||
echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')"
|
echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')"
|
||||||
echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')"
|
echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')"
|
||||||
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
|
if [[ "$OS" != "macos" ]]; then
|
||||||
|
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! docker_is_installed; then
|
# ── macOS: we do not install Docker, we check for it ──
|
||||||
|
#
|
||||||
|
# Docker Desktop is the only thing that works here without a fight. Lima and
|
||||||
|
# colima both technically run containers on a Mac and both cost an evening the
|
||||||
|
# first time something does not resolve, so this asks for Desktop by name
|
||||||
|
# rather than installing an alternative that will disappoint later.
|
||||||
|
#
|
||||||
|
# Not installed by the script either: it is a GUI app that wants to be opened,
|
||||||
|
# granted permissions and left running, none of which a shell script should be
|
||||||
|
# doing on somebody's laptop.
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
if docker_daemon_ok; then
|
||||||
|
ok "Docker Desktop is running"
|
||||||
|
SUMMARY+=("Docker: Docker Desktop running")
|
||||||
|
elif docker_is_installed; then
|
||||||
|
warn "the docker CLI is here but the daemon is not answering"
|
||||||
|
echo " Open Docker Desktop from Applications and let it finish starting."
|
||||||
|
SUMMARY+=("Docker: installed but not running — open Docker Desktop")
|
||||||
|
else
|
||||||
|
warn "Docker is not installed"
|
||||||
|
echo ""
|
||||||
|
echo " Officer needs it for Postgres and for anything the app store"
|
||||||
|
echo " installs. Get Docker Desktop:"
|
||||||
|
echo ""
|
||||||
|
echo " https://www.docker.com/products/docker-desktop/"
|
||||||
|
echo ""
|
||||||
|
echo " Open it once after installing, then run this step again:"
|
||||||
|
echo " ./machine-setup.sh --only Docker"
|
||||||
|
SUMMARY+=("Docker: NOT installed — install Docker Desktop, then re-run this step")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
elif ! docker_is_installed; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " to install: docker-ce, the CLI, containerd, buildx and compose,"
|
echo " to install: docker-ce, the CLI, containerd, buildx and compose,"
|
||||||
echo " from Docker's own repository"
|
echo " from Docker's own repository — plus uidmap,"
|
||||||
|
echo " dbus-user-session and docker-ce-rootless-extras,"
|
||||||
|
echo " which every member's own rootless daemon needs"
|
||||||
if confirm "Install it?"; then
|
if confirm "Install it?"; then
|
||||||
if install_docker_engine; then
|
if install_docker_engine; then
|
||||||
ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
|
ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
|
||||||
@@ -1665,7 +1779,10 @@ if ! skip; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if docker_is_installed; then
|
# The group-vs-rootless choice below is Linux only: Docker Desktop runs
|
||||||
|
# containers in a VM owned by whoever is logged in, so there is no group to
|
||||||
|
# join and no rootless variant to pick.
|
||||||
|
if [[ "$OS" != "macos" ]] && docker_is_installed; then
|
||||||
# ── how this account reaches the daemon ──
|
# ── how this account reaches the daemon ──
|
||||||
if user_in_docker_group || docker_rootless_installed; then
|
if user_in_docker_group || docker_rootless_installed; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1710,10 +1827,7 @@ if ! skip; then
|
|||||||
|
|
||||||
DOCKER_ACCESS=""
|
DOCKER_ACCESS=""
|
||||||
while [[ -z "$DOCKER_ACCESS" ]]; do
|
while [[ -z "$DOCKER_ACCESS" ]]; do
|
||||||
if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then
|
menu_answer DOCKER_CHOICE " Which one? (1/2/3) [1]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
case "${DOCKER_CHOICE:-1}" in
|
case "${DOCKER_CHOICE:-1}" in
|
||||||
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
|
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
|
||||||
*) warn "Pick 1, 2 or 3." ;;
|
*) warn "Pick 1, 2 or 3." ;;
|
||||||
@@ -1821,10 +1935,7 @@ if ! skip; then
|
|||||||
NVIM_REPO=""
|
NVIM_REPO=""
|
||||||
NVIM_PICK=""
|
NVIM_PICK=""
|
||||||
while [[ -z "$NVIM_PICK" ]]; do
|
while [[ -z "$NVIM_PICK" ]]; do
|
||||||
if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then
|
menu_answer NVIM_CHOICE " Which one? (1/2/3) [1]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
case "${NVIM_CHOICE:-1}" in
|
case "${NVIM_CHOICE:-1}" in
|
||||||
1)
|
1)
|
||||||
NVIM_REPO="https://github.com/LazyVim/starter"
|
NVIM_REPO="https://github.com/LazyVim/starter"
|
||||||
@@ -1984,7 +2095,7 @@ if ! skip; then
|
|||||||
echo ""
|
echo ""
|
||||||
info "Agent CLIs — the programs Officer's chat actually runs"
|
info "Agent CLIs — the programs Officer's chat actually runs"
|
||||||
echo " claude $(agent_version claude || echo 'not installed')"
|
echo " claude $(agent_version claude || echo 'not installed')"
|
||||||
echo " spawned by officer-agent; chat does not work without it."
|
echo " spawned by officer-claude-code; chat does not work without it."
|
||||||
echo " opencode $(agent_version opencode || echo 'not installed')"
|
echo " opencode $(agent_version opencode || echo 'not installed')"
|
||||||
echo " the alternative agent, run by officer-opencode."
|
echo " the alternative agent, run by officer-opencode."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -2110,10 +2221,20 @@ if ! skip; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo " ${USERNAME}'s login shell is ${SHELL_NOW}. Changing it to zsh takes"
|
echo " ${USERNAME}'s login shell is ${SHELL_NOW}. Changing it to zsh takes"
|
||||||
echo " effect at the next login, and does not affect this session."
|
echo " effect at the next login, and does not affect this session."
|
||||||
|
# Guarded, not bare: `chsh` can refuse — a PAM policy, a shell missing from
|
||||||
|
# /etc/shells, an account whose password field blocks it — and a bare call would
|
||||||
|
# end the run there under `set -e`. Reported instead, because a machine with the
|
||||||
|
# right shell installed and the wrong one at login still works.
|
||||||
if confirm "Make zsh the login shell?"; then
|
if confirm "Make zsh the login shell?"; then
|
||||||
set_login_shell "$(command -v zsh)"
|
if set_login_shell "$(command -v zsh)"; then
|
||||||
ok "login shell is now $(user_login_shell)"
|
ok "login shell is now $(user_login_shell)"
|
||||||
SUMMARY+=("Shell: login shell set to zsh")
|
SUMMARY+=("Shell: login shell set to zsh")
|
||||||
|
else
|
||||||
|
warn "chsh refused — login shell is still $(user_login_shell)"
|
||||||
|
echo " Change it later with: chsh -s $(command -v zsh) ${USERNAME}"
|
||||||
|
ERRORS+=("Shell: chsh refused, login shell left as $(user_login_shell)")
|
||||||
|
SUMMARY+=("Shell: login shell NOT changed")
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
warn "left as ${SHELL_NOW}"
|
warn "left as ${SHELL_NOW}"
|
||||||
SUMMARY+=("Shell: login shell left as ${SHELL_NOW}")
|
SUMMARY+=("Shell: login shell left as ${SHELL_NOW}")
|
||||||
@@ -2137,11 +2258,29 @@ if ! skip; then
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -r "$SCRIPT_DIR/.tmux.conf" ]]; then
|
# scripts/setup/tmux.conf, one level up — the shell templates live together
|
||||||
install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME" && RC=0 || RC=$?
|
# beside starship.toml, which has to be there because the PLATFORM reads it
|
||||||
|
# too (os-user-shell.ts, for every member's Linux account). Keeping them in
|
||||||
|
# one directory means "where do the dotfile templates live" has one answer.
|
||||||
|
#
|
||||||
|
# No leading dot on any of them: they are templates in a repository, not
|
||||||
|
# dotfiles in a home directory, and tmux's destination is increasingly
|
||||||
|
# ~/.config/tmux/tmux.conf, which has no dot either.
|
||||||
|
if [[ -r "$SCRIPT_DIR/../tmux.conf" ]]; then
|
||||||
|
# Not always ~/.tmux.conf — see tmux_config_target. tmux 3.1+ prefers
|
||||||
|
# ~/.config/tmux/tmux.conf, so writing the old path on a machine that has
|
||||||
|
# the new one produces a file tmux never reads and a success message that
|
||||||
|
# means nothing.
|
||||||
|
TMUX_TARGET="$(tmux_config_target "$USER_HOME")"
|
||||||
|
if tmux_dot_conf_is_shadowed "$USER_HOME"; then
|
||||||
|
warn "you have BOTH ~/.tmux.conf and ~/.config/tmux/tmux.conf — tmux reads the second"
|
||||||
|
echo " Targeting the one it actually reads: ${TMUX_TARGET}"
|
||||||
|
fi
|
||||||
|
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$(dirname "$TMUX_TARGET")"
|
||||||
|
install_config "$SCRIPT_DIR/../tmux.conf" "$TMUX_TARGET" "$USERNAME" && RC=0 || RC=$?
|
||||||
case $RC in
|
case $RC in
|
||||||
0) ok "tmux config installed" ;;
|
0) ok "tmux config installed — ${TMUX_TARGET}" ;;
|
||||||
1) echo " tmux config already matches" ;;
|
1) echo " tmux config already matches (${TMUX_TARGET})" ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -2172,8 +2311,41 @@ EOF
|
|||||||
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The eza aliases are GUARDED and the rest are not, for one reason: these
|
||||||
|
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
|
||||||
|
# failed to install leaves the owner with no working `ls` at all, in every new
|
||||||
|
# shell, which reads as a broken machine rather than a missing package. The
|
||||||
|
# others degrade honestly — `alias ld=lazydocker` without lazydocker is one
|
||||||
|
# command-not-found when you type it, not a core utility gone.
|
||||||
|
#
|
||||||
|
# Same principle shell-skel/zshrc already holds to: every optional tool is used
|
||||||
|
# only if present, so one file works on a minimal VPS and a full workstation.
|
||||||
if append_once "$ZSHRC" aliases <<'EOF'
|
if append_once "$ZSHRC" aliases <<'EOF'
|
||||||
|
if command -v eza >/dev/null 2>&1; then
|
||||||
|
alias ls='eza --icons'
|
||||||
|
alias la='eza --icons -la'
|
||||||
|
alias ll='eza --icons -l'
|
||||||
|
alias lll='eza --icons -lA'
|
||||||
|
alias lh='eza --icons -lhA'
|
||||||
|
alias ltr='eza --icons -ltr'
|
||||||
|
alias l='eza --icons -la'
|
||||||
|
fi
|
||||||
|
|
||||||
|
alias grep='grep --color=auto'
|
||||||
|
alias less='less -R'
|
||||||
|
alias diff='diff --color=auto'
|
||||||
|
alias cp='cp -iv'
|
||||||
|
alias mv='mv -iv'
|
||||||
|
alias rm='rm -i'
|
||||||
|
alias mkdir='mkdir -p'
|
||||||
|
alias which='which -a'
|
||||||
|
alias history='fc -l 1'
|
||||||
|
|
||||||
|
alias n="nvim"
|
||||||
|
alias vim="n"
|
||||||
alias sz="source ~/.zshrc"
|
alias sz="source ~/.zshrc"
|
||||||
|
alias ld="lazydocker"
|
||||||
|
alias httpserver="python3 -m http.server 8888"
|
||||||
EOF
|
EOF
|
||||||
then
|
then
|
||||||
ok "shell aliases added"
|
ok "shell aliases added"
|
||||||
@@ -2202,10 +2374,7 @@ EOF
|
|||||||
|
|
||||||
EDITOR_PICK=""
|
EDITOR_PICK=""
|
||||||
while [[ -z "$EDITOR_PICK" ]]; do
|
while [[ -z "$EDITOR_PICK" ]]; do
|
||||||
if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then
|
menu_answer EDITOR_CHOICE " Which one? (1-${#EDITORS[@]}) [1]: "
|
||||||
echo ""
|
|
||||||
fail "No answer."
|
|
||||||
fi
|
|
||||||
EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
|
EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
|
||||||
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
|
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
|
||||||
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
|
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
|
||||||
@@ -2397,5 +2566,35 @@ echo " Officer: $OFFICER_ROOT"
|
|||||||
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
|
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# ── Who you are when this exits ──
|
||||||
|
#
|
||||||
|
# Root. This script never becomes ${USERNAME} — it cannot, since a process cannot
|
||||||
|
# change its own uid — so it stays root and drops privileges per command instead.
|
||||||
|
# Everything written into their home was written that way.
|
||||||
|
#
|
||||||
|
# Worth saying out loud because the two things a fresh session fixes are both
|
||||||
|
# invisible until they bite: group membership is fixed at LOGIN, so the `docker`
|
||||||
|
# group just granted does not exist in this session, and their shell configuration
|
||||||
|
# lives in their home and is not loaded in root's.
|
||||||
|
#
|
||||||
|
# Suppressed when officer-setup is about to run — install.sh sets the variable. It
|
||||||
|
# would be wrong advice in the middle of an install, because the half that follows
|
||||||
|
# still needs the root session this would tell you to leave.
|
||||||
|
if [[ "$EUID" -eq 0 && -z "${OFFICER_SETUP_FOLLOWS:-}" ]]; then
|
||||||
|
echo -e "${BOLD} You are still root.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " This machine is set up for ${USERNAME}. To carry on as them:"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
|
||||||
|
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
|
||||||
|
echo ""
|
||||||
|
echo " A new session is what makes their docker group membership and their"
|
||||||
|
echo " shell configuration take effect — neither applies to the session you"
|
||||||
|
echo " are in now."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# Clean up progress file on success
|
# Clean up progress file on success
|
||||||
rm -f "$PROGRESS_FILE"
|
rm -f "$PROGRESS_FILE"
|
||||||
|
|
||||||
|
report_mark_complete
|
||||||
|
|||||||
+465
-13
@@ -14,6 +14,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
|
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
|
||||||
|
|
||||||
ONLY_STEP=""
|
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
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--only)
|
--only)
|
||||||
@@ -24,19 +31,46 @@ while [[ $# -gt 0 ]]; do
|
|||||||
ONLY_STEP="${1#*=}"
|
ONLY_STEP="${1#*=}"
|
||||||
shift
|
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)
|
-l | --list)
|
||||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
-h | --help)
|
-h | --help)
|
||||||
echo "usage: officer-setup.sh [--only <step>] [--list]"
|
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
|
||||||
|
echo ""
|
||||||
|
echo " --only <step> run one step; --list names them"
|
||||||
|
echo " --unattended take the default for every question that has one (-y)"
|
||||||
|
echo " --repo <url> clone from here instead of the default, which is a"
|
||||||
|
echo " private Gitea over SSH and only authenticates on a"
|
||||||
|
echo " machine whose key it already knows. Same as exporting"
|
||||||
|
echo " OFFICER_REPO. Ignored once the repo is checked out."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
export OFFICER_REPO="${OFFICER_REPO:-}"
|
||||||
|
|
||||||
# shellcheck source=officer-setup/lib/base.sh
|
# shellcheck source=officer-setup/lib/base.sh
|
||||||
|
source "$SCRIPT_DIR/report.sh"
|
||||||
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
|
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
|
||||||
# shellcheck source=officer-setup/lib/preflight.sh
|
# shellcheck source=officer-setup/lib/preflight.sh
|
||||||
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
|
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
|
||||||
@@ -48,6 +82,14 @@ source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
|
|||||||
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
|
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
|
||||||
# shellcheck source=officer-setup/lib/env.sh
|
# shellcheck source=officer-setup/lib/env.sh
|
||||||
source "$SCRIPT_DIR/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
|
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
||||||
|
|
||||||
@@ -60,10 +102,37 @@ echo -e "${BOLD}╔════════════════════
|
|||||||
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
||||||
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||||
|
|
||||||
if [[ "$EUID" -ne 0 ]]; then
|
# ── Privileges: asked for, not demanded ──
|
||||||
fail "Please run as root: sudo ./officer-setup.sh"
|
#
|
||||||
|
# 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
|
fi
|
||||||
|
|
||||||
|
trap report_flush EXIT
|
||||||
|
|
||||||
# ── what machine-setup already established ──
|
# ── what machine-setup already established ──
|
||||||
echo ""
|
echo ""
|
||||||
if load_machine_answers; then
|
if load_machine_answers; then
|
||||||
@@ -114,6 +183,26 @@ info "Account: ${USERNAME} (home ${USER_HOME})"
|
|||||||
info "Officer: ${OFFICER_ROOT}"
|
info "Officer: ${OFFICER_ROOT}"
|
||||||
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
|
[[ -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 ──
|
# ── is the machine actually ready ──
|
||||||
#
|
#
|
||||||
# Checked and reported together. Finding out about a missing bun three sections
|
# Checked and reported together. Finding out about a missing bun three sections
|
||||||
@@ -167,6 +256,7 @@ fi
|
|||||||
#
|
#
|
||||||
# Before the repository, because the repository is cloned into it.
|
# Before the repository, because the repository is cloned into it.
|
||||||
|
|
||||||
|
report_section "Layout"
|
||||||
step "Layout"
|
step "Layout"
|
||||||
if ! skip; then
|
if ! skip; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -210,6 +300,7 @@ fi
|
|||||||
# 3. Repository
|
# 3. Repository
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Repository"
|
||||||
step "Repository"
|
step "Repository"
|
||||||
if ! skip; then
|
if ! skip; then
|
||||||
PLATFORM_DIR="$(platform_dir)"
|
PLATFORM_DIR="$(platform_dir)"
|
||||||
@@ -282,6 +373,7 @@ fi
|
|||||||
# 4. Dependencies
|
# 4. Dependencies
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Dependencies"
|
||||||
step "Dependencies"
|
step "Dependencies"
|
||||||
if ! skip; then
|
if ! skip; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -336,6 +428,7 @@ fi
|
|||||||
#
|
#
|
||||||
# POSTGRES_URL is set here and written by the environment section below.
|
# POSTGRES_URL is set here and written by the environment section below.
|
||||||
|
|
||||||
|
report_section "Database"
|
||||||
step "Database"
|
step "Database"
|
||||||
if ! skip; then
|
if ! skip; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -453,6 +546,7 @@ fi
|
|||||||
# 6. Environment
|
# 6. Environment
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Environment"
|
||||||
step "Environment"
|
step "Environment"
|
||||||
if ! skip; then
|
if ! skip; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -460,7 +554,7 @@ if ! skip; then
|
|||||||
|
|
||||||
# Read back before anything is asked; existing values become the defaults.
|
# Read back before anything is asked; existing values become the defaults.
|
||||||
ENV_PORT="$(env_get PORT)"
|
ENV_PORT="$(env_get PORT)"
|
||||||
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
|
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
|
||||||
|
|
||||||
if env_exists; then
|
if env_exists; then
|
||||||
echo " exists — its values are the defaults below"
|
echo " exists — its values are the defaults below"
|
||||||
@@ -471,11 +565,23 @@ if ! skip; then
|
|||||||
# ── what is asked ──
|
# ── what is asked ──
|
||||||
echo ""
|
echo ""
|
||||||
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
||||||
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
|
|
||||||
|
echo ""
|
||||||
|
echo " PUBLIC_URL is where Officer is reached from a browser. It is the one"
|
||||||
|
echo " thing this machine cannot work out for itself, and three things need"
|
||||||
|
echo " it: the OpenGraph tags baked into the page by 'bun gen:index', the"
|
||||||
|
echo " host the task API hands to scripts, and the CalDAV profile an iPhone"
|
||||||
|
echo " installs — that last one requires https."
|
||||||
|
echo ""
|
||||||
|
echo " Defaulting to this machine's tailnet address, not localhost: the"
|
||||||
|
echo " tailnet is where Officer is actually reached from, and localhost"
|
||||||
|
echo " works from here and nowhere else."
|
||||||
|
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-$(default_public_url "$ENV_PORT")}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " to write:"
|
echo " to write:"
|
||||||
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
|
echo " PORT=${ENV_PORT}"
|
||||||
|
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||||
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
|
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
|
||||||
echo ""
|
echo ""
|
||||||
echo " the install root is not written here — the platform derives it as the"
|
echo " the install root is not written here — the platform derives it as the"
|
||||||
@@ -486,6 +592,7 @@ if ! skip; then
|
|||||||
if confirm "Write it?"; then
|
if confirm "Write it?"; then
|
||||||
write_env
|
write_env
|
||||||
ok "written, 0600, owned by ${USERNAME}"
|
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"
|
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
|
||||||
SUMMARY+=("Environment: $(env_file)")
|
SUMMARY+=("Environment: $(env_file)")
|
||||||
else
|
else
|
||||||
@@ -496,13 +603,358 @@ if ! skip; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NOT BUILT YET
|
# 7. Secrets
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 6 Schema db:push
|
#
|
||||||
# 7 Build gen:index
|
# The store creates keys on demand, so this section is not strictly required —
|
||||||
# 8 Services pm2 startOrRestart · save · startup
|
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
|
||||||
# 9 Verify are the processes actually up
|
# reasons: the file should exist with the right owner and mode before anything
|
||||||
|
# races to create it, and an install that finishes without ever saying the words
|
||||||
|
# "back this up" is one where nobody learns the file matters until it is gone.
|
||||||
|
|
||||||
|
report_section "Secrets"
|
||||||
|
step "Secrets"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Secret store — $(secret_store_path)"
|
||||||
|
echo " Every encryption and signing key the platform holds, one SQLite file,"
|
||||||
|
echo " one key per purpose. Nothing goes in .env."
|
||||||
|
echo ""
|
||||||
|
echo " bootstrapped now:"
|
||||||
|
echo " jwt signs every session token"
|
||||||
|
echo " headscale encrypts the Headscale admin API key in Postgres"
|
||||||
|
echo ""
|
||||||
|
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
|
||||||
|
echo " service-connections — is created when its plugin is installed. A"
|
||||||
|
echo " plugin cannot read another plugin's key."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if confirm "Create it?"; then
|
||||||
|
if bootstrap_secret_store; then
|
||||||
|
ok "created, 0600, owned by ${USERNAME}"
|
||||||
|
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted."
|
||||||
|
echo ""
|
||||||
|
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
|
||||||
|
echo " Losing it signs everyone out and makes every encrypted column in"
|
||||||
|
echo " Postgres unreadable. For the wallet seed that is unrecoverable:"
|
||||||
|
echo " the passphrase opens the inner envelope, this is the outer one."
|
||||||
|
echo ""
|
||||||
|
echo " Keeping it beside a dump defeats it — the dump is the ciphertext"
|
||||||
|
echo " and this is the key. Separate backups, or it is one theft."
|
||||||
|
SUMMARY+=("Secrets: $(secret_store_path)")
|
||||||
|
else
|
||||||
|
warn "could not create the store — the platform will create it on first use"
|
||||||
|
SUMMARY+=("Secrets: NOT created; the platform will do it on first use")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "skipped by request — the platform will create it on first use"
|
||||||
|
SUMMARY+=("Secrets: SKIPPED; the platform will create it on first use")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 8. Schema
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Schema"
|
||||||
|
step "Schema"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
# Counted from the aggregator rather than hardcoded, so the number is the truth
|
||||||
|
# even when a plugin line is uncommented. It was written as ${SCHEMA_TABLES:-?}
|
||||||
|
# and never assigned, so the section said "? tables" — a placeholder that looked
|
||||||
|
# like the count could not be determined rather than like nobody had set it.
|
||||||
|
SCHEMA_TABLES="$(schema_table_count)"
|
||||||
|
info "Database schema"
|
||||||
|
echo " ${SCHEMA_TABLES:-?} tables, applied with 'bun db:push' — drizzle-kit"
|
||||||
|
echo " diffs the schema code against Postgres and alters it directly. There"
|
||||||
|
echo " are no migration files and no migration table; the code is the source"
|
||||||
|
echo " of truth."
|
||||||
|
echo ""
|
||||||
|
echo " Only the CORE tables. Every plugin's tables are commented out in"
|
||||||
|
echo " src/databases/officer_db/src/schema.ts and get created when the"
|
||||||
|
echo " plugin is installed."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if confirm "Push it?"; then
|
||||||
|
if OUT="$(push_schema)"; then
|
||||||
|
ok "schema applied"
|
||||||
|
report_changed "applied ${SCHEMA_TABLES} tables to Postgres with 'bun db:push' (drizzle-kit; no migration files)"
|
||||||
|
SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed")
|
||||||
|
else
|
||||||
|
warn "db:push failed"
|
||||||
|
echo "$OUT" | tail -12 | sed 's/^/ /'
|
||||||
|
SUMMARY+=("Schema: FAILED — see the output above")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "skipped by request — the platform will not start without it"
|
||||||
|
SUMMARY+=("Schema: SKIPPED by request")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 9. Build
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Build"
|
||||||
|
step "Build"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "index.gen.html"
|
||||||
|
echo " 'bun gen:index' substitutes your public URL into index.html and"
|
||||||
|
echo " writes index.gen.html, which is the file the server imports. It is"
|
||||||
|
echo " gitignored, so a fresh clone never has one and the server has no page"
|
||||||
|
echo " to serve until this runs."
|
||||||
|
echo ""
|
||||||
|
echo " URL: ${ENV_PUBLIC_URL:-<not set — run the Environment section first>}"
|
||||||
|
echo ""
|
||||||
|
echo " To change it later: bun gen:index https://your.new.url"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -z "$ENV_PUBLIC_URL" ]]; then
|
||||||
|
warn "PUBLIC_URL is not in $(env_file) — run the Environment section, then this one"
|
||||||
|
SUMMARY+=("Build: SKIPPED — no PUBLIC_URL")
|
||||||
|
elif confirm "Generate it?"; then
|
||||||
|
if OUT="$(gen_index)"; then
|
||||||
|
ok "$(gen_index_output)"
|
||||||
|
report_changed "generated $(gen_index_output) from index.html, substituting PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||||
|
SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}")
|
||||||
|
else
|
||||||
|
warn "gen:index failed"
|
||||||
|
echo "$OUT" | tail -8 | sed 's/^/ /'
|
||||||
|
SUMMARY+=("Build: FAILED — see the output above")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "skipped by request — the server has no page to serve without it"
|
||||||
|
SUMMARY+=("Build: SKIPPED by request")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 10. Services
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Services"
|
||||||
|
step "Services"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "pm2 — $(ecosystem_file)"
|
||||||
|
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
||||||
|
echo " install and nothing else, so nothing in git can drift from it."
|
||||||
|
echo ""
|
||||||
|
echo " six processes:"
|
||||||
|
for entry in "${CORE_PROCESSES[@]}"; do
|
||||||
|
IFS='|' read -r _name _script _args <<<"$entry"
|
||||||
|
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo " Nothing else. Every plugin adds its own entry when it is installed."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if confirm "Write it and start them?"; then
|
||||||
|
write_ecosystem
|
||||||
|
ok "written — $(ecosystem_file)"
|
||||||
|
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||||
|
|
||||||
|
# Starting against a database that is not answering is not fatal — the server
|
||||||
|
# waits and the agent retries forever — but it makes the Verify section below
|
||||||
|
# report a failure that is really just a race, and that is the kind of noise
|
||||||
|
# that teaches people to ignore a red line.
|
||||||
|
if pg_container_running && ! pg_wait_ready 30; then
|
||||||
|
warn "Postgres is not answering — starting anyway, but Verify may report failures"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if OUT="$(pm2_start)"; then
|
||||||
|
ok "processes started"
|
||||||
|
report_started "pm2 startOrRestart: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||||
|
pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if confirm "Start them on boot too?"; then
|
||||||
|
if pm2_enable_startup; then
|
||||||
|
ok "pm2 will resurrect them at boot"
|
||||||
|
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
||||||
|
SUMMARY+=("Services: 6 processes started, enabled at boot")
|
||||||
|
else
|
||||||
|
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
||||||
|
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
SUMMARY+=("Services: 6 processes started; not enabled at boot")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "pm2 did not start cleanly"
|
||||||
|
echo "$OUT" | tail -12 | sed 's/^/ /'
|
||||||
|
SUMMARY+=("Services: FAILED to start — see the output above")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "skipped by request"
|
||||||
|
SUMMARY+=("Services: SKIPPED by request")
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 11. Verify
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
report_section "Verify"
|
||||||
|
step "Verify"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Are the processes actually up?"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
VERIFY_BAD=0
|
||||||
|
while IFS='|' read -r vname vstatus vrestarts; do
|
||||||
|
[[ -z "$vname" ]] && continue
|
||||||
|
if [[ "$vstatus" == "online" ]]; then
|
||||||
|
if (( vrestarts > 3 )); then
|
||||||
|
warn "$(printf '%-24s online, but restarted %s times — check: pm2 logs %s' "$vname" "$vrestarts" "$vname")"
|
||||||
|
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||||
|
else
|
||||||
|
ok "$(printf '%-24s online' "$vname")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "$(printf '%-24s %s — check: pm2 logs %s' "$vname" "$vstatus" "$vname")"
|
||||||
|
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||||
|
fi
|
||||||
|
done < <(pm2_status_lines)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
# A process can be `online` and still be failing to serve — a restart loop takes
|
||||||
|
# a few seconds to show up in the counter, and the app can be up with a broken
|
||||||
|
# database. So the port is asked directly.
|
||||||
|
if curl -fsS --max-time 5 "http://127.0.0.1:${ENV_PORT:-9000}/api" >/dev/null 2>&1; then
|
||||||
|
ok "the API answers on 127.0.0.1:${ENV_PORT:-9000}"
|
||||||
|
SUMMARY+=("Verify: API answering on port ${ENV_PORT:-9000}")
|
||||||
|
else
|
||||||
|
warn "nothing answered on 127.0.0.1:${ENV_PORT:-9000}/api"
|
||||||
|
echo " pm2 logs officer is where the reason will be."
|
||||||
|
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||||
|
SUMMARY+=("Verify: the API did NOT answer on port ${ENV_PORT:-9000}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( VERIFY_BAD == 0 )); then
|
||||||
|
echo ""
|
||||||
|
ok "Officer is running. Open ${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT:-9000}} and the"
|
||||||
|
echo " first-run screen will create the owner account."
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 12. Proxy
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Optional, and last, because it is the only step that needs Officer to be already
|
||||||
|
# running: NPM proxies to it, and the gate below checks the bind address rather than
|
||||||
|
# taking a curl to loopback as proof.
|
||||||
|
#
|
||||||
|
# ── Why this section ignores --unattended ──
|
||||||
|
#
|
||||||
|
# Every other question in this script has a defensible default. None of these do — a
|
||||||
|
# domain name, a DNS provider and that provider's API credentials cannot be guessed —
|
||||||
|
# and the step is opt-in besides. So its prompts read stdin directly instead of going
|
||||||
|
# through confirm()/ask_required(), which honour ASSUME_YES.
|
||||||
|
#
|
||||||
|
# The valve is a TTY check, not the flag: with no terminal there is nobody to ask, so
|
||||||
|
# it skips and prints the manual instructions. That keeps a cron-driven install working
|
||||||
|
# without letting --unattended silently agree to publishing a public hostname.
|
||||||
|
|
||||||
|
report_section "Proxy"
|
||||||
|
step "Proxy"
|
||||||
|
if ! skip; then
|
||||||
|
echo ""
|
||||||
|
info "Reverse proxy — a real hostname and an HTTPS certificate"
|
||||||
|
echo " Optional. Skip it if you already run a proxy elsewhere, or if you"
|
||||||
|
echo " reach this instance over the tailnet and are happy with that."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
PROXY_PORT="${ENV_PORT:-9000}"
|
||||||
|
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
warn "no terminal — skipping the proxy, which cannot be answered unattended"
|
||||||
|
proxy_skip_instructions "$PROXY_PORT"
|
||||||
|
SUMMARY+=("Proxy: skipped (no terminal)")
|
||||||
|
elif ! proxy_require_listening "$PROXY_PORT"; then
|
||||||
|
warn "skipping the proxy — Officer is not reachable the way NPM would reach it"
|
||||||
|
echo " Fix the bind address, then: officer-setup.sh --only Proxy"
|
||||||
|
SUMMARY+=("Proxy: skipped (Officer not listening on 0.0.0.0)")
|
||||||
|
elif ! proxy_confirm "Set up Nginx Proxy Manager now?"; then
|
||||||
|
proxy_skip_instructions "$PROXY_PORT"
|
||||||
|
SUMMARY+=("Proxy: skipped by request")
|
||||||
|
else
|
||||||
|
# One failure path for all of it: every function warns and returns non-zero rather
|
||||||
|
# than exiting, so a proxy that does not come up leaves a finished Officer install
|
||||||
|
# behind rather than a failed one. It is the last section for that reason.
|
||||||
|
PROXY_DOMAIN="$(proxy_ask 'Domain for this instance (e.g. officer.example.com)')"
|
||||||
|
if [[ -z "$PROXY_DOMAIN" ]]; then
|
||||||
|
warn "no domain given — skipping"
|
||||||
|
SUMMARY+=("Proxy: skipped (no domain)")
|
||||||
|
elif
|
||||||
|
proxy_detect_target &&
|
||||||
|
proxy_ensure_network &&
|
||||||
|
proxy_order_docker_after_tailscaled &&
|
||||||
|
proxy_write_compose &&
|
||||||
|
proxy_start &&
|
||||||
|
proxy_claim_admin &&
|
||||||
|
proxy_get_token &&
|
||||||
|
{ [[ "$CHALLENGE" != "dns" ]] || proxy_prompt_dns_credentials; } &&
|
||||||
|
proxy_wait_for_dns "$PROXY_DOMAIN" "$TARGET_IP" &&
|
||||||
|
proxy_allow_bridge_to_host "$PROXY_PORT" &&
|
||||||
|
proxy_create_host "$PROXY_DOMAIN" "$PROXY_PORT" &&
|
||||||
|
proxy_issue_certificate "$PROXY_DOMAIN" &&
|
||||||
|
proxy_attach_certificate
|
||||||
|
then
|
||||||
|
proxy_verify "$PROXY_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
ok "Officer is published at https://${PROXY_DOMAIN}"
|
||||||
|
echo " NPM admin: http://127.0.0.1:81$([[ "$CHALLENGE" == "dns" ]] && echo " or http://${TARGET_IP}:81")"
|
||||||
|
SUMMARY+=("Proxy: https://${PROXY_DOMAIN}")
|
||||||
|
else
|
||||||
|
warn "the proxy did not finish — Officer itself is unaffected and still running"
|
||||||
|
echo " Retry just this part with: officer-setup.sh --only Proxy"
|
||||||
|
ERRORS+=("Proxy: did not finish")
|
||||||
|
SUMMARY+=("Proxy: FAILED — retry with --only Proxy")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
step_ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Who you are when this exits ──
|
||||||
|
#
|
||||||
|
# Root, and that surprises people — reasonably, because everything this script just
|
||||||
|
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
||||||
|
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
|
||||||
|
# the installer's privilege, never the platform's.
|
||||||
|
#
|
||||||
|
# Saying so matters for two things that are invisible until they bite:
|
||||||
|
#
|
||||||
|
# - group membership is fixed at login. ${USERNAME} was added to `docker` during
|
||||||
|
# machine setup, and a session that started before that does not have it — so
|
||||||
|
# `docker ps` fails for a reason that has nothing to do with docker.
|
||||||
|
# - the shell config was written into THEIR home. Staying as root means none of it
|
||||||
|
# is loaded, and the machine looks unconfigured.
|
||||||
|
if [[ "$EUID" -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD} One more thing — you are still root.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " Officer runs as ${USERNAME}, and everything it installed is theirs."
|
||||||
|
echo " Nothing here needs root any more. To carry on as them:"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
|
||||||
|
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
|
||||||
|
echo ""
|
||||||
|
echo " Either gives a new session, which is what makes their docker group"
|
||||||
|
echo " membership and their shell configuration take effect. Staying as root"
|
||||||
|
echo " means neither does, and the machine will look half-configured."
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
|
|
||||||
echo ""
|
report_mark_complete
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ confirm() {
|
|||||||
|
|
||||||
ask_required() {
|
ask_required() {
|
||||||
local __var="$1" message="$2" default="$3" answer=""
|
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
|
while [[ -z "$answer" ]]; do
|
||||||
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — schema and build
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# Both run AS the owner, from the repo. Neither is idempotent in the sense of
|
||||||
|
# "does nothing the second time" — both are safe to repeat, which is not the same
|
||||||
|
# thing and is the property that matters for a script people re-run.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_BUILD_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_BUILD_LOADED=1
|
||||||
|
|
||||||
|
# `bun db:push` — drizzle-kit diffs the schema code against the live database.
|
||||||
|
#
|
||||||
|
# No migrations here and no __drizzle_migrations table: the schema code IS the
|
||||||
|
# source of truth (src/databases/CLAUDE.md). On the empty database section 5 just
|
||||||
|
# created there is nothing to drop, so the prompt drizzle-kit shows for a
|
||||||
|
# destructive change cannot appear.
|
||||||
|
#
|
||||||
|
# It can still appear on a RE-RUN against a database with data, and a prompt
|
||||||
|
# nobody sees would hang the script forever — so stdin is closed rather than left
|
||||||
|
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
|
||||||
|
# want at 3am.
|
||||||
|
push_schema() {
|
||||||
|
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# What tables the schema will create, read from the aggregator rather than
|
||||||
|
# guessed. This is what makes the section able to say what it is about to do.
|
||||||
|
schema_table_count() {
|
||||||
|
local dir
|
||||||
|
dir="$(platform_dir)/src/databases/officer_db/src"
|
||||||
|
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
|
||||||
|
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
|
||||||
|
done | awk '{s+=$1} END {print s+0}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# `bun gen:index` — substitutes PUBLIC_URL into index.html and writes
|
||||||
|
# index.gen.html, which is what the server actually imports.
|
||||||
|
#
|
||||||
|
# Not optional and not cosmetic: without it the server has no page to serve. It
|
||||||
|
# is gitignored, so a fresh clone never has one.
|
||||||
|
gen_index() {
|
||||||
|
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
|
||||||
@@ -5,16 +5,14 @@
|
|||||||
#
|
#
|
||||||
# Definitions only.
|
# Definitions only.
|
||||||
#
|
#
|
||||||
# ── What is NOT here ──
|
# ── No secrets are written here ──
|
||||||
#
|
#
|
||||||
# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite
|
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
|
||||||
# key store (docs/secret-store.md), and writing them here in the meantime would
|
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
|
||||||
# mean generating a value that the store then has to be reconciled with — two
|
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
|
||||||
# origins for one secret, which is the failure the store exists to end.
|
|
||||||
#
|
#
|
||||||
# The consequence is honest and deliberate: jwt.ts throws at module load without
|
# So this file holds no credential except POSTGRES_URL, which is a connection
|
||||||
# JWT_SECRET, so an install made by this script does not boot until the store
|
# string to a database bound to loopback.
|
||||||
# lands. That sequencing was chosen rather than stumbled into.
|
|
||||||
#
|
#
|
||||||
# ── Derived, not asked ──
|
# ── Derived, not asked ──
|
||||||
#
|
#
|
||||||
@@ -65,8 +63,8 @@ write_env() {
|
|||||||
|
|
||||||
PORT="${ENV_PORT}"
|
PORT="${ENV_PORT}"
|
||||||
|
|
||||||
# The browser relay listens on its own port, separate from the app.
|
# Where Officer is reached from a browser. Not derivable — see the section.
|
||||||
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
|
PUBLIC_URL="${ENV_PUBLIC_URL}"
|
||||||
|
|
||||||
POSTGRES_URL="${POSTGRES_URL}"
|
POSTGRES_URL="${POSTGRES_URL}"
|
||||||
|
|
||||||
@@ -78,3 +76,49 @@ ENVF
|
|||||||
chmod 600 "$dest"
|
chmod 600 "$dest"
|
||||||
return 0
|
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,492 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# officer-setup — Nginx Proxy Manager, the optional last step.
|
||||||
|
#
|
||||||
|
# Publishes the running instance on a real hostname with a Let's Encrypt certificate.
|
||||||
|
# Entirely optional: someone with a proxy elsewhere declines and is printed the values
|
||||||
|
# they need instead.
|
||||||
|
#
|
||||||
|
# PRECONDITION: Officer is running and bound to 0.0.0.0. Checked, not assumed — see
|
||||||
|
# proxy_require_listening.
|
||||||
|
#
|
||||||
|
# ── Why this section ignores --unattended ──
|
||||||
|
#
|
||||||
|
# Every other question in this script has a defensible default. None of these do: a
|
||||||
|
# domain name, a DNS provider and that provider's API credentials cannot be guessed,
|
||||||
|
# and the whole step is opt-in besides. So the prompts here read stdin directly rather
|
||||||
|
# than going through confirm()/ask_required(), which honour ASSUME_YES.
|
||||||
|
#
|
||||||
|
# The safety valve is a TTY check rather than the flag: with no terminal there is
|
||||||
|
# nobody to ask, so the section skips itself and prints the manual instructions. That
|
||||||
|
# covers a cron-driven install without making --unattended silently agree to a proxy.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_PROXY_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_PROXY_LOADED=1
|
||||||
|
|
||||||
|
# `${OFFICER_ROOT}/dockers`, matching src/servers/data-path.ts, which derives that
|
||||||
|
# directory from the install root. The draft used $HOME/dockers, which is a different
|
||||||
|
# place on every machine and not the one the app store provisions into.
|
||||||
|
proxy_dir() { echo "${OFFICER_ROOT}/dockers/nginx-proxy-manager"; }
|
||||||
|
|
||||||
|
# The network machine-setup already created. It defaults to `services` there, so a
|
||||||
|
# second name would leave two bridges on the same box with containers unable to see
|
||||||
|
# each other by name.
|
||||||
|
PROXY_NET="${SETUP_DOCKER_NETWORK:-services}"
|
||||||
|
PROXY_API="http://127.0.0.1:81/api"
|
||||||
|
|
||||||
|
# ── prompts that always ask ──
|
||||||
|
#
|
||||||
|
# Deliberately not confirm()/ask_required(): see the header. Named apart so nobody
|
||||||
|
# later "fixes" them into the shared helpers and quietly makes --unattended agree to
|
||||||
|
# provisioning a public hostname.
|
||||||
|
proxy_confirm() {
|
||||||
|
local answer
|
||||||
|
read -rp " $1 [y/N]: " answer || return 1
|
||||||
|
[[ "$answer" =~ ^[Yy] ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_ask() {
|
||||||
|
local answer
|
||||||
|
read -rp " $1: " answer || return 1
|
||||||
|
printf '%s' "$answer"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 0. is Officer reachable the way NPM will reach it? ──
|
||||||
|
#
|
||||||
|
# `curl 127.0.0.1:$PORT` succeeds even when the process binds loopback ONLY, which is
|
||||||
|
# exactly the case NPM cannot reach: it dials from inside a container, where 127.0.0.1
|
||||||
|
# is the container itself. Passing this gate on a curl check produces a 504 later that
|
||||||
|
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
|
||||||
|
proxy_require_listening() {
|
||||||
|
local port="$1" listen
|
||||||
|
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
|
||||||
|
[[ -n "$listen" ]] || {
|
||||||
|
warn "nothing is listening on port ${port} — start Officer first"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ! grep -qE '(^|\s)(0\.0\.0\.0|\*):'"$port"'$' <<<"$listen"; then
|
||||||
|
warn "Officer is listening on: ${listen}"
|
||||||
|
info "NPM runs in a container, so 127.0.0.1 there is the container itself."
|
||||||
|
info "A loopback-only listener is invisible to it and yields a 504."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
ok "Officer is listening on 0.0.0.0:${port}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. where will the hostname point? ──
|
||||||
|
#
|
||||||
|
# Tailnet DNS-01 is mandatory. Let's Encrypt cannot reach 100.64.0.0/10, so HTTP-01
|
||||||
|
# always fails. The A record is not needed to ISSUE (validation is a TXT
|
||||||
|
# record) but is needed to USE the name.
|
||||||
|
# Public HTTP-01 works with no API keys, but the A record must already resolve here.
|
||||||
|
proxy_detect_target() {
|
||||||
|
local ts=""
|
||||||
|
command -v tailscale >/dev/null 2>&1 && ts="$(tailscale ip -4 2>/dev/null | head -1 || true)"
|
||||||
|
if [[ -n "$ts" ]]; then
|
||||||
|
TARGET_IP="$ts"
|
||||||
|
CHALLENGE="dns"
|
||||||
|
ok "Tailscale detected — ${TARGET_IP}"
|
||||||
|
info "Tailnet addresses are unreachable from Let's Encrypt, so the certificate"
|
||||||
|
info "needs a DNS-01 challenge, which needs your DNS provider's API credentials."
|
||||||
|
else
|
||||||
|
TARGET_IP="$(curl -sf --max-time 10 https://api.ipify.org || true)"
|
||||||
|
[[ -n "$TARGET_IP" ]] || {
|
||||||
|
warn "could not determine this machine's public IP"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
CHALLENGE="http"
|
||||||
|
ok "No Tailscale — public IP ${TARGET_IP} (HTTP-01, no API keys needed)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read by indirect expansion — `${!hint}` where hint is "DNS_HINT_${DNS_PROVIDER}" —
|
||||||
|
# which shellcheck cannot follow, hence the disable rather than a rewrite. Naming them
|
||||||
|
# this way is what lets a provider with no hint simply not have one.
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
DNS_HINT_godaddy="Create an API key at https://developer.godaddy.com/keys (Production).
|
||||||
|
You need both the Key and the Secret. Scope it to DNS only if offered."
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
DNS_HINT_cloudflare="Create a token at https://dash.cloudflare.com/profile/api-tokens
|
||||||
|
Use template 'Edit zone DNS'. Permissions: Zone:DNS:Edit for the zone."
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
DNS_HINT_digitalocean="Create a Personal Access Token with WRITE scope at
|
||||||
|
https://cloud.digitalocean.com/account/api/tokens"
|
||||||
|
|
||||||
|
# The exact credential file format per provider ships INSIDE the NPM image, so it is
|
||||||
|
# read from there rather than hardcoded — that keeps working as certbot plugins change.
|
||||||
|
proxy_prompt_dns_credentials() {
|
||||||
|
echo ""
|
||||||
|
info "Supported providers include: cloudflare, godaddy, digitalocean, route53,"
|
||||||
|
info "namecheap, ovh, linode, vultr, hetzner, gandi, google, azure …"
|
||||||
|
DNS_PROVIDER="$(proxy_ask 'DNS provider')"
|
||||||
|
[[ -n "$DNS_PROVIDER" ]] || {
|
||||||
|
warn "no provider given"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
local hint="DNS_HINT_${DNS_PROVIDER}"
|
||||||
|
[[ -n "${!hint:-}" ]] && {
|
||||||
|
echo ""
|
||||||
|
info "${!hint}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Credential format this provider expects:"
|
||||||
|
docker exec npm python3 -c \
|
||||||
|
"import json;d=json.load(open('/app/certbot/dns-plugins.json'));print(d['${DNS_PROVIDER}']['credentials'])" \
|
||||||
|
2>/dev/null | sed 's/^/ /' ||
|
||||||
|
warn "could not read the template — check the provider name is spelled correctly"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Paste the credential lines exactly as shown above (blank line to finish):"
|
||||||
|
DNS_CREDENTIALS=""
|
||||||
|
local line
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && break
|
||||||
|
DNS_CREDENTIALS+="$line"$'\n'
|
||||||
|
done
|
||||||
|
[[ -n "$DNS_CREDENTIALS" ]] || {
|
||||||
|
warn "no credentials entered"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 2. wait for DNS ──
|
||||||
|
#
|
||||||
|
# `getent hosts` rather than `dig`: dig comes from dnsutils, which this platform does
|
||||||
|
# not install, so the draft's version was command-not-found on a fresh VPS — and since
|
||||||
|
# an empty answer is indistinguishable from "not resolving yet", it waited the full
|
||||||
|
# thirty minutes before failing. getent is in libc and always there.
|
||||||
|
#
|
||||||
|
# The cost is that it reads the system resolver rather than a public one, so a stale
|
||||||
|
# local cache can satisfy it. Worth it against a check that cannot run at all.
|
||||||
|
proxy_wait_for_dns() {
|
||||||
|
local domain="$1" want="$2" got elapsed=0 interval=15 timeout=1800
|
||||||
|
echo ""
|
||||||
|
info "Point this DNS record at the machine now:"
|
||||||
|
echo ""
|
||||||
|
info " ${domain}. A ${want}"
|
||||||
|
echo ""
|
||||||
|
[[ "$CHALLENGE" == "dns" ]] &&
|
||||||
|
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
|
||||||
|
|
||||||
|
while ((elapsed < timeout)); do
|
||||||
|
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||||
|
if [[ "$got" == "$want" ]]; then
|
||||||
|
ok "${domain} resolves to ${want}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '\r waiting — %s (%ss) ' "${got:-not resolving yet}" "$elapsed"
|
||||||
|
sleep "$interval"
|
||||||
|
elapsed=$((elapsed + interval))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
warn "${domain} still does not resolve to ${want} after $((timeout / 60)) minutes"
|
||||||
|
[[ "$CHALLENGE" == "dns" ]] && proxy_confirm "Continue anyway and issue the certificate?" && return 0
|
||||||
|
warn "cannot issue an HTTP-01 certificate until DNS resolves here"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_ensure_network() {
|
||||||
|
docker network inspect "$PROXY_NET" >/dev/null 2>&1 && return 0
|
||||||
|
docker network create "$PROXY_NET" >/dev/null && ok "created docker network ${PROXY_NET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
|
||||||
|
# address does not exist yet and the WHOLE container fails to start, not just that port.
|
||||||
|
proxy_order_docker_after_tailscaled() {
|
||||||
|
[[ "$CHALLENGE" == "dns" ]] || return 0
|
||||||
|
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
|
||||||
|
[[ -f "$f" ]] && return 0
|
||||||
|
mkdir -p "$(dirname "$f")"
|
||||||
|
cat >"$f" <<'EOF'
|
||||||
|
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled, that
|
||||||
|
# address does not exist and the container fails to start entirely.
|
||||||
|
[Unit]
|
||||||
|
After=tailscaled.service
|
||||||
|
Wants=tailscaled.service
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
ok "docker ordered after tailscaled"
|
||||||
|
report_changed "$f" "docker ordered after tailscaled so NPM can bind the tailnet IP"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin UI (81) is NEVER published on 0.0.0.0. Until it is claimed, anyone who reaches
|
||||||
|
# it can take the instance; afterwards it can issue certificates and re-point every
|
||||||
|
# proxied service on the box. 80/443 are public only when they need to be.
|
||||||
|
proxy_write_compose() {
|
||||||
|
local dir admin_binds public_binds
|
||||||
|
dir="$(proxy_dir)"
|
||||||
|
install -d -o "$USERNAME" -g "$(user_group)" "$dir" "$dir/npm_data" "$dir/letsencrypt"
|
||||||
|
|
||||||
|
admin_binds=" - \"127.0.0.1:81:81\""
|
||||||
|
if [[ "$CHALLENGE" == "dns" ]]; then
|
||||||
|
admin_binds+=$'\n'" - \"${TARGET_IP}:81:81\""
|
||||||
|
public_binds=" - \"${TARGET_IP}:80:80\""$'\n'" - \"${TARGET_IP}:443:443\""
|
||||||
|
else
|
||||||
|
public_binds=" - \"80:80\""$'\n'" - \"443:443\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >"${dir}/docker-compose.yaml" <<EOF
|
||||||
|
# Generated by officer-setup. Reverse proxy for this Officer instance.
|
||||||
|
#
|
||||||
|
# The admin UI (81) is bound to loopback$([[ "$CHALLENGE" == "dns" ]] && echo " and the tailnet") only, never
|
||||||
|
# 0.0.0.0 — it can issue certificates and re-point every proxied service on this box.
|
||||||
|
#
|
||||||
|
# NOTE: ufw does NOT filter docker-published ports. Exposure is decided by the bind
|
||||||
|
# addresses below and by the DOCKER-USER chain in /etc/ufw/after.rules.
|
||||||
|
name: npm
|
||||||
|
services:
|
||||||
|
npm:
|
||||||
|
image: jc21/nginx-proxy-manager:latest
|
||||||
|
container_name: npm
|
||||||
|
restart: always
|
||||||
|
networks: [${PROXY_NET}]
|
||||||
|
ports:
|
||||||
|
${public_binds}
|
||||||
|
${admin_binds}
|
||||||
|
volumes:
|
||||||
|
- ./npm_data:/data
|
||||||
|
- ./letsencrypt:/etc/letsencrypt
|
||||||
|
|
||||||
|
networks:
|
||||||
|
${PROXY_NET}:
|
||||||
|
external: true
|
||||||
|
EOF
|
||||||
|
chown "${USERNAME}:$(user_group)" "${dir}/docker-compose.yaml"
|
||||||
|
ok "wrote ${dir}/docker-compose.yaml"
|
||||||
|
report_changed "${dir}/docker-compose.yaml" "nginx-proxy-manager compose file"
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_start() {
|
||||||
|
as_owner "docker compose --project-directory '$(proxy_dir)' up -d" / >/dev/null
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
curl -sf "$PROXY_API/" >/dev/null 2>&1 && {
|
||||||
|
ok "NPM answered after ${i}s"
|
||||||
|
report_started "npm" "nginx-proxy-manager container"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
warn "NPM did not become ready — check: docker logs npm"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── claim the admin account immediately ──
|
||||||
|
#
|
||||||
|
# NPM 2.15 replaced the fixed default login with a first-run wizard: while the user
|
||||||
|
# count is zero, ANYONE who reaches port 81 can claim admin. Done in the same breath as
|
||||||
|
# starting the container. The bind addresses above already make that window unreachable
|
||||||
|
# from outside, but this does not rely on that alone.
|
||||||
|
#
|
||||||
|
# The re-run path is the half the draft was missing: it returned early on an already
|
||||||
|
# claimed instance WITHOUT setting NPM_EMAIL/NPM_PASSWORD, and the next function
|
||||||
|
# dereferenced both under `set -u`. So the second run of a "re-runnable" script died on
|
||||||
|
# an unbound variable. An existing instance asks for the credentials instead.
|
||||||
|
proxy_claim_admin() {
|
||||||
|
if curl -sf "$PROXY_API/" | grep -q '"setup":true'; then
|
||||||
|
ok "NPM admin is already claimed"
|
||||||
|
echo ""
|
||||||
|
info "This instance already has an admin account. Its credentials are needed to"
|
||||||
|
info "add the proxy host below."
|
||||||
|
NPM_EMAIL="$(proxy_ask 'NPM admin email')"
|
||||||
|
NPM_PASSWORD="$(proxy_ask 'NPM admin password')"
|
||||||
|
[[ -n "$NPM_EMAIL" && -n "$NPM_PASSWORD" ]] || {
|
||||||
|
warn "both are needed to continue"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Create the NPM admin account."
|
||||||
|
NPM_EMAIL="$(proxy_ask 'Admin email')"
|
||||||
|
[[ -n "$NPM_EMAIL" ]] || {
|
||||||
|
warn "no email given"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
NPM_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)"
|
||||||
|
|
||||||
|
curl -sf -X POST "$PROXY_API/users" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg e "$NPM_EMAIL" --arg p "$NPM_PASSWORD" \
|
||||||
|
'{name:"Admin",nickname:"Admin",email:$e,roles:["admin"],is_disabled:false,auth:{type:"password",secret:$p}}')" \
|
||||||
|
>/dev/null || {
|
||||||
|
warn "failed to create the NPM admin user"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
curl -sf "$PROXY_API/" | grep -q '"setup":true' || {
|
||||||
|
warn "admin creation did not take"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ok "NPM admin claimed: ${NPM_EMAIL}"
|
||||||
|
|
||||||
|
# ── the admin password ──
|
||||||
|
#
|
||||||
|
# Deliberately NOT written to a file. The platform's shape is that
|
||||||
|
# secrets/officer-keys.db holds ENCRYPTION KEYS, one per purpose, and the credential
|
||||||
|
# itself lives encrypted in Postgres. A third plaintext location is the pattern
|
||||||
|
# headscale/schema.ts calls "debt to avoid copying, not a precedent to follow".
|
||||||
|
#
|
||||||
|
# Nothing programmatic needs this after setup — only a human logging into the admin
|
||||||
|
# UI — so not storing it is a legitimate outcome rather than a gap.
|
||||||
|
#
|
||||||
|
# The DNS API credentials are deliberately never handled either: NPM must keep a
|
||||||
|
# plaintext copy in npm_data/database.sqlite for certbot to auto-renew, so copying
|
||||||
|
# them anywhere else adds exposure without adding protection.
|
||||||
|
echo ""
|
||||||
|
warn "This password is shown ONCE and is not stored anywhere:"
|
||||||
|
echo ""
|
||||||
|
echo " ${NPM_EMAIL}"
|
||||||
|
echo " ${NPM_PASSWORD}"
|
||||||
|
echo ""
|
||||||
|
info "Put it in your password manager now."
|
||||||
|
proxy_confirm "Saved it?" || {
|
||||||
|
warn "stopping so the password is not lost — the container is running and claimed"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_api() {
|
||||||
|
local method="$1" path="$2" body="${3:-}"
|
||||||
|
if [[ -n "$body" ]]; then
|
||||||
|
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d "$body"
|
||||||
|
else
|
||||||
|
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_get_token() {
|
||||||
|
TOKEN="$(curl -sf -X POST "$PROXY_API/tokens" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg i "$NPM_EMAIL" --arg s "$NPM_PASSWORD" '{identity:$i,secret:$s}')" |
|
||||||
|
jq -r '.token')" || {
|
||||||
|
warn "could not authenticate to the NPM API"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || {
|
||||||
|
warn "NPM rejected those admin credentials"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── let the bridge reach the host process ──
|
||||||
|
#
|
||||||
|
# Officer runs on the HOST under pm2, not in a container. Bridge → host traffic DOES
|
||||||
|
# traverse INPUT, so ufw's default-deny drops it — unlike docker-published ports, which
|
||||||
|
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
|
||||||
|
# upstream would need none of this, which is why container upstreams are preferable when
|
||||||
|
# there is a choice.
|
||||||
|
proxy_allow_bridge_to_host() {
|
||||||
|
local port="$1" subnet
|
||||||
|
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
|
||||||
|
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
|
||||||
|
ok "ufw already allows the bridge to reach port ${port}"
|
||||||
|
else
|
||||||
|
ufw allow from "$subnet" to any port "$port" proto tcp >/dev/null
|
||||||
|
ok "ufw: allowed ${subnet} → :${port}"
|
||||||
|
report_changed "ufw" "allowed ${subnet} to reach port ${port} (bridge to host)"
|
||||||
|
fi
|
||||||
|
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Created WITHOUT ssl first, deliberately. Enabling force-SSL before a certificate
|
||||||
|
# exists gives a host that 301s to https and then fails the handshake — curl reports
|
||||||
|
# 000, which reads like a network fault rather than a config mistake.
|
||||||
|
proxy_create_host() {
|
||||||
|
local domain="$1" port="$2" existing
|
||||||
|
existing="$(proxy_api GET /nginx/proxy-hosts | jq -r --arg d "$domain" \
|
||||||
|
'map(select(.domain_names | index($d))) | .[0].id // empty')"
|
||||||
|
if [[ -n "$existing" ]]; then
|
||||||
|
HOST_ID="$existing"
|
||||||
|
ok "proxy host already exists (id ${HOST_ID})"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOST_ID="$(proxy_api POST /nginx/proxy-hosts "$(jq -nc \
|
||||||
|
--arg d "$domain" --arg h "$BRIDGE_GATEWAY" --argjson p "$port" \
|
||||||
|
'{domain_names:[$d],forward_scheme:"http",forward_host:$h,forward_port:$p,
|
||||||
|
access_list_id:0,certificate_id:0,block_exploits:true,caching_enabled:false,
|
||||||
|
allow_websocket_upgrade:true,ssl_forced:false,http2_support:false,
|
||||||
|
hsts_enabled:false,hsts_subdomains:false,meta:{},advanced_config:"",locations:[]}')" |
|
||||||
|
jq -r '.id')"
|
||||||
|
[[ -n "$HOST_ID" && "$HOST_ID" != "null" ]] || {
|
||||||
|
warn "could not create the proxy host"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ok "proxy host created (id ${HOST_ID}) → ${BRIDGE_GATEWAY}:${port}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# NPM 2.15 REMOVED letsencrypt_email and letsencrypt_agree from the certificate schema.
|
||||||
|
# Sending them returns: 400 data/meta must NOT have additional properties.
|
||||||
|
proxy_issue_certificate() {
|
||||||
|
local domain="$1" meta
|
||||||
|
CERT_ID="$(proxy_api GET /nginx/certificates | jq -r --arg d "$domain" \
|
||||||
|
'map(select(.domain_names | index($d))) | .[0].id // empty')"
|
||||||
|
[[ -n "$CERT_ID" ]] && {
|
||||||
|
ok "certificate already exists (id ${CERT_ID})"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$CHALLENGE" == "dns" ]]; then
|
||||||
|
meta="$(jq -nc --arg p "$DNS_PROVIDER" --arg c "$DNS_CREDENTIALS" \
|
||||||
|
'{dns_challenge:true,dns_provider:$p,dns_provider_credentials:$c,propagation_seconds:120}')"
|
||||||
|
info "Requesting the certificate via DNS-01 — about two minutes, for the plugin"
|
||||||
|
info "install and DNS propagation."
|
||||||
|
else
|
||||||
|
meta='{"dns_challenge":false}'
|
||||||
|
info "Requesting the certificate via HTTP-01"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CERT_ID="$(proxy_api POST /nginx/certificates "$(jq -nc \
|
||||||
|
--arg d "$domain" --argjson m "$meta" \
|
||||||
|
'{provider:"letsencrypt",nice_name:$d,domain_names:[$d],meta:$m}')" | jq -r '.id')"
|
||||||
|
[[ -n "$CERT_ID" && "$CERT_ID" != "null" ]] || {
|
||||||
|
warn "the certificate request failed — see: docker logs npm"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ok "certificate issued (id ${CERT_ID})"
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_attach_certificate() {
|
||||||
|
proxy_api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(jq -nc --argjson c "$CERT_ID" \
|
||||||
|
'{certificate_id:$c,ssl_forced:true,http2_support:true,hsts_enabled:false,hsts_subdomains:false}')" \
|
||||||
|
>/dev/null || {
|
||||||
|
warn "could not attach the certificate"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ok "certificate attached, force-SSL and HTTP/2 on"
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_verify() {
|
||||||
|
local domain="$1" code
|
||||||
|
code="$(curl -so /dev/null -w '%{http_code}' --max-time 20 \
|
||||||
|
--resolve "${domain}:443:${TARGET_IP}" "https://${domain}/" || echo 000)"
|
||||||
|
case "$code" in
|
||||||
|
200 | 30[0-9]) ok "https://${domain} → ${code}" ;;
|
||||||
|
000) warn "TLS handshake failed — certificate not attached, or force-SSL set before it existed" ;;
|
||||||
|
502) warn "502 — nothing listening on the upstream port" ;;
|
||||||
|
504) warn "504 — upstream unreachable: ufw dropping bridge→host, or the wrong forward_host" ;;
|
||||||
|
*) warn "unexpected response: ${code}" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_skip_instructions() {
|
||||||
|
local port="$1" gw
|
||||||
|
gw="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || echo '<bridge-gateway>')"
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
To put Officer behind your own proxy, point it at:
|
||||||
|
|
||||||
|
http://<this-machine>:${port}
|
||||||
|
|
||||||
|
If that proxy runs in a container ON this machine, use ${gw}:${port} — inside a
|
||||||
|
container 127.0.0.1 is the container itself — and let it through ufw:
|
||||||
|
|
||||||
|
ufw allow from <container-subnet> to any port ${port} proto tcp
|
||||||
|
|
||||||
|
Officer must bind 0.0.0.0, not 127.0.0.1, or the proxy cannot reach it.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
@@ -14,6 +14,22 @@
|
|||||||
[[ -n "${OFFICER_SETUP_REPO_LOADED:-}" ]] && return 0
|
[[ -n "${OFFICER_SETUP_REPO_LOADED:-}" ]] && return 0
|
||||||
OFFICER_SETUP_REPO_LOADED=1
|
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}"
|
OFFICER_REPO="${OFFICER_REPO:-https://gitea.officer.dev/officerdev/platform.git}"
|
||||||
|
|
||||||
platform_dir() { echo "${OFFICER_ROOT}/platform"; }
|
platform_dir() { echo "${OFFICER_ROOT}/platform"; }
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# officer-setup — the secret store
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Definitions only.
|
||||||
|
#
|
||||||
|
# The store is $OFFICER_ROOT/secrets/officer-keys.db, deliberately a sibling of
|
||||||
|
# the repo and NOT under data/ — that directory holds the managed homes and
|
||||||
|
# attachments people back up, and a key store travelling in the same tarball as a
|
||||||
|
# database dump rebuilds the exact problem it exists to avoid.
|
||||||
|
#
|
||||||
|
# Bootstrapping runs the platform's own module rather than reimplementing the
|
||||||
|
# schema in bash. There is exactly one writer of this file's format, and a second
|
||||||
|
# one in shell would drift the first time a column is added.
|
||||||
|
|
||||||
|
[[ -n "${OFFICER_SETUP_SECRETS_LOADED:-}" ]] && return 0
|
||||||
|
OFFICER_SETUP_SECRETS_LOADED=1
|
||||||
|
|
||||||
|
secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; }
|
||||||
|
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; }
|
||||||
|
|
||||||
|
# Create the store and the two purposes a core install needs.
|
||||||
|
#
|
||||||
|
# Run AS the owner, not as root: the platform runs as them, and a store root
|
||||||
|
# created would be a store they cannot write. `install -d -o` sets the owner in
|
||||||
|
# one step rather than mkdir-then-chown, so it is never briefly root's.
|
||||||
|
bootstrap_secret_store() {
|
||||||
|
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(secret_store_dir)" || return 1
|
||||||
|
|
||||||
|
# From the repo, because the module derives the install root as the parent of
|
||||||
|
# the working directory — the same rule as src/servers/data-path.ts.
|
||||||
|
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
|
||||||
|
const { getKey } = await import('officerdb/secret-store');
|
||||||
|
getKey('jwt');
|
||||||
|
getKey('headscale');
|
||||||
|
\"" >/dev/null 2>&1 || return 1
|
||||||
|
|
||||||
|
[[ -f "$(secret_store_path)" ]]
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/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"
|
||||||
|
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
|
||||||
|
)
|
||||||
|
|
||||||
|
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,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;
|
||||||
@@ -46,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,
|
||||||
@@ -105,6 +109,27 @@ export const UsersSection = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new platform password, generated by the server and shown once.
|
||||||
|
*
|
||||||
|
* Generated rather than typed because the failure this exists for is "I forgot to copy it down", and an
|
||||||
|
* owner typing a replacement can lose it the same way twice. Only the argon2 hash is stored, so the
|
||||||
|
* dialog below really is the only time anyone sees it — which is why it is a dialog and not a toast.
|
||||||
|
*/
|
||||||
|
const resetPassword = async (user: ManagedUser) => {
|
||||||
|
setPendingId(user.id);
|
||||||
|
setConfirmReset(null);
|
||||||
|
try {
|
||||||
|
const result = await client.post<{ email: string; password: string }>(`/users/${user.id}/password`, {});
|
||||||
|
setNewPassword(result);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
|
||||||
|
} catch (ex) {
|
||||||
|
toast.error(ex instanceof Error ? ex.message : 'Could not reset the password');
|
||||||
|
} finally {
|
||||||
|
setPendingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async (user: ManagedUser) => {
|
const remove = async (user: ManagedUser) => {
|
||||||
setPendingId(user.id);
|
setPendingId(user.id);
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
@@ -209,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');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -217,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"
|
||||||
@@ -254,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.
|
||||||
|
|||||||
+32
-12
@@ -9,18 +9,38 @@ 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
|
||||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
│ ├── schema.ts # what db:push creates — see below
|
||||||
│ └── schema/
|
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||||
│ ├── index.ts # re-exports all schema files
|
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||||
│ └── *.ts # table definitions, grouped by domain
|
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||||
└── package.json # exports "." and "./types"
|
│ └── <feature>/
|
||||||
|
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||||
|
│ ├── schema.ts # its tables
|
||||||
|
│ └── queries.ts # everything that reads or writes them
|
||||||
|
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema files are grouped by domain, not by table: `auth`, `chat-events`, `dashboards`, `email`,
|
**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 ──
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
getAllRoleGrants,
|
||||||
|
getRoleGrants,
|
||||||
|
setRoleGrant,
|
||||||
|
revokeRoleGrant,
|
||||||
|
replaceRoleGrants,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type { RoleGrant } from './queries';
|
||||||
|
|
||||||
|
export type { CapabilityLevelValue } from './schema';
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { roleCapabilities } from '../schema';
|
import { roleCapabilities } from './schema';
|
||||||
import type { UserRole } from '../schema/auth';
|
import type { UserRole } from '../auth/schema';
|
||||||
import type { CapabilityLevelValue } from '../schema/capabilities';
|
import type { CapabilityLevelValue } from './schema';
|
||||||
|
|
||||||
// Grants, keyed on role. Absence denies — see the table comment.
|
// Grants, keyed on role. Absence denies — see the table comment.
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { USER_ROLES } from './auth';
|
import { USER_ROLES } from '../auth/schema';
|
||||||
|
|
||||||
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
||||||
//
|
//
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
appendChatEvent,
|
||||||
|
getChatEventsSince,
|
||||||
|
getLastChatEventSeq,
|
||||||
|
pruneChatEventsOlderThan,
|
||||||
|
} from './queries';
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, gt, asc, desc, lt } from 'drizzle-orm';
|
import { eq, and, gt, asc, desc, lt } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { chatSessionEvents } from '../schema';
|
import { chatSessionEvents } from './schema';
|
||||||
|
|
||||||
/** Append one outbound event to a session's durable log; returns its global cursor id. */
|
/** Append one outbound event to a session's durable log; returns its global cursor id. */
|
||||||
export async function appendChatEvent(sessionId: string, event: unknown): Promise<number> {
|
export async function appendChatEvent(sessionId: string, event: unknown): Promise<number> {
|
||||||
@@ -1,40 +1,51 @@
|
|||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { getKey } from './secret-store';
|
||||||
|
|
||||||
// AES-256-GCM at-rest encryption for vault secrets (the brokered Vaultwarden token set + the Officer-app
|
// AES-256-GCM at-rest encryption for every secret column in Postgres. The property this exists to hold is
|
||||||
// protector key). The whole point of the vault store is that a DB dump must not hand over the keys to the
|
// that a database dump must not hand over the credentials in it, so these columns are never plaintext.
|
||||||
// vault, so these columns are never stored plaintext.
|
|
||||||
//
|
//
|
||||||
// Key = SHA-256(VAULT_STORE_KEY) so any sufficiently strong secret works (mirrors the JWT_SECRET style).
|
// Format = base64(iv[12] | authTag[16] | ciphertext).
|
||||||
// Format = base64(iv[12] | authTag[16] | ciphertext). The key is read LAZILY so the platform still boots
|
//
|
||||||
// without a vault configured — vault storage ops then throw a clear error instead of crashing startup.
|
// ── One key per purpose ──
|
||||||
|
//
|
||||||
|
// This took a single VAULT_STORE_KEY from the environment until 2026-08-13. That key encrypted seven
|
||||||
|
// unrelated things — the Headscale admin credential, the wallet seed, Vaultwarden's token set, Jellyfin,
|
||||||
|
// Immich, InvoiceShelf, and every app-store upstream secret — so one leak opened all of them, and its
|
||||||
|
// name pointed at whichever plugin happened to need it first.
|
||||||
|
//
|
||||||
|
// `purpose` is now the first argument everywhere, and the caller passes the one that owns the data. A
|
||||||
|
// plugin's key is created on first use and cannot decrypt another plugin's column, because the AES key
|
||||||
|
// derives from a different stored secret. See ./secret-store.ts and docs/secret-store.md.
|
||||||
|
//
|
||||||
|
// SHA-256 over the stored key rather than using its bytes directly, so the store is free to change how it
|
||||||
|
// represents a key without every ciphertext in the database becoming unreadable.
|
||||||
|
|
||||||
let cachedKey: Buffer | null = null;
|
const cache = new Map<string, Buffer>();
|
||||||
function key(): Buffer {
|
|
||||||
if (cachedKey) return cachedKey;
|
function key(purpose: string): Buffer {
|
||||||
const secret = process.env.VAULT_STORE_KEY;
|
const hit = cache.get(purpose);
|
||||||
if (!secret || secret.length < 16) {
|
if (hit) return hit;
|
||||||
throw new Error('VAULT_STORE_KEY must be set (>=16 chars) to store vault secrets');
|
const derived = createHash('sha256').update(getKey(purpose)).digest();
|
||||||
}
|
cache.set(purpose, derived);
|
||||||
cachedKey = createHash('sha256').update(secret).digest();
|
return derived;
|
||||||
return cachedKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encrypt a UTF-8 secret for at-rest storage → base64(iv|tag|ciphertext). */
|
/** Encrypt a UTF-8 secret for at-rest storage → base64(iv|tag|ciphertext). */
|
||||||
export function encryptSecret(plaintext: string): string {
|
export function encryptSecret(purpose: string, plaintext: string): string {
|
||||||
const iv = randomBytes(12);
|
const iv = randomBytes(12);
|
||||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
const cipher = createCipheriv('aes-256-gcm', key(purpose), iv);
|
||||||
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
const tag = cipher.getAuthTag();
|
const tag = cipher.getAuthTag();
|
||||||
return Buffer.concat([iv, tag, ct]).toString('base64');
|
return Buffer.concat([iv, tag, ct]).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypt a value produced by encryptSecret. Throws if the ciphertext/tag/key don't verify. */
|
/** Decrypt a value produced by encryptSecret under the SAME purpose. Throws if it does not verify. */
|
||||||
export function decryptSecret(blob: string): string {
|
export function decryptSecret(purpose: string, blob: string): string {
|
||||||
const buf = Buffer.from(blob, 'base64');
|
const buf = Buffer.from(blob, 'base64');
|
||||||
const iv = buf.subarray(0, 12);
|
const iv = buf.subarray(0, 12);
|
||||||
const tag = buf.subarray(12, 28);
|
const tag = buf.subarray(12, 28);
|
||||||
const ct = buf.subarray(28);
|
const ct = buf.subarray(28);
|
||||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
const decipher = createDecipheriv('aes-256-gcm', key(purpose), iv);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
getAllDashboardState,
|
||||||
|
upsertDashboard,
|
||||||
|
updateDashboard,
|
||||||
|
deleteDashboard,
|
||||||
|
setDashboardPanelState,
|
||||||
|
upsertScreen,
|
||||||
|
deleteScreen,
|
||||||
|
upsertDefaults,
|
||||||
|
setDefaultsPanelState,
|
||||||
|
} from './queries';
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { dashboards, screens, dashboardDefaults } from '../schema';
|
import { dashboards, screens, dashboardDefaults } from './schema';
|
||||||
|
|
||||||
// ── Full state read ──
|
// ── Full state read ──
|
||||||
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `LayoutNode` is an object, and the whole write path to these columns is `unknown` — the PATCH body is
|
* A `LayoutNode` is an object, and the whole write path to these columns is `unknown` — the PATCH body is
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
listDavAppPasswords,
|
||||||
|
createDavAppPassword,
|
||||||
|
revokeDavAppPassword,
|
||||||
|
deleteDavAppPassword,
|
||||||
|
verifyDavAppPassword,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type { DavAppPassword, DavAppPasswordView } from './queries';
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm';
|
|||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { davAppPasswords } from '../schema/dav';
|
import { davAppPasswords } from './schema';
|
||||||
|
|
||||||
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
||||||
//
|
//
|
||||||
@@ -10,3 +10,47 @@ if (!POSTGRES_URL) {
|
|||||||
|
|
||||||
const client = postgres(POSTGRES_URL);
|
const client = postgres(POSTGRES_URL);
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block until Postgres answers, or give up after `timeoutMs`.
|
||||||
|
*
|
||||||
|
* `postgres()` above is LAZY — it opens no socket until the first query — so nothing here fails at
|
||||||
|
* import time when the database is not up yet. That is the right default and it has a cost: startup
|
||||||
|
* work that queries fires, fails once, and is swallowed by whatever `.catch()` it was written with.
|
||||||
|
*
|
||||||
|
* That is not hypothetical. `server.tsx` runs `initQueue()` and `cleanupOnStartup()` as
|
||||||
|
* fire-and-forget promises, and the second marks jobs that were interrupted by the last restart and
|
||||||
|
* promotes the queued backlog. If Postgres is a few seconds behind — which is exactly what happens on
|
||||||
|
* a reboot, when pm2's resurrect races Docker starting the container — both log a line and do nothing.
|
||||||
|
* Interrupted jobs then stay marked running forever, because the only thing that would have corrected
|
||||||
|
* them already ran.
|
||||||
|
*
|
||||||
|
* So: wait, rather than try once. Callers that genuinely cannot proceed without the database await
|
||||||
|
* this first; request handlers do not, since by then it is either up or the request fails honestly.
|
||||||
|
*
|
||||||
|
* Bounded, and it resolves false rather than throwing on timeout. An unbounded wait here would hold a
|
||||||
|
* process open with no way to tell whether it is starting or hung, and the caller is better placed to
|
||||||
|
* decide what "gave up" means than this function is.
|
||||||
|
*/
|
||||||
|
export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
|
||||||
|
const started = Date.now();
|
||||||
|
let announced = false;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await client`select 1`;
|
||||||
|
if (announced) console.log('[db] Postgres is up');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (Date.now() - started >= timeoutMs) {
|
||||||
|
console.error(`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`, err instanceof Error ? err.message : err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!announced) {
|
||||||
|
console.log('[db] waiting for Postgres…');
|
||||||
|
announced = true;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
getEmailAccounts,
|
||||||
|
getEmailAccount,
|
||||||
|
createEmailAccount,
|
||||||
|
deleteEmailAccount,
|
||||||
|
updateEmailAccountStatus,
|
||||||
|
updateEmailAccountSyncMeta,
|
||||||
|
getAllSyncedAccounts,
|
||||||
|
} from './queries';
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { emailAccounts } from '../schema';
|
import { emailAccounts } from './schema';
|
||||||
import type { EmailAccountInsert, EmailAccountSelect } from '../types';
|
import type { EmailAccountInsert, EmailAccountSelect } from '../types';
|
||||||
|
|
||||||
export async function getEmailAccounts(userId: number): Promise<EmailAccountSelect[]> {
|
export async function getEmailAccounts(userId: number): Promise<EmailAccountSelect[]> {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
export const emailAccounts = pgTable(
|
export const emailAccounts = pgTable(
|
||||||
'email_accounts',
|
'email_accounts',
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
listHeadscaleServers,
|
||||||
|
getActiveHeadscaleCredentials,
|
||||||
|
getHeadscaleCredentials,
|
||||||
|
createHeadscaleServer,
|
||||||
|
updateHeadscaleServer,
|
||||||
|
setActiveHeadscaleServer,
|
||||||
|
deleteHeadscaleServer,
|
||||||
|
recordHeadscaleProbe,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { headscaleServers } from '../schema';
|
import { headscaleServers } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from '../crypto';
|
import { encryptSecret, decryptSecret } from '../crypto';
|
||||||
|
|
||||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||||
@@ -54,7 +54,7 @@ export async function getActiveHeadscaleCredentials(userId: number): Promise<Hea
|
|||||||
.from(headscaleServers)
|
.from(headscaleServers)
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||||
@@ -64,7 +64,7 @@ export async function getHeadscaleCredentials(userId: number, id: number): Promi
|
|||||||
.from(headscaleServers)
|
.from(headscaleServers)
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateHeadscaleServerParams = {
|
type CreateHeadscaleServerParams = {
|
||||||
@@ -95,7 +95,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
|||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
apiKey: encryptSecret(apiKey),
|
apiKey: encryptSecret('headscale', apiKey),
|
||||||
version,
|
version,
|
||||||
sshHost,
|
sshHost,
|
||||||
isActive: activate,
|
isActive: activate,
|
||||||
@@ -119,7 +119,7 @@ export async function updateHeadscaleServer(
|
|||||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
if (params.name !== undefined) set.name = params.name;
|
if (params.name !== undefined) set.name = params.name;
|
||||||
if (params.url !== undefined) set.url = params.url;
|
if (params.url !== undefined) set.url = params.url;
|
||||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
|
||||||
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||||
@@ -1,297 +1,59 @@
|
|||||||
export {
|
// The package's public surface.
|
||||||
getUsers,
|
//
|
||||||
getUserById,
|
// One line per feature, and nothing else. What each feature exports is stated in its own index.ts,
|
||||||
getUserByEmail,
|
// beside the schema and queries it exports — so adding a query function means editing one file in one
|
||||||
getUserByUsername,
|
// directory, not that file plus a list three levels up that nobody remembers to update.
|
||||||
getOwnerUser,
|
//
|
||||||
getUserCount,
|
// This was 297 lines of hand-written named exports until 2026-08-13. Every symbol was listed here, twice
|
||||||
createUser,
|
// for most features (values, then types, repeating the path), and `db` and `schema` sat at line 270 with
|
||||||
updateUser,
|
// three feature blocks appended after them.
|
||||||
deleteUser,
|
//
|
||||||
getPasskeysByUserId,
|
// The surface is unchanged: the same names are exported, they are just declared next to what they
|
||||||
getPasskeysByUserIdAndOrigin,
|
// describe. 107 files import from 'officerdb' and none of them notice.
|
||||||
getPasskeyByCredentialId,
|
//
|
||||||
createPasskey,
|
// ── Core above, plugins below ──
|
||||||
updatePasskey,
|
//
|
||||||
storeChallenge,
|
// The split mirrors schema.ts, where the plugin TABLES are commented out so db:push does not create
|
||||||
consumeChallenge,
|
// them. These lines are NOT commented, and the difference is worth stating: commenting them would break
|
||||||
blacklistToken,
|
// nothing at runtime — hono.ts no longer mounts a single plugin router, so none of this is ever
|
||||||
isTokenBlacklisted,
|
// reached — but `bunx tsgo` checks every file under src/ whether it runs or not, and the plugin
|
||||||
cleanupExpiredTokens,
|
// sidecars still import these symbols. So they stay exported until each plugin is extracted, and the
|
||||||
} from './queries/auth';
|
// blank line below is the only thing marking the boundary.
|
||||||
|
|
||||||
export {
|
// The connection, and drizzle-kit's view of the schema. See ./schema.ts for the core/plugin split.
|
||||||
findLiveApiKeyByHash,
|
export { db, waitForDatabase } from './db';
|
||||||
createApiKey,
|
|
||||||
listApiKeys,
|
|
||||||
revokeApiKey,
|
|
||||||
touchApiKey,
|
|
||||||
type ApiKeyIdentity,
|
|
||||||
} from './queries/api-keys';
|
|
||||||
|
|
||||||
export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getUserSettings,
|
|
||||||
setUserSettings,
|
|
||||||
getUserState,
|
|
||||||
patchUserState,
|
|
||||||
getDockPaths,
|
|
||||||
setDockPaths,
|
|
||||||
} from './queries/user-data';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getServerIntegrations,
|
|
||||||
getServerIntegration,
|
|
||||||
upsertServerIntegration,
|
|
||||||
deleteServerIntegration,
|
|
||||||
getUserIntegrations,
|
|
||||||
getUserIntegration,
|
|
||||||
getIntegrationsByProvider,
|
|
||||||
upsertUserIntegration,
|
|
||||||
deleteUserIntegration,
|
|
||||||
findUserByIntegrationConfig,
|
|
||||||
} from './queries/integrations';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getEmailAccounts,
|
|
||||||
getEmailAccount,
|
|
||||||
createEmailAccount,
|
|
||||||
deleteEmailAccount,
|
|
||||||
updateEmailAccountStatus,
|
|
||||||
updateEmailAccountSyncMeta,
|
|
||||||
getAllSyncedAccounts,
|
|
||||||
} from './queries/email-accounts';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getAllDashboardState,
|
|
||||||
upsertDashboard,
|
|
||||||
updateDashboard,
|
|
||||||
deleteDashboard,
|
|
||||||
setDashboardPanelState,
|
|
||||||
upsertScreen,
|
|
||||||
deleteScreen,
|
|
||||||
upsertDefaults,
|
|
||||||
setDefaultsPanelState,
|
|
||||||
} from './queries/dashboards';
|
|
||||||
|
|
||||||
export {
|
|
||||||
createPipelineJob,
|
|
||||||
getPipelineJob,
|
|
||||||
updatePipelineJob,
|
|
||||||
getPipelineJobsForUser,
|
|
||||||
getOldestPendingJob,
|
|
||||||
getPendingJobs,
|
|
||||||
countPendingJobs,
|
|
||||||
deletePipelineJob,
|
|
||||||
deleteTerminalJobsForUser,
|
|
||||||
markInterruptedJobs,
|
|
||||||
} from './queries/pipeline-jobs';
|
|
||||||
|
|
||||||
export {
|
|
||||||
appendChatEvent,
|
|
||||||
getChatEventsSince,
|
|
||||||
getLastChatEventSeq,
|
|
||||||
pruneChatEventsOlderThan,
|
|
||||||
} from './queries/chat-events';
|
|
||||||
|
|
||||||
export {
|
|
||||||
listAgentPanels,
|
|
||||||
getAgentPanelByPanelId,
|
|
||||||
getAgentPanelByName,
|
|
||||||
getAgentPanelByHandoffToken,
|
|
||||||
createAgentPanel,
|
|
||||||
updateAgentPanel,
|
|
||||||
markAgentPanelIntroduced,
|
|
||||||
deleteAgentPanel,
|
|
||||||
toAgentPanelView,
|
|
||||||
} from './queries/agent-panels';
|
|
||||||
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries/agent-panels';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getMusicFavorites,
|
|
||||||
addMusicFavorite,
|
|
||||||
removeMusicFavorite,
|
|
||||||
getNowPlaying,
|
|
||||||
setNowPlaying,
|
|
||||||
clearNowPlaying,
|
|
||||||
getPlaylists,
|
|
||||||
getPlaylist,
|
|
||||||
createPlaylist,
|
|
||||||
renamePlaylist,
|
|
||||||
deletePlaylist,
|
|
||||||
addPlaylistItems,
|
|
||||||
setPlaylistItems,
|
|
||||||
} from './queries/music';
|
|
||||||
export type {
|
|
||||||
FavoriteKind,
|
|
||||||
GroupedFavorites,
|
|
||||||
NowPlaying,
|
|
||||||
NowPlayingInput,
|
|
||||||
PlaylistSummary,
|
|
||||||
Playlist,
|
|
||||||
} from './queries/music';
|
|
||||||
export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries/soulseek';
|
|
||||||
export {
|
|
||||||
getSoulseekBrowseSnapshots,
|
|
||||||
getSoulseekBrowseSnapshot,
|
|
||||||
startSoulseekBrowse,
|
|
||||||
finishSoulseekBrowse,
|
|
||||||
failSoulseekBrowse,
|
|
||||||
resetStaleSoulseekBrowses,
|
|
||||||
getSoulseekBrowseLevel,
|
|
||||||
searchSoulseekBrowseTree,
|
|
||||||
getSoulseekBrowseDirFiles,
|
|
||||||
getSoulseekBrowseDownload,
|
|
||||||
deleteSoulseekBrowse,
|
|
||||||
} from './queries/soulseek';
|
|
||||||
export type {
|
|
||||||
BrowseDownloadFile,
|
|
||||||
BrowsedFile,
|
|
||||||
BrowseDirInput,
|
|
||||||
BrowseDirRow,
|
|
||||||
BrowseTreeNode,
|
|
||||||
BrowseLevel,
|
|
||||||
BrowseTreeSearch,
|
|
||||||
SoulseekBrowseSnapshot,
|
|
||||||
} from './queries/soulseek';
|
|
||||||
export {
|
|
||||||
listHeadscaleServers,
|
|
||||||
getActiveHeadscaleCredentials,
|
|
||||||
getHeadscaleCredentials,
|
|
||||||
createHeadscaleServer,
|
|
||||||
updateHeadscaleServer,
|
|
||||||
setActiveHeadscaleServer,
|
|
||||||
deleteHeadscaleServer,
|
|
||||||
recordHeadscaleProbe,
|
|
||||||
} from './queries/headscale';
|
|
||||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
|
|
||||||
export {
|
|
||||||
listInvoiceshelfAccounts,
|
|
||||||
getActiveInvoiceshelfCredentials,
|
|
||||||
getInvoiceshelfCredentials,
|
|
||||||
createInvoiceshelfAccount,
|
|
||||||
updateInvoiceshelfAccount,
|
|
||||||
setActiveInvoiceshelfAccount,
|
|
||||||
deleteInvoiceshelfAccount,
|
|
||||||
recordInvoiceshelfProbe,
|
|
||||||
} from './queries/invoiceshelf';
|
|
||||||
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf';
|
|
||||||
export {
|
|
||||||
listJellyfinServers,
|
|
||||||
getActiveJellyfinCredentials,
|
|
||||||
getJellyfinCredentials,
|
|
||||||
createJellyfinServer,
|
|
||||||
updateJellyfinServer,
|
|
||||||
setActiveJellyfinServer,
|
|
||||||
deleteJellyfinServer,
|
|
||||||
recordJellyfinProbe,
|
|
||||||
} from './queries/jellyfin';
|
|
||||||
export type { JellyfinServer, JellyfinCredentials } from './queries/jellyfin';
|
|
||||||
export {
|
|
||||||
listPhotosAccounts,
|
|
||||||
getActivePhotosCredentials,
|
|
||||||
getPhotosCredentials,
|
|
||||||
createPhotosAccount,
|
|
||||||
updatePhotosAccount,
|
|
||||||
setActivePhotosAccount,
|
|
||||||
deletePhotosAccount,
|
|
||||||
recordPhotosProbe,
|
|
||||||
} from './queries/photos';
|
|
||||||
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
|
|
||||||
export {
|
|
||||||
listDavAppPasswords,
|
|
||||||
createDavAppPassword,
|
|
||||||
revokeDavAppPassword,
|
|
||||||
deleteDavAppPassword,
|
|
||||||
verifyDavAppPassword,
|
|
||||||
} from './queries/dav';
|
|
||||||
export type { DavAppPassword, DavAppPasswordView } from './queries/dav';
|
|
||||||
export {
|
|
||||||
getServiceConnection,
|
|
||||||
getServiceCredentials,
|
|
||||||
saveServiceConnection,
|
|
||||||
deleteServiceConnection,
|
|
||||||
recordServiceProbe,
|
|
||||||
getServiceInstanceUrl,
|
|
||||||
getResolvedServiceCredentials,
|
|
||||||
} from './queries/service-connections';
|
|
||||||
export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections';
|
|
||||||
export {
|
|
||||||
getAllRoleGrants,
|
|
||||||
getRoleGrants,
|
|
||||||
setRoleGrant,
|
|
||||||
revokeRoleGrant,
|
|
||||||
replaceRoleGrants,
|
|
||||||
} from './queries/capabilities';
|
|
||||||
export type { RoleGrant } from './queries/capabilities';
|
|
||||||
export type { CapabilityLevelValue } from './schema/capabilities';
|
|
||||||
export {
|
|
||||||
getVaultTokens,
|
|
||||||
setVaultTokens,
|
|
||||||
updateVaultAccess,
|
|
||||||
clearVaultTokens,
|
|
||||||
getVaultUnlockKey,
|
|
||||||
setVaultUnlockKey,
|
|
||||||
clearVaultUnlockKey,
|
|
||||||
} from './queries/vault';
|
|
||||||
export type { VaultTokenSet } from './queries/vault';
|
|
||||||
export {
|
|
||||||
listWallets,
|
|
||||||
getWallet,
|
|
||||||
getActiveWallet,
|
|
||||||
getWalletSecrets,
|
|
||||||
getSealedSeed,
|
|
||||||
createWallet,
|
|
||||||
updateWallet,
|
|
||||||
replaceSealedSeed,
|
|
||||||
setActiveWallet,
|
|
||||||
deleteWallet,
|
|
||||||
getWalletLabels,
|
|
||||||
setWalletLabel,
|
|
||||||
getFrozenOutpoints,
|
|
||||||
setUtxoFrozen,
|
|
||||||
getWalletChainCache,
|
|
||||||
saveWalletChainCache,
|
|
||||||
recordWalletChainError,
|
|
||||||
} from './queries/wallet';
|
|
||||||
export type {
|
|
||||||
WalletKind,
|
|
||||||
WalletSummary,
|
|
||||||
WalletSecrets,
|
|
||||||
WalletLabel,
|
|
||||||
CreateWalletParams,
|
|
||||||
WalletChainCache,
|
|
||||||
} from './queries/wallet';
|
|
||||||
export type { WalletChainSnapshot } from './schema/wallet';
|
|
||||||
|
|
||||||
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
|
|
||||||
// column definition is the only place that list should exist.
|
|
||||||
export { USER_ROLES, OWNER_USER_ID } from './schema/auth';
|
|
||||||
export type { UserRole } from './schema/auth';
|
|
||||||
|
|
||||||
export { db } from './db';
|
|
||||||
export * as schema from './schema';
|
export * as schema from './schema';
|
||||||
|
|
||||||
export {
|
// ── Core ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
upsertPushDevice,
|
|
||||||
getPushDevices,
|
|
||||||
deletePushDevice,
|
|
||||||
recordPushFailure,
|
|
||||||
markPushDeviceSeen,
|
|
||||||
} from './queries/notify';
|
|
||||||
export type { PushDeviceSelect, PushDeviceInsert } from './types';
|
|
||||||
|
|
||||||
// App store — what the owner has installed, and whether it should be running.
|
export * from './agent-panels';
|
||||||
export {
|
export * from './api-keys';
|
||||||
listSidecarInstalls,
|
export * from './app-store';
|
||||||
getSidecarInstall,
|
export * from './auth';
|
||||||
beginInstall,
|
export * from './capabilities';
|
||||||
recordSteps,
|
export * from './chat-events';
|
||||||
markInstalled,
|
export * from './dashboards';
|
||||||
markFailed,
|
export * from './headscale';
|
||||||
markBlocked,
|
export * from './integrations';
|
||||||
setEnabled,
|
export * from './pipeline-jobs';
|
||||||
removeInstall,
|
export * from './server';
|
||||||
type SidecarInstall,
|
export * from './service-connections';
|
||||||
} from './queries/sidecar-installs';
|
export * from './user-data';
|
||||||
|
|
||||||
|
|
||||||
|
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||||
|
|
||||||
|
export * from './dav';
|
||||||
|
export * from './email';
|
||||||
|
export * from './invoiceshelf';
|
||||||
|
export * from './jellyfin';
|
||||||
|
export * from './music';
|
||||||
|
export * from './notify';
|
||||||
|
export * from './photos';
|
||||||
|
export * from './soulseek';
|
||||||
|
export * from './vault';
|
||||||
|
export * from './wallet';
|
||||||
|
|
||||||
|
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
||||||
|
// deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read
|
||||||
|
// path over a table nothing could write to.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
getServerIntegrations,
|
||||||
|
getServerIntegration,
|
||||||
|
upsertServerIntegration,
|
||||||
|
deleteServerIntegration,
|
||||||
|
getUserIntegrations,
|
||||||
|
getUserIntegration,
|
||||||
|
getIntegrationsByProvider,
|
||||||
|
upsertUserIntegration,
|
||||||
|
deleteUserIntegration,
|
||||||
|
findUserByIntegrationConfig,
|
||||||
|
} from './queries';
|
||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { serverIntegrations, userIntegrations } from '../schema';
|
import { serverIntegrations } from '../server/schema';
|
||||||
import { users } from '../schema/auth';
|
import { userIntegrations } from '../user-data/schema';
|
||||||
|
import { users } from '../auth/schema';
|
||||||
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
|
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
|
||||||
|
|
||||||
// ── Server Integrations ──
|
// ── Server Integrations ──
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
listInvoiceshelfAccounts,
|
||||||
|
getActiveInvoiceshelfCredentials,
|
||||||
|
getInvoiceshelfCredentials,
|
||||||
|
createInvoiceshelfAccount,
|
||||||
|
updateInvoiceshelfAccount,
|
||||||
|
setActiveInvoiceshelfAccount,
|
||||||
|
deleteInvoiceshelfAccount,
|
||||||
|
recordInvoiceshelfProbe,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries';
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { invoiceshelfAccounts } from '../schema';
|
import { invoiceshelfAccounts } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from '../crypto';
|
import { encryptSecret, decryptSecret } from '../crypto';
|
||||||
|
|
||||||
// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption
|
// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption
|
||||||
@@ -60,7 +60,7 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
|
|||||||
.from(invoiceshelfAccounts)
|
.from(invoiceshelfAccounts)
|
||||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
|
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
||||||
@@ -70,7 +70,7 @@ export async function getInvoiceshelfCredentials(userId: number, id: number): Pr
|
|||||||
.from(invoiceshelfAccounts)
|
.from(invoiceshelfAccounts)
|
||||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
|
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateInvoiceshelfAccountParams = {
|
type CreateInvoiceshelfAccountParams = {
|
||||||
@@ -101,7 +101,7 @@ export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccoun
|
|||||||
userId,
|
userId,
|
||||||
label,
|
label,
|
||||||
url,
|
url,
|
||||||
token: encryptSecret(token),
|
token: encryptSecret('invoiceshelf', token),
|
||||||
companyId,
|
companyId,
|
||||||
companyName,
|
companyName,
|
||||||
version,
|
version,
|
||||||
@@ -131,7 +131,7 @@ export async function updateInvoiceshelfAccount(
|
|||||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
if (params.label !== undefined) set.label = params.label;
|
if (params.label !== undefined) set.label = params.label;
|
||||||
if (params.url !== undefined) set.url = params.url;
|
if (params.url !== undefined) set.url = params.url;
|
||||||
if (params.token !== undefined) set.token = encryptSecret(params.token);
|
if (params.token !== undefined) set.token = encryptSecret('invoiceshelf', params.token);
|
||||||
if (params.companyId !== undefined) set.companyId = params.companyId;
|
if (params.companyId !== undefined) set.companyId = params.companyId;
|
||||||
if (params.companyName !== undefined) set.companyName = params.companyName;
|
if (params.companyName !== undefined) set.companyName = params.companyName;
|
||||||
if (params.version !== undefined) set.version = params.version;
|
if (params.version !== undefined) set.version = params.version;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar.
|
// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar.
|
||||||
//
|
//
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
listJellyfinServers,
|
||||||
|
getActiveJellyfinCredentials,
|
||||||
|
getJellyfinCredentials,
|
||||||
|
createJellyfinServer,
|
||||||
|
updateJellyfinServer,
|
||||||
|
setActiveJellyfinServer,
|
||||||
|
deleteJellyfinServer,
|
||||||
|
recordJellyfinProbe,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type { JellyfinServer, JellyfinCredentials } from './queries';
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { jellyfinServers } from '../schema';
|
import { jellyfinServers } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from '../crypto';
|
import { encryptSecret, decryptSecret } from '../crypto';
|
||||||
|
|
||||||
// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and
|
// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and
|
||||||
@@ -51,7 +51,7 @@ const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCreden
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
accessToken: decryptSecret(row.accessToken),
|
accessToken: decryptSecret('jellyfin', row.accessToken),
|
||||||
jellyfinUserId: row.jellyfinUserId,
|
jellyfinUserId: row.jellyfinUserId,
|
||||||
deviceId: row.deviceId,
|
deviceId: row.deviceId,
|
||||||
});
|
});
|
||||||
@@ -112,7 +112,7 @@ export async function createJellyfinServer(params: CreateJellyfinServerParams):
|
|||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
...rest,
|
...rest,
|
||||||
accessToken: encryptSecret(accessToken),
|
accessToken: encryptSecret('jellyfin', accessToken),
|
||||||
isActive: activate,
|
isActive: activate,
|
||||||
lastSeenAt: rest.version ? new Date() : null,
|
lastSeenAt: rest.version ? new Date() : null,
|
||||||
})
|
})
|
||||||
@@ -142,7 +142,7 @@ export async function updateJellyfinServer(
|
|||||||
.update(jellyfinServers)
|
.update(jellyfinServers)
|
||||||
.set({
|
.set({
|
||||||
...rest,
|
...rest,
|
||||||
...(accessToken ? { accessToken: encryptSecret(accessToken) } : {}),
|
...(accessToken ? { accessToken: encryptSecret('jellyfin', accessToken) } : {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar.
|
// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar.
|
||||||
//
|
//
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export {
|
||||||
|
getMusicFavorites,
|
||||||
|
addMusicFavorite,
|
||||||
|
removeMusicFavorite,
|
||||||
|
getNowPlaying,
|
||||||
|
setNowPlaying,
|
||||||
|
clearNowPlaying,
|
||||||
|
getPlaylists,
|
||||||
|
getPlaylist,
|
||||||
|
createPlaylist,
|
||||||
|
renamePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItems,
|
||||||
|
setPlaylistItems,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
FavoriteKind,
|
||||||
|
GroupedFavorites,
|
||||||
|
NowPlaying,
|
||||||
|
NowPlayingInput,
|
||||||
|
PlaylistSummary,
|
||||||
|
Playlist,
|
||||||
|
} from './queries';
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from '../schema';
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||||
|
|
||||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
upsertPushDevice,
|
||||||
|
getPushDevices,
|
||||||
|
deletePushDevice,
|
||||||
|
recordPushFailure,
|
||||||
|
markPushDeviceSeen,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export type { PushDeviceSelect, PushDeviceInsert } from '../types';
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { pushDevices } from '../schema';
|
import { pushDevices } from './schema';
|
||||||
import type { PushDeviceSelect, PushDeviceInsert } from '../types';
|
import type { PushDeviceSelect, PushDeviceInsert } from '../types';
|
||||||
|
|
||||||
// The push device registry. Only the officer-notify sidecar uses these.
|
// The push device registry. Only the officer-notify sidecar uses these.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, timestamp, index, check, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, timestamp, index, check, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from './auth';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
// Devices that can receive a push, for the officer-notify sidecar.
|
// Devices that can receive a push, for the officer-notify sidecar.
|
||||||
//
|
//
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user