feat: introduced nix packaging and path-based binary resolution

- Add comprehensive Nix flake definitions, derivations, modules, and CI workflows.
- Update tests and executables to resolve binaries from PATH rather than absolute paths.
- Ensure byte reproducibility and zeroed timestamps in embedded dashboard archives.
- Add handling for Nix-managed installations in CLI update checks.
This commit is contained in:
can1357
2026-08-13 03:52:47 +02:00
parent 2e492a3076
commit b60bef961c
33 changed files with 3515 additions and 89 deletions
@@ -1,7 +1,6 @@
#!/usr/bin/env bun
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
@@ -11,6 +10,14 @@ const DIST_CLIENT_DIR = path.join("dist", "client");
const GENERATE_FLAG = "--generate";
const RESET_FLAG = "--reset";
const TAR_BLOCK_SIZE = 512;
const TAR_SIZE_OFFSET = 124;
const TAR_SIZE_LENGTH = 12;
const TAR_MTIME_OFFSET = 136;
const TAR_MTIME_LENGTH = 12;
const TAR_CHECKSUM_OFFSET = 148;
const TAR_CHECKSUM_LENGTH = 8;
// `--reset` restores the checked-in state: an empty file. The runtime treats
// blank (or any non-base64) content as "no archive embedded" and builds the
// dashboard from source instead; see src/embedded-client.ts.
@@ -26,29 +33,60 @@ async function collectFiles(dir: string): Promise<string[]> {
files.push(fullPath);
}
}
files.sort((a, b) => a.localeCompare(b));
files.sort();
return files;
}
async function buildArchiveBase64(dir: string): Promise<string> {
function readTarOctal(bytes: Uint8Array, offset: number, length: number): number {
const value = Buffer.from(bytes.subarray(offset, offset + length))
.toString("ascii")
.replace(/\0.*$/, "")
.trim();
return value ? Number.parseInt(value, 8) : 0;
}
function writeTarOctal(bytes: Uint8Array, offset: number, length: number, value: number): void {
const octal = value.toString(8);
if (octal.length >= length) throw new Error(`Tar value ${value} does not fit in ${length} bytes`);
bytes.fill(0x30, offset, offset + length - 1);
bytes.set(Buffer.from(octal), offset + length - 1 - octal.length);
bytes[offset + length - 1] = 0;
}
function normalizeTarMetadata(bytes: Uint8Array): void {
for (let offset = 0; offset + TAR_BLOCK_SIZE <= bytes.length; ) {
const header = bytes.subarray(offset, offset + TAR_BLOCK_SIZE);
if (header.every(byte => byte === 0)) return;
const size = readTarOctal(header, TAR_SIZE_OFFSET, TAR_SIZE_LENGTH);
writeTarOctal(header, TAR_MTIME_OFFSET, TAR_MTIME_LENGTH, 0);
header.fill(0x20, TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH);
let checksum = 0;
for (const byte of header) checksum += byte;
const checksumOctal = checksum.toString(8).padStart(6, "0");
if (checksumOctal.length > 6) throw new Error(`Tar checksum ${checksum} exceeds the header field`);
header.set(Buffer.from(checksumOctal), TAR_CHECKSUM_OFFSET);
header[TAR_CHECKSUM_OFFSET + 6] = 0;
header[TAR_CHECKSUM_OFFSET + 7] = 0x20;
offset += TAR_BLOCK_SIZE * (1 + Math.ceil(size / TAR_BLOCK_SIZE));
if (offset > bytes.length) throw new Error("Tar entry extends beyond the archive");
}
}
/** Build a byte-stable gzip archive of a directory for embedding in the OMP binary. */
export async function buildArchiveBase64(dir: string): Promise<string> {
const files = await collectFiles(dir);
const entries: Record<string, Uint8Array> = {};
for (const filePath of files) {
const relativePath = path.relative(dir, filePath).split(path.sep).join("/");
entries[relativePath] = await fs.readFile(filePath);
entries[relativePath] = await Bun.file(filePath).bytes();
}
const tempArchivePath = path.join(
os.tmpdir(),
`omp-stats-client-${Bun.hash(Date.now().toString() + Math.random().toString(16)).toString(16)}.tar.gz`,
);
try {
await Bun.Archive.write(tempArchivePath, entries, { compress: "gzip" });
const archiveBytes = await Bun.file(tempArchivePath).bytes();
return Buffer.from(archiveBytes).toString("base64");
} finally {
await fs.rm(tempArchivePath, { force: true });
}
const archiveBytes = await new Bun.Archive(entries).bytes();
normalizeTarMetadata(archiveBytes);
return Buffer.from(Bun.gzipSync(archiveBytes, { level: 9 })).toString("base64");
}
async function main(): Promise<void> {
@@ -69,4 +107,4 @@ async function main(): Promise<void> {
console.log(`Generated ${GENERATED_FILE}`);
}
await main();
if (import.meta.main) await main();