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:
@@ -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
|
||||
@@ -12,6 +12,8 @@ target/
|
||||
*.tsbuildinfo
|
||||
*.node
|
||||
*.b64.js
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¶ms)
|
||||
.run_string("sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, ¶ms)
|
||||
.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,
|
||||
¶ms,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
)
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }");
|
||||
});
|
||||
});
|
||||
Executable
+27
@@ -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();
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user