a plugin can depend on a release binary, and prove it is the right one
osDependencies could only say "this package, from this manager". Gitea's `tea`
is not in any distribution — it is a Go binary from dl.gitea.com — and Debian
has an UNRELATED package of that name: a graphical text editor. So the one
dependency gitea actually wants could not be expressed, and its marketplace
page showed no dependencies at all while music's showed two.
`release: { url, identify }` covers it. {os} and {arch} are filled from the
host in the spelling Go releases use, and the binary is fetched to
~/.local/bin — no root, and nothing a plugin downloaded from the internet
sitting beside what the distribution installed. Written to a temporary name and
moved into place, so a half-downloaded file is never on PATH under the real
one.
`identify` is the part that matters. Bun.which('tea') cannot tell Gitea's CLI
from the text editor, so presence was never the same question as identity —
and reporting the wrong program as satisfied is worse than reporting it
missing, because the install succeeds and every call afterwards fails
strangely. A binary that fails to identify is now `present: false, imposter:
true`.
Found while testing: Bun.which() with no options reads the PATH the PROCESS
started with, which under PM2 frequently excludes ~/.local/bin entirely. A
release binary fetched during an install would have been reported missing
seconds later by stillMissing, failing an install that had just succeeded. The
search now passes PATH explicitly, with ~/.local/bin first.
Verified against the real binaries on this machine: the genuine tea identifies,
a fake that prints text-editor output is caught as an imposter, and both
dl.gitea.com URLs (linux-amd64, darwin-arm64) answer 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -298,6 +298,22 @@ pane belonging to the folder you left; and the breadcrumb opted out of that last
|
||||
restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both —
|
||||
but worth re-deciding rather than inheriting.
|
||||
|
||||
## Release-binary dependencies (2026-08-19)
|
||||
|
||||
Built: a plugin can declare `release: { url, identify }` for a dependency no package manager carries.
|
||||
Gitea's `tea` is the motivating case and the first user.
|
||||
|
||||
Two things it does NOT do, both deliberate and both worth knowing:
|
||||
|
||||
- **No checksum.** The download is verified by RUNNING it through the same `identify` rule the probe
|
||||
uses, which catches an HTML error page, a truncated file and a wrong-architecture build — none of them
|
||||
print what the plugin said to look for. It does not catch a compromised upstream. A checksum per
|
||||
os/arch is a table that goes stale every release and would stop installs the day upstream re-cuts a
|
||||
tag; HTTPS and a URL the owner reads in the install dialog is what stands behind it today.
|
||||
- **The version is pinned in the manifest.** `tea` is fixed at 0.15.1, so a new release needs a plugin
|
||||
update. That is the right default — an install that silently fetches whatever is latest is not
|
||||
reproducible — but there is no "check for a newer one" anywhere.
|
||||
|
||||
## Affordances (2026-08-19)
|
||||
|
||||
Agreed and written down in `plugins/CLAUDE.md`; **nothing is built**. An affordance is what the surface
|
||||
|
||||
@@ -20,7 +20,7 @@ import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
import { declaredTables, dropTables, existingTables } from './tables';
|
||||
import type { OnLog } from './run-logged';
|
||||
import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
|
||||
import { installDependencies, installRelease, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
|
||||
// The install runner: the four verbs, each as a short ordered list of effects.
|
||||
@@ -219,8 +219,24 @@ export async function installPlugin(
|
||||
};
|
||||
}
|
||||
|
||||
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
|
||||
const installed = await installDependencies(deps.manager!, deps.packagesToInstall);
|
||||
// Release binaries first, and separately: they need no package manager, which is the reason they
|
||||
// exist. `tea` is the motivating case — Gitea publishes it from dl.gitea.com, and the Debian
|
||||
// package of that name is an unrelated text editor.
|
||||
for (const dep of deps.missing.filter((d) => d.releaseUrl)) {
|
||||
await step(steps, onStep, `dependencies: fetching ${dep.binary} from ${dep.releaseUrl}`);
|
||||
const got = await installRelease(dep);
|
||||
if (!got.ok) {
|
||||
return { ok: false, appName, steps, error: `could not fetch ${dep.binary}: ${got.error}` };
|
||||
}
|
||||
await step(steps, onStep, `dependencies: ${dep.binary} installed to ${got.path}`);
|
||||
}
|
||||
|
||||
if (deps.packagesToInstall.length) {
|
||||
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
|
||||
}
|
||||
const installed = deps.packagesToInstall.length
|
||||
? await installDependencies(deps.manager!, deps.packagesToInstall)
|
||||
: { ok: true as const, installed: [], output: '' };
|
||||
if (!installed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -86,6 +86,34 @@ export type OsDependency = {
|
||||
* to the owner rather than a guess.
|
||||
*/
|
||||
packages: Partial<Record<PackageManager, string>>;
|
||||
/**
|
||||
* A release binary, for a dependency no package manager carries.
|
||||
*
|
||||
* `packages` covers the ordinary case: the thing is in the distribution and `apt install` fetches it.
|
||||
* Some tools are not, and the motivating one is Gitea's `tea` — distributed as a Go binary from
|
||||
* dl.gitea.com, and **Debian has an unrelated package of the same name**, a graphical text editor.
|
||||
* Declaring `apt: 'tea'` would install the wrong program, report success, and leave every call failing
|
||||
* strangely.
|
||||
*
|
||||
* `{os}` and `{arch}` are substituted from the host: `linux`/`darwin`, `amd64`/`arm64` — the spelling
|
||||
* Go releases use, since that is what publishes binaries this way.
|
||||
*
|
||||
* Downloaded to `~/.local/bin`, not a system directory: this needs no root, and a binary a plugin
|
||||
* fetched from the internet has no business in `/usr/local/bin` beside things the distribution put
|
||||
* there.
|
||||
*/
|
||||
release?: {
|
||||
/** e.g. `https://dl.gitea.com/tea/0.9.2/tea-0.9.2-{os}-{arch}` */
|
||||
url: string;
|
||||
/**
|
||||
* How to prove the binary on PATH is THIS one and not a namesake.
|
||||
*
|
||||
* Presence is not identity. `Bun.which('tea')` cannot tell Gitea's CLI from the text editor, so
|
||||
* without this the platform would find a `tea`, report the dependency satisfied, and be wrong. The
|
||||
* command is run with these arguments and its output must contain `contains`.
|
||||
*/
|
||||
identify?: { args: string[]; contains: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginManifest = {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { manifestProblems, type PluginManifest } from './manifest';
|
||||
import { installCommand, manualInstallHint } from './os-deps';
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { manifestProblems, type DiscoveredPlugin, type OsDependency, type PluginManifest } from './manifest';
|
||||
import { hostTarget, installCommand, manualInstallHint, reportDependencies } from './os-deps';
|
||||
|
||||
// The manifest field that reaches a ROOT command line, and the argv it turns into.
|
||||
//
|
||||
@@ -130,3 +133,98 @@ describe('manualInstallHint', () => {
|
||||
expect(manualInstallHint(null, ['ffmpeg'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Release binaries, and the namesake problem ──
|
||||
//
|
||||
// The motivating case is real: Gitea's `tea` is a Go binary from dl.gitea.com, and Debian has an
|
||||
// unrelated package called `tea` that is a graphical text editor. `Bun.which('tea')` cannot tell them
|
||||
// apart, so a platform that treats "on PATH" as "satisfied" installs the wrong program, reports success,
|
||||
// and leaves every call failing in a way nobody traces back to here.
|
||||
|
||||
describe('a dependency that no package manager carries', () => {
|
||||
const bin = join(tmpdir(), `dep-test-${process.pid}`);
|
||||
|
||||
const plugin = (dep: Partial<OsDependency>): DiscoveredPlugin =>
|
||||
({
|
||||
appName: 'test',
|
||||
dir: '/tmp',
|
||||
manifest: {
|
||||
osDependencies: [{ binary: 'dep-test-fake', reason: 'testing', packages: {}, ...dep }],
|
||||
},
|
||||
}) as DiscoveredPlugin;
|
||||
|
||||
afterAll(() => rmSync(bin, { recursive: true, force: true }));
|
||||
|
||||
test('{os} and {arch} are filled from the host', () => {
|
||||
const { os, arch } = hostTarget();
|
||||
const report = reportDependencies(plugin({ release: { url: 'https://x/y-{os}-{arch}' } }));
|
||||
expect(report.dependencies[0]!.releaseUrl).toBe(`https://x/y-${os}-${arch}`);
|
||||
});
|
||||
|
||||
test('a release makes it resolvable even with no package manager entry', () => {
|
||||
const report = reportDependencies(plugin({ release: { url: 'https://x/y-{os}-{arch}' } }));
|
||||
// The whole point: `packages: {}` would otherwise be unresolvable and block the install.
|
||||
expect(report.unresolvable).toEqual([]);
|
||||
});
|
||||
|
||||
test('with neither a package nor a release it is still unresolvable', () => {
|
||||
expect(reportDependencies(plugin({})).unresolvable.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity — presence is not the same as being the right program', () => {
|
||||
const dir = join(tmpdir(), `dep-id-${process.pid}`);
|
||||
const name = 'officer-dep-probe';
|
||||
|
||||
// Written to `~/.local/bin` because that is where `searchPath` looks — `Bun.which` reads the PATH the
|
||||
// process STARTED with, so a temp directory added to `process.env.PATH` here would be invisible. That
|
||||
// is the same reason the production code passes PATH explicitly.
|
||||
const localBin = join(homedir(), '.local', 'bin');
|
||||
const writeFake = (body: string) => {
|
||||
mkdirSync(localBin, { recursive: true });
|
||||
writeFileSync(join(localBin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(join(localBin, name), { force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const plugin = (identify?: { args: string[]; contains: string }): DiscoveredPlugin =>
|
||||
({
|
||||
appName: 'test',
|
||||
dir: '/tmp',
|
||||
manifest: {
|
||||
osDependencies: [
|
||||
{
|
||||
binary: name,
|
||||
reason: 'testing',
|
||||
packages: {},
|
||||
release: { url: 'https://x/y-{os}-{arch}', ...(identify ? { identify } : {}) },
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as DiscoveredPlugin;
|
||||
|
||||
test('a namesake that does not identify is NOT present, and is named an imposter', () => {
|
||||
writeFake('echo "graphical text editor 62.0.2"');
|
||||
const status = reportDependencies(plugin({ args: ['--version'], contains: 'go-sdk' })).dependencies[0]!;
|
||||
expect(status.path).not.toBeNull(); // it IS on PATH
|
||||
expect(status.present).toBe(false); // and that is not enough
|
||||
expect(status.imposter).toBe(true);
|
||||
});
|
||||
|
||||
test('the right program identifies and is present', () => {
|
||||
writeFake('echo "Version: 0.15.1 golang: 1.26.5 go-sdk: v1.2.0"');
|
||||
const status = reportDependencies(plugin({ args: ['--version'], contains: 'go-sdk' })).dependencies[0]!;
|
||||
expect(status.present).toBe(true);
|
||||
expect(status.imposter).toBe(false);
|
||||
});
|
||||
|
||||
test('without an identify rule, presence is taken at face value — running an unknown binary has a cost', () => {
|
||||
writeFake('echo "anything at all"');
|
||||
const status = reportDependencies(plugin()).dependencies[0]!;
|
||||
expect(status.present).toBe(true);
|
||||
expect(status.imposter).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { chmodSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest';
|
||||
|
||||
// Host binaries a plugin needs, and getting them onto the machine.
|
||||
@@ -56,17 +59,81 @@ export type DependencyStatus = {
|
||||
present: boolean;
|
||||
/** What would be installed on THIS machine, or null when this manager has no entry for it. */
|
||||
packageName: string | null;
|
||||
/** Resolved download URL when the plugin ships a release binary, or null. */
|
||||
releaseUrl: string | null;
|
||||
/**
|
||||
* A binary of the right NAME that is the wrong PROGRAM.
|
||||
*
|
||||
* The motivating case: Debian's `tea` is a graphical text editor and Gitea's `tea` is a CLI. Presence
|
||||
* is not identity, and reporting "satisfied" here would be worse than reporting missing — the install
|
||||
* succeeds and every call afterwards fails strangely.
|
||||
*/
|
||||
imposter: boolean;
|
||||
};
|
||||
|
||||
/** `linux`/`darwin` and `amd64`/`arm64` — the spelling Go releases use, which is who publishes this way. */
|
||||
export function hostTarget(): { os: string; arch: string } {
|
||||
const os = process.platform === 'darwin' ? 'darwin' : 'linux';
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
||||
return { os, arch };
|
||||
}
|
||||
|
||||
const releaseUrlFor = (dep: OsDependency): string | null => {
|
||||
if (!dep.release) return null;
|
||||
const { os, arch } = hostTarget();
|
||||
return dep.release.url.replaceAll('{os}', os).replaceAll('{arch}', arch);
|
||||
};
|
||||
|
||||
/**
|
||||
* Is the binary on PATH actually the one meant?
|
||||
*
|
||||
* Only asked when the plugin says how to tell. Running an unknown binary to identify it is a real cost,
|
||||
* so it is opt-in — and the answer is only interesting for dependencies whose name is contested.
|
||||
*/
|
||||
function identifies(dep: OsDependency, path: string): boolean {
|
||||
if (!dep.release?.identify) return true;
|
||||
try {
|
||||
const { args, contains } = dep.release.identify;
|
||||
const out = Bun.spawnSync([path, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const text = `${new TextDecoder().decode(out.stdout)}${new TextDecoder().decode(out.stderr)}`;
|
||||
return text.toLowerCase().includes(contains.toLowerCase());
|
||||
} catch {
|
||||
// Could not run it at all: treat as not-ours rather than assume. A binary that will not execute is
|
||||
// no more useful than a missing one.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to look for a binary.
|
||||
*
|
||||
* `~/.local/bin` FIRST, and explicitly, for two reasons that are both bugs otherwise:
|
||||
*
|
||||
* `Bun.which()` with no options reads the PATH this PROCESS started with. Under PM2 that is a minimal
|
||||
* environment which frequently does not include `~/.local/bin` at all — so a release binary fetched
|
||||
* during an install would be reported missing seconds later by `stillMissing`, and the install would
|
||||
* fail having just succeeded.
|
||||
*
|
||||
* It is also where `installRelease` puts things, so preferring it means the copy the platform installed
|
||||
* wins over a namesake the distribution supplied. `identify` catches the wrong one either way; this
|
||||
* decides which is found first.
|
||||
*/
|
||||
const searchPath = (): string => `${join(homedir(), '.local', 'bin')}:${process.env.PATH ?? ''}`;
|
||||
|
||||
/** Probe one dependency against the live PATH. */
|
||||
function probe(dep: OsDependency, manager: PackageManager | null): DependencyStatus {
|
||||
const path = Bun.which(dep.binary);
|
||||
const path = Bun.which(dep.binary, { PATH: searchPath() });
|
||||
const genuine = path ? identifies(dep, path) : false;
|
||||
return {
|
||||
binary: dep.binary,
|
||||
reason: dep.reason,
|
||||
path,
|
||||
present: !!path,
|
||||
// Present means USABLE. A namesake on PATH is not the dependency, and calling it present is how the
|
||||
// wrong program gets used for a week before anyone works out why.
|
||||
present: !!path && genuine,
|
||||
imposter: !!path && !genuine,
|
||||
packageName: (manager && dep.packages[manager]) || null,
|
||||
releaseUrl: releaseUrlFor(dep),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +179,9 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
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);
|
||||
// A release binary resolves it too, and without a package manager at all — which is the whole point:
|
||||
// the dependencies that need this are the ones no distribution carries.
|
||||
const unresolvable = missing.filter((d) => !d.packageName && !d.releaseUrl);
|
||||
|
||||
// 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.
|
||||
@@ -176,6 +245,47 @@ export function manualInstallHint(manager: PackageManager | null, packages: stri
|
||||
return argv.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a release binary into `~/.local/bin`.
|
||||
*
|
||||
* For the dependency no package manager carries. Not root, not `/usr/local/bin`: a binary a plugin
|
||||
* fetched from the internet has no business sitting beside what the distribution installed, and needing
|
||||
* sudo for it would be a worse trade than needing PATH to include `~/.local/bin` — which it already does
|
||||
* on every machine this platform sets up.
|
||||
*
|
||||
* ── What is checked, and what is not ──
|
||||
*
|
||||
* The download is verified by RUNNING it, through the same `identify` the probe uses. That catches an
|
||||
* HTML error page saved as a binary, a truncated download, and a wrong-architecture build, because none
|
||||
* of them will print what the plugin said to look for.
|
||||
*
|
||||
* It is NOT verified by checksum, and that is a real gap rather than an oversight: a checksum per
|
||||
* os/arch is a table that goes stale on every upstream release, and a plugin that pinned one would stop
|
||||
* installing the day upstream re-cut a tag. HTTPS and a URL the owner can read in the install dialog are
|
||||
* what stands behind this today. Recorded in TODO.md.
|
||||
*/
|
||||
export async function installRelease(dep: DependencyStatus): Promise<{ ok: boolean; path?: string; error?: string }> {
|
||||
if (!dep.releaseUrl) return { ok: false, error: `${dep.binary} ships no release binary` };
|
||||
|
||||
const dir = join(homedir(), '.local', 'bin');
|
||||
const target = join(dir, dep.binary);
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const res = await fetch(dep.releaseUrl, { redirect: 'follow', signal: AbortSignal.timeout(120_000) });
|
||||
if (!res.ok) return { ok: false, error: `${dep.releaseUrl} answered ${res.status}` };
|
||||
|
||||
// Written to a temporary name and moved into place, so a half-downloaded file is never on PATH under
|
||||
// the real name — the failure that leaves a machine looking installed and behaving broken.
|
||||
const tmp = `${target}.download`;
|
||||
await Bun.write(tmp, res);
|
||||
chmodSync(tmp, 0o755);
|
||||
renameSync(tmp, target);
|
||||
return { ok: true, path: target };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallDependenciesResult = { ok: boolean; installed: string[]; output: string; error?: string };
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user