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:
2026-08-19 08:48:18 +00:00
co-authored by Claude Opus 5
parent 51afa40a19
commit 09f2cfa267
5 changed files with 277 additions and 9 deletions
+101 -3
View File
@@ -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);
});
});