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
+59
View File
@@ -0,0 +1,59 @@
name: OMP Nix
on:
push:
branches: [main]
paths:
- ".github/workflows/nix.yml"
- ".cargo/**"
- "flake.nix"
- "flake.lock"
- "nix/**"
- "bun.lock"
- "package.json"
- "patches/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "packages/**"
- "crates/**"
- "scripts/**"
- "docs/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/nix.yml"
- ".cargo/**"
- "flake.nix"
- "flake.lock"
- "nix/**"
- "bun.lock"
- "package.json"
- "patches/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "packages/**"
- "crates/**"
- "scripts/**"
- "docs/**"
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
permissions:
contents: read
jobs:
evaluate:
name: Evaluate flake
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
accept-flake-config = true
- name: Evaluate every supported system
run: nix flake check --all-systems --no-build --show-trace
+2
View File
@@ -12,6 +12,8 @@ target/
*.tsbuildinfo
*.node
*.b64.js
/result
/result-*
# Environment
.env
+41
View File
@@ -54,6 +54,31 @@ brew install can1357/tap/omp
bun install -g @oh-my-pi/pi-coding-agent
```
**Nix**
```sh
# Run without installing
nix run github:can1357/oh-my-pi
# Or install into the active profile
nix profile install github:can1357/oh-my-pi
```
Flake consumers can use `packages.<system>.omp`, `overlays.default`, `nixosModules.default`, or `homeManagerModules.default`. A Home Manager configuration can install OMP and own its settings declaratively:
```nix
{
inputs.omp.url = "github:can1357/oh-my-pi";
# In your Home Manager module:
imports = [ inputs.omp.homeManagerModules.default ];
programs.omp = {
enable = true;
settings.startup.quiet = true;
};
}
```
**Windows (PowerShell)**
```powershell
@@ -581,6 +606,22 @@ bun dev
`bun setup` installs Bun workspaces and builds `@oh-my-pi/pi-natives`. Re-run `bun run build:native` after changing Rust crates or `packages/natives`.
Nix users get the pinned Bun and Rust toolchains plus all native build dependencies:
```sh
nix develop
bun setup
bun dev
```
Build and smoke-test the distributable Nix package with `nix build .#omp`. `nix/bun.nix` is generated only when `bun.lock` changes; releases regenerate it automatically. For dependency changes, run:
```sh
bun run gen:nix
```
The command uses `bun2nix` from `nix develop` when available, otherwise enters the development shell through Nix. Do not edit `nix/bun.nix` manually.
For a non-interactive smoke check:
```sh
+6 -6
View File
@@ -255,35 +255,35 @@ mod tests {
#[cfg(unix)]
#[test]
fn nonempty_stdin_runs_command_with_stdin() {
let result = run_in("hello world\n", &["/bin/cat"]);
let result = run_in("hello world\n", &["cat"]);
assert_eq!(result, (0, "hello world\n".to_string(), String::new()));
}
#[cfg(unix)]
#[test]
fn empty_stdin_skips_command() {
let result = run_in("", &["/bin/sh", "-c", "echo ran"]);
let result = run_in("", &["sh", "-c", "echo ran"]);
assert_eq!(result, (0, String::new(), String::new()));
}
#[cfg(unix)]
#[test]
fn invert_runs_command_on_empty_stdin() {
let result = run_in("", &["-n", "/bin/sh", "-c", "echo ran"]);
let result = run_in("", &["-n", "sh", "-c", "echo ran"]);
assert_eq!(result, (0, "ran\n".to_string(), String::new()));
}
#[cfg(unix)]
#[test]
fn invert_passes_nonempty_stdin_through() {
let result = run_in("data\n", &["-n", "/bin/sh", "-c", "echo ran"]);
let result = run_in("data\n", &["-n", "sh", "-c", "echo ran"]);
assert_eq!(result, (0, "data\n".to_string(), String::new()));
}
#[cfg(unix)]
#[test]
fn child_exit_code_propagates() {
let result = run_in("x", &["/bin/sh", "-c", "exit 3"]);
let result = run_in("x", &["sh", "-c", "exit 3"]);
assert_eq!(result, (3, String::new(), String::new()));
}
@@ -299,7 +299,7 @@ mod tests {
#[test]
fn early_exiting_child_is_not_an_error() {
let big = "a".repeat(1 << 20);
let result = run_in(&big, &["/usr/bin/head", "-c", "1"]);
let result = run_in(&big, &["head", "-c", "1"]);
assert_eq!(result, (0, "a".to_string(), String::new()));
}
+1 -1
View File
@@ -36,7 +36,7 @@ mod tests {
#[cfg(unix)]
fn matching_process() -> std::process::Child {
std::process::Command::new("/bin/sleep")
std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn matching process")
+1 -1
View File
@@ -7930,7 +7930,7 @@ fn re_or_saved_re<'a>(
#[cfg(unix)]
fn shell_command(cmd: &str, host: &Host) -> std::process::Command {
let mut c = std::process::Command::new("/bin/sh");
let mut c = std::process::Command::new("sh");
c.arg("-c").arg(cmd);
// run relative to the shell's cwd,
// not the host process cwd. `output()` already keeps the child's stdio
+1 -1
View File
@@ -491,7 +491,7 @@ mod tests {
shell
.run(
CoreShellRunOptions {
command: "/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'".to_string(),
command: "sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'".to_string(),
cwd: None,
env: None,
timeout_ms: None,
+59 -44
View File
@@ -1991,12 +1991,30 @@ mod tests {
.expect("child did not enter expected executable");
}
#[cfg(unix)]
fn test_executable(name: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt as _;
std::env::var_os("PATH")
.and_then(|path| {
std::env::split_paths(&path)
.map(|directory| directory.join(name))
.find(|candidate| {
candidate.metadata().is_ok_and(|metadata| {
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
})
})
})
.unwrap_or_else(|| panic!("{name} executable on PATH"))
}
#[cfg(unix)]
fn process_test_command(prefix: &str) -> (tempfile::TempDir, std::path::PathBuf, String) {
let dir = tempfile::tempdir().expect("process test directory");
let name = format!("{prefix}{}", std::process::id());
let command = dir.path().join(&name);
std::os::unix::fs::symlink("/bin/sleep", &command).expect("sleep symlink");
let sleep = test_executable("sleep");
std::os::unix::fs::symlink(sleep, &command).expect("sleep symlink");
(dir, command, name)
}
@@ -2881,13 +2899,14 @@ mod tests {
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn kill_builtin_refuses_ancestors_but_not_unrelated_processes() {
let (result, output) = execute_captured(
let sleep = test_executable("sleep");
let command = format!(
"parent=$(ps -o ppid= -p $$ | tr -d ' ')\nkill -CONT \"$parent\"; printf \
'ancestor=%s\\n' \"$?\"\n/bin/sleep 30 &\nchild=$!\nkill -TERM \"$child\"; printf \
'child=%s\\n' \"$?\"\nprintf 'survived\\n'"
.to_string(),
)
.await;
'ancestor=%s\\n' \"$?\"\n{} 30 &\nchild=$!\nkill -TERM \"$child\"; printf 'child=%s\\n' \
\"$?\"\nprintf 'survived\\n'",
quote_arg(sleep.to_str().expect("utf8 sleep path"))
);
let (result, output) = execute_captured(command).await;
assert_eq!(result.exit_code, Some(0), "the shell must survive: {output:?}");
assert!(output.contains("survived"), "{output:?}");
assert!(
@@ -3024,11 +3043,14 @@ mod tests {
// An identity "compressor" that also proves it was started with the
// shell's working directory and reaches the command's stderr.
let shim = bin.join("pi-test-compress");
std::fs::write(
&shim,
"#!/bin/sh\nprintf 'compressor cwd=%s\\n' \"$PWD\" >&2\nexec /bin/cat\n",
)
.expect("write shim");
let shell = test_executable("sh");
let cat = test_executable("cat");
let shim_source = format!(
"#!{}\nprintf 'compressor cwd=%s\\n' \"$PWD\" >&2\nexec {}\n",
shell.display(),
quote_arg(cat.to_str().expect("utf8 cat path"))
);
std::fs::write(&shim, shim_source).expect("write shim");
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).expect("chmod shim");
// Enough distinct lines that the 1K buffer forces spilling through the
@@ -4476,9 +4498,8 @@ replace = [{ pattern = "hello", replacement = "HI" }]
/// external background jobs and 1 while one is running. The host relies on
/// this to retain a per-call shell whose `&`/`nohup` child is still alive
/// instead of dropping it (which would SIGKILL the child via kill-on-drop).
/// Path-qualified `/bin/sleep` is used so it spawns a real external process
/// (the bare `sleep` builtin runs in-process and is intentionally not
/// counted).
/// `sh -c` forces an external process because the bare `sleep` builtin runs
/// in-process and is intentionally not counted.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn live_background_job_count_tracks_external_background_jobs() {
@@ -4502,7 +4523,7 @@ replace = [{ pattern = "hello", replacement = "HI" }]
// An external background process is tracked while it runs.
shell
.run(
ShellRunOptions { command: "/bin/sleep 30 &".into(), ..Default::default() },
ShellRunOptions { command: "sh -c 'sleep 30' &".into(), ..Default::default() },
None,
CancelToken::default(),
)
@@ -4640,7 +4661,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
let root = unique_temp_dir("heredoc-chain");
let minimizer = printf_minimizer(&root.join("minimizer.toml"), None);
let (result, output) = run_command_capture(
"/bin/cat <<'PY'\nhello $USER\nPY\nprintf 'after\\n'",
"cat <<'PY'\nhello $USER\nPY\nprintf 'after\\n'",
None,
Some(minimizer),
CancelToken::default(),
@@ -4845,7 +4866,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
// `printf '%d\n' "$$"` then `sleep 0.5`. Long enough for our `getsid`.
let exec = session
.shell
.run_string("/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, &params)
.run_string("sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, &params)
.await
.expect("run_string");
drop(params);
@@ -4910,7 +4931,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
shell_b
.run(
ShellRunOptions {
command: "/bin/sh -c 'printf \"ready\\n\"; sleep 30'".into(),
command: "sh -c 'printf \"ready\\n\"; sleep 30'".into(),
..Default::default()
},
Some(tx_b),
@@ -4942,7 +4963,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
shell_a
.run(
ShellRunOptions {
command: "/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 2'".into(),
command: "sh -c 'printf \"%d\\n\" \"$$\"; sleep 2'".into(),
..Default::default()
},
Some(tx_a),
@@ -5003,9 +5024,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
let escaped_pid_path = pid_path.to_string_lossy().replace('\'', "'\\''");
std::fs::write(
&snapshot_path,
format!(
"/bin/sh -c 'printf \"%d\\n\" \"$$\" > \"$1\"; sleep 30' sh '{escaped_pid_path}'\n"
),
format!("sh -c 'printf \"%d\\n\" \"$$\" > \"$1\"; sleep 30' sh '{escaped_pid_path}'\n"),
)
.expect("write snapshot file");
@@ -5058,7 +5077,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
let child_dead = time::timeout(Duration::from_secs(5), async {
loop {
// SAFETY: `child_pid` came from the foreground `/bin/sh` spawned by the
// SAFETY: `child_pid` came from the foreground `sh` spawned by the
// snapshot; `kill(pid, 0)` only probes whether that process still exists.
let kill_result = unsafe { libc::kill(child_pid, 0) };
if kill_result == -1 {
@@ -5148,14 +5167,14 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
let shell_handle = tokio::spawn(async move {
let source_info = SourceInfo::from("pi-natives:test");
// First stage prints its own PID and sleeps; `cat` forwards the PID
// line to our reader and exits on EOF. The first stage leads the
// pipeline's process group, the second (`cat`) is the join-or-detach
// stage that would EPERM without the wiring fix.
// First stage prints its own PID and sleeps; `sh -c cat` forwards
// the PID line to our reader and exits on EOF. The first stage
// leads the pipeline's process group, while the second stage is the
// join-or-detach process that would EPERM without the wiring fix.
let exec = session
.shell
.run_string(
"/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 1' | /bin/cat",
"sh -c 'printf \"%d\\n\" \"$$\"; sleep 1' | sh -c cat",
&source_info,
&params,
)
@@ -5210,7 +5229,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
#[tokio::test(flavor = "multi_thread")]
async fn wait_accepts_last_background_process_id() {
let options = ShellExecuteOptions {
command: "/bin/sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(),
command: "sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(),
..Default::default()
};
@@ -5227,9 +5246,9 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
#[tokio::test(flavor = "multi_thread")]
async fn wait_n_p_records_completed_process_id() {
let options = ShellExecuteOptions {
command: "/bin/sh -c 'sleep 0.2; exit 42' & slow=$!; /bin/sh -c 'exit 13' & fast=$!; \
wait -n -p hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" \
-eq 13 ] && [ \"$hit\" = \"$fast\" ]"
command: "sh -c 'sleep 0.2; exit 42' & slow=$!; sh -c 'exit 13' & fast=$!; wait -n -p \
hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" -eq 13 ] && \
[ \"$hit\" = \"$fast\" ]"
.to_string(),
..Default::default()
};
@@ -5247,7 +5266,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
#[tokio::test(flavor = "multi_thread")]
async fn wait_f_accepts_process_id() {
let options = ShellExecuteOptions {
command: "/bin/sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(),
command: "sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(),
..Default::default()
};
@@ -5390,13 +5409,9 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn quoted_heredoc_without_trailing_newline_runs() {
let (result, output) = run_command_capture(
"/bin/cat <<'PY'\nhello $USER\nPY",
None,
None,
CancelToken::default(),
)
.await;
let (result, output) =
run_command_capture("cat <<'PY'\nhello $USER\nPY", None, None, CancelToken::default())
.await;
assert_eq!(result.exit_code, Some(0));
assert_eq!(output, "hello $USER\n");
@@ -5437,7 +5452,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
let command = if cfg!(windows) {
"nohup cmd /C exit 7"
} else {
"nohup /bin/sh -c 'exit 7'"
"nohup sh -c 'exit 7'"
};
let options = ShellExecuteOptions { command: command.to_string(), ..Default::default() };
let result = execute_shell(options, None, CancelToken::default())
@@ -5455,8 +5470,8 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
async fn nohup_background_captures_operand_pid() {
let (tx, rx) = flume::unbounded::<String>();
let options = ShellExecuteOptions {
command: "nohup /bin/sh -c 'exit 0' >/dev/null 2>&1 & pid=$!; printf 'pid=%s\n' \
\"$pid\"; test -n \"$pid\""
command: "nohup sh -c 'exit 0' >/dev/null 2>&1 & pid=$!; printf 'pid=%s\n' \"$pid\"; \
test -n \"$pid\""
.to_string(),
..Default::default()
};
+1 -2
View File
@@ -393,8 +393,7 @@ mod tests {
#[test]
fn resolve_executable_returns_input_unchanged() {
// /bin/sh exists and is executable on every supported Unix host.
let path = PathBuf::from("/bin/sh");
let path = std::env::current_exe().expect("current test executable");
let resolved = resolve_executable(path.clone());
assert_eq!(resolved.as_deref(), Some(path.as_path()));
}
Generated
+248
View File
@@ -0,0 +1,248 @@
{
"nodes": {
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1784665499,
"narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "bun2nix",
"type": "github"
}
},
"bun2nix-darwin-x64": {
"inputs": {
"flake-parts": "flake-parts_2",
"nixpkgs": [
"nixpkgs-darwin-x64"
],
"systems": "systems_2",
"treefmt-nix": "treefmt-nix_2"
},
"locked": {
"lastModified": 1784665499,
"narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "bun2nix",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"bun2nix-darwin-x64",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nix-bun": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786530394,
"narHash": "sha256-Bxx47lHMVHFD7JCt0bQGwuYK71qKioYq2Y74CPg/KHs=",
"owner": "ryoppippi",
"repo": "nix-bun",
"rev": "3c2ccb115cc79d0556743743654475d4f6446f11",
"type": "github"
},
"original": {
"owner": "ryoppippi",
"repo": "nix-bun",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1786384358,
"narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-darwin-x64": {
"locked": {
"lastModified": 1786527240,
"narHash": "sha256-OLtJPnSXcRy79Rf7BhYaMeXAVF625FpLcd3+Svq641Y=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e0c84f9d0ad137f076dc957494f5b39885597d4f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-26.05-darwin",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"bun2nix": "bun2nix",
"bun2nix-darwin-x64": "bun2nix-darwin-x64",
"nix-bun": "nix-bun",
"nixpkgs": "nixpkgs",
"nixpkgs-darwin-x64": "nixpkgs-darwin-x64",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786507911,
"narHash": "sha256-w5aZRLbiu7H6TqsYXVMdRKg0S4DRaJpxyqxx86AwxVk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "39db48099ad16834af7e27485a4babf9c28b3897",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1776166891,
"narHash": "sha256-bI8yrEGjrohR5hkQox7UrxDH7XqrYMwI8SL/LrJ1+S8=",
"owner": "nix-systems",
"repo": "triplet",
"rev": "6de7bc09397911ce03636afbcf6118745ab2cda0",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "triplet",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1680978224,
"narHash": "sha256-+xT9B1ZbhMg/zpJqd00S06UCZb/A2URW9bqqrZ/JTOg=",
"owner": "nix-systems",
"repo": "x86_64-darwin",
"rev": "db0463cce4cd60fb791f33a83d29a1ed53edab9b",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "x86_64-darwin",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784369104,
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"treefmt-nix_2": {
"inputs": {
"nixpkgs": [
"bun2nix-darwin-x64",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784369104,
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+186
View File
@@ -0,0 +1,186 @@
{
description = "OMP coding agent and development environment";
nixConfig = {
extra-substituters = [ "https://nix-community.cachix.org" ];
extra-trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
];
};
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# nixpkgs unstable dropped Intel macOS in 26.11; keep that supported
# platform on the final stable branch that still receives security fixes.
nixpkgs-darwin-x64.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin";
bun2nix = {
url = "github:nix-community/bun2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# bun2nix's per-system helper packages must use the same Intel-compatible
# package set as the derivation consuming its overlay.
bun2nix-darwin-x64 = {
url = "github:nix-community/bun2nix";
inputs.nixpkgs.follows = "nixpkgs-darwin-x64";
inputs.systems.url = "github:nix-systems/x86_64-darwin";
};
nix-bun = {
url = "github:ryoppippi/nix-bun";
inputs.nixpkgs.follows = "nixpkgs";
};
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
bun2nix,
bun2nix-darwin-x64,
nix-bun,
nixpkgs,
nixpkgs-darwin-x64,
rust-overlay,
...
}:
let
systems = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
nixpkgsFor = system: if system == "x86_64-darwin" then nixpkgs-darwin-x64 else nixpkgs;
bun2nixFor = system: if system == "x86_64-darwin" then bun2nix-darwin-x64 else bun2nix;
pkgsFor =
system:
import (nixpkgsFor system) {
inherit system;
overlays = [
rust-overlay.overlays.default
(bun2nixFor system).overlays.default
(final: _previous: {
# Instantiate the pinned upstream binary against this package
# set so Intel macOS does not re-enter nix-bun's unstable input.
bun = final.callPackage (nix-bun.outPath + "/package.nix") {
sourcesFile = nix-bun.outPath + "/versions/1.3.14.json";
};
})
];
};
packageFor =
system:
let
pkgs = pkgsFor system;
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
in
pkgs.callPackage ./nix/package.nix {
inherit rustToolchain;
source = self.outPath;
};
in
{
packages = forAllSystems (system: {
default = packageFor system;
omp = packageFor system;
});
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/omp";
meta.description = "Run OMP";
};
omp = self.apps.${system}.default;
});
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
in
{
default = import ./nix/dev-shell.nix { inherit pkgs rustToolchain; };
}
);
checks = forAllSystems (
system:
let
pkgs = pkgsFor system;
homeManagerEvaluation = pkgs.lib.evalModules {
specialArgs = { inherit pkgs; };
modules = [
{
options.home.packages = pkgs.lib.mkOption {
type = pkgs.lib.types.listOf pkgs.lib.types.package;
default = [ ];
};
options.home.file = pkgs.lib.mkOption {
type = pkgs.lib.types.attrsOf pkgs.lib.types.anything;
default = { };
};
}
self.homeManagerModules.default
{
programs.omp.enable = true;
programs.omp.settings.startup.quiet = true;
}
];
};
nixosEvaluation = pkgs.lib.evalModules {
specialArgs = { inherit pkgs; };
modules = [
{
options.environment.systemPackages = pkgs.lib.mkOption {
type = pkgs.lib.types.listOf pkgs.lib.types.package;
default = [ ];
};
}
self.nixosModules.default
{ programs.omp.enable = true; }
];
};
modulesEvaluate =
assert builtins.elem self.packages.${system}.default homeManagerEvaluation.config.home.packages;
assert homeManagerEvaluation.config.home.file ? ".omp/agent/config.yml";
assert builtins.elem self.packages.${system}.default
nixosEvaluation.config.environment.systemPackages;
pkgs.runCommand "omp-module-evaluation" { } "touch $out";
in
{
bun-lock = pkgs.runCommand "omp-bun-lock" { nativeBuildInputs = [ pkgs.bun2nix ]; } ''
cp -R ${self.outPath} source
chmod -R u+w source
cd source
mv nix/bun.nix nix/bun.expected.nix
bun2nix -l bun.lock -c ../ -o nix/bun.nix
diff -u nix/bun.expected.nix nix/bun.nix
touch "$out"
'';
modules = modulesEvaluate;
omp = self.packages.${system}.default;
}
);
formatter = forAllSystems (system: (pkgsFor system).nixfmt);
overlays.default = _final: previous: {
omp = self.packages.${previous.stdenv.hostPlatform.system}.default;
};
homeManagerModules.default = import ./nix/home-manager.nix { inherit self; };
homeManagerModules.omp = self.homeManagerModules.default;
nixosModules.default = import ./nix/nixos-module.nix { inherit self; };
nixosModules.omp = self.nixosModules.default;
};
}
+2170
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
{
pkgs,
rustToolchain,
}:
let
inherit (pkgs) lib;
linuxLibraries = with pkgs; [
libpulseaudio
pipewire
stdenv.cc.cc.lib
zlib
];
in
pkgs.mkShell (
{
name = "omp-dev";
packages =
(with pkgs; [
bun
bun2nix
rustToolchain
bazelisk
cargo-nextest
rustPlatform.bindgenHook
nixfmt
typescript-language-server
python312
python312Packages.pip
uv
basedpyright
bash
cacert
curl
fd
git
git-lfs
imagemagick
openssh
ripgrep
sqlite
unzip
cmake
ninja
pkg-config
zig
cairo
giflib
libjpeg
libopus
librsvg
openssl
pango
pcre2
zlib
])
++ lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxLibraries;
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
PCRE2_SYS_STATIC = "1";
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
}
// lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
LD_LIBRARY_PATH = lib.makeLibraryPath linuxLibraries;
}
)
+40
View File
@@ -0,0 +1,40 @@
{ self }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.omp;
yaml = pkgs.formats.yaml { };
in
{
options.programs.omp = {
enable = lib.mkEnableOption "OMP coding agent";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "inputs.omp.packages.${pkgs.stdenv.hostPlatform.system}.default";
description = "OMP package to install.";
};
settings = lib.mkOption {
type = lib.types.nullOr yaml.type;
default = null;
description = "Settings written declaratively to ~/.omp/agent/config.yml.";
example = {
theme.dark = "titanium";
startup.quiet = true;
};
};
};
config = lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
home.file.".omp/agent/config.yml" = lib.mkIf (cfg.settings != null) {
source = yaml.generate "omp-config.yml" cfg.settings;
};
};
}
+26
View File
@@ -0,0 +1,26 @@
{ self }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.omp;
in
{
options.programs.omp = {
enable = lib.mkEnableOption "OMP coding agent";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "inputs.omp.packages.${pkgs.stdenv.hostPlatform.system}.default";
description = "OMP package to install system-wide.";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
};
}
+213
View File
@@ -0,0 +1,213 @@
{
autoPatchelfHook ? null,
autoSignDarwinBinariesHook ? null,
bun,
bun2nix,
cmake,
lib,
libopus,
libpulseaudio ? null,
makeWrapper,
ninja,
openssl,
pcre2,
pipewire ? null,
pkg-config,
rustPlatform,
rustToolchain,
source,
stdenv,
stdenvNoCC,
unzip,
zig,
zlib,
}:
let
packageJson = lib.importJSON ../packages/coding-agent/package.json;
rootPackageJson = lib.importJSON ../package.json;
platform =
{
aarch64-darwin = {
addon = "pi_natives.darwin-arm64.node";
nativeLibrary = "libpi_natives.dylib";
};
aarch64-linux = {
addon = "pi_natives.linux-arm64.node";
nativeLibrary = "libpi_natives.so";
};
x86_64-darwin = {
addon = "pi_natives.darwin-x64-baseline.node";
nativeLibrary = "libpi_natives.dylib";
rustFlags = "-C target-cpu=x86-64-v2";
};
x86_64-linux = {
addon = "pi_natives.linux-x64-baseline.node";
nativeLibrary = "libpi_natives.so";
rustFlags = "-C target-cpu=x86-64-v2";
};
}
.${stdenv.hostPlatform.system} or (throw "Unsupported OMP platform: ${stdenv.hostPlatform.system}");
patchedDependencies = lib.mapAttrs (
_: patch: source + "/${patch}"
) rootPackageJson.patchedDependencies;
patchOverrides = bun2nix.patchedDependenciesToOverrides { inherit patchedDependencies; };
bunRuntimeTemplate = stdenvNoCC.mkDerivation {
pname = "omp-bun-runtime-template";
inherit (bun) version;
src = bun.src;
nativeBuildInputs = [ unzip ];
dontUnpack = true;
dontFixup = true;
installPhase = ''
runHook preInstall
unzip -q "$src"
install -Dm755 bun-*/bun "$out/libexec/bun"
runHook postInstall
'';
};
runtimeLibraries = [
libopus
openssl
pcre2
]
++ lib.optionals stdenv.hostPlatform.isLinux [
libpulseaudio
pipewire
stdenv.cc.cc.lib
zlib
];
in
stdenv.mkDerivation (
{
pname = "omp";
inherit (packageJson) version;
src = source;
cargoDeps = rustPlatform.importCargoLock { lockFile = ../Cargo.lock; };
bunDeps = bun2nix.fetchBunDeps {
bunNix = ./bun.nix;
overrides = patchOverrides;
};
nativeBuildInputs = [
bun
bun2nix.hook
cmake
makeWrapper
ninja
pkg-config
rustPlatform.bindgenHook
rustPlatform.cargoSetupHook
rustToolchain
zig
]
++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook ]
++ lib.optionals stdenv.hostPlatform.isDarwin [ autoSignDarwinBinariesHook ];
buildInputs = [
libopus
openssl
pcre2
]
++ lib.optionals stdenv.hostPlatform.isLinux [
libpulseaudio
pipewire
stdenv.cc.cc.lib
zlib
];
strictDeps = true;
# Nix builders cannot reliably hardlink cache files into node_modules
# (and Darwin's clonefile backend also rejects store permissions).
bunInstallFlags = [
"--linker=isolated"
"--backend=copyfile"
];
dontConfigure = true;
dontRunLifecycleScripts = true;
dontUseBunBuild = true;
dontUseBunCheck = true;
dontUseBunInstall = true;
dontStrip = true;
env = {
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
PCRE2_SYS_STATIC = "1";
SOURCE_DATE_EPOCH = "1";
}
// lib.optionalAttrs (platform ? rustFlags) { RUSTFLAGS = platform.rustFlags; }
// lib.optionalAttrs stdenv.hostPlatform.isDarwin { BUN_NO_CODESIGN_MACHO_BINARY = "1"; };
buildPhase = ''
runHook preBuild
echo "Building pi-natives"
cargo build --release -p pi-natives ${lib.optionalString stdenv.hostPlatform.isLinux "--features wayland-pipewire"}
install -Dm755 "target/release/${platform.nativeLibrary}" \
"packages/natives/native/${platform.addon}"
${lib.optionalString stdenv.hostPlatform.isLinux ''
# The loader extracts this archived addon at runtime, so fix its
# interpreter-independent Nix RPATH before Bun embeds it.
autoPatchelf -- "packages/natives/native/${platform.addon}"
''}
${lib.optionalString stdenv.hostPlatform.isDarwin ''
# arm64 Darwin requires even locally-built Mach-O addons to carry an
# ad-hoc signature. Sign before Bun archives the file.
signIfRequired "packages/natives/native/${platform.addon}"
''}
echo "Compiling OMP"
BUN_COMPILE_EXECUTABLE_PATH="${bunRuntimeTemplate}/libexec/bun" \
bun --cwd="$PWD/packages/coding-agent" run build
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 packages/coding-agent/dist/omp "$out/libexec/omp/omp"
makeWrapper "$out/libexec/omp/omp" "$out/bin/omp" \
--set PI_SKIP_VERSION_CHECK 1 \
${
if stdenv.hostPlatform.isLinux then "--prefix LD_LIBRARY_PATH" else "--prefix DYLD_LIBRARY_PATH"
} : "${lib.makeLibraryPath runtimeLibraries}"
runHook postInstall
'';
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
HOME="$TMPDIR" "$out/bin/omp" --smoke-test | grep -q "smoke-test: ok"
BUN_BE_BUN=1 "$out/libexec/omp/omp" -e \
'if (Bun.version !== "${bun.version}" || typeof Bun.Image !== "function") process.exit(1)'
runHook postInstallCheck
'';
meta = {
description = "Terminal-based coding agent with multi-model support";
homepage = "https://omp.sh";
changelog = "https://github.com/can1357/oh-my-pi/releases/tag/v${packageJson.version}";
license = lib.licenses.mit;
mainProgram = "omp";
platforms = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
sourceProvenance = with lib.sourceTypes; [
binaryNativeCode
fromSource
];
};
}
// lib.optionalAttrs stdenv.hostPlatform.isDarwin {
# The compile output is intentionally unsigned until all Darwin fixups are complete.
darwinDontCodeSign = false;
}
)
+1
View File
@@ -167,6 +167,7 @@
"gen:stats": "bun --cwd=packages/stats run gen:stats",
"gen:stats:reset": "bun --cwd=packages/stats run gen:stats:reset",
"gen:changelog": "bun scripts/rewrite-changelog.ts",
"gen:nix": "bun scripts/gen-nix-bun.ts",
"gen:tool-views": "bun --cwd=packages/collab-web run gen:tool-views",
"gen:bundle": "bun --cwd=packages/coding-agent run gen:bundle",
"gen:mupdf": "bun --cwd=packages/coding-agent run gen:mupdf",
+1
View File
@@ -5,6 +5,7 @@
### Added
- Added Astral `ty` as a built-in Python primary LSP server (`ty server`), ordered behind `pyright`/`basedpyright`/`pylsp` so it becomes the primary Python LSP only when the existing servers are unavailable. `ruff` remains the Python linter and coexists alongside `ty` ([#4617](https://github.com/can1357/oh-my-pi/issues/4617)).
- Added first-party Nix support with reproducible source builds for Linux and macOS on x86-64 and ARM64, a pinned development shell, an overlay, NixOS and Home Manager modules, offline Bun dependencies, and lightweight flake evaluation in CI. Nix-managed installs now direct updates back through Nix instead of replacing store-managed executables.
### Changed
@@ -53,10 +53,6 @@ if (
}
const transformersVersion = transformersManifest.version;
function shouldAdhocSignDarwinBinary(crossBuild: CrossBuild | null): boolean {
return process.platform === "darwin" && !crossBuild;
}
async function runCommand(
command: string[],
env: NodeJS.ProcessEnv = Bun.env,
@@ -76,6 +72,7 @@ async function runCommand(
async function main(): Promise<void> {
const crossBuild = resolveCrossBuild(Bun.env.CROSS_TARGET);
const shouldAdhocSign = process.platform === "darwin" && !crossBuild && Bun.env.BUN_NO_CODESIGN_MACHO_BINARY !== "1";
const outName = crossBuild ? `omp-${crossBuild.id}` : "omp";
const outputPath = path.join(packageDir, "dist", outName);
// Generate inside the try so the finally always restores the empty checked-in
@@ -99,10 +96,11 @@ async function main(): Promise<void> {
outfile: outputPath,
transformersVersion,
target: crossBuild?.target,
skipBuiltinCodesign: shouldAdhocSignDarwinBinary(crossBuild),
executablePath: Bun.env.BUN_COMPILE_EXECUTABLE_PATH || undefined,
skipBuiltinCodesign: shouldAdhocSign,
});
if (shouldAdhocSignDarwinBinary(crossBuild)) {
if (shouldAdhocSign) {
await runCommand(["codesign", "--force", "--sign", "-", outputPath]);
}
} finally {
@@ -16,6 +16,8 @@ export interface CodingAgentCompileOptions {
readonly transformersVersion: string;
/** Optional cross-compilation runtime target. */
readonly target?: Bun.Build.CompileTarget;
/** Optional unmodified Bun executable used as the standalone runtime template. */
readonly executablePath?: string;
/** Match release builds that minify identifiers while retaining names. */
readonly minifyIdentifiers?: boolean;
/** Disable Bun's built-in Darwin signing before the caller re-signs. */
@@ -47,7 +49,11 @@ export async function compileCodingAgent(options: CodingAgentCompileOptions): Pr
},
plugins: [await createLegacyPiVirtualModulePlugin()],
compile: {
...(options.target ? { target: options.target } : {}),
...(options.executablePath
? { executablePath: options.executablePath }
: options.target
? { target: options.target }
: {}),
outfile: options.outfile,
autoloadBunfig: false,
autoloadDotenv: false,
+8 -2
View File
@@ -20,6 +20,7 @@ const REPO = "can1357/oh-my-pi";
const PACKAGE = "@oh-my-pi/pi-coding-agent";
const HOMEBREW_FORMULA = "can1357/tap/omp";
const MISE_TOOL = "github:can1357/oh-my-pi";
const NIX_STORE_DIR = "/nix/store";
/**
* Official npm registry origin.
*
@@ -421,7 +422,7 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
return isPathInDirectoryLexical(resolvedFile, dirReal);
}
type UpdateMethod = "brew" | "mise" | "bun" | "npm" | "binary";
type UpdateMethod = "brew" | "mise" | "nix" | "bun" | "npm" | "binary";
interface UpdateMethodResolutionOptions {
homebrewPrefix?: string;
@@ -440,6 +441,7 @@ interface UpdateMethodResolutionOptions {
type UpdateTarget =
| { method: "brew" }
| { method: "mise" }
| { method: "nix" }
| { method: "bun"; path?: string }
| { method: "npm"; path?: string }
| { method: "binary"; path: string; replacesSymlink: boolean };
@@ -453,6 +455,7 @@ function resolveUpdateMethod(
const launcherExtension = path.extname(ompPath).toLowerCase();
const isWindowsScriptLauncher =
launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat";
if (isPathInDirectory(ompPath, NIX_STORE_DIR)) return "nix";
if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew";
if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise";
if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise";
@@ -1355,7 +1358,10 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
try {
const forceBinary = shouldForceBinaryUpdate(release);
const target = await resolveUpdateTarget({ allowPackageManagers: !forceBinary });
if (target.method === "brew") {
if (target.method === "nix") {
console.log(chalk.yellow("This installation is managed by Nix and cannot update itself."));
console.log(chalk.dim("Update the flake input or profile that provides omp, then rebuild."));
} else if (target.method === "brew") {
await updateViaHomebrew(release.version, opts.force);
} else if (target.method === "mise") {
await updateViaMise(release.version, opts.force);
@@ -1,3 +1,8 @@
import { $which } from "@oh-my-pi/pi-utils";
/** Portable command that rejects credential prompts without assuming an FHS layout. */
export const REJECT_PROMPT_COMMAND = $which("false") ?? "false";
export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
// Disable pagers so commands don't block on interactive views.
PAGER: "cat",
@@ -22,7 +27,7 @@ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
VISUAL: "true",
EDITOR: "true",
GIT_TERMINAL_PROMPT: "0",
SSH_ASKPASS: "/usr/bin/false",
SSH_ASKPASS: REJECT_PROMPT_COMMAND,
CI: "true",
AGENT: "1",
// Package manager defaults for unattended execution.
@@ -5,7 +5,7 @@ import { spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $env, Snowflake } from "@oh-my-pi/pi-utils";
import { $env, $which, Snowflake } from "@oh-my-pi/pi-utils";
/**
* Returns the user's preferred editor command, or a platform default.
@@ -56,7 +56,7 @@ export async function openInEditor(
const child =
process.platform === "win32"
? spawn(editor, [...editorArgs, tmpFile], { stdio, shell: true })
: spawn("/bin/sh", ["-c", `${editorCmd} "$1"`, "sh", tmpFile], { stdio });
: spawn($which("sh") ?? "sh", ["-c", `${editorCmd} "$1"`, "sh", tmpFile], { stdio });
const { promise, reject, resolve } = Promise.withResolvers<number>();
child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0)));
child.once("error", error => reject(error));
+2 -1
View File
@@ -10,6 +10,7 @@ import {
parseNumstat,
} from "../commit/git/diff";
import type { FileDiff, FileHunks, NumstatEntry } from "../commit/types";
import { REJECT_PROMPT_COMMAND } from "../exec/non-interactive-env";
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
// ════════════════════════════════════════════════════════════════════════════
@@ -199,7 +200,7 @@ const GIT_NON_INTERACTIVE_ENV = {
GIT_TERMINAL_PROMPT: "0",
LC_ALL: undefined,
LC_MESSAGES: "C",
SSH_ASKPASS: "/usr/bin/false",
SSH_ASKPASS: REJECT_PROMPT_COMMAND,
} satisfies Record<string, string | undefined>;
const GH_NON_INTERACTIVE_ENV = {
...GIT_NON_INTERACTIVE_ENV,
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { runUpdateCommand } from "../../src/cli/update-cli";
import { getLatestRelease, runUpdateCommand } from "../../src/cli/update-cli";
type FetchInput = string | URL | Request;
type FetchInit = RequestInit | BunFetchRequestInit;
@@ -26,3 +26,66 @@ describe("runUpdateCommand fetch cancellation", () => {
expect(requestSignal).toBeInstanceOf(AbortSignal);
});
});
describe("getLatestRelease rename pointers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
function stubRegistry(manifests: Record<string, unknown>): string[] {
const urls: string[] = [];
const fetchStub = Object.assign(
async (input: FetchInput) => {
const url = String(input);
urls.push(url);
let manifest: unknown;
for (const pkg in manifests) {
if (url.includes(pkg)) {
manifest = manifests[pkg];
break;
}
}
if (!manifest) return new Response(null, { status: 404, statusText: "Not Found" });
return Response.json(manifest);
},
{ preconnect: globalThis.fetch.preconnect },
);
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
return urls;
}
it("follows omp.rename to the new package and resolves version, dist, and names from its manifest", async () => {
const urls = stubRegistry({
"@new/omp": { version: "999.1.0", omp: { dist: "npm" } },
"@oh-my-pi/pi-coding-agent": {
version: "999.0.0",
omp: { dist: "binary", rename: { package: "@new/omp", natives: "@new/natives" } },
},
});
const release = await getLatestRelease();
expect(release.version).toBe("999.1.0");
expect(release.dist).toBe("npm");
expect(release.packages).toEqual({ pkg: "@new/omp", natives: "@new/natives" });
expect(urls).toEqual([
"https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest",
"https://registry.npmjs.org/@new/omp/latest",
]);
});
it("ignores a rename pointer that cycles back to an already-visited package", async () => {
const urls = stubRegistry({
"@oh-my-pi/pi-coding-agent": {
version: "999.0.0",
omp: { rename: { package: "@oh-my-pi/pi-coding-agent" } },
},
});
const release = await getLatestRelease();
expect(urls).toHaveLength(1);
expect(release.version).toBe("999.0.0");
expect(release.packages).toEqual({ pkg: "@oh-my-pi/pi-coding-agent", natives: "@oh-my-pi/pi-natives" });
});
});
@@ -10,7 +10,7 @@ import {
VaultProtocolHandler,
} from "@oh-my-pi/pi-coding-agent/internal-urls";
import * as vaultProtocol from "@oh-my-pi/pi-coding-agent/internal-urls/vault-protocol";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { $which, removeWithRetries } from "@oh-my-pi/pi-utils";
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vault-protocol-"));
@@ -367,9 +367,10 @@ describe("VaultProtocolHandler", () => {
});
it("aborts an in-flight spawn when the AbortSignal is cancelled", async () => {
if (!(await Bun.file("/bin/sleep").exists())) return;
const sleep = $which("sleep");
if (!sleep) return;
const controller = new AbortController();
const promise = vaultProtocol.spawnObsidian("/bin/sleep", ["10"], controller.signal, 30_000);
const promise = vaultProtocol.spawnObsidian(sleep, ["10"], controller.signal, 30_000);
await Bun.sleep(20);
controller.abort();
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { buildNonInteractiveEnv } from "@oh-my-pi/pi-coding-agent/exec/non-interactive-env";
import { buildNonInteractiveEnv, NON_INTERACTIVE_ENV } from "@oh-my-pi/pi-coding-agent/exec/non-interactive-env";
describe("buildNonInteractiveEnv", () => {
it("defaults Windows child-process encoding to UTF-8 when inherited env is unset", () => {
@@ -60,6 +60,16 @@ describe("buildNonInteractiveEnv", () => {
expect(env.GPG_TTY).toBe("/dev/pts/7");
});
it("uses an executable SSH askpass rejector on POSIX", async () => {
if (process.platform === "win32") return;
const proc = Bun.spawn([NON_INTERACTIVE_ENV.SSH_ASKPASS], {
stdout: "ignore",
stderr: "ignore",
});
expect(await proc.exited).toBe(1);
});
it("injects clap-compatible CI=true by default", () => {
expect(buildNonInteractiveEnv(undefined, {}, "linux").CI).toBe("true");
expect(buildNonInteractiveEnv(undefined, {}, "win32").CI).toBe("true");
@@ -12,14 +12,19 @@ import {
buildMiseForceInstallArgs,
buildMiseUpgradeArgs,
buildNpmInstallArgs,
buildRenameCleanupPackages,
downloadVerifiedBinary,
isMuslLinuxForTest,
migrateRenamedInstall,
parseUpdateArgs,
pruneBunInstallCache,
type ReleaseInfo,
type RenameMigrationSteps,
replaceBinaryForUpdate,
resolveBunGlobalNodeModulesDirFromLocations,
resolveReleaseBinaryAsset,
resolveReleaseDist,
resolveReleaseRename,
resolveUpdateMethodForTest,
shouldForceBinaryUpdate,
sweepStaleBackups,
@@ -102,6 +107,15 @@ describe("update-cli libc detection", () => {
});
describe("update-cli install target detection", () => {
it("leaves Nix store installations under Nix management", () => {
const method = resolveUpdateMethodForTest(
"/nix/store/0123456789-omp-17.2.15/bin/omp",
"/nix/store/9876543210-bun-1.3.14/bin",
);
expect(method).toBe("nix");
});
it("uses bun update when prioritized omp is inside bun global bin", () => {
const method = resolveUpdateMethodForTest("/Users/test/.bun/bin/omp", "/Users/test/.bun/bin");
@@ -243,6 +257,130 @@ describe("update-cli package manager commands", () => {
});
});
describe("update-cli npm rename contract", () => {
it("parses a well-formed omp.rename pointer and rejects malformed ones", () => {
expect(resolveReleaseRename({ omp: { rename: { package: "@new/omp", natives: "@new/natives" } } })).toEqual({
pkg: "@new/omp",
natives: "@new/natives",
});
expect(resolveReleaseRename({ omp: { rename: { package: "@new/omp" } } })).toEqual({
pkg: "@new/omp",
natives: undefined,
});
expect(resolveReleaseRename({ omp: { rename: { package: "" } } })).toBeUndefined();
expect(resolveReleaseRename({ omp: { rename: "@new/omp" } })).toBeUndefined();
expect(resolveReleaseRename({ omp: {} })).toBeUndefined();
expect(resolveReleaseRename(undefined)).toBeUndefined();
});
it("installs renamed package names in lock-step, with no old-name leftovers in the argv", () => {
const packages = { pkg: "@new/omp", natives: "@new/natives" };
const bunArgs = buildBunInstallArgs("17.0.0", "linux-x64", packages);
expect(bunArgs).toContain("@new/omp@17.0.0");
expect(bunArgs).toContain("@new/natives@17.0.0");
expect(bunArgs).toContain("@new/natives-linux-x64@17.0.0");
expect(bunArgs.some(arg => arg.startsWith("@oh-my-pi/"))).toBe(false);
expect(buildNpmInstallArgs("17.0.0", "linux-x64", packages)).toContain("@new/omp@17.0.0");
});
it("adds --force to npm argv only for rename migrations so the old package's bin can be clobbered", () => {
const packages = { pkg: "@new/omp", natives: "@new/natives" };
expect(buildNpmInstallArgs("17.0.0", "linux-x64", packages, { force: true })).toContain("--force");
expect(buildNpmInstallArgs("16.3.15", "win32-x64")).not.toContain("--force");
});
it("removes the old agent package and its natives companions, including the supported platform leaf", () => {
expect(buildRenameCleanupPackages("darwin-arm64")).toEqual([
"@oh-my-pi/pi-coding-agent",
"@oh-my-pi/pi-natives",
"@oh-my-pi/pi-natives-darwin-arm64",
]);
expect(buildRenameCleanupPackages("linux-arm")).toEqual(["@oh-my-pi/pi-coding-agent", "@oh-my-pi/pi-natives"]);
});
});
describe("migrateRenamedInstall transaction", () => {
const release: ReleaseInfo = {
tag: "v999.1.0",
version: "999.1.0",
packages: { pkg: "@new/omp", natives: "@new/natives" },
};
function scriptedSteps(script: { install: number[]; removeOld?: number; verify: boolean[] }): {
steps: RenameMigrationSteps;
calls: string[];
} {
const calls: string[] = [];
let installs = 0;
let verifies = 0;
return {
calls,
steps: {
async install() {
calls.push("install");
return script.install[installs++] ?? 0;
},
async removeOld() {
calls.push("removeOld");
return script.removeOld ?? 0;
},
async verify() {
calls.push("verify");
return script.verify[verifies++]
? { ok: true, actual: "999.1.0", path: "/bin/omp" }
: { ok: false, path: "/bin/omp" };
},
},
};
}
it("never touches the old install when the new install fails", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const { steps, calls } = scriptedSteps({ install: [1], verify: [] });
await expect(migrateRenamedInstall(release, steps)).rejects.toThrow("left untouched");
expect(calls).toEqual(["install"]);
});
it("installs the new package before removing the old one and verifies the result", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const { steps, calls } = scriptedSteps({ install: [0], verify: [true] });
await migrateRenamedInstall(release, steps);
expect(calls).toEqual(["install", "removeOld", "verify"]);
});
it("restores the bin link by reinstalling when old-package removal breaks verification", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const { steps, calls } = scriptedSteps({ install: [0, 0], verify: [false, true] });
await migrateRenamedInstall(release, steps);
expect(calls).toEqual(["install", "removeOld", "verify", "install", "verify"]);
});
it("treats old-package removal failure as a warning when the new install verifies", async () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation(message => {
logs.push(String(message));
});
const { steps, calls } = scriptedSteps({ install: [0], removeOld: 1, verify: [true] });
await migrateRenamedInstall(release, steps);
expect(calls).toEqual(["install", "removeOld", "verify"]);
expect(logs.some(line => line.includes("could not remove the old"))).toBe(true);
});
it("aborts with a recovery hint when verification still fails after the restore install", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const { steps, calls } = scriptedSteps({ install: [0, 0], verify: [false, false] });
await expect(migrateRenamedInstall(release, steps)).rejects.toThrow("curl -fsSL https://omp.sh/install");
expect(calls).toEqual(["install", "removeOld", "verify", "install", "verify"]);
});
});
describe("update-cli bun install command", () => {
it("pins the official npm registry and bypasses the manifest cache so a stale mirror or snapshot cannot mask a freshly published version", () => {
// Regression: omp queries https://registry.npmjs.org/<pkg>/latest directly.
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Made the embedded dashboard archive byte-reproducible by sorting entries and zeroing tar and gzip timestamps before it is compiled into OMP.
## [17.2.10] - 2026-08-06
### Changed
@@ -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();
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { buildArchiveBase64 } from "../scripts/generate-client-bundle";
const tempDirs: string[] = [];
async function createFixture(order: readonly string[]): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stats-archive-"));
tempDirs.push(root);
for (const relativePath of order) {
const filePath = path.join(root, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await Bun.write(filePath, relativePath === "index.html" ? "<main>OMP</main>" : "body { color: blue; }");
}
return root;
}
function tarHeaderMtimes(bytes: Uint8Array): number[] {
const mtimes: number[] = [];
for (let offset = 0; offset + 512 <= bytes.length; ) {
const header = bytes.subarray(offset, offset + 512);
if (header.every(byte => byte === 0)) break;
const sizeField = Buffer.from(header.subarray(124, 136)).toString("ascii").replace(/\0.*$/, "").trim();
const mtimeField = Buffer.from(header.subarray(136, 148)).toString("ascii").replace(/\0.*$/, "").trim();
const size = sizeField ? Number.parseInt(sizeField, 8) : 0;
mtimes.push(mtimeField ? Number.parseInt(mtimeField, 8) : 0);
offset += 512 * (1 + Math.ceil(size / 512));
}
return mtimes;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
describe("embedded stats client archive", () => {
test("is byte-stable across filesystem order and carries zero timestamps", async () => {
const firstDir = await createFixture(["index.html", "assets/app.css"]);
const secondDir = await createFixture(["assets/app.css", "index.html"]);
const first = await buildArchiveBase64(firstDir);
const second = await buildArchiveBase64(secondDir);
expect(second).toBe(first);
const gzipBytes = Buffer.from(first, "base64");
expect(gzipBytes.readUInt32LE(4)).toBe(0);
const tarBytes = Bun.gunzipSync(gzipBytes);
expect(tarHeaderMtimes(tarBytes)).toEqual([0, 0]);
const files = await new Bun.Archive(gzipBytes).files();
expect(await files.get("index.html")?.text()).toBe("<main>OMP</main>");
expect(await files.get("assets/app.css")?.text()).toBe("body { color: blue; }");
});
});
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bun
import * as path from "node:path";
import { $ } from "bun";
import { $which } from "../packages/utils/src/which";
const repoRoot = path.join(import.meta.dir, "..");
/** Regenerate the checked-in Bun dependency expression with the pinned bun2nix input. */
export async function generateNixBunDeps(): Promise<void> {
const bun2nix = $which("bun2nix");
if (bun2nix) {
await $`${bun2nix} -l bun.lock -c ../ -o nix/bun.nix`.cwd(repoRoot);
return;
}
const nix = $which("nix");
if (!nix) {
throw new Error("Generating nix/bun.nix requires bun2nix from `nix develop`, or Nix to enter that shell.");
}
await $`${nix} --extra-experimental-features ${"nix-command flakes"} --accept-flake-config develop --command bun2nix -l bun.lock -c ../ -o nix/bun.nix`.cwd(
repoRoot,
);
}
if (import.meta.main) await generateNixBunDeps();
+2
View File
@@ -11,6 +11,7 @@
import { $, Glob } from "bun";
import { compareVersions } from "../packages/utils/src/version.ts";
import { runChangelogFixer } from "./fix-changelogs";
import { generateNixBunDeps } from "./gen-nix-bun";
const changelogGlob = new Glob("packages/*/CHANGELOG.md");
const packageJsonGlob = new Glob("packages/*/package.json");
@@ -341,6 +342,7 @@ async function cmdRelease(versionOrBump: string): Promise<void> {
await $`rm -f bun.lock`;
await $`bun install`;
await $`cargo generate-lockfile`;
await generateNixBunDeps();
console.log();
// 5. Update changelogs