feat: add auto update support

This commit is contained in:
2026-05-15 01:47:56 -07:00
parent d1ec678d7a
commit 094bcce0dc
101 changed files with 4978 additions and 163 deletions
@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
buildProtectedLauncherUpdateCommand,
looksLikeSubminerLauncher,
updateLauncherAtPath,
} from './launcher-updater';
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
const launcherHash = createHash('sha256').update(launcherBytes).digest('hex');
test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
assert.equal(looksLikeSubminerLauncher(Buffer.from('#!/bin/sh\necho nope\n')), false);
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
});
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
assert.equal(
buildProtectedLauncherUpdateCommand(
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
'/usr/local/bin/subminer',
),
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
);
});
test('updateLauncherAtPath verifies hash and atomically replaces writable launcher', async () => {
const writes: Array<{ path: string; data: Buffer }> = [];
const renames: Array<{ from: string; to: string }> = [];
const chmods: Array<{ path: string; mode: number }> = [];
const result = await updateLauncherAtPath({
launcherPath: '/home/kyle/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async (filePath, data) => {
writes.push({ path: filePath, data: Buffer.from(data) });
},
chmod: async (filePath, mode) => {
chmods.push({ path: filePath, mode });
},
rename: async (from, to) => {
renames.push({ from, to });
},
unlink: async () => undefined,
},
});
assert.equal(result.status, 'updated');
assert.equal(writes.length, 1);
assert.equal(writes[0]!.path, '/home/kyle/.local/bin/.subminer.update');
assert.equal(writes[0]!.data.equals(launcherBytes), true);
assert.deepEqual(chmods, [{ path: '/home/kyle/.local/bin/.subminer.update', mode: 0o755 }]);
assert.deepEqual(renames, [
{ from: '/home/kyle/.local/bin/.subminer.update', to: '/home/kyle/.local/bin/subminer' },
]);
});
test('updateLauncherAtPath reports protected command without replacing non-writable launcher', async () => {
const result = await updateLauncherAtPath({
launcherPath: '/usr/local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
assert.equal(result.status, 'protected');
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
});
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
const suspicious = await updateLauncherAtPath({
launcherPath: '/home/kyle/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\necho not-subminer\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async () => undefined,
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
const mismatch = await updateLauncherAtPath({
launcherPath: '/home/kyle/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: '0'.repeat(64),
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
assert.equal(suspicious.status, 'skipped');
assert.equal(mismatch.status, 'hash-mismatch');
});