diff --git a/README.md b/README.md index f382976..1f9040f 100644 --- a/README.md +++ b/README.md @@ -2,82 +2,42 @@ A Go SDK for TypeSafe AI and the `jevwise` decision CLI. -- SDK: `pkg/typesafe`, with typed questions, responses, raw metadata, and retries. -- CLI: send a prompt and options to Jev, then print the service's probabilities as JSON. -- Codex and Claude providers are placeholders only. No external sessions are started. +## Installation -## Build and run - -Use Go 1.27.2 or newer, matching `go.mod`. Run these commands from the checkout: +### Homebrew ```sh -git clone https://github.com/bitbrew-dev/jevwise.git -cd jevwise -go build -o bin/jevwise ./cmd -bin/jevwise --help -bin/jevwise decide --help +brew tap benbenbang/forge +brew install jevwise ``` -Help does not read credentials, configuration files, or contact the API. - -The current checkout uses `jevwise`; releases through v1.2.0 used `jev`. There is no `jev` alias. Existing configuration and the `jev` provider remain unchanged. Verify ownership before removing an older binary; another project may own the `jev` command. +- On Homebrew versions requiring explicit tap trust, or to avoid formula-name collisions, use `brew install benbenbang/forge/jevwise`. This selects and trusts only that formula: see [Homebrew tap trust](https://docs.brew.sh/Tap-Trust). +- Manage Homebrew installations with `brew upgrade jevwise`, not the standalone self-updater or install script's `--force` option. -```sh -export TYPESAFE_API_KEY='' -bin/jevwise decide --prompt 'Which task should I tackle first?' \ - --option 'Fix the bug' --option 'Write documentation' - -printf '%s' 'Which task should I tackle first?' | bin/jevwise decide --stdin \ - --option 'Fix the bug' --option 'Write documentation' -``` +### GitHub install script (macOS/Linux) -- Select exactly one of `--prompt` or `--stdin`. Stdin is read only when requested. -- Repeat `--option` at least twice. Labels must be distinct and nonblank after trimming. -- Commas remain part of a label; prompts preserve their original text and newlines. -- Stdin is limited to 1 MiB. Generic blocking input readers cannot be interrupted during a read; cancellation is checked before and after it. -- Stdout contains one JSON object with `model`, `choice`, `confidence`, `probabilities`, and optional `usage`. Values come from Jev, not local calculations. -- Errors go to stderr and cause a nonzero exit. Arbitrary service/input error text is not printed. -- `--timeout` covers the service operation, including retries and waits, after input is read. -- `--provider codex` and `--provider claude` return a clear not-implemented error without credentials or subprocesses. - -## Version and platform builds +Latest stable release, installed as `~/.local/bin/jevwise`: ```sh -bin/jevwise version -bin/jevwise --version -make build-platform GOOS=windows GOARCH=amd64 -make build-platform GOOS=windows GOARCH=arm64 +curl -fsSL https://raw.githubusercontent.com/bitbrew-dev/jevwise/main/scripts/install.sh | sh -s -- +# Pin a release and choose a writable directory: +curl -fsSL https://raw.githubusercontent.com/bitbrew-dev/jevwise/main/scripts/install.sh | sh -s -- --version v1.2.3 --dir "$HOME/bin" +# From a checkout, without piping remote shell code: +sh scripts/install.sh --version latest --dir "$HOME/.local/bin" ``` -- Both version forms report version, commit, and build date without credentials, config loading, or network requests. -- Plain `go build` reports `dev` with unknown metadata. Make injects metadata; only a clean exact tag is automatically a release version, otherwise `dev`. -- Release linker symbols are `github.com/bitbrew-dev/jevwise/internal/buildinfo.Version`, `.Commit`, and `.Date`. -- Make produces `build/jevwise-windows-amd64.exe` or `build/jevwise-windows-arm64.exe`. Make helpers require Unix shell tools; native Windows PowerShell can use `go build -o jevwise.exe ./cmd`, then `.\jevwise.exe --version`. - -## Release checks and updates +- `--version latest` is the default; pinned versions must be canonical stable `vX.Y.Z` tags. Latest selection uses GitHub's public release redirect, not the anonymous REST API quota. Private releases require manual authenticated downloads. +- `--dir PATH` selects the installation directory, including relative paths. The default is `$HOME/.local/bin`, not `/usr/local/bin`. Add it to `PATH` yourself if needed. +- Requires macOS/Linux amd64/arm64, POSIX `sh`, curl **8.4+**, standard Unix utilities and `sha256sum` or `shasum`. Windows installation remains manual. +- Existing files are preserved unless `--force` is supplied. Replacement requires a regular, nonsymlink executable owned by the current user. A pinned install with `--force` can downgrade; use your package manager for managed installations. +- Exact platform assets are verified against bounded `SHA256SUMS` before same-directory publication. Legacy `jev_...` assets are supported only when the manifest lacks the new name, never after a failed download/hash check. Older binaries retain their original branding and behavior even when installed as `jevwise`. +- The script uses no sudo, modifies no shell/config/agent settings, and never executes the downloaded binary. Restart running MCP servers after replacement. Cooperating installers/updaters share a lock; stale locks require manual inspection. +- Trust the repository, GitHub/TLS, local tools and installation parents. Final directory symlinks and world-writable directories are refused; hostile concurrent writers and power-loss durability are not supported. Checksums detect corruption, not a compromised publisher. +- Piping executes remote code before you can inspect it. Prefer downloading and reviewing the script first, and pin its URL to a trusted commit/tag when reproducibility matters. The script itself is not authenticated by the binary checksum. The public URL becomes available when this change lands on `main`. -```sh -bin/jevwise update --check -bin/jevwise update --check --timeout 20s -bin/jevwise update --timeout 60s # Install on supported standalone Linux/macOS builds -``` +### Manual GitHub download -- `--debug update --check` shows the fixed `bitbrew-dev/jevwise` endpoint, HTTP status, rate-limit remaining/reset/retry seconds and failure category. Anonymous checks can be rate-limited: wait for the limit to reset. A 403 without rate-limit evidence is reported as an HTTP failure, not assumed to be rate limiting. -- `--check` checks the latest stable GitHub release without downloading a binary or changing files. No background checks, API credentials, or decision configuration are used. -- `--timeout` is a positive flag-only deadline for the whole update, default `10s`; decision timeout environment/config values do not affect updates. Increase it for slow downloads. -- Reports newer/current releases or no published release. Development/unknown builds report the latest release without inventing a version comparison. -- Accepts canonical stable `vX.Y.Z` tags only; prereleases/drafts and malformed or oversized responses are refused. -- Without `--check`, downloads and verifies a strictly newer release before replacement. Equal/newer installed versions are not rewritten or downgraded. Development, unknown and unstamped builds require manual installation. -- Automatic installation supports Linux/macOS amd64/arm64 only. Windows still supports version reporting and release checks, but updates are manual. Use your package manager for managed installations. -- After success, start a new invocation to use the new binary. An error saying the update was installed means publication succeeded but cancellation, cleanup or reporting failed: inspect `jevwise version` and any lock before retrying; no rollback occurs. - -## Release binaries - -The release-assets workflow attaches raw CLI binaries and `SHA256SUMS` to an existing stable [GitHub release](https://github.com/bitbrew-dev/jevwise/releases). It does not create versions or releases. - -- Publishing supports the upstream repository whether public or private. Actions authenticates planning and upload; source-fetch credentials are temporary and never persisted or passed to builds. -- CLI release checks and self-update remain anonymous/public-only. For private releases, download assets through authenticated GitHub access and install manually. -- To retry asset publication without creating another release, run the release-assets workflow on `main` with the existing release tag. Existing assets are never overwritten or automatically resumed. +Download a binary and `SHA256SUMS` from [GitHub Releases](https://github.com/bitbrew-dev/jevwise/releases). Choose your platform and verify the SHA-256 checksum before installation. | Platform | Architectures | Asset pattern | | --- | --- | --- | @@ -85,237 +45,21 @@ The release-assets workflow attaches raw CLI binaries and `SHA256SUMS` to an exi | macOS | amd64, arm64 | `jevwise_vX.Y.Z_darwin_ARCH` | | Windows | amd64, arm64 | `jevwise_vX.Y.Z_windows_ARCH.exe` | -- Future releases use the patterns above; existing v1.0.0, v1.1.0 and v1.2.0 keep their legacy `jev_...` names and are not rewritten. -- Download the exact OS/architecture asset. Install it as `jevwise` (`jevwise.exe` on Windows); Unix users must make it executable. Verify `jevwise version` before use. -- For the first renamed release, install manually: older `jev` clients cannot find `jevwise_...` assets. Confirm binary ownership before removing an old copy; never remove another project's `jev`. Regenerate shell completions with `jevwise completion SHELL` and review saved scripts or agent commands. -- The new updater prefers `jevwise_...`, using `jev_...` only when the new exact platform name is absent from release metadata, never after a verification/download failure. It replaces its current executable path without renaming that path or installing a legacy alias. -- `SHA256SUMS` hashes the exact raw binary bytes. Verify the hash before use. Checksums detect corruption, not a compromised publisher; trust remains the repository and GitHub/TLS. -- The internal update downloader verifies exact platform asset names, metadata sizes and SHA-256 before returning bytes. Binary/manifest limits are 64 MiB/64 KiB; downloads use fixed release URLs and bounded HTTPS redirects to allowlisted GitHub hosts, without credentials or cookies. -- Release/Make builds embed a passive update stamp that survives stripping and path trimming. Version guards inspect Go module/platform metadata, recheck the candidate checksum, and reject equal/older or mismatched on-disk versions without executing binaries. Stamps are self-declared metadata, not signatures. -- Your manual semantic-release workflow still owns release creation. Asset publication runs after a published release, with a manual tag-based fallback. No release is created during development QA. -- Publication rejects existing target assets and uploads the checksum manifest last. If publication fails midway, inspect and remove incomplete assets manually before retrying; no automatic overwrite/delete/resume occurs. -- Native CI tests Linux, macOS, and Windows CLI execution. Windows arm64 is cross-built, not claimed as natively executed. -- On Windows, default config discovery uses `%USERPROFILE%\.config\ts-jev\config.toml` when `XDG_CONFIG_HOME` is unset. - -## Replacement safety - -- Automatic replacement is limited to standalone Linux/macOS binaries. Windows updates remain manual; the updater never uses sudo or elevates privileges. -- A per-executable `..jev-update-lock` directory serializes cooperating updates. Crashed/stale locks require manual inspection and removal, never automatic deletion. -- Guards reject unsafe returned-path targets and changed versions. Only permission bits are preserved, not group ownership, ACLs or xattrs. A complete same-directory staging file is permissioned, synced and closed before renaming; failures before publication preserve the original. Errors after publication do not roll it back. -- Filesystem checks assume ordinary cooperative local storage, not hostile concurrent writers, mount changes or power-loss durability. Context cancellation is checked at safe boundaries; filesystem calls are not interruptible. -- Use your package manager for managed installs. `os.Executable` may resolve a symlink launch to its physical target, so the updater cannot reliably identify every symlink-invoked or package-managed installation. - -## Debug logging - -```sh -jevwise --debug update --check -jevwise --debug mcp -``` - -- Debug logging is opt-in via `--debug`, uses `github.com/phuslu/log`, and writes JSON to stderr. Normal stdout stays unchanged; logs can be redirected with `2>debug.log`. -- Covers command/stage outcomes and timing, config loading, API attempts/retries/status codes, release verification/replacement, skills, MCP requests/lifecycle and authenticated management. -- Never logs credentials, prompts/options/results, user-supplied URLs/paths, raw headers/bodies, or raw error causes. Release checks may log their fixed public GitHub endpoint and strictly numeric rate-limit metadata. Treat timing/status diagnostics as operationally sensitive when sharing logs. -- Detached background mode traces the launcher only; child output remains isolated. There is no persistent debug log file. Use foreground MCP for request diagnostics, then restart without `--debug` when finished. -- Logging is best-effort. Write failures do not change operation results. No global logger or SDK configuration is changed. - -## CLI configuration - -Precedence: **changed flags > nonblank environment > TOML > defaults**. - -| Setting | Flag | Environment | Default | -| --- | --- | --- | --- | -| API key | `--api-key` | `TS_JEV_API_KEY`, then `TYPESAFE_API_KEY` | Required for Jev | -| Base URL | `--base-url` | `TS_JEV_BASE_URL`, then `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | -| Model | `--model` | `TS_JEV_MODEL`, then `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | -| Provider | `--provider` | `TS_JEV_PROVIDER` | `jev` | -| Operation timeout | `--timeout` | `TS_JEV_TIMEOUT` | `10s` | - -- Explicit file: `--config /path/to/config.toml`. Missing or invalid explicit files fail. -- Otherwise: current-directory `jevwise.toml`, then `$XDG_CONFIG_HOME/ts-jev/config.toml` or `$HOME/.config/ts-jev/config.toml`. Only a missing global file is allowed. The selected file replaces rather than merges with global settings; no ancestor-directory search. -- Trust the current directory before running decision/MCP commands: local settings can change the upstream destination. Environment/changed flags still win. Invalid, oversized (over 1 MiB), linked or nonregular local files fail without global fallback; help/version do not load them. -- Start with [config.example.toml](config.example.toml). Prefer environment credentials rather than command-line secrets. -- TOML `api_key` supports `$NAME`/`${NAME}`, for example `api_key = "${MY_JEV_KEY}"`. Runtime loading expands references and trims the result; undefined variables become empty. -- Environment and `--api-key` values stay literal. File viewing never expands references or requires a key; Jev service initialization requires a nonblank key. - -### Initialize, inspect and edit - -```sh -jevwise config init # Global XDG/HOME config, even if local exists -jevwise config init --local # ./jevwise.toml, automatically selected in this cwd -jevwise config view # Selected file/defaults, API key redacted -jevwise config view --config /path/to/config.toml -jevwise config edit # Selected local/global file, vim by default -jevwise config edit --editor nvim # One-shot override, also for repairs -``` - -- Init never overwrites existing files, directories or links. It writes a template, not your environment credentials; Unix file permissions are requested as `0600`, new parent directories as `0700`. Use a trusted parent with safe ownership/ACLs: mode bits alone do not guarantee privacy, including inherited Unix ACLs. Windows users must manage ACLs separately. -- `--config PATH` also selects an init destination; do not combine it with `--local` or pass a blank path. View ignores environment/decision flag overrides, unknown keys and comments; invalid files fail safely. It is not a dump of effective runtime settings. Only the `api_key` field is automatically redacted; do not store credentials in other displayed settings. -- Set `editor = "nvim"` (or another executable name/path) in TOML to change the editor. Selection is `config edit --editor` > selected file's `editor` > `vim` on PATH; `$EDITOR`/`$VISUAL` are not read. No editor is bundled and no shell is used. Arguments are not parsed: use a trusted wrapper executable if options such as `--wait` are needed. -- Edit forwards terminal input/output and waits for the direct editor process. It uses the command context, not the decision timeout; missing editors/nonzero exits fail safely. Invalid TOML falls back to Vim for repair; wrong-typed editor settings or oversized files can be repaired using `--editor`. It refuses missing files, directories and final symlinks. -- Go-copy pipe waits are bounded after process exit/cancellation. Only the direct editor process is managed, not wrapper descendants; arbitrary blocking custom readers/writers cannot be forcibly interrupted. -- Trust local editor settings and wrappers before editing: they select a program to execute. Editor terminal output intentionally may show the file you are editing. macOS/Linux support assumes an installed editor; no Windows editor availability/behavior is promised. -- Add `jevwise.toml` to your project's ignore rules before storing credentials. Prefer environment API keys. - -## Skill setup - -```sh -bin/jevwise skill # Default: .agent/skills/typesafe-ai/SKILL.md -bin/jevwise skill --agent # Explicit default target -bin/jevwise skill --claude # .claude/skills/typesafe-ai/SKILL.md -bin/jevwise skill --force # Replace an existing regular skill file -bin/jevwise skill --online # Print the upstream GitHub URL only -``` - -- Downloads the latest [upstream skill](https://github.com/typesafe-ai/skills/blob/main/skills/typesafe-ai/SKILL.md) unchanged, relative to the current project directory. -- Local installation is the default; `--local` selects it explicitly. `--online` and `--local`, or `--agent` and `--claude`, cannot be combined. Online mode rejects installation flags and performs no download or write. -- Hidden `--jev` selects the only supported skill. No decision credentials/configuration are read or sent. `--timeout` sets a `10s` default deadline for fetching and checks before file publication; filesystem calls are not forcibly interrupted. -- Downloads require HTTP 200 and valid UTF-8 Markdown/front matter, are limited to 1 MiB, and never follow redirects. Invalid downloads leave existing skills untouched. -- Existing files require `--force`. Static symlinks and nonregular destinations are refused; staging files are cleaned and complete bytes published without truncating the old inode. -- Native Unix supports force replacement. Windows force replacement and JS/WASI/Plan 9 installation are unsupported; filesystems without hardlinks fail safely. -- Filesystem operations assume ordinary native filesystems without hostile mounts/directory renames. Failure/cancellation after publication may mean the skill is already installed; no unsafe rollback is attempted. - -## Go SDK quickstart +- Install Unix binaries as `jevwise` in a directory on PATH and make them executable. Windows users install as `jevwise.exe` in a directory on PATH. +- Releases through v1.2.0 retain legacy `jev_...` filenames and original branding. No `jev` alias is installed; verify ownership before removing an old binary belonging to another project. +- Checksums detect corruption, not a compromised publisher. See [release assets and verification](docs/releases.md) for details. +- Prefer building from source? Follow the [development guide](docs/development.md#build-from-source). -Import the public SDK from `github.com/bitbrew-dev/jevwise/pkg/typesafe`. The CLI executable is `jevwise`. +## Documentation -Migration: replace previous `github.com/benbenbang/ts-jev-go-sdk/pkg/typesafe` imports with the new path. No old-module compatibility alias is provided; SDK APIs and configuration names are unchanged. - -```go -package main - -import ( - "context" - "fmt" - - "github.com/bitbrew-dev/jevwise/pkg/typesafe" -) - -func main() { - client, err := typesafe.NewClient(typesafe.ClientOptions{}) // TYPESAFE_API_KEY - if err != nil { panic(err) } - defer client.Close() - - ctx, cancel := context.WithTimeout(context.Background(), typesafe.DefaultTimeout) - defer cancel() - result, err := client.SystemOne(ctx, typesafe.SystemOneRequest{ - State: "Which task should I tackle first?", - Questions: map[string]typesafe.Question{ - "decision": typesafe.Choice{Criteria: map[string]typesafe.JSONContent{ - "Fix the bug": nil, "Write documentation": nil, - }}, - }, - }) - if err != nil { panic(err) } - fmt.Println(result.Choices()["decision"].Probabilities) -} -``` - -| Need | API | -| --- | --- | -| Yes/no, choices, rubric scores | `Noul`, `Choice`, `Score` | -| Future/custom question fields | `RawQuestion` | -| Available models | `client.ListModels(ctx)` | -| Raw JSON and HTTP metadata | `SystemOneRaw`, `ListModelsRaw` | -| Independent custom schema | `SystemOneInto(ctx, request, &destination)` | -| Decode previously returned raw bytes | `raw.Decode(&destination)` | -| Per-call settings | Optional `RequestOptions` argument | - -- SDK string options resolve explicit values > corresponding `TYPESAFE_*` environment > defaults. CLI-only `TS_JEV_*` variables do not configure SDK clients directly. -- `RequestOptions` supports model, timeout, headers, extra body, and retry policy. Model-list calls accept only timeout, headers, and retry. -- Model precedence: client default < nonempty request model < non-nil per-call model < extra-body model. A model pointer preserves explicit empty text. -- Extra body shallowly replaces fields, including nulls, after original state/questions are validated. Final overrides are not revalidated. -- Default timeout is 10 seconds **per attempt**. Set a caller context deadline to bound the entire SDK operation. -- Nil retry policy inherits defaults/client policy. `Retry: &typesafe.RetryPolicy{}` disables retries. Overrides replace the complete policy. -- Defaults: two retries, HTTP 408/429/500-599, connection/timeouts, 500 ms exponential backoff capped at 5 s, subtractive 25% jitter, 30 s retry budget. -- Retry budget includes attempts and waits but does not cancel in-flight work. Caller context expiration always stops further retries. Server retry delays are respected without the backoff cap. -- A predicate adds retryable errors to built-in rules. HTTP and typed/custom decoding share an attempt; standalone `RawResponse.Decode` never retries. -- Injected HTTP clients remain caller-owned. `Close` releases only SDK-owned idle connections. Configuration maps are copied; do not mutate inputs while constructing/preparing a call. -- Use `errors.As` with `APIError`, `ConnectionError`, `TimeoutError`, or `ResponseValidationError`. Inspectable raw bodies, headers, and causes may contain sensitive data; do not log them indiscriminately. - -## Development - -```sh -go mod tidy -diff -go test -count=1 ./... -go test -race -count=1 ./... -go vet ./... -go build ./... -``` - -Tests use injected services/transports and local HTTP fixtures, not paid API requests. - -- Compatibility is pinned to Python SDK v0.7.2, revision `f078f1e208a0d885154dc758344ae4fce77ac168`. -- Release/update QA uses injected HTTP responses and temporary executables, never overwriting the developer's running CLI or publishing a release. - -## Local MCP - -MCP is included in v1.1.0; diagnostic PID tracking and schema-2 state are included in v1.2.0. The executable rename is newer than those releases. - -| Capability | v1.0.0 | v1.1.0 | v1.2.0 | Current checkout | -| --- | --- | --- | --- | --- | -| Foreground and background MCP | Not included | Included | Included | Included | -| Authenticated status and stop | Not included | Included | Included | Included | -| Recorded child PID and schema-2 state | Not included | Not included | Included | Included | - -The examples below describe the current checkout, using synthetic paths and addresses. They are not services started by this documentation. - -### Start and manage - -Configure upstream credentials as for `jevwise decide`. Separately prepare a random agent bearer token in a protected, current-user-only file. Do not reuse your API key. - -```sh -# Synthetic examples: supply your own protected token file before running. -bin/jevwise mcp --token-file /private/example/agent-token --listen 127.0.0.1:8080 -bin/jevwise mcp --background --token-file /private/example/agent-token -bin/jevwise mcp status -bin/jevwise mcp stop -``` - -| Command or flag | Behavior | +| Guide | Contents | | --- | --- | -| `jevwise mcp` | Foreground server; prints the public `/mcp` endpoint after startup. Ctrl+C stops it. | -| `--background` | Starts a terminal-independent child after authenticated readiness and ownership checks. | -| `jevwise mcp status` | Authenticates the exact instance and reports its startup/running state. v1.2.0 and newer also show the recorded PID when available. | -| `jevwise mcp stop` | Authenticates the exact instance and requests shutdown; an acknowledgement is not a promise that cleanup has already finished. | -| `--listen` | Literal loopback IP and nonzero port; default `127.0.0.1:8080`. | -| `--runtime-dir` | Private background state directory; default `os.UserCacheDir()/jevwise-mcp`. Use the same override for start, status and stop. | -| `--token-file` | Explicit protected agent-token file, overriding `JEV_MCP_TOKEN`; unreadable or unsafe files fail without fallback. | - -- Without `--token-file`, startup reads `JEV_MCP_TOKEN`. Tokens must be nonempty printable ASCII without whitespace; a file may end in one LF or CRLF. No secret-valued token flag exists. -- Keep credentials out of command arguments and shell history. Use protected files or appropriately secured environment configuration instead of `--api-key`. -- `status` and `stop` do not load decision configuration, API keys, the decision service or the agent token file. They use the private runtime management key, with no upstream API request. -- Background mode does not install a login item, reboot service or automatic restart. On Windows, a restrictive parent Job Object can prevent detachment or terminate the child when its parent exits. -- A repeated background start returns an endpoint only for an authenticated running instance. If it is still preparing or stopping, wait and check status instead. -- Stop and restart after changing credentials, decision configuration or the executable. Updating a binary does not change an already-running process. -- PID support (v1.2.0 and newer): background children save their own PID in private `/state.json`; authenticated status prints `PID: NUMBER (inspection only)`. Normal owned shutdown removes that state along with the key and lease. PID reuse means this number is not proof of ownership or liveness: use `jevwise mcp stop`, not a blind PID-based kill. -- State format (v1.2.0 and newer): updated children write schema-2 state. The updated CLI still manages schema-1 instances without displaying a PID; older CLIs, including v1.1.0, cannot read schema-2 state. Stop an old instance before upgrading/restarting, and use the matching or newer CLI to manage a new child. - -### Agent connection and limits - -For token-safe Codex and Claude Code setup, discovery checks and cleanup, follow the [local agent onboarding guide](docs/mcp-agents.md). - -Connect an HTTP-capable local agent to `http://127.0.0.1:8080/mcp`, supplying `Authorization: Bearer `. The only tool is `decide`: send `prompt` and at least two distinct `options`. Results contain Jev probabilities and optional usage; ordinary decisions can incur upstream API charges. - -- Stateless Streamable HTTP uses POST with SSE responses, not legacy `/sse` or standalone GET streams. GET/DELETE do not create sessions. Supported revisions: 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05; JSON-RPC batches are rejected. -- Only literal loopback addresses are supported. Requests need the exact configured Host and, when supplied, the exact `http://IP:port` Origin. Proxy headers do not establish trust; this is not a public-network deployment mode. -- Limits: 1 MiB encoded request, 8 MiB encoded decision JSON and 32 MiB SSE event. Escaped text and structured output appear together, so an event can be larger than the decision JSON. Go SDK clients needing maximum-size results should set `StreamableClientTransport.MaxEventSize: 32 << 20`; its default is 16 MiB. -- Four immediate request slots prevent queued decisions. Requests beyond capacity are rejected, not delayed for a later paid call. -- `--timeout` bounds each decision, including retries and waits, not daemon lifetime. Modern request disconnect cancels work; older protocols rely on decision deadlines and lifetime shutdown. Shutdown cancels active decisions and drains connections for up to five seconds. -- Services, startup callbacks and output writers must cooperate with cancellation. Blocking caller code and filesystem calls cannot be forcibly interrupted safely. - -### Credentials, state and safety - -| Credential | Purpose | Where it belongs | -| --- | --- | --- | -| Upstream API key | Server-to-Jev decisions | Existing decision configuration; never give it to the agent. | -| Agent bearer token | Agent-to-local `/mcp` access | `JEV_MCP_TOKEN` or an explicit protected token file. | -| Management key | Status/stop and instance verification | Separate private runtime file; never give it to the agent or transmit it as a bearer token. | - -- Background bootstrap passes secrets through owned anonymous pipes, not child arguments, public state or logs. State contains bounded instance metadata, never credentials; the post-v1.1.0 checkout also records an inspection-only PID. Agent and API credentials must differ; the management key is separately generated. -- Management requests and responses use nonce-bound HMAC proofs for the exact instance, method and path. The private control key itself is never transmitted. Status/stop do not trust PIDs, signal unrelated processes or follow HTTP redirects/proxies. -- Fresh nonces prevent accepting replayed responses. Signed requests are not replay-cached; stop is idempotent and bound to the exact instance. -- Child stdout/stderr are discarded, with no retained output logs. Diagnostics are bounded, fixed safe messages through authenticated status/startup handling; prompts and arbitrary backend errors are not logged. -- Runtime files and token files must be owner-private. Unsafe permissions, links or ACLs are refused, not repaired. macOS refuses extended ACLs; Windows requires a protected current-user-only runtime-directory DACL and protected token-file DACLs. Background publication requires filesystem hardlink support and refuses unsupported storage. -- Existing locks, partial state, mismatched instances and unverified listeners require manual inspection. No automatic stale-lock deletion or PID-based kill occurs. Do not delete a runtime directory until you have established that its instance is stopped. -- Trusted runtime parent directories must already exist; startup creates only the private runtime leaf. -- Filesystem protection assumes caller-trusted runtime/token parent directories and ordinary cooperative local storage. It does not defend against hostile mount changes or ancestor-directory renaming. Local HTTP is not TLS, and same-user compromise is outside this boundary. +| [CLI usage](docs/usage.md) | Decisions and skill setup | +| [Configuration](docs/configuration.md) | Precedence, API keys, local config and editor | +| [Go SDK](docs/sdk.md) | Quickstart, request options and retries | +| [Development](docs/development.md) | Source builds, platform builds and tests | +| [Debugging](docs/debug.md) | Debug logging and safe diagnostics | +| [Updates](docs/updates.md) | Release checks, self-update and replacement safety | +| [Releases](docs/releases.md) | Assets, verification and publication | +| [Local MCP](docs/mcp.md) | Foreground/background mode, credentials and limits | +| [Agent onboarding](docs/mcp-agents.md) | Connect Codex or Claude Code safely | diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..306ce2a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,42 @@ +# Configuration + +[Documentation index](../README.md#documentation) + +## CLI configuration + +Precedence: **changed flags > nonblank environment > TOML > defaults**. + +| Setting | Flag | Environment | Default | +| --- | --- | --- | --- | +| API key | `--api-key` | `TS_JEV_API_KEY`, then `TYPESAFE_API_KEY` | Required for Jev | +| Base URL | `--base-url` | `TS_JEV_BASE_URL`, then `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | +| Model | `--model` | `TS_JEV_MODEL`, then `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | +| Provider | `--provider` | `TS_JEV_PROVIDER` | `jev` | +| Operation timeout | `--timeout` | `TS_JEV_TIMEOUT` | `10s` | + +- Explicit file: `--config /path/to/config.toml`. Missing or invalid explicit files fail. +- Otherwise: current-directory `jevwise.toml`, then `$XDG_CONFIG_HOME/ts-jev/config.toml` or `$HOME/.config/ts-jev/config.toml`. Only a missing global file is allowed. The selected file replaces rather than merges with global settings; no ancestor-directory search. +- On Windows, default config discovery uses `%USERPROFILE%\.config\ts-jev\config.toml` when `XDG_CONFIG_HOME` is unset. +- Trust the current directory before running decision/MCP commands: local settings can change the upstream destination. Environment/changed flags still win. Invalid, oversized (over 1 MiB), linked or nonregular local files fail without global fallback; help/version do not load them. +- Start with [config.example.toml](../config.example.toml). Prefer environment credentials rather than command-line secrets. +- TOML `api_key` supports `$NAME`/`${NAME}`, for example `api_key = "${MY_JEV_KEY}"`. Runtime loading expands references and trims the result; undefined variables become empty. +- Environment and `--api-key` values stay literal. File viewing never expands references or requires a key; Jev service initialization requires a nonblank key. + +### Initialize, inspect and edit + +```sh +jevwise config init # Global XDG/HOME config, even if local exists +jevwise config init --local # ./jevwise.toml, automatically selected in this cwd +jevwise config view # Selected file/defaults, API key redacted +jevwise config view --config /path/to/config.toml +jevwise config edit # Selected local/global file, vim by default +jevwise config edit --editor nvim # One-shot override, also for repairs +``` + +- Init never overwrites existing files, directories or links. It writes a template, not your environment credentials; Unix file permissions are requested as `0600`, new parent directories as `0700`. Use a trusted parent with safe ownership/ACLs: mode bits alone do not guarantee privacy, including inherited Unix ACLs. Windows users must manage ACLs separately. +- `--config PATH` also selects an init destination; do not combine it with `--local` or pass a blank path. View ignores environment/decision flag overrides, unknown keys and comments; invalid files fail safely. It is not a dump of effective runtime settings. Only the `api_key` field is automatically redacted; do not store credentials in other displayed settings. +- Set `editor = "nvim"` (or another executable name/path) in TOML to change the editor. Selection is `config edit --editor` > selected file's `editor` > `vim` on PATH; `$EDITOR`/`$VISUAL` are not read. No editor is bundled and no shell is used. Arguments are not parsed: use a trusted wrapper executable if options such as `--wait` are needed. +- Edit forwards terminal input/output and waits for the direct editor process. It uses the command context, not the decision timeout; missing editors/nonzero exits fail safely. Invalid TOML falls back to Vim for repair; wrong-typed editor settings or oversized files can be repaired using `--editor`. It refuses missing files, directories and final symlinks. +- Go-copy pipe waits are bounded after process exit/cancellation. Only the direct editor process is managed, not wrapper descendants; arbitrary blocking custom readers/writers cannot be forcibly interrupted. +- Trust local editor settings and wrappers before editing: they select a program to execute. Editor terminal output intentionally may show the file you are editing. macOS/Linux support assumes an installed editor; no Windows editor availability/behavior is promised. +- Add `jevwise.toml` to your project's ignore rules before storing credentials. Prefer environment API keys. diff --git a/docs/debug.md b/docs/debug.md new file mode 100644 index 0000000..a05f80a --- /dev/null +++ b/docs/debug.md @@ -0,0 +1,16 @@ +# Debugging + +[Documentation index](../README.md#documentation) + +## Debug logging + +```sh +jevwise --debug update --check +jevwise --debug mcp +``` + +- Debug logging is opt-in via `--debug`, uses `github.com/phuslu/log`, and writes JSON to stderr. Normal stdout stays unchanged; logs can be redirected with `2>debug.log`. +- Covers command/stage outcomes and timing, config loading, API attempts/retries/status codes, release verification/replacement, skills, MCP requests/lifecycle and authenticated management. +- Never logs credentials, prompts/options/results, user-supplied URLs/paths, raw headers/bodies, or raw error causes. Release checks may log their fixed public GitHub endpoint and strictly numeric rate-limit metadata. Treat timing/status diagnostics as operationally sensitive when sharing logs. +- Detached background mode traces the launcher only; child output remains isolated. There is no persistent debug log file. Use foreground MCP for request diagnostics, then restart without `--debug` when finished. +- Logging is best-effort. Write failures do not change operation results. No global logger or SDK configuration is changed. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..bf0c70f --- /dev/null +++ b/docs/development.md @@ -0,0 +1,48 @@ +# Development + +[Documentation index](../README.md#documentation) + +## Build from source + +Use Go 1.27.2 or newer, matching `go.mod`. Run these commands from the checkout: + +```sh +git clone https://github.com/bitbrew-dev/jevwise.git +cd jevwise +go build -o bin/jevwise ./cmd +bin/jevwise --help +bin/jevwise decide --help +``` + +Help does not read credentials, configuration files, or contact the API. + +The current checkout uses `jevwise`; releases through v1.2.0 used `jev`. There is no `jev` alias. Existing configuration and the `jev` provider remain unchanged. Verify ownership before removing an older binary; another project may own the `jev` command. + +## Version and platform builds + +```sh +bin/jevwise version +bin/jevwise --version +make build-platform GOOS=windows GOARCH=amd64 +make build-platform GOOS=windows GOARCH=arm64 +``` + +- Both version forms report version, commit, and build date without credentials, config loading, or network requests. +- Plain `go build` reports `dev` with unknown metadata. Make injects metadata; only a clean exact tag is automatically a release version, otherwise `dev`. +- Release linker symbols are `github.com/bitbrew-dev/jevwise/internal/buildinfo.Version`, `.Commit`, and `.Date`. +- Make produces `build/jevwise-windows-amd64.exe` or `build/jevwise-windows-arm64.exe`. Make helpers require Unix shell tools; native Windows PowerShell can use `go build -o jevwise.exe ./cmd`, then `.\jevwise.exe --version`. + +## Tests + +```sh +go mod tidy -diff +go test -mod=readonly -count=1 ./... +go test -mod=readonly -race -count=1 ./... +go vet -mod=readonly ./... +go build -mod=readonly ./... +``` + +Tests use injected services/transports and local HTTP fixtures, not paid API requests. + +- Compatibility is pinned to Python SDK v0.7.2, revision `f078f1e208a0d885154dc758344ae4fce77ac168`. +- Release/update QA uses injected HTTP responses and temporary executables, never overwriting the developer's running CLI or publishing a release. diff --git a/docs/mcp-agents.md b/docs/mcp-agents.md index 52e3b46..28d6379 100644 --- a/docs/mcp-agents.md +++ b/docs/mcp-agents.md @@ -2,7 +2,7 @@ Use Codex or Claude Code on the **same computer** as the Jevwise MCP server. Jevwise serves Streamable HTTP at a literal-loopback `/mcp` endpoint, not stdio or legacy `/sse`. Do not register `jevwise mcp` as a stdio command or expose it through a public tunnel. -MCP is available in v1.1.0. Recorded PID/schema-2 state requires v1.2.0 or newer; see [release availability](../README.md#local-mcp). +MCP is available in v1.1.0. Recorded PID/schema-2 state requires v1.2.0 or newer; see [release availability](mcp.md). The commands below use the renamed checkout CLI, `jevwise`; published releases through v1.2.0 use `jev`. @@ -10,8 +10,8 @@ The commands below use the renamed checkout CLI, `jevwise`; published releases t Prerequisites: -- Install/build `jevwise` and configure the Jev provider as in the [README](../README.md#build-and-run). -- Prepare a random, separate agent bearer token in a protected current-user-only file. Follow the [token and runtime permissions](../README.md#credentials-state-and-safety), including Windows ACL requirements. +- Install/build `jevwise` and configure the Jev provider using the [installation guide](../README.md#installation) and [configuration guide](configuration.md). +- Prepare a random, separate agent bearer token in a protected current-user-only file. Follow the [token and runtime permissions](mcp.md#credentials-state-and-safety), including Windows ACL requirements. - Replace the example token-file path below with your own existing protected file. Do not reuse your upstream API key or the runtime management key. ```sh diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..1853f8b --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,74 @@ +# Local MCP + +[Documentation index](../README.md#documentation) + +MCP is included in v1.1.0; diagnostic PID tracking and schema-2 state are included in v1.2.0. The executable rename is newer than those releases. + +| Capability | v1.0.0 | v1.1.0 | v1.2.0 | Current checkout | +| --- | --- | --- | --- | --- | +| Foreground and background MCP | Not included | Included | Included | Included | +| Authenticated status and stop | Not included | Included | Included | Included | +| Recorded child PID and schema-2 state | Not included | Not included | Included | Included | + +The examples below describe the current checkout, using synthetic paths and addresses. They are not services started by this documentation. + +### Start and manage + +Configure upstream credentials as for `jevwise decide`. Separately prepare a random agent bearer token in a protected, current-user-only file. Do not reuse your API key. + +```sh +# Synthetic examples: supply your own protected token file before running. +jevwise mcp --token-file /private/example/agent-token --listen 127.0.0.1:8080 +jevwise mcp --background --token-file /private/example/agent-token +jevwise mcp status +jevwise mcp stop +``` + +| Command or flag | Behavior | +| --- | --- | +| `jevwise mcp` | Foreground server; prints the public `/mcp` endpoint after startup. Ctrl+C stops it. | +| `--background` | Starts a terminal-independent child after authenticated readiness and ownership checks. | +| `jevwise mcp status` | Authenticates the exact instance and reports its startup/running state. v1.2.0 and newer also show the recorded PID when available. | +| `jevwise mcp stop` | Authenticates the exact instance and requests shutdown; an acknowledgement is not a promise that cleanup has already finished. | +| `--listen` | Literal loopback IP and nonzero port; default `127.0.0.1:8080`. | +| `--runtime-dir` | Private background state directory; default `os.UserCacheDir()/jevwise-mcp`. Use the same override for start, status and stop. | +| `--token-file` | Explicit protected agent-token file, overriding `JEV_MCP_TOKEN`; unreadable or unsafe files fail without fallback. | + +- Without `--token-file`, startup reads `JEV_MCP_TOKEN`. Tokens must be nonempty printable ASCII without whitespace; a file may end in one LF or CRLF. No secret-valued token flag exists. +- Keep credentials out of command arguments and shell history. Use protected files or appropriately secured environment configuration instead of `--api-key`. +- `status` and `stop` do not load decision configuration, API keys, the decision service or the agent token file. They use the private runtime management key, with no upstream API request. +- Background mode does not install a login item, reboot service or automatic restart. On Windows, a restrictive parent Job Object can prevent detachment or terminate the child when its parent exits. +- A repeated background start returns an endpoint only for an authenticated running instance. If it is still preparing or stopping, wait and check status instead. +- Stop and restart after changing credentials, decision configuration or the executable. Updating a binary does not change an already-running process. +- PID support (v1.2.0 and newer): background children save their own PID in private `/state.json`; authenticated status prints `PID: NUMBER (inspection only)`. Normal owned shutdown removes that state along with the key and lease. PID reuse means this number is not proof of ownership or liveness: use `jevwise mcp stop`, not a blind PID-based kill. +- State format (v1.2.0 and newer): updated children write schema-2 state. The updated CLI still manages schema-1 instances without displaying a PID; older CLIs, including v1.1.0, cannot read schema-2 state. Stop an old instance before upgrading/restarting, and use the matching or newer CLI to manage a new child. + +### Agent connection and limits + +For token-safe Codex and Claude Code setup, discovery checks and cleanup, follow the [local agent onboarding guide](mcp-agents.md). + +Connect an HTTP-capable local agent to `http://127.0.0.1:8080/mcp`, supplying `Authorization: Bearer `. The only tool is `decide`: send `prompt` and at least two distinct `options`. Results contain Jev probabilities and optional usage; ordinary decisions can incur upstream API charges. + +- Stateless Streamable HTTP uses POST with SSE responses, not legacy `/sse` or standalone GET streams. GET/DELETE do not create sessions. Supported revisions: 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05; JSON-RPC batches are rejected. +- Only literal loopback addresses are supported. Requests need the exact configured Host and, when supplied, the exact `http://IP:port` Origin. Proxy headers do not establish trust; this is not a public-network deployment mode. +- Limits: 1 MiB encoded request, 8 MiB encoded decision JSON and 32 MiB SSE event. Escaped text and structured output appear together, so an event can be larger than the decision JSON. Go SDK clients needing maximum-size results should set `StreamableClientTransport.MaxEventSize: 32 << 20`; its default is 16 MiB. +- Four immediate request slots prevent queued decisions. Requests beyond capacity are rejected, not delayed for a later paid call. +- `--timeout` bounds each decision, including retries and waits, not daemon lifetime. Modern request disconnect cancels work; older protocols rely on decision deadlines and lifetime shutdown. Shutdown cancels active decisions and drains connections for up to five seconds. +- Services, startup callbacks and output writers must cooperate with cancellation. Blocking caller code and filesystem calls cannot be forcibly interrupted safely. + +### Credentials, state and safety + +| Credential | Purpose | Where it belongs | +| --- | --- | --- | +| Upstream API key | Server-to-Jev decisions | Existing decision configuration; never give it to the agent. | +| Agent bearer token | Agent-to-local `/mcp` access | `JEV_MCP_TOKEN` or an explicit protected token file. | +| Management key | Status/stop and instance verification | Separate private runtime file; never give it to the agent or transmit it as a bearer token. | + +- Background bootstrap passes secrets through owned anonymous pipes, not child arguments, public state or logs. State contains bounded instance metadata, never credentials; the post-v1.1.0 checkout also records an inspection-only PID. Agent and API credentials must differ; the management key is separately generated. +- Management requests and responses use nonce-bound HMAC proofs for the exact instance, method and path. The private control key itself is never transmitted. Status/stop do not trust PIDs, signal unrelated processes or follow HTTP redirects/proxies. +- Fresh nonces prevent accepting replayed responses. Signed requests are not replay-cached; stop is idempotent and bound to the exact instance. +- Child stdout/stderr are discarded, with no retained output logs. Diagnostics are bounded, fixed safe messages through authenticated status/startup handling; prompts and arbitrary backend errors are not logged. +- Runtime files and token files must be owner-private. Unsafe permissions, links or ACLs are refused, not repaired. macOS refuses extended ACLs; Windows requires a protected current-user-only runtime-directory DACL and protected token-file DACLs. Background publication requires filesystem hardlink support and refuses unsupported storage. +- Existing locks, partial state, mismatched instances and unverified listeners require manual inspection. No automatic stale-lock deletion or PID-based kill occurs. Do not delete a runtime directory until you have established that its instance is stopped. +- Trusted runtime parent directories must already exist; startup creates only the private runtime leaf. +- Filesystem protection assumes caller-trusted runtime/token parent directories and ordinary cooperative local storage. It does not defend against hostile mount changes or ancestor-directory renaming. Local HTTP is not TLS, and same-user compromise is outside this boundary. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..246aa93 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,26 @@ +# Release binaries and publishing + +[Documentation index](../README.md#documentation) + +The release-assets workflow attaches raw CLI binaries and `SHA256SUMS` to an existing stable [GitHub release](https://github.com/bitbrew-dev/jevwise/releases). It does not create versions or releases. + +- Publishing supports the upstream repository whether public or private. Actions authenticates planning and upload; source-fetch credentials are temporary and never persisted or passed to builds. +- CLI release checks and self-update remain anonymous/public-only. For private releases, download assets through authenticated GitHub access and install manually. +- To retry asset publication without creating another release, run the release-assets workflow on `main` with the existing release tag. Existing assets are never overwritten or automatically resumed. + +| Platform | Architectures | Asset pattern | +| --- | --- | --- | +| Linux | amd64, arm64 | `jevwise_vX.Y.Z_linux_ARCH` | +| macOS | amd64, arm64 | `jevwise_vX.Y.Z_darwin_ARCH` | +| Windows | amd64, arm64 | `jevwise_vX.Y.Z_windows_ARCH.exe` | + +- Future releases use the patterns above; existing v1.0.0, v1.1.0 and v1.2.0 keep their legacy `jev_...` names and are not rewritten. +- Download the exact OS/architecture asset. Install it as `jevwise` (`jevwise.exe` on Windows); Unix users must make it executable. Verify `jevwise version` before use. +- For the first renamed release, use the install script or install manually: older `jev` clients cannot find `jevwise_...` assets. Confirm binary ownership before removing an old copy; never remove another project's `jev`. Regenerate shell completions with `jevwise completion SHELL` and review saved scripts or agent commands. +- The new updater prefers `jevwise_...`, using `jev_...` only when the new exact platform name is absent from release metadata, never after a verification/download failure. It replaces its current executable path without renaming that path or installing a legacy alias. +- `SHA256SUMS` hashes the exact raw binary bytes. Verify the hash before use. Checksums detect corruption, not a compromised publisher; trust remains the repository and GitHub/TLS. +- The internal update downloader verifies exact platform asset names, metadata sizes and SHA-256 before returning bytes. Binary/manifest limits are 64 MiB/64 KiB; downloads use fixed release URLs and bounded HTTPS redirects to allowlisted GitHub hosts, without credentials or cookies. +- Release/Make builds embed a passive update stamp that survives stripping and path trimming. Version guards inspect Go module/platform metadata, recheck the candidate checksum, and reject equal/older or mismatched on-disk versions without executing binaries. Stamps are self-declared metadata, not signatures. +- Your manual semantic-release workflow still owns release creation. Asset publication runs after a published release, with a manual tag-based fallback. No release is created during development QA. +- Publication rejects existing target assets and uploads the checksum manifest last. If publication fails midway, inspect and remove incomplete assets manually before retrying; no automatic overwrite/delete/resume occurs. +- Native CI tests Linux, macOS, and Windows CLI execution. Windows arm64 is cross-built, not claimed as natively executed. diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..bdbab10 --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,61 @@ +# Go SDK + +[Documentation index](../README.md#documentation) + +## Go SDK quickstart + +Import the public SDK from `github.com/bitbrew-dev/jevwise/pkg/typesafe`. The CLI executable is `jevwise`. + +Migration: replace previous `github.com/benbenbang/ts-jev-go-sdk/pkg/typesafe` imports with the new path. No old-module compatibility alias is provided; SDK APIs and configuration names are unchanged. + +```go +package main + +import ( + "context" + "fmt" + + "github.com/bitbrew-dev/jevwise/pkg/typesafe" +) + +func main() { + client, err := typesafe.NewClient(typesafe.ClientOptions{}) // TYPESAFE_API_KEY + if err != nil { panic(err) } + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), typesafe.DefaultTimeout) + defer cancel() + result, err := client.SystemOne(ctx, typesafe.SystemOneRequest{ + State: "Which task should I tackle first?", + Questions: map[string]typesafe.Question{ + "decision": typesafe.Choice{Criteria: map[string]typesafe.JSONContent{ + "Fix the bug": nil, "Write documentation": nil, + }}, + }, + }) + if err != nil { panic(err) } + fmt.Println(result.Choices()["decision"].Probabilities) +} +``` + +| Need | API | +| --- | --- | +| Yes/no, choices, rubric scores | `Noul`, `Choice`, `Score` | +| Future/custom question fields | `RawQuestion` | +| Available models | `client.ListModels(ctx)` | +| Raw JSON and HTTP metadata | `SystemOneRaw`, `ListModelsRaw` | +| Independent custom schema | `SystemOneInto(ctx, request, &destination)` | +| Decode previously returned raw bytes | `raw.Decode(&destination)` | +| Per-call settings | Optional `RequestOptions` argument | + +- SDK string options resolve explicit values > corresponding `TYPESAFE_*` environment > defaults. CLI-only `TS_JEV_*` variables do not configure SDK clients directly. +- `RequestOptions` supports model, timeout, headers, extra body, and retry policy. Model-list calls accept only timeout, headers, and retry. +- Model precedence: client default < nonempty request model < non-nil per-call model < extra-body model. A model pointer preserves explicit empty text. +- Extra body shallowly replaces fields, including nulls, after original state/questions are validated. Final overrides are not revalidated. +- Default timeout is 10 seconds **per attempt**. Set a caller context deadline to bound the entire SDK operation. +- Nil retry policy inherits defaults/client policy. `Retry: &typesafe.RetryPolicy{}` disables retries. Overrides replace the complete policy. +- Defaults: two retries, HTTP 408/429/500-599, connection/timeouts, 500 ms exponential backoff capped at 5 s, subtractive 25% jitter, 30 s retry budget. +- Retry budget includes attempts and waits but does not cancel in-flight work. Caller context expiration always stops further retries. Server retry delays are respected without the backoff cap. +- A predicate adds retryable errors to built-in rules. HTTP and typed/custom decoding share an attempt; standalone `RawResponse.Decode` never retries. +- Injected HTTP clients remain caller-owned. `Close` releases only SDK-owned idle connections. Configuration maps are copied; do not mutate inputs while constructing/preparing a call. +- Use `errors.As` with `APIError`, `ConnectionError`, `TimeoutError`, or `ResponseValidationError`. Inspectable raw bodies, headers, and causes may contain sensitive data; do not log them indiscriminately. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..f1861b0 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,26 @@ +# Release checks and updates + +[Documentation index](../README.md#documentation) + +```sh +jevwise update --check +jevwise update --check --timeout 20s +jevwise update --timeout 60s # Install on supported standalone Linux/macOS builds +``` + +- `--debug update --check` shows the fixed `bitbrew-dev/jevwise` endpoint, HTTP status, rate-limit remaining/reset/retry seconds and failure category. Anonymous checks can be rate-limited: wait for the limit to reset. A 403 without rate-limit evidence is reported as an HTTP failure, not assumed to be rate limiting. +- `--check` checks the latest stable GitHub release without downloading a binary or changing files. No background checks, API credentials, or decision configuration are used. +- `--timeout` is a positive flag-only deadline for the whole update, default `10s`; decision timeout environment/config values do not affect updates. Increase it for slow downloads. +- Reports newer/current releases or no published release. Development/unknown builds report the latest release without inventing a version comparison. +- Accepts canonical stable `vX.Y.Z` tags only; prereleases/drafts and malformed or oversized responses are refused. +- Without `--check`, downloads and verifies a strictly newer release before replacement. Equal/newer installed versions are not rewritten or downgraded. Development, unknown and unstamped builds require manual installation. +- Automatic installation supports Linux/macOS amd64/arm64 only. Windows still supports version reporting and release checks, but updates are manual. Use your package manager for managed installations. +- After success, start a new invocation to use the new binary. An error saying the update was installed means publication succeeded but cancellation, cleanup or reporting failed: inspect `jevwise version` and any lock before retrying; no rollback occurs. + +## Replacement safety + +- Automatic replacement is limited to standalone Linux/macOS binaries. Windows updates remain manual; the updater never uses sudo or elevates privileges. +- A per-executable `..jev-update-lock` directory serializes cooperating updates. Crashed/stale locks require manual inspection and removal, never automatic deletion. +- Guards reject unsafe returned-path targets and changed versions. Only permission bits are preserved, not group ownership, ACLs or xattrs. A complete same-directory staging file is permissioned, synced and closed before renaming; failures before publication preserve the original. Errors after publication do not roll it back. +- Filesystem checks assume ordinary cooperative local storage, not hostile concurrent writers, mount changes or power-loss durability. Context cancellation is checked at safe boundaries; filesystem calls are not interruptible. +- Use your package manager for managed installs. `os.Executable` may resolve a symlink launch to its physical target, so the updater cannot reliably identify every symlink-invoked or package-managed installation. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..a48b84c --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,43 @@ +# CLI usage + +[Documentation index](../README.md#documentation) + +The examples assume `jevwise` is installed on PATH. For a source build, use `bin/jevwise` instead. + +## Decisions + +```sh +export TYPESAFE_API_KEY='' +jevwise decide --prompt 'Which task should I tackle first?' \ + --option 'Fix the bug' --option 'Write documentation' + +printf '%s' 'Which task should I tackle first?' | jevwise decide --stdin \ + --option 'Fix the bug' --option 'Write documentation' +``` + +- Select exactly one of `--prompt` or `--stdin`. Stdin is read only when requested. +- Repeat `--option` at least twice. Labels must be distinct and nonblank after trimming. +- Commas remain part of a label; prompts preserve their original text and newlines. +- Stdin is limited to 1 MiB. Generic blocking input readers cannot be interrupted during a read; cancellation is checked before and after it. +- Stdout contains one JSON object with `model`, `choice`, `confidence`, `probabilities`, and optional `usage`. Values come from Jev, not local calculations. +- Errors go to stderr and cause a nonzero exit. Arbitrary service/input error text is not printed. +- `--timeout` covers the service operation, including retries and waits, after input is read. +- `--provider codex` and `--provider claude` return a clear not-implemented error without credentials or subprocesses. + +## Skill setup + +```sh +jevwise skill # Default: .agent/skills/typesafe-ai/SKILL.md +jevwise skill --agent # Explicit default target +jevwise skill --claude # .claude/skills/typesafe-ai/SKILL.md +jevwise skill --force # Replace an existing regular skill file +jevwise skill --online # Print the upstream GitHub URL only +``` + +- Downloads the latest [upstream skill](https://github.com/typesafe-ai/skills/blob/main/skills/typesafe-ai/SKILL.md) unchanged, relative to the current project directory. +- Local installation is the default; `--local` selects it explicitly. `--online` and `--local`, or `--agent` and `--claude`, cannot be combined. Online mode rejects installation flags and performs no download or write. +- Hidden `--jev` selects the only supported skill. No decision credentials/configuration are read or sent. `--timeout` sets a `10s` default deadline for fetching and checks before file publication; filesystem calls are not forcibly interrupted. +- Downloads require HTTP 200 and valid UTF-8 Markdown/front matter, are limited to 1 MiB, and never follow redirects. Invalid downloads leave existing skills untouched. +- Existing files require `--force`. Static symlinks and nonregular destinations are refused; staging files are cleaned and complete bytes published without truncating the old inode. +- Native Unix supports force replacement. Windows force replacement and JS/WASI/Plan 9 installation are unsupported; filesystems without hardlinks fail safely. +- Filesystem operations assume ordinary native filesystems without hostile mounts/directory renames. Failure/cancellation after publication may mean the skill is already installed; no unsafe rollback is attempted. diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..917e87f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,142 @@ +#!/bin/sh +# Public release installer. Requires trusted local tools and installation parents. +set -eu +LC_ALL=C +export LC_ALL + +main() { + version=latest; directory=${HOME:+$HOME/.local/bin}; force=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --version|--dir) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail 'Missing option value' + if [ "$1" = --version ]; then version=$2; else directory=$2; fi + shift 2 ;; + --force) force=1; shift ;; + --help|-h) + printf '%s\n' 'Usage: sh install.sh [--version latest|vX.Y.Z] [--dir PATH] [--force]' \ + 'Default: latest release into ~/.local/bin. No automatic sudo.' + return ;; + *) fail 'Unknown option; use --help' ;; + esac + done + for tool in curl awk mktemp uname mkdir rm rmdir cat chmod mv ln id stat find; do + command -v "$tool" >/dev/null 2>&1 || fail "Required tool missing: $tool" + done + curl -q --version | awk 'NR==1 {split($2,v,"."); ok=(v[1]>8 || v[1]==8 && v[2]>=4)} END {exit !ok}' || fail 'curl 8.4+ is required for bounded downloads' + case $(uname -s) in Darwin) system=darwin ;; Linux) system=linux ;; *) fail 'Installer supports macOS/Linux only' ;; esac + case $(uname -m) in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) fail 'Unsupported architecture' ;; esac + if command -v sha256sum >/dev/null 2>&1; then hash=sha256sum + elif command -v shasum >/dev/null 2>&1; then hash=shasum + else fail 'sha256sum or shasum is required'; fi + [ "$version" = latest ] || valid_version "$version" || fail 'Version must be a canonical stable vX.Y.Z tag' + [ -n "$directory" ] || fail 'Set HOME or provide --dir' + case "$directory" in /*) ;; *) directory=$(pwd -P)/$directory ;; esac + while [ "$directory" != / ] && [ "${directory%/}" != "$directory" ]; do directory=${directory%/}; done + [ ! -L "$directory" ] || fail 'Install directory must not be a symlink' + (umask 022; mkdir -p "$directory") || fail 'Cannot create install directory; choose a writable --dir' + directory=$(cd "$directory" && pwd -P) + [ -z "$(find "$directory" -prune -perm -0002)" ] || fail 'Install directory must not be world-writable' + target=$directory/jevwise; stage=; work=; lock=$directory/.jevwise.jev-update-lock; locked=0 + trap cleanup 0 + trap 'exit 130' INT + trap 'exit 143' TERM + (umask 077; mkdir "$lock") || fail 'Update lock exists or directory is not writable; inspect before retrying' + locked=1 + if [ -e "$target" ] || [ -L "$target" ]; then + [ ! -L "$target" ] && [ -f "$target" ] || fail 'Destination must be a regular file, not a symlink' + [ "$force" = 1 ] || fail 'Destination exists; use --force only after verifying ownership' + case "$system" in darwin) owner=$(stat -f %u "$target") ;; linux) owner=$(stat -c %u "$target") ;; esac + [ "$owner" = "$(id -u)" ] || fail 'Existing executable is owned by another user' + fi + case ${TMPDIR:-/tmp} in /*) ;; *) fail 'TMPDIR must be absolute' ;; esac + umask 077 + work=$(mktemp -d "${TMPDIR:-/tmp}/jevwise-install.XXXXXXXX") + repo=https://github.com/bitbrew-dev/jevwise + if [ "$version" = latest ]; then + status=$(curl -q --silent --head --globoff --proto '=https' --connect-timeout 10 --max-time 60 \ + --dump-header "$work/headers" --output /dev/null --write-out '%{http_code}' "$repo/releases/latest") || fail 'Latest release lookup failed' + case "$status" in 301|302|303|307|308) ;; *) fail 'No latest public release redirect is available; try --version' ;; esac + location=$(redirect) + case "$location" in "$repo/releases/tag/"*) version=${location#"$repo/releases/tag/"} ;; *) fail 'Unexpected latest release location' ;; esac + valid_version "$version" || fail 'Latest release tag is not a stable vX.Y.Z version' + fi + base=$repo/releases/download/$version + fetch "$base/SHA256SUMS" "$work/manifest" 65536 + primary=jevwise_${version}_${system}_${arch}; legacy=jev_${version}_${system}_${arch} + selection=$(awk -v p="$primary" -v l="$legacy" ' + {if (NF!=2 || length($1)!=64 || $1~/[^0-9a-fA-F]/ || $2~/[^A-Za-z0-9_.-]/ || $0!=$1" "$2) bad=1 + if ($2==p) {pc++; ph=tolower($1)}; if ($2==l) {lc++; lh=tolower($1)}} + END {if (bad || pc>1 || lc>1) exit 1; if (pc==1) print p,ph; else if (lc==1) print l,lh; else exit 1} + ' "$work/manifest") || fail 'Manifest is invalid, ambiguous or lacks this platform' + # Both fields are restricted to safe ASCII above; no shell evaluation occurs. + asset=${selection%% *}; expected=${selection#* } + fetch "$base/$asset" "$work/download" 67108864 + [ -s "$work/download" ] || fail 'Downloaded executable is empty' + if [ "$hash" = sha256sum ]; then actual=$(sha256sum < "$work/download") + else actual=$(shasum -a 256 < "$work/download"); fi + actual=${actual%% *} + [ "$actual" = "$expected" ] || fail 'SHA-256 mismatch; no executable installed' + stage=$(mktemp "$directory/.jevwise-install.XXXXXXXX") + cat "$work/download" > "$stage" + chmod 755 "$stage" + # Cooperating installers/updaters share the lock; hostile concurrent writers are unsupported. + if [ -L "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then + fail 'Destination changed to an unsafe entry' + fi + if [ "$force" = 1 ]; then mv -f "$stage" "$target"; stage= + else ln "$stage" "$target" || fail 'Destination appeared or publication failed'; fi + printf 'Installed Jevwise %s at %s\n' "$version" "$target" + printf '%s\n' 'Add the install directory to PATH if needed; restart any running MCP server.' +} + +fail() { printf 'jevwise installer: %s\n' "$*" >&2; exit 1; } + +valid_version() { + printf '%s\n' "$1" | awk ' + NR==1 && /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/ { + sub(/^v/,""); split($0,a,"."); ok=1 + for (i=1;i<=3;i++) if (length(a[i])>20 || length(a[i])==20 && "x"a[i]>"x18446744073709551615") ok=0 + } END {exit !(NR==1 && ok)}' +} + +redirect() { + awk 'tolower($1)=="location:" {sub(/\r$/,""); sub(/^[^:]*:[ \t]*/,""); value=$0; n++} + END {if(n!=1) exit 1; print value}' "$work/headers" || fail 'Missing or ambiguous redirect' +} + +fetch() { + original=$1; url=$1; output=$2; limit=$3; hops=0 + while :; do + status=$(curl -q --silent --globoff --proto '=https' --connect-timeout 10 --max-time 60 \ + --max-filesize "$limit" --dump-header "$work/headers" --output "$output" --write-out '%{http_code}' "$url") || fail 'Download failed (network, TLS, timeout or size limit)' + case "$status" in + 200) return ;; + 301|302|303|307|308) + [ "$hops" -lt 5 ] || fail 'Too many download redirects' + url=$(redirect) + case "$url" in *'#'*) fail 'Redirect fragment refused' ;; esac + case "$url" in + "$original"|https://release-assets.githubusercontent.com/*|https://objects.githubusercontent.com/*) ;; + *) fail 'Download redirect host refused' ;; + esac + hops=$((hops + 1)) ;; + [0-9][0-9][0-9]) fail "Release download failed (HTTP $status)" ;; + *) fail 'Invalid download status' ;; + esac + done +} + +cleanup() { + result=$? + trap - 0 + if [ -n "$stage" ]; then rm -f "$stage" || result=1; fi + if [ -n "$work" ]; then + rm -f "$work/headers" "$work/manifest" "$work/download" || result=1 + rmdir "$work" || result=1 + fi + if [ "$locked" = 1 ]; then rmdir "$lock" || result=1; fi + exit "$result" +} + +main "$@" diff --git a/scripts/test_install.py b/scripts/test_install.py new file mode 100644 index 0000000..1b2f754 --- /dev/null +++ b/scripts/test_install.py @@ -0,0 +1,205 @@ +"""Installer fixtures never download or execute release binaries.""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name("install.sh").read_bytes() +PAYLOAD = b"fixture binary, not executable code\n" +# One fixture program supplies curl, uname and portable ownership responses. +STUB = r''' +import hashlib, json, os, pathlib, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +if name == "uname": + print(os.environ["SYSTEM"] if args == ["-s"] else os.environ["ARCH"]) + sys.exit() +if name == "stat": + print(os.environ.get("OWNER", str(os.getuid()))) + sys.exit() +assert args[0] == "-q" +if args == ["-q", "--version"]: + print("curl " + os.environ.get("CURL_VERSION", "8.7.1") + " fixture") + sys.exit() +assert "--location" not in args and "-L" not in args +assert args[args.index("--proto") + 1] == "=https" +assert "--connect-timeout" in args and "--max-time" in args +url, scenario = args[-1], os.environ.get("SCENARIO", "ok") +assert url.startswith("https://") +with open(os.environ["CALLS"], "a") as log: + log.write(json.dumps(args) + "\n") +header = pathlib.Path(args[args.index("--dump-header") + 1]) +output = pathlib.Path(args[args.index("--output") + 1]) +repo = "https://github.com/bitbrew-dev/jevwise" +status, location, body = 200, None, b"" +if scenario == "network": + sys.exit(7) +if url.endswith("/releases/latest"): + status, location = 302, repo + "/releases/tag/v1.2.3" + if scenario == "latest_bad": location += "-rc1" + if scenario == "latest_host": location = "https://evil.example/tag/v1.2.3" + if scenario == "rate": status, location = 403, None +else: + tag = url.split("/download/")[1].split("/")[0] if "/download/" in url else "v1.2.3" + system = {"Darwin": "darwin", "Linux": "linux"}[os.environ["SYSTEM"]] + arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}[os.environ["ARCH"]] + asset = "jevwise_" + tag + "_" + system + "_" + arch + payload = b"fixture binary, not executable code\n" + digest = hashlib.sha256(payload).hexdigest() + if url.endswith("SHA256SUMS"): + body = (digest + " " + asset + "\n").encode() + if scenario == "legacy": body = body.replace(b"jevwise_", b"jev_") + if scenario == "missing": body = body.replace(asset.encode(), b"other_asset") + if scenario == "duplicate": body += body + if scenario == "malformed": body = b"not a manifest\n" + if scenario in ("both", "both_fail"): body += body.replace(b"jevwise_", b"jev_") + else: + body = payload + if scenario in ("mismatch", "both_fail"): body += b"tampered" + if scenario == "empty": body = b"" + if scenario == "http": status = 404 + if scenario == "size": sys.exit(63) + if scenario.startswith("redirect") and url.startswith(repo): + status, body = 302, b"" + location = "https://release-assets.githubusercontent.com/fixture/binary?token=fake" + if scenario == "redirect_evil": location = "https://evil.example/binary" + if scenario == "redirect_http": location = "http://release-assets.githubusercontent.com/binary" + if scenario == "redirect_fragment": location += "#fragment" +header.write_text("HTTP/1.1 " + str(status) + " Fixture\r\n" + + ("Location: " + location + "\r\n" if location else "") + + ("Location: " + location + "\r\n" if scenario == "ambiguous" and location else "")) +if "--head" not in args: + limit = int(args[args.index("--max-filesize") + 1]) + assert limit == (65536 if url.endswith("SHA256SUMS") else 67108864) + assert len(body) <= limit + output.write_bytes(body) +print(status, end="") +''' + + +@unittest.skipUnless(os.name == "posix", "POSIX installer; Windows installs are manual") +class InstallerFixtures(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="jevwise-installer-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / "tools" + self.bin.mkdir() + for name in ("awk", "mktemp", "mkdir", "rm", "rmdir", "cat", "chmod", "mv", "ln", "id", "find", "shasum"): + executable = shutil.which(name) + self.assertIsNotNone(executable, name) + (self.bin / name).symlink_to(executable) + for name in ("curl", "uname", "stat"): + stub = self.bin / name + stub.write_text("#!" + sys.executable + "\n" + STUB) + stub.chmod(0o755) + self.home, self.tmp = self.root / "home", self.root / "tmp" + self.home.mkdir(); self.tmp.mkdir() + self.env = {"PATH": str(self.bin), "HOME": str(self.home), "TMPDIR": str(self.tmp), + "SYSTEM": "Linux", "ARCH": "x86_64", "CALLS": str(self.root / "calls")} + self.target = self.home / ".local/bin/jevwise" + + def run_install(self, *args, ok=True, shell="sh", **env): + result = subprocess.run([shutil.which(shell), "-s", "--", *args], input=SCRIPT, + cwd=self.root, env=dict(self.env, **env), capture_output=True, timeout=15) + self.assertEqual(result.returncode == 0, ok, result.stderr.decode()) + self.assertEqual(list(self.tmp.iterdir()), []) + self.assertEqual(list(self.home.rglob(".jevwise-install.*")), []) + self.assertEqual(list(self.home.rglob(".jevwise.jev-update-lock")), []) + return result + + def calls(self): + path = self.root / "calls" + return [json.loads(line)[-1] for line in path.read_text().splitlines()] if path.exists() else [] + + def test_latest_platforms_and_piped_shells(self): + for shell in ("sh", "dash"): + if not shutil.which(shell): continue + for system, arch in (("Linux", "x86_64"), ("Linux", "aarch64"), ("Darwin", "amd64"), ("Darwin", "arm64")): + with self.subTest(shell=shell, system=system, arch=arch): + self.run_install(shell=shell, SYSTEM=system, ARCH=arch) + self.assertEqual(self.target.read_bytes(), PAYLOAD) + self.assertEqual(self.target.stat().st_mode & 0o777, 0o755) + self.target.unlink() + self.assertTrue(any(url.endswith("/releases/latest") for url in self.calls())) + + def test_pinned_relative_custom_path_and_sha256sum(self): + sha = shutil.which("sha256sum") + if sha: (self.bin / "sha256sum").symlink_to(sha) + self.run_install("--version", "v0.0.1", "--dir", "custom bin", HOME="") + directory = self.root / "custom bin" + self.assertEqual((directory / "jevwise").read_bytes(), PAYLOAD) + self.assertEqual(sorted(p.name for p in directory.iterdir()), ["jevwise"]) + self.assertFalse(any(url.endswith("/releases/latest") for url in self.calls())) + self.assertTrue(all("/download/v0.0.1/" in url for url in self.calls())) + + def test_help_and_invalid_inputs_do_not_fetch(self): + self.run_install("--help") + for args in (("--unknown",), ("--version",), ("--dir", ""), + *(("--version", v) for v in ("1.2.3", "v01.2.3", "v1.2.3-rc1", "v1.2.3\n", "v1.2.3/../x", "v18446744073709551616.0.0"))): + with self.subTest(args=args): self.run_install(*args, ok=False) + self.run_install(ok=False, HOME="") + self.run_install(ok=False, CURL_VERSION="8.3.0") + self.run_install(ok=False, SYSTEM="Windows") + self.run_install(ok=False, ARCH="riscv64") + self.assertEqual(self.calls(), []) + self.assertFalse(self.target.exists()) + + def test_failures_preserve_existing_binary_and_cleanup(self): + self.target.parent.mkdir(parents=True) + self.target.write_bytes(b"original") + self.run_install(ok=False) + self.assertEqual(self.calls(), []) + for scenario in ("network", "rate", "latest_bad", "latest_host", "ambiguous", "missing", "duplicate", "malformed", + "mismatch", "empty", "http", "size", "both_fail", "redirect_evil", "redirect_http", "redirect_fragment"): + with self.subTest(scenario=scenario): + self.run_install("--force", ok=False, SCENARIO=scenario) + self.assertEqual(self.target.read_bytes(), b"original") + self.assertFalse(any("/jev_v" in url for url in self.calls())) + self.run_install("--force", SCENARIO="redirect_ok") + self.assertEqual(self.target.read_bytes(), PAYLOAD) + + def test_legacy_manifest_selection(self): + self.run_install(SCENARIO="legacy") + self.assertEqual(self.target.read_bytes(), PAYLOAD) + self.assertTrue(self.calls()[-1].endswith("/jev_v1.2.3_linux_amd64")) + self.target.unlink() + self.run_install(SCENARIO="both") + self.assertTrue(self.calls()[-1].endswith("/jevwise_v1.2.3_linux_amd64")) + + def test_unsafe_files_and_stale_lock_are_preserved(self): + self.target.parent.mkdir(parents=True) + victim = self.root / "victim" + victim.write_bytes(b"untouched") + self.target.symlink_to(victim) + self.run_install("--force", ok=False) + self.assertEqual(victim.read_bytes(), b"untouched") + self.target.unlink(); self.target.mkdir() + self.run_install("--force", ok=False) + self.target.rmdir(); self.target.write_bytes(b"original") + self.run_install("--force", ok=False, OWNER=str(os.getuid() + 1)) + self.assertEqual(self.target.read_bytes(), b"original") + lock = self.target.parent / ".jevwise.jev-update-lock" + lock.mkdir() + result = subprocess.run([shutil.which("sh"), "-s", "--", "--force"], input=SCRIPT, + cwd=self.root, env=self.env, capture_output=True, timeout=15) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(lock.is_dir()) + self.assertEqual(self.calls(), []) + lock.rmdir() + link = self.root / "linked dir" + link.symlink_to(self.target.parent) + self.run_install("--dir", str(link), ok=False) + self.run_install("--dir", str(link) + "/", ok=False) + self.target.parent.chmod(0o777) + self.run_install("--force", ok=False) + self.target.parent.chmod(0o755) + + +if __name__ == "__main__": + unittest.main()