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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user