install.test.ts
Rama main · réplica de lectura del repositorio original.
import { spawnSync } from 'node:child_process';
import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
existsSync,
mkdirSync,
chmodSync,
statSync,
symlinkSync,
renameSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
// Tests for the whole installer system: the repo-root `install.sh` shim AND
// the `apps/plugin/install.sh` orchestrator it forwards to. Co-located with the
// root shim (the canonical entry point). Drives the installer through its
// headless (non-interactive) surface — the arrow-key TUI needs a real /dev/tty
// and is verified by the operator (see the rembric-tui-installer-e2e skill).
// Everything runs with REMBRIC_SRC pointed at the repo so the script reads
// artifacts from disk (cp), never the network.
const REPO_ROOT = dirname(fileURLToPath(import.meta.url));
const INSTALL_SH = join(REPO_ROOT, 'apps', 'plugin', 'install.sh');
const ROOT_SHIM = join(REPO_ROOT, 'install.sh');
// The installer reports each agent's "available" plugin version from
// .release-please-manifest.json per component. Derive expectations from the
// same source so a release-please bump doesn't break these tests.
const MANIFEST = JSON.parse(
readFileSync(join(REPO_ROOT, '.release-please-manifest.json'), 'utf8'),
) as Record<string, string>;
const INSTALLER_SRC = readFileSync(INSTALL_SH, 'utf8');
// The regex requires the installer to keep its single-line `CLIENTS='…'` form.
const CLIENTS: string[] = (() => {
const m = /^CLIENTS='([^']+)'$/m.exec(INSTALLER_SRC);
if (!m) throw new Error('apps/plugin/install.sh has no single CLIENTS= definition');
return m[1].split(' ');
})();
// Parsed from the installer's own definition so the expectations cannot drift
// from what the parser accepts. Same single-line requirement as CLIENTS.
const ACTIONS: string[] = (() => {
const m = /^ACTIONS='([^']+)'$/m.exec(INSTALLER_SRC);
if (!m) throw new Error('apps/plugin/install.sh has no single ACTIONS= definition');
return m[1].split(' ');
})();
// The status table's ACTION column prints one of these when the state warrants
// no action at all. Everything else in that column must be an ACTIONS verb.
const NON_RECOMMENDATIONS = ['up to date', 'ahead', '-'];
// Core tools live in /usr/bin:/bin and no client binary does, so a case that
// needs a client to look absent points PATH here.
const CORE_PATH = '/usr/bin:/bin';
// A stub client binary first on PATH, so the real one never runs from the suite
// and installs into the developer's own configuration. The `RAN:` sentinel is
// what distinguishes "executed" from "merely printed".
function fakeClientBinDir(name: 'claude' | 'codex' | 'pi' | 'hermes'): string {
const d = mkdtempSync(join(tmpdir(), 'rembric-fakeclient-'));
writeFileSync(join(d, name), `#!/bin/sh\necho "RAN:${name} $*"\n`);
chmodSync(join(d, name), 0o755);
return d;
}
const PI_STUB_DIR = fakeClientBinDir('pi');
afterAll(() => rmSync(PI_STUB_DIR, { recursive: true, force: true }));
const PLUGIN_VERSION: Record<string, string> = Object.fromEntries(
// All clients ship under the single unified `plugin` release-please component
// (`apps/plugin`) — one shared version (unify-plugin-release-track).
CLIENTS.map((c) => [c, MANIFEST['apps/plugin']]),
);
interface RunOpts {
cwd?: string;
home?: string;
env?: Record<string, string>;
path?: string;
script?: string;
}
function run(args: string[], opts: RunOpts = {}): { code: number; out: string } {
const env: Record<string, string> = {
REMBRIC_SRC: REPO_ROOT,
REMBRIC_NONINTERACTIVE: '1',
REMBRIC_UPDATE_CHECK: 'off', // no GitHub network in tests unless a case opts in
PATH: opts.path ?? process.env.PATH ?? '/usr/bin:/bin',
HOME: opts.home ?? process.env.HOME ?? '/tmp',
...opts.env,
};
const res = spawnSync('/bin/sh', [opts.script ?? INSTALL_SH, ...args], {
cwd: opts.cwd,
env,
encoding: 'utf8',
});
return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
}
let dir: string;
let home: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rembric-cwd-'));
home = mkdtempSync(join(tmpdir(), 'rembric-home-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
describe('argument handling', () => {
it('--help exits 0 and documents the full flag set', () => {
const { code, out } = run(['--help']);
expect(code).toBe(0);
expect(out).toContain('rembric installer');
for (const flag of [
'--server',
'--agent=',
'--action=',
'--status',
'--json',
'--token=',
'--port=',
'--up',
'--ref=',
]) {
expect(out).toContain(flag);
}
for (const c of CLIENTS) expect(out).toContain(c); // the --agent list derives from CLIENTS
});
it('no flags (headless) refuses and exits non-zero', () => {
const { code, out } = run([]);
expect(code).toBe(2);
expect(out).toContain('Interactive:');
});
it('--agent without --action errors', () => {
const { code, out } = run(['--agent=opencode']);
expect(code).toBe(2);
expect(out).toContain('--agent requires --action');
});
it('unknown agent errors', () => {
const { code, out } = run(['--agent=bogus', '--action=install']);
expect(code).toBe(2);
expect(out).toContain('unknown agent');
});
it('an unrecognised --action is refused at parse time, before anything runs', () => {
// --yes is on so the run WOULD execute the client CLI if it got that far.
const opts = { home, path: `${PI_STUB_DIR}:${CORE_PATH}` };
const { code, out } = run(['--agent=pi', '--action=bogus', '--yes'], opts);
expect(code).toBe(2);
expect(out).toContain('invalid --action=bogus');
for (const a of ACTIONS) expect(out).toContain(a); // the error names what is accepted
expect(out).not.toContain('RAN:pi');
expect(out).not.toContain('Next');
// Control: the same invocation with an accepted verb does all three.
const ok = run(['--agent=pi', '--action=install', '--yes'], opts);
expect(ok.code).toBe(0);
expect(ok.out).toContain('RAN:pi');
expect(ok.out).toContain('Next');
});
it('--server refuses an action it has no backend for', () => {
// do_server treats every non-`update` action as install, so accepting
// `uninstall` here would install under an "uninstall" heading.
const { code, out } = run(['--server', '--action=uninstall'], { cwd: dir });
expect(code).toBe(2);
expect(out).toContain('--server accepts --action=install|update');
expect(existsSync(join(dir, '.env'))).toBe(false);
});
});
describe('preflight', () => {
it('aborts listing missing core tools when PATH is empty (remote mode)', () => {
// Empty PATH + no REMBRIC_SRC → curl + core tools missing. command -v is a
// shell builtin so preflight still runs and reports.
const { code, out } = run(['--server'], { path: '/nonexistent', env: { REMBRIC_SRC: '' } });
expect(code).toBe(1);
expect(out).toContain('missing required tool');
});
});
describe('server install', () => {
it('fresh dir: generates a 64-hex token, prepares files, never starts docker', () => {
const { code, out } = run(['--server', '--action=install'], { cwd: dir });
expect(code).toBe(0);
expect(out).toContain('Generated admin token');
expect(existsSync(join(dir, 'docker-compose.yml'))).toBe(true);
const envText = readFileSync(join(dir, '.env'), 'utf8');
const token = /^REMBRIC_ADMIN_TOKEN=([0-9a-f]+)$/m.exec(envText)?.[1];
expect(token).toBeDefined();
expect(token).toHaveLength(64);
// Headless without --up must not bring the stack up.
expect(out).not.toContain('Up.');
});
it('interrupted run (empty token in existing .env) gets filled on re-run', () => {
writeFileSync(join(dir, '.env'), 'REMBRIC_ADMIN_TOKEN=\n# REMBRIC_VERSION=\n');
const { code, out } = run(['--server', '--action=install'], { cwd: dir });
expect(code).toBe(0);
expect(out).toContain('is empty');
const token = /^REMBRIC_ADMIN_TOKEN=([0-9a-f]{64})$/m.exec(
readFileSync(join(dir, '.env'), 'utf8'),
)?.[1];
expect(token).toBeDefined();
});
it('configured .env is left untouched and its token shown', () => {
writeFileSync(join(dir, '.env'), 'REMBRIC_ADMIN_TOKEN=existingtok123\n');
const { code, out } = run(['--server', '--action=install'], { cwd: dir });
expect(code).toBe(0);
expect(out).toContain('already configured');
expect(out).toContain('existingtok123');
expect(readFileSync(join(dir, '.env'), 'utf8')).toContain('REMBRIC_ADMIN_TOKEN=existingtok123');
});
});
describe('server update', () => {
it('with a configured .env: refetches and offers the gated bring-up', () => {
writeFileSync(join(dir, '.env'), 'REMBRIC_ADMIN_TOKEN=tok\n');
const { code, out } = run(['--server', '--action=update'], { cwd: dir });
expect(code).toBe(0);
expect(out).toContain('Refetched');
expect(out).toContain('docker compose pull && docker compose up -d');
});
it('without a .env: refuses to bring up and points to install', () => {
const { code, out } = run(['--server', '--action=update'], { cwd: dir });
expect(code).toBe(0);
expect(out).toContain('No ./.env');
expect(out).toContain('install first');
});
});
function nativeFixture(version = '0.0.1'): void {
const plugins = join(home, '.hermes', 'plugins');
mkdirSync(join(plugins, 'rembric'), { recursive: true });
writeFileSync(join(plugins, 'rembric', 'plugin.yaml'), `name: rembric\nversion: ${version}\n`);
writeFileSync(
join(plugins, '.install-metadata.json'),
JSON.stringify({
rembric: {
source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
pinned: false,
revision: '2e8cca2dc3a2ed23ee193bf1cfeb68ea9b6b7590',
},
}),
);
}
function nativeRun(action: string, yes = true, failure = false, absent = false) {
const bin = join(home, 'bin');
mkdirSync(bin, { recursive: true });
if (!absent) {
writeFileSync(
join(bin, 'hermes'),
`#!/bin/sh\necho "RAN:hermes $*"\nexit ${failure ? 7 : 0}\n`,
);
chmodSync(join(bin, 'hermes'), 0o755);
}
writeFileSync(
join(bin, 'curl'),
`#!/bin/sh\nfor arg do case "$arg" in https://raw.githubusercontent.com/susomejias/rembric/main/*) rel="\u0024{arg#https://raw.githubusercontent.com/susomejias/rembric/main/}" ;; esac; done\nwhile [ "$#" -gt 0 ]; do if [ "$1" = -o ]; then cp '${REPO_ROOT}/'"$rel" "$2"; exit; fi; shift; done\nexit 91\n`,
);
chmodSync(join(bin, 'curl'), 0o755);
return run(['--agent=hermes', `--action=${action}`, ...(yes ? ['--yes'] : [])], {
home,
path: `${bin}:${absent ? CORE_PATH : process.env.PATH}`,
env: { REMBRIC_SRC: '' },
});
}
describe('hermes native distribution', () => {
it('prints without consent and executes native install only with --yes', () => {
const printed = nativeRun('install', false);
expect(printed.code).toBe(0);
expect(printed.out).toContain(
'hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
);
expect(printed.out).not.toContain('RAN:hermes');
expect(existsSync(join(home, '.hermes'))).toBe(false);
const executed = nativeRun('install');
expect(executed.code).toBe(0);
expect(executed.out).toContain(
'RAN:hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
);
expect(executed.out).not.toContain('--force');
expect(executed.out).not.toContain('--yes-deps');
});
it.each(['update', 'uninstall'])(
'executes the native %s verb for a source-tracked install',
(action) => {
nativeFixture();
const result = nativeRun(action);
expect(result.code).toBe(0);
expect(result.out).toContain(
`RAN:hermes plugins ${action === 'uninstall' ? 'remove' : 'update'} rembric`,
);
},
);
it('propagates native failure without repair or restart claims', () => {
nativeFixture();
const config = join(home, '.hermes', 'config.yaml');
const incomplete =
"mcp_servers:\n rembric:\n command: npx\n args: ['-y', '@rembric/mcp-bridge@0.29.1']\n";
writeFileSync(config, incomplete);
const result = nativeRun('update', true, true);
expect(readFileSync(config, 'utf8')).toBe(incomplete);
expect(existsSync(`${config}.rembric-mcp-env.bak`)).toBe(false);
expect(result.code).not.toBe(0);
expect(result.out).toContain('RAN:hermes plugins update rembric');
expect(result.out).not.toContain('gateway restart');
expect(result.out).not.toContain('migrated');
});
it('does not execute with an absent binary even under --yes', () => {
const inherited = join(home, 'inherited');
mkdirSync(inherited);
writeFileSync(join(inherited, 'hermes'), '#!/bin/sh\necho RAN:inherited-hermes\nexit 99\n');
chmodSync(join(inherited, 'hermes'), 0o755);
const previousPath = process.env.PATH;
process.env.PATH = `${inherited}:${previousPath}`;
try {
const result = nativeRun('install', true, false, true);
expect(result.code).toBe(0);
expect(result.out).toContain(
'hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
);
expect(result.out).not.toContain('RAN:');
expect(existsSync(join(home, '.hermes'))).toBe(false);
} finally {
process.env.PATH = previousPath;
}
});
it('rejects copied and invalid metadata without touching unknown files', () => {
nativeFixture();
const plugins = join(home, '.hermes', 'plugins');
const unknown = join(plugins, 'rembric', 'operator-file');
writeFileSync(unknown, 'keep');
for (const metadata of [
'{}',
'{',
JSON.stringify({ rembric: { source: 'foreign', pinned: false, revision: 'a'.repeat(40) } }),
JSON.stringify({
rembric: {
source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
pinned: true,
revision: 'a'.repeat(40),
},
}),
]) {
writeFileSync(join(plugins, '.install-metadata.json'), metadata);
for (const action of ACTIONS) {
const result = nativeRun(action);
expect(result.code).not.toBe(0);
expect(result.out).not.toContain('RAN:hermes');
expect(result.out).toContain('backup');
expect(readFileSync(unknown, 'utf8')).toBe('keep');
expect(readFileSync(join(plugins, '.install-metadata.json'), 'utf8')).toBe(metadata);
}
}
});
it.each([
{
source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
revision: 'short',
pinned: false,
},
{
source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
revision: 'a'.repeat(40),
},
{
source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
revision: 42,
pinned: false,
},
null,
])('rejects incomplete metadata: %j', (record) => {
nativeFixture();
writeFileSync(
join(home, '.hermes', 'plugins', '.install-metadata.json'),
JSON.stringify({ rembric: record }),
);
const result = nativeRun('uninstall');
expect(result.code).not.toBe(0);
expect(result.out).not.toContain('RAN:hermes');
});
it.each([
'linked-target',
'linked-metadata',
'missing-target',
'missing-manifest',
'non-object-metadata',
])('refuses %s without touching the existing installation', (state) => {
nativeFixture();
const plugins = join(home, '.hermes', 'plugins');
const target = join(plugins, 'rembric');
const metadata = join(plugins, '.install-metadata.json');
if (state === 'linked-target' || state === 'missing-target') {
renameSync(target, join(home, 'preserved'));
if (state === 'linked-target') symlinkSync(join(home, 'preserved'), target);
} else if (state === 'linked-metadata') {
renameSync(metadata, join(home, 'metadata'));
symlinkSync(join(home, 'metadata'), metadata);
} else if (state === 'missing-manifest') {
renameSync(join(target, 'plugin.yaml'), join(target, 'saved-manifest'));
} else writeFileSync(metadata, '[]');
const result = nativeRun('uninstall');
expect(result.code).not.toBe(0);
expect(result.out).not.toContain('RAN:hermes');
expect(
existsSync(
state === 'linked-target' || state === 'missing-target'
? join(home, 'preserved', 'plugin.yaml')
: target,
),
).toBe(true);
});
it('a repeated install of a managed source is a no-op, never a force reinstall', () => {
nativeFixture();
const result = nativeRun('install');
expect(result.code).toBe(0);
expect(result.out).toContain('already installed');
expect(result.out).not.toContain('RAN:hermes');
});
it('already-absent uninstall is a successful no-op', () => {
const result = nativeRun('uninstall');
expect(result.code).toBe(0);
expect(result.out).toContain('already absent');
expect(result.out).not.toContain('RAN:hermes');
});
it.each([
{ args: [], env: { REMBRIC_SRC: REPO_ROOT } },
{ args: ['--ref=main'], env: { REMBRIC_SRC: '' } },
{ args: [], env: { REMBRIC_SRC: '', REMBRIC_REF: 'main' } },
{ args: [], env: { REMBRIC_SRC: '', PLUGIN_SRC: '/unused' } },
])(
'rejects unsupported source overrides before mixed-operation mutation: %j',
({ args, env }) => {
nativeRun('install', false);
const result = run(['--server', '--agent=opencode,hermes', '--action=install', ...args], {
home,
cwd: dir,
env,
});
expect(result.code).not.toBe(0);
expect(result.out).toContain('Hermes supports only the default native source');
expect(existsSync(join(dir, 'docker-compose.yml'))).toBe(false);
expect(existsSync(join(home, '.config'))).toBe(false);
},
);
it.each([
{ args: ['--ref=main'], env: {} },
{ args: [], env: { PLUGIN_SRC: '/unused' } },
{ args: [], env: { REMBRIC_SRC: REPO_ROOT } },
{ args: [], env: { REMBRIC_REF: 'main' } },
])('direct native adapter rejects unsupported input: %j', ({ args, env }) => {
nativeRun('install', false);
const result = run(args, {
home,
script: join(REPO_ROOT, 'apps/plugin/.hermes-plugin/install.sh'),
path: `${home}/bin:${process.env.PATH}`,
env: { REMBRIC_SRC: '', ...env },
});
expect(result.code).toBe(2);
expect(result.out).toContain('default native source');
expect(result.out).not.toContain('RAN:hermes');
expect(existsSync(join(home, '.hermes'))).toBe(false);
});
it('reports update status unknown and skips native Hermes in update-all', () => {
nativeFixture();
const result = run(['--status', '--json'], { home, path: CORE_PATH });
const hermes = JSON.parse(result.out).agents.find(
(a: { agent: string }) => a.agent === 'hermes',
);
expect(hermes.present).toBe(false);
expect(hermes.installed).toBe('0.0.1');
expect(hermes.action).toBe('unknown');
nativeRun('install', false);
const detected = run(['--status', '--json'], { home, path: `${home}/bin:${process.env.PATH}` });
expect(
JSON.parse(detected.out).agents.find((a: { agent: string }) => a.agent === 'hermes').present,
).toBe(true);
const updated = run(['--action=update', '--yes'], {
home,
path: `${home}/bin:${process.env.PATH}`,
});
expect(updated.code).toBe(0);
expect(updated.out).toContain('hermes: version unknown');
expect(updated.out).not.toContain('hermes (update)');
});
});
describe('agent routing', () => {
it('codex install prints marketplace commands, copies nothing', () => {
const { code, out } = run(['--agent=codex', '--action=install'], { home });
expect(code).toBe(0);
expect(out).toContain('codex plugin marketplace add');
expect(out).toContain('codex plugin add rembric@rembric');
});
it('claude uninstall prints the marketplace command and the conservative note', () => {
const { out } = run(['--agent=claude', '--action=uninstall'], { home });
expect(out).toContain('claude plugin uninstall rembric@rembric');
expect(out).toContain('Left in place');
});
it('claude/codex update use their real upgrade commands, not re-install', () => {
const claude = run(['--agent=claude', '--action=update'], { home });
expect(claude.code).toBe(0);
expect(claude.out).toContain('claude plugin update rembric@rembric');
expect(claude.out).not.toContain('claude plugin install'); // update ≠ re-install
expect(claude.out).not.toContain('marketplace add'); // marketplace already added
const codex = run(['--agent=codex', '--action=update'], { home });
expect(codex.code).toBe(0);
expect(codex.out).toContain('codex plugin marketplace upgrade rembric');
expect(codex.out).not.toContain('codex plugin install'); // no such subcommand in the Codex CLI
});
it('a comma-separated --agent list drives multiple agents in one run', () => {
const { code, out } = run(['--agent=codex,claude', '--action=install'], { home });
expect(code).toBe(0);
expect(out).toContain('codex plugin add rembric@rembric');
expect(out).toContain('claude plugin install rembric@rembric');
});
it('install surfaces the required post-install steps per agent', () => {
const codex = run(['--agent=codex', '--action=install'], { home });
expect(codex.out).not.toContain('plugin_hooks'); // flag removed upstream in codex-cli 0.142.3+
expect(codex.out).toContain('/hooks');
const hermes = nativeRun('install', false);
expect(hermes.out).toContain(
'hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
);
expect(hermes.out).toContain('memory.provider');
expect(hermes.out).not.toContain('hermes plugins enable rembric');
expect(hermes.out).toContain('hermes gateway restart');
});
it('hermes update explains the automatic legacy migration and exact fallback before restart', () => {
const { code, out } = nativeRun('update', false);
expect(code).toBe(0);
expect(out).toContain(`args: ['-y', '@rembric/mcp-bridge@${PLUGIN_VERSION.hermes}']`);
expect(out).toContain('REMBRIC_SERVER_URL: ${REMBRIC_SERVER_URL}');
expect(out).toContain('enabled: true');
expect(out).toContain('repairs recognized Rembric MCP blocks');
expect(out).toContain('writes a backup before changing config');
expect(out).toContain('hermes gateway restart');
expect(out).not.toContain('hermes plugins install rembric');
});
it('hermes update migrates only the documented legacy MCP block and preserves a backup', () => {
const hermesHome = join(home, '.hermes');
mkdirSync(hermesHome, { recursive: true });
const config = join(hermesHome, 'config.yaml');
const legacy = `mcp_servers:\n rembric:\n command: npx\n args:\n [\n '-y',\n 'mcp-remote@latest',\n '\${REMBRIC_SERVER_URL}/mcp/\${REMBRIC_PROJECT_SLUG}',\n '--header',\n 'Authorization: Bearer \${REMBRIC_API_TOKEN}',\n '--allow-http',\n ]\n other:\n command: other-mcp\n`;
writeFileSync(config, legacy);
nativeFixture(PLUGIN_VERSION.hermes);
const { code, out } = nativeRun('update');
expect(code).toBe(0);
expect(readFileSync(join(hermesHome, 'config.yaml.rembric-mcp-remote.bak'), 'utf8')).toBe(
legacy,
);
expect(out).toContain('migrated mcp_servers.rembric');
expect(readFileSync(config, 'utf8')).toBe(
`mcp_servers:\n rembric:\n command: npx\n args: ['-y', '@rembric/mcp-bridge@${PLUGIN_VERSION.hermes}']\n env:\n REMBRIC_SERVER_URL: \${REMBRIC_SERVER_URL}\n REMBRIC_API_TOKEN: \${REMBRIC_API_TOKEN}\n REMBRIC_PROJECT_SLUG: \${REMBRIC_PROJECT_SLUG}\n enabled: true\n other:\n command: other-mcp\n`,
);
});
it('hermes update repairs only the incomplete bridge block emitted by the prior updater', () => {
const hermesHome = join(home, '.hermes');
mkdirSync(hermesHome, { recursive: true });
const config = join(hermesHome, 'config.yaml');
const incomplete = `mcp_servers:\n rembric:\n command: npx\n args: ['-y', '@rembric/mcp-bridge@0.29.1']\n`;
writeFileSync(config, incomplete);
nativeFixture(PLUGIN_VERSION.hermes);
expect(nativeRun('update').code).toBe(0);
expect(readFileSync(join(hermesHome, 'config.yaml.rembric-mcp-env.bak'), 'utf8')).toBe(
incomplete,
);
expect(readFileSync(config, 'utf8')).toContain('REMBRIC_API_TOKEN: ${REMBRIC_API_TOKEN}');
expect(readFileSync(config, 'utf8')).toContain('enabled: true');
});
it('hermes update preserves canonical and custom bridge blocks', () => {
const hermesHome = join(home, '.hermes');
mkdirSync(hermesHome, { recursive: true });
const config = join(hermesHome, 'config.yaml');
const custom = `mcp_servers:\n rembric:\n command: wrapper\n args: ['rembric']\n`;
writeFileSync(config, custom);
nativeFixture(PLUGIN_VERSION.hermes);
expect(nativeRun('update').code).toBe(0);
expect(readFileSync(config, 'utf8')).toBe(custom);
expect(existsSync(join(hermesHome, 'config.yaml.rembric-mcp-env.bak'))).toBe(false);
});
it('opencode/claude update notes drop the install-only wiring (just restart)', () => {
const opencode = run(['--agent=opencode', '--action=update'], { home });
expect(opencode.code).toBe(0);
expect(opencode.out).toContain('restart opencode');
expect(opencode.out).not.toContain('paste the printed MCP block'); // install-only
const claude = run(['--agent=claude', '--action=update'], { home });
expect(claude.code).toBe(0);
expect(claude.out).toContain('restart Claude Code');
expect(claude.out).not.toContain('prompts for the server URL'); // install-only
});
it('uninstall does not print post-install "Next" steps', () => {
const { out } = run(['--agent=codex', '--action=uninstall'], { home });
expect(out).not.toContain('Next');
});
it('opencode install then uninstall round-trips against a throwaway HOME', () => {
const installed = join(home, '.config', 'opencode', 'plugins', 'rembric.ts');
const ins = run(['--agent=opencode', '--action=install'], { home });
expect(ins.code).toBe(0);
expect(existsSync(installed)).toBe(true);
// The version comment survives the install rewrite (used for detection).
expect(readFileSync(installed, 'utf8')).toMatch(/@rembric-plugin-version\s+\d+\.\d+\.\d+/);
const un = run(['--agent=opencode', '--action=uninstall'], { home });
expect(un.code).toBe(0);
expect(existsSync(installed)).toBe(false);
expect(un.out).toContain('Left in place');
});
});
describe('--action=update with no --agent (update-all)', () => {
// #262 / memory.about's `update_all` command: before this feature, a bare
// `--action=update` errored ("--agent requires --action" is backwards —
// actually it fell through to the usage error because ARG_AGENTS was
// empty). This section proves the fix: it updates only what has an update
// available and never errors.
function ageOpencodePlugin(version: string): void {
const dir = join(home, '.config', 'opencode', 'plugins');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'rembric.ts'),
`// @rembric-plugin-version ${version}\nimport { readRembricSlug } from './lib/rembric-dotenv.mjs';\nexport const RembricPlugin = () => ({});\n`,
);
}
it('updates only the installed-and-outdated agent, skips the rest, never errors', () => {
ageOpencodePlugin('0.0.1'); // older than PLUGIN_VERSION.opencode from the manifest
const { code, out } = run(['--action=update'], { home });
expect(code).toBe(0);
expect(out).toContain('Updating all plugins with an update available');
// opencode was outdated → updated (its update note, not the install-only one).
expect(out).toContain('restart opencode');
expect(out).not.toContain('paste the printed MCP block');
// claude/codex/hermes were never installed → skipped, not errored.
expect(out).toContain('claude: not installed — skipped');
expect(out).toContain('codex: not installed — skipped');
expect(out).toContain('hermes: not installed — skipped');
expect(out).toContain(`Done: 1 updated, ${CLIENTS.length - 1} skipped.`);
});
it('--agent=all --action=update is an explicit alias for the same behavior', () => {
ageOpencodePlugin('0.0.1');
const { code, out } = run(['--agent=all', '--action=update'], { home });
expect(code).toBe(0);
expect(out).toContain(`Done: 1 updated, ${CLIENTS.length - 1} skipped.`);
});
it('an up-to-date agent is skipped as "up to date", not re-updated', () => {
ageOpencodePlugin(PLUGIN_VERSION.opencode); // matches the manifest exactly
const { code, out } = run(['--action=update'], { home });
expect(code).toBe(0);
expect(out).toContain('opencode: up to date — skipped');
expect(out).toContain(`Done: 0 updated, ${CLIENTS.length} skipped.`);
});
});
describe('the client set has a single definition every surface agrees with', () => {
it('is exactly the five supported clients, in a stable order', () => {
// Non-vacuity control for every assertion below that derives from CLIENTS.
expect(CLIENTS).toEqual(['claude', 'codex', 'hermes', 'opencode', 'pi']);
});
it('no second line in the installer enumerates the client set', () => {
const enumerating = INSTALLER_SRC.split('\n').filter(
(line) => CLIENTS.filter((c) => new RegExp(`\\b${c}\\b`).test(line)).length >= 4,
);
expect(enumerating).toEqual([`CLIENTS='${CLIENTS.join(' ')}'`]);
});
it('the interactive agent menu and its index mapping both derive from it', () => {
// The arrow-key menu needs a real /dev/tty, so this is asserted at the source.
expect(INSTALLER_SRC).toContain('arrow_menu "Which agent?" "all — update outdated" $CLIENTS');
expect(INSTALLER_SRC).toContain('c=$(client_at "$MENU_INDEX")');
});
it('--status --json emits one entry per client, in the same order', () => {
const { code, out } = run(['--status', '--json'], { home });
expect(code).toBe(0);
expect(JSON.parse(out).agents.map((a: { agent: string }) => a.agent)).toEqual(CLIENTS);
});
it('update-all accounts for every client in the set', () => {
const { code, out } = run(['--action=update'], { home });
expect(code).toBe(0);
for (const c of CLIENTS) expect(out).toMatch(new RegExp(`^ ${c}: `, 'm'));
expect(out).toContain(`Done: 0 updated, ${CLIENTS.length} skipped.`);
});
it.each(CLIENTS)('--agent=%s is routed to a backend and has post-install steps', (client) => {
const { code, out } =
client === 'hermes'
? nativeRun('install', false)
: run([`--agent=${client}`, '--action=install'], {
home,
path: `${PI_STUB_DIR}:${process.env.PATH ?? '/usr/bin:/bin'}`,
});
expect(code).toBe(0);
expect(out).not.toContain('unknown agent');
expect(out).toContain(`${client} (install)`);
// A client with no post_install_notes arm installs and says nothing about the
// wiring it still needs.
expect(out).toContain('Next');
});
it('every client has a presence adapter (an unmatched case would report present)', () => {
// Every row must read absent here, and a client missing from client_present's
// `case` falls through to an empty arm, which exits 0 — i.e. reports present.
const { out } = run(['--status', '--json'], { home, path: '/usr/bin:/bin' });
const agents = JSON.parse(out).agents as { agent: string; present: boolean }[];
expect(agents.map((a) => a.present)).toEqual(CLIENTS.map(() => false));
});
});
describe('the ACTION column recommends only actions --action accepts', () => {
// The three surfaces derived from client_state print different things: the
// table prints the recommended VERB, `--status --json` the detected STATE, and
// update-all the verb it would take.
function tableRows(out: string): { agent: string; action: string }[] {
return out
.split('\n')
.map((line) => line.split(/\s{2,}/).filter(Boolean))
.filter((cells) => cells.length === 5 && CLIENTS.includes(cells[0]))
.map((cells) => ({ agent: cells[0], action: cells[4] }));
}
function writeOpencodePlugin(version: string): void {
const d = join(home, '.config', 'opencode', 'plugins');
mkdirSync(d, { recursive: true });
writeFileSync(join(d, 'rembric.ts'), `// @rembric-plugin-version ${version}\n`);
}
it('no cell in any detectable state is outside the accepted verbs', () => {
const withPi = { home, path: `${PI_STUB_DIR}:${CORE_PATH}` };
// One scenario per state that reaches the column: not installed (install),
// installed-and-old (update), installed-and-current (up to date), ahead of
// the published version, and present-but-unreadable (the pi row).
const scenarios = [
() => undefined,
() => writeOpencodePlugin('0.0.1'),
() => writeOpencodePlugin(PLUGIN_VERSION.opencode),
() => writeOpencodePlugin('99.0.0'),
];
const seen = new Set<string>();
for (const setup of scenarios) {
setup();
const rows = tableRows(run(['--status'], withPi).out);
// Non-vacuity: an unparsed table would make every assertion below empty.
expect(rows.map((r) => r.agent)).toEqual(CLIENTS);
for (const row of rows) {
expect([...ACTIONS, ...NON_RECOMMENDATIONS]).toContain(row.action);
seen.add(row.action);
}
}
// …and the scenarios really did exercise more than one outcome.
expect(seen.size).toBeGreaterThan(2);
});
it('following the recommendation the table prints resolves to a real action', () => {
const withPi = { home, path: `${PI_STUB_DIR}:${CORE_PATH}` };
const pi = tableRows(run(['--status'], withPi).out).find((r) => r.agent === 'pi');
expect(pi).toBeDefined();
expect(ACTIONS).toContain(pi!.action);
const followed = run(['--agent=pi', `--action=${pi!.action}`, '--yes'], withPi);
expect(followed.code).toBe(0);
expect(followed.out).toContain('RAN:pi');
expect(followed.out).not.toMatch(/invalid --action|parameter not set|unsupported action/);
});
it.each(ACTIONS)(
'--yes with --action=%s reaches a real command, never an unset one',
(action) => {
const { code, out } = run(['--agent=pi', `--action=${action}`, '--yes'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
});
expect(code).toBe(0);
expect(out).not.toMatch(/parameter not set|unbound variable|unsupported action/);
expect(out).toContain('RAN:pi');
},
);
it("the CLI backend's action table has one arm per verb and fails closed", () => {
// Unreachable from the CLI while the parser refuses unknown verbs, so it is
// asserted at the source: an unmatched POSIX `case` exits 0, silently.
const block = /client_cli_cmds\(\)[\s\S]*?\n {2}case "\$action" in\n([\s\S]*?)\n {2}esac/.exec(
INSTALLER_SRC,
);
expect(block).not.toBeNull();
const arms = [...block![1].matchAll(/^[ \t]*([a-z]+|\*)\)/gm)].map((m) => m[1]);
expect(arms).toContain('*');
expect(arms.filter((a) => a !== '*').sort()).toEqual([...ACTIONS].sort());
});
it('update-all names a verb the parser accepts when it declines to act', () => {
const { code, out } = run(['--action=update', '--yes'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
});
expect(code).toBe(0);
const hint = /pi: version unknown — skipped \(use --agent=pi --action=(\S+) to force\)/.exec(
out,
);
expect(hint).not.toBeNull();
expect(ACTIONS).toContain(hint![1]);
});
});
describe('pi (registry-CLI backend)', () => {
const piAgent = (root: string): string => join(root, 'npm', 'node_modules', '@rembric', 'pi');
function installedFixture(agentDir: string, version: string): void {
const pkg = piAgent(agentDir);
mkdirSync(pkg, { recursive: true });
writeFileSync(
join(pkg, 'package.json'),
`${JSON.stringify({ name: '@rembric/pi', version }, null, 2)}\n`,
);
}
function piRow(out: string): string {
return out.split('\n').find((l) => /^ {2}pi\s/.test(l)) ?? '';
}
it('has no repo-side install or uninstall script', () => {
const piPlugin = join(REPO_ROOT, 'apps', 'plugin', '.pi-plugin');
expect(existsSync(join(piPlugin, 'install.sh'))).toBe(false);
expect(existsSync(join(piPlugin, 'uninstall.sh'))).toBe(false);
});
it('install and update print the SAME unpinned command, even under --ref', () => {
const spec = 'pi install npm:@rembric/pi';
const ins = run(['--agent=pi', '--action=install'], { home, path: CORE_PATH });
const upd = run(['--agent=pi', '--action=update'], { home, path: CORE_PATH });
const pinned = run(['--agent=pi', '--action=install', '--ref=v9.9.9'], {
home,
path: CORE_PATH,
});
expect(ins.code).toBe(0);
expect(upd.code).toBe(0);
expect(pinned.code).toBe(0);
for (const { out } of [ins, upd, pinned]) {
expect(out).toContain(spec);
// A version-pinned spec is skipped by `pi update`, freezing the operator
// while reporting success.
expect(out).not.toMatch(/@rembric\/pi@/);
}
// --ref names a git ref; this artifact comes from the registry.
expect(pinned.out).not.toContain('9.9.9');
});
it("uninstall routes to the client's own removal verb and keeps credentials", () => {
const { code, out } = run(['--agent=pi', '--action=uninstall'], { home, path: CORE_PATH });
expect(code).toBe(0);
expect(out).toContain('pi remove npm:@rembric/pi');
expect(out).toContain('Left in place');
expect(out).not.toContain('Next');
});
it('install prints the shell-environment step and offers no settings-file alternative', () => {
const { out } = run(['--agent=pi', '--action=install'], { home, path: CORE_PATH });
expect(out).toContain('REMBRIC_SERVER_URL');
expect(out).toContain('REMBRIC_API_TOKEN');
// Pi reads no environment from its settings file, so there is no
// settings-file alternative to offer.
expect(out).not.toMatch(/settings/i);
});
it('update prints only the restart, not the install-only credential step', () => {
const { out } = run(['--agent=pi', '--action=update'], { home, path: CORE_PATH });
expect(out).toContain('restart Pi');
expect(out).not.toContain('REMBRIC_API_TOKEN');
});
it('--yes runs the registry command when the binary is present, and nothing when absent', () => {
const present = run(['--agent=pi', '--action=install', '--yes'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
});
expect(present.code).toBe(0);
expect(present.out).toContain('RAN:pi install npm:@rembric/pi');
const noFlag = run(['--agent=pi', '--action=install'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
});
expect(noFlag.out).toContain('pi install npm:@rembric/pi'); // printed
expect(noFlag.out).not.toContain('RAN:pi'); // never executed
const absent = run(['--agent=pi', '--action=install', '--yes'], { home, path: CORE_PATH });
expect(absent.out).toContain('pi install npm:@rembric/pi');
expect(absent.out).not.toContain('RAN:pi');
});
describe('installed-version detection', () => {
// A user-scope `pi install npm:<pkg>` leaves the manifest under
// <agentDir>/npm/node_modules/; every other install vector leaves no version
// on disk at all.
it('reads the version from the deterministic location under PI_CODING_AGENT_DIR', () => {
const agentDir = join(home, 'piagent');
installedFixture(agentDir, '0.0.1');
const { out } = run(['--status'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(piRow(out)).toContain('0.0.1');
expect(piRow(out)).toContain('update');
installedFixture(agentDir, PLUGIN_VERSION.pi);
const current = run(['--status'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(piRow(current.out)).toContain('up to date');
});
it('defaults to ~/.pi/agent when PI_CODING_AGENT_DIR is unset', () => {
installedFixture(join(home, '.pi', 'agent'), '0.0.2');
const { out } = run(['--status'], { home, path: `${PI_STUB_DIR}:${CORE_PATH}` });
expect(piRow(out)).toContain('0.0.2');
});
it('reads a prerelease version whole, not truncated to its release core', () => {
// The digits-only extraction the other four adapters use returns empty or a
// truncated `9.9.9` here, so vercmp would compare a version nobody has.
const agentDir = join(home, 'piagent');
installedFixture(agentDir, '9.9.9-rc.1');
const { out } = run(['--status'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(piRow(out)).toContain('9.9.9-rc.1');
});
it('renders unknown + the idempotent install verb when no version is on disk', () => {
const { code, out } = run(['--status'], { home, path: `${PI_STUB_DIR}:${CORE_PATH}` });
expect(code).toBe(0);
expect(piRow(out)).toContain('unknown');
const action = piRow(out)
.trim()
.split(/\s{2,}/)
.at(-1);
expect(action).toBe('install');
expect(ACTIONS).toContain(action);
// An unreadable version is neither determinate state, so the table must
// claim neither.
expect(piRow(out)).not.toContain('up to date');
expect(piRow(out)).not.toContain('update');
});
it('--status --json carries a null version and an unknown action', () => {
const { out } = run(['--status', '--json'], { home, path: `${PI_STUB_DIR}:${CORE_PATH}` });
const pi = JSON.parse(out).agents.find((a: { agent: string }) => a.agent === 'pi');
expect(pi.present).toBe(true);
expect(pi.installed).toBeNull(); // a semver or null, never a marker string
expect(pi.action).toBe('unknown');
});
});
describe('update-all', () => {
it('skips an unknown row with unknown as the reason, exits 0, and runs nothing', () => {
const { code, out } = run(['--action=update', '--yes'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
});
expect(code).toBe(0);
expect(out).toContain('pi: version unknown — skipped');
// Unattended, so reinstalling on ignorance would act on every run.
expect(out).not.toContain('RAN:pi');
});
it('control — the same command DOES update it when the version is readable and old', () => {
const agentDir = join(home, 'piagent');
installedFixture(agentDir, '0.0.1');
const { code, out } = run(['--action=update', '--yes'], {
home,
path: `${PI_STUB_DIR}:${CORE_PATH}`,
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(code).toBe(0);
expect(out).toContain('RAN:pi install npm:@rembric/pi');
expect(out).not.toContain('pi: version unknown');
});
});
});
describe('opencode installer verifications', () => {
const OPENCODE_INSTALL = join(REPO_ROOT, 'apps', 'plugin', '.opencode-plugin', 'install.sh');
it('is idempotent: a second install produces byte-identical files', () => {
const files = [
join(home, '.config', 'opencode', 'plugins', 'rembric.ts'),
join(home, '.config', 'rembric', 'bin', 'rembric-dotenv.mjs'),
join(home, '.config', 'rembric', 'bin', 'rembric-plugin-core.mjs'),
];
const first = run(['--agent=opencode', '--action=install'], { home });
expect(first.code).toBe(0);
const snapshot = files.map((f) => readFileSync(f, 'utf8'));
expect(existsSync(join(home, '.config', 'opencode', 'opencode.json'))).toBe(false);
expect(first.out).toContain('adds the MCP entry in memory');
const second = run(['--agent=opencode', '--action=install'], { home });
expect(second.code).toBe(0);
expect(second.out).toContain('left untouched');
expect(files.map((f) => readFileSync(f, 'utf8'))).toEqual(snapshot);
});
it('leaves an existing opencode.json byte-identical', () => {
const cfgDir = join(home, '.config', 'opencode');
mkdirSync(cfgDir, { recursive: true });
const configPath = join(cfgDir, 'opencode.json');
const oldConfig = JSON.stringify(
{
mcp: {
rembric: {
type: 'local',
command: ['node', '/home/user/.config/rembric/bin/rembric-bridge.mjs'],
environment: { REMBRIC_SERVER_URL: 'old-url', REMBRIC_API_TOKEN: 'old-token' },
},
},
operatorSetting: 'leave-me-alone',
},
null,
2,
);
writeFileSync(configPath, oldConfig);
const result = run(['--agent=opencode', '--action=update'], { home });
expect(result.code).toBe(0);
expect(readFileSync(configPath, 'utf8')).toBe(oldConfig);
expect(result.out).toContain('left untouched');
expect(result.out).toContain('adds the MCP entry in memory');
});
it('forwards the local bridge source and keeps the public ref source wired', () => {
const noNetwork = mkdtempSync(join(tmpdir(), 'rembric-no-network-'));
writeFileSync(join(noNetwork, 'curl'), '#!/bin/sh\nexit 91\n');
chmodSync(join(noNetwork, 'curl'), 0o755);
const result = run(['--agent=opencode', '--action=install'], {
home,
path: `${noNetwork}:${CORE_PATH}`,
});
rmSync(noNetwork, { recursive: true, force: true });
expect(result.code).toBe(0);
expect(INSTALLER_SRC).toContain('MCP_BRIDGE_SRC="$REMBRIC_SRC/apps/plugin/mcp-bridge"');
expect(INSTALLER_SRC).toContain('MCP_BRIDGE_SRC="$base/mcp-bridge"');
});
it('an unrelated "rembric" string elsewhere in opencode.json is NOT treated as configured', () => {
const cfgDir = join(home, '.config', 'opencode');
mkdirSync(cfgDir, { recursive: true });
const cfg = JSON.stringify({ mcp: { 'rembric-foo': { type: 'local' } }, theme: 'rembric' });
writeFileSync(join(cfgDir, 'opencode.json'), cfg);
const { code, out } = run(['--agent=opencode', '--action=install'], { home });
expect(code).toBe(0);
expect(out).toContain('left untouched');
expect(readFileSync(join(cfgDir, 'opencode.json'), 'utf8')).toBe(cfg); // untouched
});
it('a real mcp.rembric entry is detected as already configured', () => {
const cfgDir = join(home, '.config', 'opencode');
mkdirSync(cfgDir, { recursive: true });
const cfg = JSON.stringify({ mcp: { rembric: { type: 'local', enabled: true } } });
writeFileSync(join(cfgDir, 'opencode.json'), cfg);
const { out } = run(['--agent=opencode', '--action=install'], { home });
expect(out).toContain('left untouched');
expect(readFileSync(join(cfgDir, 'opencode.json'), 'utf8')).toBe(cfg);
});
it('aborts loudly and removes the partial plugin when the import rewrite no-ops', () => {
const drift = mkdtempSync(join(tmpdir(), 'rembric-drift-'));
writeFileSync(
join(drift, 'plugin.ts'),
`// @rembric-plugin-version 0.0.0\nimport { readRembricSlug } from './lib/rembric-dotenv.mjs';\nexport const RembricPlugin = () => ({});\n`,
);
const { code, out } = run([], {
home,
script: OPENCODE_INSTALL,
env: {
PLUGIN_SRC: drift,
BIN_SRC: join(REPO_ROOT, 'apps', 'plugin', 'bin'),
MCP_BRIDGE_SRC: join(REPO_ROOT, 'apps', 'plugin', 'mcp-bridge'),
},
});
rmSync(drift, { recursive: true, force: true });
expect(code).toBe(1);
expect(out).toContain('rewrite failed');
expect(existsSync(join(home, '.config', 'opencode', 'plugins', 'rembric.ts'))).toBe(false);
});
it.each([
{ label: 'core', good: 'rembric-dotenv.mjs', drifted: 'rembric-plugin-core.mjs' },
{ label: 'dotenv', good: 'rembric-plugin-core.mjs', drifted: 'rembric-dotenv.mjs' },
])('a drifted $label import aborts even though the other rewrite succeeded', ({ drifted }) => {
const drift = mkdtempSync(join(tmpdir(), 'rembric-drift-'));
writeFileSync(
join(drift, 'plugin.ts'),
[
'// @rembric-plugin-version 0.0.0',
"import { readRembricSlug } from '../mcp-bridge/rembric-dotenv.mjs';",
"import { createSessionProtocol } from '../bin/rembric-plugin-core.mjs';",
'export const RembricPlugin = () => ({});',
'',
]
.join('\n')
// Drift ONE import out of the sed pattern's reach; the other still rewrites.
.replace(
`../${drifted === 'rembric-dotenv.mjs' ? 'mcp-bridge' : 'bin'}/${drifted}`,
`./lib/${drifted}`,
),
);
const { code, out } = run([], {
home,
script: OPENCODE_INSTALL,
env: {
PLUGIN_SRC: drift,
BIN_SRC: join(REPO_ROOT, 'apps', 'plugin', 'bin'),
MCP_BRIDGE_SRC: join(REPO_ROOT, 'apps', 'plugin', 'mcp-bridge'),
},
});
rmSync(drift, { recursive: true, force: true });
expect(code).toBe(1);
expect(out).toContain('rewrite failed');
expect(out).toContain(drifted);
expect(existsSync(join(home, '.config', 'opencode', 'plugins', 'rembric.ts'))).toBe(false);
});
it('the installed plugin loads, resolving both shared modules from disk', async () => {
const ins = run(['--agent=opencode', '--action=install'], { home });
expect(ins.code).toBe(0);
const installed = join(home, '.config', 'opencode', 'plugins', 'rembric.ts');
const mod = (await import(installed)) as Record<string, unknown>;
expect(typeof mod.RembricPlugin).toBe('function');
expect(mod.default).toEqual({ id: 'rembric.lifecycle', setup: expect.any(Function) });
expect(Object.keys(mod)).toEqual(['RembricPlugin', 'default']);
});
it('every file the install copies is removed by the uninstall', () => {
const filesUnder = (root: string): string[] => {
const walk = (rel: string): string[] =>
readdirSync(join(root, rel), { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walk(join(rel, e.name)) : [join(rel, e.name)],
);
return walk('.').sort();
};
expect(run(['--agent=opencode', '--action=install'], { home }).code).toBe(0);
const installedFiles = filesUnder(home);
expect(installedFiles).toContain('.config/rembric/bin/rembric-plugin-core.mjs');
expect(run(['--agent=opencode', '--action=uninstall'], { home }).code).toBe(0);
expect(filesUnder(home)).toEqual([]);
});
});
describe('--yes runs the marketplace command (stubbed client binary)', () => {
it('--yes executes the claude update command when the claude binary is present', () => {
const bin = fakeClientBinDir('claude');
const { code, out } = run(['--agent=claude', '--action=update', '--yes'], {
home,
path: `${bin}:${CORE_PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('claude plugin update rembric@rembric'); // still printed
expect(out).toContain('RAN:claude plugin update rembric@rembric'); // and executed
});
it('-y is an alias for --yes', () => {
const bin = fakeClientBinDir('claude');
const { code, out } = run(['--agent=claude', '--action=update', '-y'], {
home,
path: `${bin}:${CORE_PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('RAN:claude plugin update rembric@rembric');
});
it('--yes executes the codex upgrade command when the codex binary is present', () => {
const bin = fakeClientBinDir('codex');
const { code, out } = run(['--agent=codex', '--action=update', '--yes'], {
home,
path: `${bin}:${CORE_PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('RAN:codex plugin marketplace upgrade rembric');
});
it('without --yes a headless run only prints, never executes', () => {
const bin = fakeClientBinDir('claude');
const { code, out } = run(['--agent=claude', '--action=update'], {
home,
path: `${bin}:${CORE_PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('claude plugin update rembric@rembric'); // printed
expect(out).not.toContain('RAN:claude'); // not executed
});
it('--yes with an absent client binary prints but executes nothing', () => {
const { code, out } = run(['--agent=codex', '--action=update', '--yes'], {
home,
path: CORE_PATH, // no codex on PATH
});
expect(code).toBe(0);
expect(out).toContain('codex plugin marketplace upgrade rembric'); // printed
expect(out).not.toContain('RAN:codex'); // nothing ran
});
it('--help documents the --yes / -y flag', () => {
const { out } = run(['--help']);
expect(out).toContain('--yes');
expect(out).toContain('-y');
});
});
describe('root install.sh shim', () => {
it('--help is identical to the plugin installer (pure forwarder)', () => {
const root = run(['--help'], { script: ROOT_SHIM });
const plugin = run(['--help']);
expect(root.code).toBe(0);
expect(root.out).toBe(plugin.out);
});
it('forwards flags: server install through the shim generates a token', () => {
const { code, out } = run(['--server', '--action=install'], { script: ROOT_SHIM, cwd: dir });
expect(code).toBe(0);
expect(out).toContain('Generated admin token');
const token = /^REMBRIC_ADMIN_TOKEN=([0-9a-f]{64})$/m.exec(
readFileSync(join(dir, '.env'), 'utf8'),
)?.[1];
expect(token).toBeDefined();
});
});
describe('agent CLI flags', () => {
it('--status --json emits { server, agents } with valid shapes', () => {
const { code, out } = run(['--status', '--json'], { home });
expect(code).toBe(0);
const data = JSON.parse(out);
// server block: docker-observable state + image tag; latest_release is null
// here (REMBRIC_UPDATE_CHECK=off → no GitHub call).
expect(typeof data.server.state).toBe('string'); // running|exited|absent|unknown|…
expect(data.server).toHaveProperty('version');
expect(data.server.latest_release).toBeNull();
// agents block: one object per agent.
expect(data.agents.map((d) => d.agent)).toEqual(CLIENTS);
for (const d of data.agents) {
expect(typeof d.present).toBe('boolean');
expect(d.available).toBe(PLUGIN_VERSION[d.agent]); // from .release-please-manifest.json at the ref
expect(d.installed).toBeNull(); // clean HOME → nothing installed
expect(typeof d.action).toBe('string');
}
});
it('--status surfaces the latest server release from the releases endpoint', () => {
const releases = join(dir, 'releases.json');
writeFileSync(
releases,
JSON.stringify([
{ tag_name: 'opencode-plugin-v0.10.0' }, // ignored (not server-v)
{ tag_name: 'server-v0.29.1' },
{ tag_name: 'server-v0.30.0' }, // highest server release
]),
);
const { code, out } = run(['--status', '--json'], {
home,
env: { REMBRIC_UPDATE_CHECK: 'on', REMBRIC_RELEASES_URL: `file://${releases}` },
});
expect(code).toBe(0);
expect(JSON.parse(out).server.latest_release).toBe('0.30.0');
});
it('--status (text) prints the SERVER line and the aligned agent table', () => {
const { code, out } = run(['--status'], { home });
expect(code).toBe(0);
expect(out).toContain('SERVER');
expect(out).toContain('AGENT');
expect(out).toContain('PLUGIN'); // clarifies the rows are about the plugin, not the agent
expect(out).toContain('claude');
expect(out).toContain(PLUGIN_VERSION.claude);
});
it('--token sets the admin token verbatim on server install', () => {
const { code } = run(['--server', '--action=install', '--token=tok_ABC_1234567890'], {
cwd: dir,
});
expect(code).toBe(0);
expect(readFileSync(join(dir, '.env'), 'utf8')).toMatch(
/^REMBRIC_ADMIN_TOKEN=tok_ABC_1234567890$/m,
);
});
it('--token shorter than 16 chars is refused (server would reject it)', () => {
const { code, out } = run(['--server', '--action=install', '--token=short'], { cwd: dir });
expect(code).not.toBe(0);
expect(out).toContain('too short');
});
it('--port writes REMBRIC_PORT on server install', () => {
const { code } = run(['--server', '--action=install', '--port=9001'], { cwd: dir });
expect(code).toBe(0);
expect(readFileSync(join(dir, '.env'), 'utf8')).toMatch(/^REMBRIC_PORT=9001$/m);
});
it('--port=abc is rejected at parse time, before any .env write', () => {
const { code, out } = run(['--server', '--action=install', '--port=abc'], { cwd: dir });
expect(code).toBe(2);
expect(out).toContain('invalid --port=abc');
expect(existsSync(join(dir, '.env'))).toBe(false);
});
it('--port out of range (70000) is rejected', () => {
const { code, out } = run(['--server', '--action=install', '--port=70000'], { cwd: dir });
expect(code).toBe(2);
expect(out).toContain('invalid --port=70000');
expect(existsSync(join(dir, '.env'))).toBe(false);
});
});
describe('server bring-up (--up) with a stubbed docker', () => {
// A fake `docker` on PATH lets us exercise the `up` path (and its failure
// modes) headlessly — no daemon. It answers the three subcommands bring_up
// uses: `compose version` (deps check), `compose pull`, `compose up`.
// A fake `curl` answers the post-up /healthz poll ('healthy' → {ok:true},
// 'down' → connection refused rc 7), and a no-op `sleep` collapses the
// ~30s poll ceiling so the down case stays fast.
function fakeDockerDir(
composeUp: 'ok' | 'conflict',
healthz: 'healthy' | 'down' = 'healthy',
chown: 'ok' | 'fail' = 'ok',
statUid?: string,
): string {
const d = mkdtempSync(join(tmpdir(), 'rembric-fakebin-'));
const up =
composeUp === 'conflict'
? `echo 'Error response from daemon: Conflict. The container name "/rembric" is already in use by container "abc123".' >&2; exit 1`
: `echo '[+] Running 2/2'; exit 0`;
writeFileSync(
join(d, 'docker'),
`#!/bin/sh
case "$1 $2" in
"compose version") echo "Docker Compose version v2.31.0"; exit 0 ;;
"compose pull") echo FAKEPULL; exit 0 ;;
"compose up") ${up} ;;
*) exit 0 ;;
esac
`,
);
chmodSync(join(d, 'docker'), 0o755);
const curl =
healthz === 'healthy'
? `#!/bin/sh\nprintf '{"ok":true,"version":"9.9.9"}'\nexit 0\n`
: `#!/bin/sh\nexit 7\n`;
writeFileSync(join(d, 'curl'), curl);
chmodSync(join(d, 'curl'), 0o755);
writeFileSync(join(d, 'sleep'), '#!/bin/sh\nexit 0\n');
chmodSync(join(d, 'sleep'), 0o755);
// Stubbed so the test controls the chown-vs-chmod-fallback branch
// independently of whether the test runner itself happens to be root.
writeFileSync(join(d, 'chown'), chown === 'ok' ? '#!/bin/sh\nexit 0\n' : '#!/bin/sh\nexit 1\n');
chmodSync(join(d, 'chown'), 0o755);
// When provided, stub `stat` to report this owning uid — lets a test
// simulate an already-10001-owned ./data without needing root to
// actually chown it (CI runs non-root). Ignores args so both the GNU
// `stat -c '%u'` and BSD `stat -f '%u'` forms return it.
if (statUid !== undefined) {
writeFileSync(join(d, 'stat'), `#!/bin/sh\necho ${statUid}\nexit 0\n`);
chmodSync(join(d, 'stat'), 0o755);
}
return d;
}
it('success: brings the stack up and prints the dashboard URL + token', () => {
const bin = fakeDockerDir('ok');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('Up.');
expect(out).toContain('127.0.0.1:8787/dashboard');
expect(out).toMatch(/Log in with admin token: [0-9a-f]{64}/);
});
it('healthy /healthz: success banner reports the server version from the response', () => {
const bin = fakeDockerDir('ok', 'healthy');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('Up.');
expect(out).toContain('9.9.9'); // version parsed from the stubbed healthz JSON
expect(out).toContain('127.0.0.1:8787/dashboard');
});
it('unreachable /healthz: withholds the success banner and hints at docker compose logs', () => {
const bin = fakeDockerDir('ok', 'down');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0); // bounded failure, never a set -e abort
expect(out).not.toContain('Up.');
expect(out).not.toContain('/dashboard');
expect(out).not.toContain('Log in with admin token');
expect(out).toContain('docker compose logs');
});
it('--up honours --port (dashboard URL) and --token (login line)', () => {
const bin = fakeDockerDir('ok');
const { code, out } = run(
['--server', '--action=install', '--up', '--port=8799', '--token=tok_xyz_1234567890'],
{ cwd: dir, path: `${bin}:${process.env.PATH}` },
);
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('127.0.0.1:8799/dashboard');
expect(out).toContain('Log in with admin token: tok_xyz_1234567890');
});
it('REMBRIC_NO_PULL skips `docker compose pull` but still brings the stack up', () => {
const bin = fakeDockerDir('ok');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
env: { REMBRIC_NO_PULL: '1' },
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('REMBRIC_NO_PULL'); // the "pull skipped" notice
expect(out).not.toContain('FAKEPULL'); // pull was NOT invoked
expect(out).toContain('Up.');
});
it('creates ./data and chowns it to uid 10001 when chown succeeds (no chmod fallback noise)', () => {
const bin = fakeDockerDir('ok', 'healthy', 'ok');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(existsSync(join(dir, 'data'))).toBe(true);
expect(out).not.toContain('Could not chown');
});
it('falls back to a world-writable ./data and warns when a FRESH directory is not owned by uid 10001 and chown is not possible', () => {
const bin = fakeDockerDir('ok', 'healthy', 'fail');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
const dataDir = join(dir, 'data');
expect(existsSync(dataDir)).toBe(true);
expect(statSync(dataDir).mode & 0o777).toBe(0o777);
expect(out).toContain('Could not chown ./data to the container');
expect(out).toContain('sudo chown -R 10001:10001 ./data');
});
it('does NOT relax an existing ./data already owned by uid 10001, even when chown is not possible', () => {
// Regression guard: chown fails on every non-root invocation regardless
// of whether ./data is already fine (e.g. fixed by a prior manual `sudo
// chown`, or working transparently under Docker Desktop) — that failure
// alone must never cause a re-run to downgrade an already-correct,
// already-working install to world-writable. `stat` is stubbed to report
// uid 10001 (we can't really chown to 10001 without root, and CI runs
// non-root), so this exercises the installer's decision, not the syscall.
const dataDir = join(dir, 'data');
mkdirSync(dataDir, { mode: 0o750 });
const bin = fakeDockerDir('ok', 'healthy', 'fail', '10001');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(statSync(dataDir).mode & 0o777).toBe(0o750); // unchanged, NOT widened to 0777
expect(out).not.toContain('Could not chown');
expect(out).not.toContain('world-writable');
});
it('container-name conflict: friendly message, never clobbers, no raw daemon dump', () => {
const bin = fakeDockerDir('conflict');
const { code, out } = run(['--server', '--action=install', '--up'], {
cwd: dir,
path: `${bin}:${process.env.PATH}`,
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0); // handled gracefully, not a hard crash / set -e abort
expect(out).toContain('already running');
expect(out).toContain('docker rm -f rembric');
expect(out).not.toContain('Error response from daemon');
});
});
describe('server status reporting (stubbed docker)', () => {
// A fake `docker` answering the calls server_state/server_image_version make:
// `docker ps` (daemon reachable), `docker container inspect …{{.State.Status}}`,
// `docker inspect …{{.Config.Image}}`.
function fakeServerDocker(scenario: 'running-old' | 'absent' | 'daemon-down'): string {
const d = mkdtempSync(join(tmpdir(), 'rembric-fakesrv-'));
let body: string;
if (scenario === 'running-old') {
body = `case "$1" in
ps) exit 0 ;;
container) echo running ;;
inspect) echo "ghcr.io/susomejias/rembric:0.20.0" ;;
*) exit 0 ;;
esac`;
} else if (scenario === 'absent') {
body = `case "$1" in
ps) exit 0 ;;
container) exit 1 ;;
inspect) exit 1 ;;
*) exit 0 ;;
esac`;
} else {
body = 'exit 1'; // daemon unreachable: every docker call fails
}
writeFileSync(join(d, 'docker'), `#!/bin/sh\n${body}\n`);
chmodSync(join(d, 'docker'), 0o755);
return d;
}
it('running container older than the latest release → update hint + versions', () => {
const bin = fakeServerDocker('running-old');
const releases = join(dir, 'rel.json');
writeFileSync(releases, JSON.stringify([{ tag_name: 'server-v0.30.0' }]));
const { code, out } = run(['--status'], {
home,
path: `${bin}:${process.env.PATH}`,
env: { REMBRIC_UPDATE_CHECK: 'on', REMBRIC_RELEASES_URL: `file://${releases}` },
});
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(out).toContain('0.20.0'); // running image tag
expect(out).toContain('0.30.0'); // latest release
expect(out).toContain('update available');
});
it('no rembric container → state absent (--json)', () => {
const bin = fakeServerDocker('absent');
const { code, out } = run(['--status', '--json'], { home, path: `${bin}:${process.env.PATH}` });
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
const s = JSON.parse(out).server;
expect(s.state).toBe('absent');
expect(s.version).toBeNull();
});
it('docker daemon unreachable → state unknown, never a crash (--json)', () => {
const bin = fakeServerDocker('daemon-down');
const { code, out } = run(['--status', '--json'], { home, path: `${bin}:${process.env.PATH}` });
rmSync(bin, { recursive: true, force: true });
expect(code).toBe(0);
expect(JSON.parse(out).server.state).toBe('unknown');
});
});
describe('output degradation', () => {
it('emits no ANSI escapes when stdout is not a terminal', () => {
const { out } = run(['--help']);
// eslint-disable-next-line no-control-regex
expect(out).not.toMatch(/\[/);
});
it('shows the plain wordmark (not the block banner) without a terminal', () => {
mkdirSync(join(dir, 'sub'), { recursive: true });
const { out } = run(['--server', '--action=update'], { cwd: dir });
expect(out).toContain('rembric installer');
expect(out).not.toContain('██');
});
});