app store: the catalogue, the install-state table, and what phase 0 must not foreclose
First slice, on a worktree branch so none of it touches the tree the live server runs from. `sidecar_installs` — server-level, no userId, because a sidecar is one process serving the machine. That is the line that keeps the model coherent for several users: installed is server-level and owner-only, configured is per user in service_connections. A member can use Gitea without being able to install it or point it somewhere else. `installed` and `enabled` are separate because they answer different questions, which is what gives the reversible middle ground: disable stops the process and keeps container, config, schema and data. `completedSteps` makes install resumable rather than merely retryable — the failure mode being designed against is a half-installed service that neither works nor uninstalls. The catalogue is data, not code: no functions, no compile-time coupling, because the same shape has to arrive as JSON from marketplace.officer.dev later. Its test pins it to the real estate — it offers exactly the processes the light profile excludes, names processes that exist, and claims capabilities that exist. That last check earned itself immediately: it caught `vault` (no capability at all — it is EXEMPT because Bitwarden clients carry a Vaultwarden bearer, not a platform JWT) and `notify` (which does have one, where I had written null). Docker templates follow the convention already in use across 47 services in ~/dockers: a directory per service, compose inside, relative bind mounts so data sits beside it, USER_UID/USER_GID as the owner. An existing directory is evidence of an existing install and must be adopted, never overwritten. Records what Phase 0 must not foreclose: a remote marketplace, sidecars moving to their own repositories, and third-party plugins — including the note that catalogue.test.ts pins Phase 0's invariant rather than the design's, since that relationship inverts once sidecars leave this repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,8 +60,12 @@ for someone who does not. The prompt is the fork.
|
||||
|
||||
**Officer is the installer, never the owner.** Concretely:
|
||||
|
||||
- A real `docker-compose.yml` per service, written into a **user-owned directory**
|
||||
(`~/officer-services/<service>/`), from our template.
|
||||
- A real compose file per service, written into a **user-owned directory**, from our template —
|
||||
following the convention the owner already uses for 47 services in `~/dockers/`:
|
||||
one directory per service, `docker-compose.yaml` inside, and **relative bind mounts**
|
||||
(`./data`, `./database`, `./storage`) so configuration and data sit beside the compose file where
|
||||
both we and the user can find them. Named volumes are used by 3 of those 47 and are the exception;
|
||||
templates use bind mounts, always.
|
||||
- Started with `docker compose up -d` **as the owner**, not as officer's own identity.
|
||||
- Found again by **label** (`officer.sidecar=<id>`), not by holding a handle.
|
||||
|
||||
@@ -75,8 +79,16 @@ Consequences, which are the point:
|
||||
The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and
|
||||
health checks already correct, so "install Gitea" does not become a tutorial.
|
||||
|
||||
`[open]` Rootful Docker runs container processes as root unless `user:` is set. Do we set it? And do we
|
||||
support Podman for people who want genuinely rootless?
|
||||
**`USER_UID` / `USER_GID` are set to the owner**, as the existing services already do. That answers the
|
||||
"do containers run as root" question: no, and this is not a new convention — it is the one in use.
|
||||
|
||||
**An existing directory is evidence, not an obstacle.** `~/dockers/` already holds `gitea`, `memos`,
|
||||
`immich`, `jellyfin` and `invoice_shelf`. The installer must never write into a directory that exists;
|
||||
finding one is the strongest possible signal that this is the "you already have one" case, and the store
|
||||
should offer to ADOPT it — read its ports out of the compose file and write the connection — rather than
|
||||
provision a second copy or overwrite a running service's data.
|
||||
|
||||
`[open]` Podman, for anyone wanting genuinely rootless.
|
||||
|
||||
---
|
||||
|
||||
@@ -174,6 +186,29 @@ What a plugin author is promised, and bound by. To be written properly; the shap
|
||||
|
||||
---
|
||||
|
||||
## What Phase 0 must not foreclose
|
||||
|
||||
Three things are coming, and each one constrains a decision that looks free today.
|
||||
|
||||
**1. `marketplace.officer.dev`.** Phase 1 keeps the catalogue inside this repo; later the app lists what
|
||||
is on a remote marketplace instead. So catalogue entries must stay **serialisable data** — no functions,
|
||||
no imports, nothing that only means something at compile time. They are plain objects today and must
|
||||
remain so, because the same shape has to arrive as JSON over HTTP. Compose templates travel with them.
|
||||
|
||||
**2. Every sidecar becomes its own repository.** Today `catalogue.test.ts` asserts the catalogue equals
|
||||
"everything in ecosystem.config.cjs that light excludes". That is the right check _now_, and it inverts
|
||||
later: once sidecars live elsewhere, the catalogue entry becomes the source of truth for how to run one
|
||||
(command, args, env) and the ecosystem file is generated from what is installed, not the other way
|
||||
round. **Do not treat that test as a permanent law** — it pins Phase 0's invariant, not the design's.
|
||||
|
||||
**3. Third-party plugins.** Already the reason per-sidecar schema is in scope. It is also why the
|
||||
`service_connections` ID needs namespacing before the marketplace opens, not after.
|
||||
|
||||
The through-line: **nothing in Phase 0 may assume the catalogue is compiled in, or that a sidecar's code
|
||||
is in this repository.**
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. `user:` in compose, and Podman support for rootless.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { pgTable, serial, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
// What the owner has installed from the app store, and whether it should be running.
|
||||
//
|
||||
// ── Why there is no userId ──
|
||||
//
|
||||
// A sidecar is ONE process serving the whole machine, so installing one is a server-level act, not a
|
||||
// per-user one. This is the line that keeps the model coherent once several people share a server:
|
||||
//
|
||||
// installed server-level, owner-only — this row
|
||||
// configured per user — service_connections
|
||||
//
|
||||
// Gitea is the worked example. The owner installs it once, and the row here says the process runs; each
|
||||
// member then holds their own credential in `service_connections`, inheriting the instance URL from the
|
||||
// owner's row. A member can therefore use a service without being able to install, uninstall or point it
|
||||
// somewhere else — which is the same split `capabilities/registry.ts` already draws between `app` and
|
||||
// `admin` kinds.
|
||||
//
|
||||
// ── Why `installed` and `enabled` are separate ──
|
||||
//
|
||||
// They answer different questions. `installed` means the thing EXISTS: a container was provisioned (or an
|
||||
// existing instance was named), config was written, schema was applied. `enabled` means the process
|
||||
// SHOULD BE RUNNING. Disabling is the reversible middle ground the owner asked for — stop the process,
|
||||
// keep the container, the config, the tables and the data, and start again later at no cost.
|
||||
//
|
||||
// Uninstall then has a disposal choice rather than a fixed meaning: keep the data, drop the container,
|
||||
// or drop both. None of those are this table's business beyond recording that the row is gone.
|
||||
export const sidecarInstalls = pgTable(
|
||||
'sidecar_installs',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/**
|
||||
* The catalogue id — 'gitea', 'photos'. Text rather than an enum for the same reason
|
||||
* `service_connections.service` is: adding a sidecar must not be a schema change, and a third-party
|
||||
* one cannot be in an enum we compile.
|
||||
*/
|
||||
sidecarId: text('sidecar_id').notNull(),
|
||||
/**
|
||||
* How this install was satisfied, which decides what uninstall has to undo:
|
||||
*
|
||||
* 'existing' — pointed at an instance the user already runs. We provisioned nothing.
|
||||
* 'provisioned' — we rendered a compose file and started containers. Ours to offer to remove.
|
||||
* 'config' — nothing to reach; credentials only (email, wallet).
|
||||
*/
|
||||
mode: text('mode').notNull(),
|
||||
/**
|
||||
* 'pending' | 'installing' | 'installed' | 'failed'.
|
||||
*
|
||||
* `installing` is a real, persisted state rather than a transient one, because install spans a
|
||||
* container start and a health wait and can be interrupted by a restart in the middle. A row stuck in
|
||||
* `installing` is the signal to resume, not evidence of a bug.
|
||||
*/
|
||||
status: text('status').notNull().default('pending'),
|
||||
enabled: boolean('enabled').notNull().default(false),
|
||||
/**
|
||||
* Which install steps have completed, by name. This is what makes install RESUMABLE rather than
|
||||
* merely retryable: re-running picks up after the last completed step instead of provisioning a
|
||||
* second container or re-writing config that is already right.
|
||||
*
|
||||
* The failure mode being designed against is a half-installed service that neither works nor
|
||||
* uninstalls — which is the one users cannot get themselves out of.
|
||||
*/
|
||||
completedSteps: jsonb('completed_steps').$type<string[]>().notNull().default([]),
|
||||
/** Why the last attempt failed, shown in the UI. Cleared on the next successful step. */
|
||||
lastError: text('last_error'),
|
||||
/**
|
||||
* Absolute path to the rendered compose directory, for `mode: 'provisioned'` only.
|
||||
*
|
||||
* Stored rather than derived because it is the user's directory and he may move it — and because
|
||||
* uninstall must not guess at a path it is about to run `docker compose down -v` in.
|
||||
*/
|
||||
composeDir: text('compose_dir'),
|
||||
installedAt: timestamp('installed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// One row per sidecar, machine-wide. There is no second install of the same sidecar to disambiguate,
|
||||
// which is what lets every other column be read without asking "which one".
|
||||
(t) => [uniqueIndex('uq_sidecar_installs_sidecar').on(t.sidecarId)],
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { CATALOGUE, byId } from './catalogue';
|
||||
import { CAPABILITIES } from '../capabilities/registry';
|
||||
|
||||
// The catalogue is a hand-written list describing machinery that lives elsewhere, which is the shape of
|
||||
// thing that rots silently. These tests pin it to the three sources it claims to agree with:
|
||||
// ecosystem.config.cjs, the light profile, and the capability registry.
|
||||
//
|
||||
// The intent is that adding a sidecar to the estate and forgetting the app store FAILS HERE, rather than
|
||||
// the sidecar being quietly uninstallable and nobody noticing for a release.
|
||||
|
||||
const full = (require('../../../ecosystem.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name);
|
||||
const light = (require('../../../ecosystem.light.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name);
|
||||
|
||||
describe('the catalogue against the real estate', () => {
|
||||
it('offers exactly the processes the light profile leaves out', () => {
|
||||
// This is the definition of the app store: light is the baseline, everything else is installable.
|
||||
const notInLight = full.filter((name) => !light.includes(name)).sort();
|
||||
const offered = CATALOGUE.map((e) => e.process).sort();
|
||||
|
||||
expect(offered).toEqual(notInLight);
|
||||
});
|
||||
|
||||
it('names a process that actually exists in the ecosystem', () => {
|
||||
// A typo here would install nothing and report success.
|
||||
for (const entry of CATALOGUE) expect(full).toContain(entry.process);
|
||||
});
|
||||
|
||||
it('does not offer to install the baseline', () => {
|
||||
// "Uninstall chat" is not a thing the store should be able to express.
|
||||
for (const entry of CATALOGUE) expect(light).not.toContain(entry.process);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entries are internally coherent', () => {
|
||||
it('has unique ids', () => {
|
||||
const ids = CATALOGUE.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('declares a compose template if and only if it can provision one', () => {
|
||||
// Both directions matter: a provisioning entry with no template fails at install time, and a
|
||||
// template on a non-provisioning entry is dead weight nobody will notice is unused.
|
||||
for (const entry of CATALOGUE) {
|
||||
if (entry.modes.includes('provisioned')) expect(entry.composeTemplate).toBeTruthy();
|
||||
else expect(entry.composeTemplate).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('asks for connection details exactly when it points at something existing', () => {
|
||||
for (const entry of CATALOGUE) {
|
||||
if (entry.modes.includes('existing')) expect(entry.existingFields?.length).toBeGreaterThan(0);
|
||||
else expect(entry.existingFields).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a url when pointing at an existing instance', () => {
|
||||
// Without one there is nothing to connect to, and the `existing` mode is meaningless.
|
||||
for (const entry of CATALOGUE) {
|
||||
if (!entry.modes.includes('existing')) continue;
|
||||
const url = entry.existingFields?.find((f) => f.key === 'url');
|
||||
expect(url?.required).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares at least one mode', () => {
|
||||
for (const entry of CATALOGUE) expect(entry.modes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities it claims to back', () => {
|
||||
it('names a capability that exists, or explicitly none', () => {
|
||||
// `null` is a real answer — notify has no surface of its own — but a WRONG key would silently
|
||||
// detach the store entry from the permission that governs the feature.
|
||||
const keys = new Set(CAPABILITIES.map((c) => c.key));
|
||||
for (const entry of CATALOGUE) {
|
||||
if (entry.capability === null) continue;
|
||||
expect(keys).toContain(entry.capability);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('byId', () => {
|
||||
it('finds a known entry and returns undefined for anything else', () => {
|
||||
expect(byId('photos')?.process).toBe('officer-photos');
|
||||
expect(byId('not-a-sidecar')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
// What the app store can install, and what installing each one actually requires.
|
||||
//
|
||||
// This is the catalogue, not the state — `sidecar_installs` records what the owner has done, this
|
||||
// records what is on offer. Everything the installer branches on lives here so the installer itself has
|
||||
// no per-sidecar knowledge, the same restraint `create-proxy.ts` keeps on the proxy side.
|
||||
//
|
||||
// ── The entries are derived from a fact, not invented ──
|
||||
//
|
||||
// The fourteen below are exactly the processes in `ecosystem.config.cjs` that the light profile
|
||||
// excludes. That is not a coincidence to be maintained by hand: `catalogue.test.ts` asserts it, so a
|
||||
// sidecar added to the estate and forgotten here fails a test rather than being quietly uninstallable.
|
||||
//
|
||||
// Light itself (officer, the agent, the anthropic proxy, opencode, pty, gitea) is not in the catalogue.
|
||||
// Those are the baseline — chat, terminal, file browser — and there is no meaningful "uninstall chat".
|
||||
|
||||
/**
|
||||
* How an install can be satisfied. A sidecar may support more than one, and the prompt is the fork:
|
||||
* "do you already have one, or shall we start one for you?"
|
||||
*/
|
||||
export type InstallMode =
|
||||
/** Point at an instance the user already runs, here or elsewhere. We provision nothing. */
|
||||
| 'existing'
|
||||
/** Render our compose template and start it. The connection is then known without asking. */
|
||||
| 'provisioned'
|
||||
/** Nothing to reach. Credentials or local configuration only. */
|
||||
| 'config';
|
||||
|
||||
/** One field the installer asks for before it can finish. */
|
||||
export type ConfigField = {
|
||||
key: string;
|
||||
label: string;
|
||||
/** `secret` is written encrypted and never read back to the client. */
|
||||
type: 'url' | 'text' | 'secret';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
};
|
||||
|
||||
export type CatalogueEntry = {
|
||||
/** Stable id. Matches `sidecar_installs.sidecar_id` and `service_connections.service` where both exist. */
|
||||
id: string;
|
||||
/** The PM2 process to start and stop. Must exist in ecosystem.config.cjs. */
|
||||
process: string;
|
||||
label: string;
|
||||
/** One line, shown in the store listing. */
|
||||
summary: string;
|
||||
/** Which of the three shapes this sidecar supports, in the order the UI should offer them. */
|
||||
modes: InstallMode[];
|
||||
/**
|
||||
* The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no
|
||||
* user-facing surface of its own (notify produces notifications for other features).
|
||||
*/
|
||||
capability: string | null;
|
||||
/**
|
||||
* Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the
|
||||
* answers because we wrote the compose file.
|
||||
*/
|
||||
existingFields?: ConfigField[];
|
||||
/** Asked for `config` installs, which have no instance to point at. */
|
||||
configFields?: ConfigField[];
|
||||
/** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */
|
||||
composeTemplate?: string;
|
||||
/**
|
||||
* Why this cannot be installed on some hosts, if so. Shown instead of the install button rather than
|
||||
* failing halfway through — a check the installer can make before it starts.
|
||||
*/
|
||||
requires?: 'docker' | 'linux-display';
|
||||
};
|
||||
|
||||
export const CATALOGUE: CatalogueEntry[] = [
|
||||
// ── Point at something you already run, or let us start one ────────────────────────────────────────
|
||||
{
|
||||
id: 'photos',
|
||||
process: 'officer-photos',
|
||||
label: 'Photos',
|
||||
summary: 'Your Immich library — browse, search, upload from the phone',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'photos',
|
||||
composeTemplate: 'immich',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' },
|
||||
{ key: 'secret', label: 'API key', type: 'secret', required: true, help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'jellyfin',
|
||||
process: 'officer-jellyfin',
|
||||
label: 'Jellyfin',
|
||||
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'jellyfin',
|
||||
composeTemplate: 'jellyfin',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'http://localhost:8096' },
|
||||
{ key: 'secret', label: 'Access token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'memos',
|
||||
process: 'officer-memos',
|
||||
label: 'Memos',
|
||||
summary: 'Quick notes, tagged and searchable',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'memos',
|
||||
composeTemplate: 'memos',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Memos URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'Access token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'invoiceshelf',
|
||||
process: 'officer-invoiceshelf',
|
||||
label: 'Invoices',
|
||||
summary: 'InvoiceShelf — clients, estimates and invoices',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'invoices',
|
||||
composeTemplate: 'invoiceshelf',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'API token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vault',
|
||||
process: 'officer-vault',
|
||||
label: 'Vault',
|
||||
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
|
||||
modes: ['existing', 'provisioned'],
|
||||
// No capability entry exists for this one, and the reason is about CREDENTIALS, not routing.
|
||||
//
|
||||
// Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the
|
||||
// officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly.
|
||||
//
|
||||
// What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so
|
||||
// `userMiddleware` would 401 them and a capability lookup would have no account to resolve. Hence
|
||||
// `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead.
|
||||
// The install still governs whether the sidecar runs at all.
|
||||
capability: null,
|
||||
composeTemplate: 'vaultwarden',
|
||||
existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }],
|
||||
},
|
||||
{
|
||||
id: 'transmission',
|
||||
process: 'officer-transmission',
|
||||
label: 'Transmission',
|
||||
summary: 'Torrents, with the daemon Officer talks to over RPC',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'transmission',
|
||||
composeTemplate: 'transmission',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Transmission URL', type: 'url', required: true, placeholder: 'http://localhost:9091' },
|
||||
{ key: 'path', label: 'RPC path', type: 'text', required: false, placeholder: '/transmission/rpc', help: 'Only differs behind a reverse proxy.' },
|
||||
{ key: 'username', label: 'RPC username', type: 'text', required: false, help: 'Usually blank — Transmission is normally run with no RPC auth.' },
|
||||
{ key: 'secret', label: 'RPC password', type: 'secret', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slskd',
|
||||
process: 'officer-slskd',
|
||||
label: 'Soulseek',
|
||||
summary: 'slskd — search and download from the Soulseek network',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'soulseek',
|
||||
composeTemplate: 'slskd',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'slskd URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'caldav',
|
||||
process: 'officer-caldav',
|
||||
label: 'Calendar',
|
||||
summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'calendar',
|
||||
composeTemplate: 'radicale',
|
||||
existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }],
|
||||
},
|
||||
{
|
||||
id: 'headscale',
|
||||
process: 'officer-headscale',
|
||||
label: 'Headscale',
|
||||
summary: 'Your own tailnet control plane',
|
||||
modes: ['existing'],
|
||||
capability: 'headscale',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Headscale URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Nothing to reach: configuration only ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'email',
|
||||
process: 'officer-email',
|
||||
label: 'Email',
|
||||
summary: 'Your IMAP accounts, synced and searchable',
|
||||
modes: ['config'],
|
||||
capability: 'email',
|
||||
// Deliberately empty: accounts are added from /email, which already has a working multi-account
|
||||
// form. Duplicating it here would be a second place to maintain the same credentials.
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
process: 'officer-music',
|
||||
label: 'Music',
|
||||
summary: 'Index and play the library on this machine',
|
||||
modes: ['config'],
|
||||
capability: 'music',
|
||||
configFields: [
|
||||
{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
process: 'officer-wallet',
|
||||
label: 'Wallet',
|
||||
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
|
||||
modes: ['config'],
|
||||
capability: 'wallet',
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
id: 'notify',
|
||||
process: 'officer-notify',
|
||||
label: 'Notifications',
|
||||
summary: 'Push to your phone when a job finishes or a turn needs you',
|
||||
modes: ['config'],
|
||||
capability: 'notify',
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
id: 'vnc',
|
||||
process: 'officer-vnc',
|
||||
label: 'Desktop',
|
||||
summary: 'Mirror this machine’s display in the browser',
|
||||
modes: ['config'],
|
||||
capability: 'desktop',
|
||||
// x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the
|
||||
// store should say so rather than install something that starts and immediately fails.
|
||||
requires: 'linux-display',
|
||||
configFields: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const byId = (id: string): CatalogueEntry | undefined => CATALOGUE.find((e) => e.id === id);
|
||||
|
||||
/** Every catalogue id, for validating a request before it reaches the installer. */
|
||||
export const CATALOGUE_IDS: ReadonlySet<string> = new Set(CATALOGUE.map((e) => e.id));
|
||||
Reference in New Issue
Block a user