diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..5fbf636 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Publish installer site +on: + push: + paths: + - 'web/**' + - 'tools/**' + - 'tests/**' + - '.github/workflows/**' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pages + cancel-in-progress: false +jobs: + checks: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: ./.github/workflows/test.yml + with: + revision: ${{ github.sha }} + stage: + needs: checks + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Verify public release before exposing latest.txt + run: | + python3 tools/pages-preflight.py + python3 tools/package-release.py "$(cat web/latest.txt)" "$RUNNER_TEMP/site" --site + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: ${{ runner.temp }}/site + deploy: + needs: stage + runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Refuse stale queued revision + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + BRANCH: ${{ github.event.repository.default_branch }} + REVISION: ${{ github.sha }} + run: test "$(gh api "repos/$GH_REPO/commits/$BRANCH" --jq .sha)" = "$REVISION" + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9ab7da4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Publish versioned tool +on: + workflow_dispatch: + inputs: + tag: + description: Existing reviewed tag, e.g. v0.2.0 (never overwritten) + required: true + type: string +permissions: + contents: read +concurrency: + group: release-${{ inputs.tag }} + cancel-in-progress: false +jobs: + resolve: + runs-on: ubuntu-24.04 + outputs: + revision: ${{ steps.tag.outputs.revision }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + persist-credentials: false + - id: tag + env: + TAG: ${{ inputs.tag }} + run: | + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + revision=$(git rev-parse "refs/tags/$TAG^{commit}") + test "$revision" = "$(git rev-parse HEAD)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + checks: + needs: resolve + uses: ./.github/workflows/test.yml + with: + revision: ${{ needs.resolve.outputs.revision }} + publish: + needs: [resolve, checks] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.resolve.outputs.revision }} + fetch-depth: 0 + persist-credentials: false + - name: Validate tag and prepare exact-byte assets + env: + TAG: ${{ inputs.tag }} + run: | + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + python3 tools/package-release.py "${TAG#v}" "$RUNNER_TEMP/assets" + - name: Create draft with all assets, then publish + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release create "$TAG" "$RUNNER_TEMP/assets/"* --verify-tag --draft --title "$TAG" --notes "Verified jvm tool payloads. See README for installation instructions." + gh release edit "$TAG" --draft=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36f85c8..bd46d22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,14 @@ name: Bash portability on: push: pull_request: + workflow_call: + inputs: + revision: + type: string + default: '' + real_jdk: + type: boolean + default: false workflow_dispatch: inputs: real_jdk: @@ -13,11 +21,32 @@ on: permissions: contents: read -concurrency: - group: bash-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: + packaging: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.revision || github.sha }} + persist-credentials: false + - run: python3 tests/release-tooling.py + + shells: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.revision || github.sha }} + persist-credentials: false + - run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y zsh fish csh tcsh shellcheck + csh_bin=$(dpkg -L csh | awk '/\/bsd-csh$/ {print; exit}') + test -x "$csh_bin" + sudo update-alternatives --set csh "$csh_bin" + shellcheck -S warning web/install.sh + bash tests/tool-installer.sh --native-shells + unix: name: ${{ matrix.os }} / Bash ${{ matrix.bash }} runs-on: ${{ matrix.os }} @@ -34,6 +63,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + ref: ${{ inputs.revision || github.sha }} - name: Bash 3.2 build dependencies if: runner.os == 'Linux' && matrix.bash == '3.2' run: | @@ -91,11 +121,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + ref: ${{ inputs.revision || github.sha }} - name: Windows PowerShell 5.1 smoke suite run: | $PSVersionTable.PSVersion powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/powershell.ps1 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/tool-installer.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } windows: name: Windows / ${{ matrix.environment }} / Bash ${{ matrix.bash }} @@ -121,6 +154,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + ref: ${{ inputs.revision || github.sha }} - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 if: matrix.environment == 'MSYS2' with: diff --git a/INSTALLER_PLAN.md b/INSTALLER_PLAN.md new file mode 100644 index 0000000..d7f5871 --- /dev/null +++ b/INSTALLER_PLAN.md @@ -0,0 +1,352 @@ +# Installer implementation handoff + +Date: 2026-11-03. Implementation: 2026-11-04, approved defaults applied. +The installer sources, tests and publication workflows are implemented locally; +publication and hosted verification remain maintainer steps in RELEASING.md. +SPEC.md records completed behavior and recovery boundaries. +Read `AGENTS.md` and `SPEC.md` before implementation. This document defines the +work breakdown; `SPEC.md` remains the product contract. When implementing, +update the contract to distinguish completed behavior from remaining proposals. + +## Objective and boundaries + +Deliver user-wide installers at `https://getjvm.dev/install.sh` and +`https://getjvm.dev/install.ps1`. Install the tool into the resolved user's +`~/.local/bin`, offer persistent PATH setup, and separately offer persistent +JAVA_HOME/default-JDK PATH setup. Keep JDK storage under the existing XDG roots. + +User decisions already established: + +- The installer is essential. `jvm use` is independent, optional and excluded. +- Executable placement is `~/.local/bin`, independent of XDG_DATA_HOME, on all + platforms. Windows uses native paths beneath the same home-resolution policy + as `jvm.ps1` (HOME, then USERPROFILE when HOME is unavailable). +- Offer tool PATH setup when needed and default-JDK environment setup separately. +- Windows persists these settings in the current user's environment registry. +- Put public installer sources and optional website content in root-level + `web/`. Deploy its contents as the Pages site root through GitHub Actions. +- The domain now reaches GitHub Pages through Cloudflare. On 2026-11-03 the + root returned 404, but a concrete `/index/metadata/...` URL still redirected + to the existing upstream index. Do not change that metadata routing. + +Do not implement `use`, change `env`, create shell functions for dispatch, +clear GRAALVM_HOME, install a JDK, set a persistent JDK default, edit project +copies, add runtime dependencies, or add automatic updates. No new JDK-manager +command is necessary. A landing page is optional and must not delay installers. + +## Proposed defaults for this plan + +These defaults originally filled gaps in the discussion. The user approved +them on 2026-11-04 for this implementation. The remaining text preserves the +implementation handoff; SPEC.md describes the resulting contract, and +RELEASING.md records publication steps and verification boundaries. + +1. Publish versioned GitHub Release assets; default to a stable version recorded + in a small Pages `latest.txt`. Support explicit version selection. Never fetch + tool payloads from the moving default branch or scrape JSON/HTML for versions. +2. Ship `jvm` on Unix and all three scripts (`jvm`, `jvm.ps1`, `jvm.cmd`) on + Windows, regardless of which installer is used. Require no Bash installation + for the native PowerShell route. +3. Support persistent setup for Bash, Zsh, Fish, csh and tcsh, plus Windows user + environment values. Unknown/custom shells get manual instructions. Unix + shell setup is not a prerequisite for installing the executable. +4. Rerunning an installer updates an installation it owns; matching installed + content is a no-op. Refuse unrelated or locally modified destination files + with an actionable explanation. No overwrite/force flag in version one. +5. Permit default-JDK setup before a default exists, explaining that Java becomes + available after the first JDK installation. Do not resolve it through `home` + or `env`, since those commands can install JDKs. +6. Document manual tool removal initially. Do not add an uninstall command. + +## Public installer interface + +Proposed Bash options: + +```text +install.sh [--version ] + [--path | --no-path] [--java | --no-java] + [--shell bash|zsh|fish|csh|tcsh] [--profile ] + [--non-interactive] +install.sh --help +``` + +Proposed PowerShell equivalents: `-Version`, `-Path` / `-NoPath`, `-Java` / +`-NoJava`, `-NonInteractive`, and `-Help`. Shell/profile options are Unix-only; +the native installer is Windows-only. Reject conflicting or unsupported options +before networking, directory creation or edits. Help requires no network. + +Defaults and consent: + +- Installing/updating the tool is the requested action and needs no extra prompt. +- Ask separately about missing tool PATH setup and default-JDK setup; show the + exact target files/registry values and intended changes. Default answers are no. +- Detect an already-correct managed setup and avoid repeat prompts and edits. +- Explicit positive options consent to their respective edits. Negative options + suppress them. Never silently replace an unrelated JAVA_HOME without showing + the old/new value interactively or receiving explicit `--java` / `-Java`. +- With no terminal or in noninteractive mode, install the tool and skip unspecified + environment edits, printing manual instructions. Explicit positive options still + work unattended; unresolved custom profile selection fails that requested step. +- Read Bash prompts from `/dev/tty`, not piped script stdin. PowerShell must detect + inability to prompt and follow the same noninteractive policy. +- `--shell` selects one shell per invocation. Detection may suggest the login + shell, but the interpreter running install.sh is not proof of caller shell. + Do not edit every installed shell automatically. +- `--profile` explicitly selects a startup file for the chosen shell. Reject it + without a shell selection. Unknown shell detection skips automatic setup with + instructions rather than guessing a profile. + +Examples to document once implemented: + +```sh +curl -fsSL https://getjvm.dev/install.sh | bash +curl -fsSL https://getjvm.dev/install.sh | bash -s -- --path --no-java --non-interactive +``` + +```powershell +iex (irm 'https://getjvm.dev/install.ps1') +# For options, download to a file and invoke that script with parameters. +``` + +CMD can launch the PowerShell installer with `powershell.exe -NoProfile -Command`. +Test exact quoting of the documented invocation. Do not change machine execution +policy. Use the existing Windows PowerShell 5.1 baseline and built-in .NET. + +Exit statuses: 0 for completed installation with requested setup successful (or +optional setup declined), 2 for invalid arguments, 1 for operational failure. +If requested environment setup fails after successful tool publication, return 1 +and explicitly report that the tool was installed and which setup step failed. + +## Step 1: Distribution and publication contract + +Add release packaging outside the runtime scripts, with an offline validation +mode. Proposed tag convention: `v`, with `` a documented strict +numeric dotted version. First inspect existing tags and version constants; do +not rename existing releases or publish a release without authorization. + +For each release, produce exact-byte assets: + +```text +jvm +jvm.ps1 +jvm.cmd +SHA256SUMS +``` + +The checksum file has one SHA-256 digest and one allowlisted basename per line. +Use a fixed small parser. Reject duplicate/missing entries, invalid digests, +unexpected paths and HTML/error responses. Hash the downloaded bytes before +setting permissions or publishing. Do not execute downloaded scripts to discover +their versions or verify their identity. + +Proposed URLs: + +```text +https://getjvm.dev/latest.txt +https://github.com/codejive/jvm/releases/download/v/ +``` + +Read latest.txt once, validate its single version value, then pin every payload +request to that version. Explicit versions bypass latest.txt. HTTPS redirects +must be followed with failures checked; reuse the project's capability approach +for curl/wget, SHA-256 alternatives and Windows .NET TLS support. No archive +extraction, jq or GitHub API parsing is required. + +Checksums fetched through the same HTTPS release channel detect inconsistent or +corrupted downloads; they do not authenticate against a compromised publisher. +The bootstrap script itself is trusted through HTTPS. Do not claim independent +signature verification. Signing is outside this initial proposal. + +Add release and Pages workflows using least required permissions, pinned actions +following repository practice, and the GitHub Pages deployment environment. +Consult current official action documentation when implementing. + +- Release publication builds assets from a checked, explicit tag/revision and + runs the relevant tests. Do not overwrite assets of a published version. +- Maintain reviewed `web/latest.txt`; its version advances only after the release + assets exist. A Pages preflight verifies the referenced release is complete. +- Deploy Pages on default-branch changes to `web/` or the Pages workflow, plus + manual dispatch. Gate deployment on installer checks for that same revision. +- Upload only a staging copy of `web/`, not the repository. Run no site generator + for installer scripts. Exclude contributor/test files and preserve script bytes. +- Concurrent deployments must not let an older queued run replace newer content. +- Account for the domain's actual custom-domain/proxy configuration. Do not assume + a visible HTTP redirect or change Cloudflare/DNS during repository work. +- Explain the first-release bootstrap sequence: release assets first, then the + corresponding latest.txt and Pages deployment. Do not publish a broken pointer. + +## Step 2: Installer transaction and ownership + +Implement `web/install.sh` and `web/install.ps1` as standalone files. Keep helpers +readable and separated by concern. No runtime helper downloads except the +version/checksum/payload data. Preserve LF in Bash and the project script bytes. + +Suggested ownership state: `$XDG_DATA_HOME/jvm/tool-install/` with the existing +data-home fallback and absolute-path rules. This is NEW proposed storage; add +its exact schema and lifecycle to SPEC when implementing. Store installed version, +destination and hashes for owned filenames as literal data. Do not source it. + +Algorithm: + +1. Validate arguments, host, home, roots and required capabilities. +2. Resolve/pin version, stage downloads in a private temporary directory, and + verify the complete selected payload. Nothing edits profiles or registry yet. +3. Before publication, serialize installer mutations with a small installer-only + lock in the ownership directory; recheck destination state under that lock. + This does not introduce a lock for JDK installation or inventory operations. + A concurrent invocation can fail clearly and be retried; no busy waiting needed. +4. Treat symlinks/reparse points, directories and unowned existing destination + files as conflicts. Detect locally modified owned scripts using saved hashes. +5. Stage replacement files on the destination filesystem and preserve old owned + versions plus a literal transaction record before renaming anything. +6. Publish the complete payload and ownership record with checked operations. + On failure or a catchable interruption, restore the old payload. A Windows + multi-file replacement is not atomic: record that limitation honestly. + The next invocation must recognize interrupted publication and recover before + proceeding; never delete recovery material blindly or guess at a stale lock. +7. Apply consented environment setup after successful publication. Back up each + changed profile or registry value first and restore that step on failure. + Do not roll back a usable tool installation merely because setup failed. +8. Clean only owned staging, report installed version/path, and explain how to + open a refreshed terminal or apply the displayed setup instructions. + +A process killed between file renames can leave a temporary mixed installation +until recovery. Do not promise atomic multi-file visibility or power-loss durability. +Keep the recovery design small; do not add a version-switching launcher framework. + +## Step 3: Unix persistent setup + +Generate native shell syntax containing resolved literal paths, properly quoted. +Never evaluate user metadata or an existing profile. No JDK commands, network +requests or inventory scans at shell startup. The tool bin and default-JDK bin +are separate whole PATH entries; add only if absent. Preserve existing entries. +JAVA_HOME names the stable default reference, including on macOS bundle layouts +as defined by the existing reference contract. No GRAALVM_HOME setup is needed. + +Use separate marked blocks for tool PATH and default Java, so reruns can enable +one without duplicating or removing the other. Preserve surrounding content, +encoding/line endings where practical, permissions and an exact backup. Stop +on duplicate/corrupt markers. Do not replace a symlinked profile with a regular +file: resolve and show its target or require an explicit target override. + +Shell policies: + +- Bash: update `.bashrc` and the active readable login file from `.bash_profile`, + `.bash_login`, `.profile`. If none exists, propose `.profile`. Never create a + higher-priority login file that hides an existing one. Idempotent checks allow + both files to run without duplicate entries. Keep `.profile` content POSIX. +- Zsh: use `.zshrc` beneath known ZDOTDIR or HOME. ZDOTDIR can be set in startup + code and not exported; show the assumed path and allow an explicit override. +- Fish: use a dedicated user `conf.d/jvm.fish` under XDG_CONFIG_HOME (fallback + `.config`). Treat an unowned existing file as a conflict. Use exported variables + and the shell's list PATH semantics, not POSIX shell statements. +- Csh/tcsh: use `.cshrc`, or existing `.tcshrc` for tcsh with `.cshrc` fallback. + Implement native `setenv`/`path` handling, including unset variables. Test it + in actual shells in hosted CI; do not substitute string-only assertions. + +Check both the current PATH and managed startup configuration: current membership +does not establish persistence. When ambiguous, explain that it is present now +and offer persistent setup rather than silently claiming it is installed there. +Later user startup statements can override managed values; document this boundary. +Profile setup does not configure every GUI application or noninteractive process. + +## Step 4: Windows persistent setup + +PowerShell owns registry updates. When install.sh runs under Git Bash/MSYS2/Cygwin, +use native path conversion and delegate consented registry setup to the verified +PowerShell installer without recursion, duplicate download/publication or duplicate +prompts. Design a private, validated setup-only invocation if necessary; document +it as internal and do not expose an arbitrary command-evaluation interface. +If this introduces unnecessary complexity, share a private staged-data contract, +not duplicated registry mutation code in Bash. Test the selected design end to end. + +Use `HKCU\Environment`, not machine values. Read raw values without expansion; +preserve existing REG_SZ/REG_EXPAND_SZ types and unrelated PATH tokens. Never +persist the process's merged PATH. Use semicolon entry parsing and appropriate +case/path comparison; preserve original spelling of untouched entries. Do not +use setx. Add concrete native absolute paths without quotes inside PATH entries. +Notify applications of environment changes after successful writes. + +Tool PATH setup adds `.local\bin`. Java setup sets JAVA_HOME to the existing +resolved data root's `jvm\jdks\default` and adds its `bin` path. Preview both +changes together. Handle a missing PATH value and an existing unrelated JAVA_HOME. +User PATH membership cannot guarantee precedence over a system Java entry; +report observed shadowing without modifying machine PATH or deleting other tools. + +Persisted edits do not refresh existing terminals/IDEs. Do not promise that a +new tab in an old host inherits fresh values. No PowerShell profiles are needed +for this milestone. Keep Bash-specific conversion instructions available if a +Windows shell startup file subsequently overrides the inherited PATH. + +## Step 5: Verification and acceptance + +Add `tests/tool-installer.sh` and `tests/tool-installer.ps1` (names deliberately +distinct from existing JDK installation tests). Extend `tests/ci.sh`, the hosted +matrix and `tests/README.md` with actual runnable commands when created. + +Every test uses temporary HOME/XDG/destination/profile locations and fake +downloaders or local fixture services. Registry operations need an injectable +backend/key for tests; never exercise the developer's real HKCU environment. +Verify exact raw types/values using disposable keys where native registry tests +are needed, and clean only those keys. No CI test installs a real JDK. + +Acceptance cases: + +- Help and invalid/conflicting options have no side effects; piped bootstrap + prompts never consume script bytes. Interactive and unattended choices agree. +- Latest and pinned versions, HTTPS redirects, downloader alternatives, HTTP + errors, malformed version/checksum data, missing files and checksum mismatch. +- Fresh install, matching reinstall, update, locally modified/unowned conflicts, + two concurrent installers, publication failures and interrupted-run recovery. +- Paths containing spaces and supported shell metacharacters, native Windows + paths, non-ASCII homes, XDG overrides and invalid/relative home overrides. + Reject unrepresentable PATH separator/newline paths clearly before mutation. +- Each consent combination; existing tool PATH; missing default JDK; existing + JAVA_HOME; no duplicate PATH entries; no JDK storage/default mutation. +- Correct Bash login precedence, custom ZDOTDIR/profile override, Fish config + roots, csh/tcsh unset variables, profile backups and symlink handling. +- A real fresh shell loads generated setup in hosted tests; no startup download, + accidental output or command evaluation occurs. Shell text is not merely + compared against private helper output. +- Windows registry value preservation, expandable references, missing PATH, + setup failures/rollback and repeated invocation through PowerShell and Bash. +- Launcher payload bytes remain mutually consistent; ordinary existing `jvm` + commands and project-local copies retain behavior. +- Pages artifact contains only intended public files, installer URLs have no + `/web/` prefix, and latest.txt references available verified release assets. + +Use hosted Linux/macOS Bash 3.2/current and current Git Bash/MSYS2/Cygwin plus +Windows PowerShell 5.1. Add hosted Zsh/Fish/csh/tcsh setup checks. Observe AGENTS.md: +do not install alternative shells, run WSL/containers or broaden local platform +validation without express confirmation. Report native gaps rather than claiming +simulations prove support. Run syntax checks, relevant existing regressions, +ShellCheck if available, and `git diff --check`; measure shipped script sizes. + +After authorized deployment, separately smoke-check public bootstrap downloads, +checksums, landing-page response if included, and an actual `/index/metadata/...` +redirect. Live checks are optional deployment validation, not default tests. + +## Step 6: Documentation and delivery + +Update SPEC's commands/current behavior only when implementation exists. Add +installer interfaces, ownership storage, failure/recovery rules, supported shell +setup and Windows boundaries. Update the manual-only setup statement to distinguish +the installer from ordinary manager commands. Keep `use` deferred. + +Update README with interactive and unattended installation examples, first JDK +installation, terminal refresh, repeat-install updates, pinned versions, and +manual tool removal. Removal instructions must identify only installer-owned +files/blocks/registry entries, preserve JDKs/config/cache, and avoid restoring +old environment values over subsequent user edits. Keep project-copy instructions. + +Suggested reviewable implementation sequence: + +1. Versioned payload/checksum contract, packaging fixtures and installer core. +2. Unix persistent setup and shell tests. +3. Windows persistent setup and Bash-on-Windows integration tests. +4. Release/Pages workflows and documentation, then authorized publication. + +Deliver code and tests, a list of checks actually run, remaining platform gaps, +and concrete repository/Pages settings needed by the maintainer. Do not create +commits, releases, change remote settings or deploy as a side effect of local +implementation unless separately authorized. Do not start work on `use`. diff --git a/README.md b/README.md index 2ad7ad4..06f0436 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# jvm +# jvm - Java Version Manager `jvm` installs, manages and runs different JDKs on a user's system. It finds compatible JDK builds, downloads and verifies them, keeps them in user storage @@ -81,7 +81,70 @@ sure it's the correct version! ## Using it installed -Copy the scripts to a directory on your `PATH`, for example `~/.local/bin`: +The standalone installers place the tool in `~/.local/bin` on every platform. +They do not install a JDK. The public URLs become available after the maintainer +publishes the first release and Pages site (see [publishing](RELEASING.md)). + +```sh +curl -fsSL https://getjvm.dev/install.sh | bash +# Unattended, with explicit consent to persistent tool PATH only: +curl -fsSL https://getjvm.dev/install.sh | bash -s -- --path --no-java --non-interactive +# Pin a release; rerunning without --version updates to the reviewed latest: +curl -fsSL https://getjvm.dev/install.sh | bash -s -- --version 0.2.0 --no-path --no-java +``` + +Windows PowerShell 5.1 (no Bash required): + +```powershell +iex (irm 'https://getjvm.dev/install.ps1') +# For parameters, download and invoke the script: +irm 'https://getjvm.dev/install.ps1' -OutFile "$env:TEMP\jvm-install.ps1" +& "$env:TEMP\jvm-install.ps1" -Version 0.2.0 -Path -NoJava -NonInteractive +``` + +From CMD: + +```cmd +powershell.exe -NoProfile -Command "iex (irm 'https://getjvm.dev/install.ps1')" +``` + +The installer asks separately about persistent tool PATH and default-JDK +JAVA_HOME/PATH setup, showing the target profiles or user registry changes. +Answers default to no. Without a terminal, unspecified setup is skipped; +`--path`/`--java` (PowerShell `-Path`/`-Java`) explicitly consent to edits. +`--no-path`/`--no-java` suppress them. `--non-interactive` suppresses prompts. +Tool downloads are checked against SHA-256 values from the same HTTPS release +channel; this checks consistency, not an independent publisher signature. + +Unix supports Bash, Zsh, Fish, csh and tcsh. Choose one with `--shell bash` +(or another supported name). Use `--profile /absolute/file` with `--shell` for +custom startup files or an explicit symlink target. Bash normally updates +`.bashrc` and the existing active login file; Zsh assumes exported `ZDOTDIR` or +HOME; Fish uses XDG_CONFIG_HOME's `fish/conf.d/jvm.fish`. Unknown shells need +manual setup. Exact profile backups are reported. Windows persists only +current-user environment values, with raw value/type backups; machine PATH +can still take precedence over user Java. No PowerShell profiles are edited. + +Legacy BSD csh expands `HOME` before reading profiles and can crash on homes +containing backticks or glob syntax; use tcsh for those homes. Generated setup +keeps tool/data paths literal, but cannot change the shell's own initialization. + +Open a **fresh terminal application or IDE**, since a new tab in an old host +may inherit stale values. Later startup statements can override setup. This +does not configure all GUI or noninteractive programs. Then install Java: + +```sh +jvm install +jvm x _ java -version +``` + +Default-JDK setup can precede the first JDK installation. It names the stable +`jvm/jdks/default` reference under the existing data root. Rerunning the +installer updates only its own unmodified files; unrelated or locally modified +files cause a conflict. A matching reinstall leaves the payload unchanged. +Installer recovery is described in [SPEC.md](SPEC.md#tool-installer). + +You can also copy the scripts manually to a directory on your PATH: ```sh mkdir -p ~/.local/bin @@ -89,8 +152,8 @@ cp jvm jvm.cmd jvm.ps1 ~/.local/bin/ chmod +x ~/.local/bin/jvm ``` -On Windows you will probably need to add that directory (for example -`%USERPROFILE%\.local\bin`) to your `PATH` yourself. +With a manual copy, add that directory to PATH yourself. An installer refuses +to take ownership of a manual copy; move it aside before using the installer. To make the default JDK available in every shell, add this once to your shell profile: @@ -105,6 +168,29 @@ export JAVA_HOME="$(cygpath -m "${XDG_DATA_HOME:-$HOME/.local/share}/jvm/jdks/de export PATH="$(cygpath -u "${JAVA_HOME}")/bin:$PATH" ``` +### Removing the user-wide tool + +Read `${XDG_DATA_HOME:-$HOME/.local/share}/jvm/tool-install/record` (native +Windows path separators on Windows). It records the destination and hashes of +owned files. After checking that they still match, remove **only** its listed +`jvm` file on Unix, or `jvm`, `jvm.ps1`, `jvm.cmd` on Windows. Remove the +`tool-install` directory only when no installer is active and no recovery is +pending. Preserve sibling `jdks`, configuration and cache directories. + +On Unix, remove only the `# >>> jvm path >>>` / `# <<< jvm path <<<` and +`# >>> jvm java >>>` / `# <<< jvm java <<<` blocks from the profiles the +installer reported. Leave all surrounding user content; remove Fish's +`jvm.fish` only if those blocks are its entire content. Backups are for +inspection; do not restore an old profile over later edits. + +On Windows, use the user Environment Variables editor to remove only the exact +tool-bin and default-JDK-bin entries that the installer added. Remove +JAVA_HOME only if it still equals the installer-reported stable default path +and you want Java setup removed. Consult the reported raw registry backups to +distinguish pre-existing entries. Preserve all other tokens/types; do not +restore old environment values over subsequent changes. Refresh terminal +applications afterwards. Project copies continue to work independently. + ## Usage ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a9cfb23 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,90 @@ +# Publishing the installers + +This repository contains publication workflows; no release, deployment or remote +setting is created by building or running the offline tests. Version `0.2.0` is +the initial reviewed pointer in `web/latest.txt`; no tags existed locally or on +origin when inspected on 2026-11-04. The installer defaults from +[INSTALLER_PLAN.md](INSTALLER_PLAN.md) were approved for this implementation. + +## Maintainer sequence + +1. Review and merge the implementation onto the default branch. Require the + Bash portability checks to pass: Linux/macOS Bash 3.2/current, Windows Git + Bash/MSYS2/Cygwin, Windows PowerShell 5.1, shell startup and packaging checks. + The initial automatic Pages preflight will refuse deployment until the + release exists. Do not bypass this check or publish a placeholder pointer. +2. In repository Settings → Pages, select **GitHub Actions** as the build source. + Verify the `github-pages` environment permits only the default branch and + configure any required reviewers. Ensure Actions policy permits the pinned + actions and the release job's `contents: write` permission. These are + maintainer actions; the workflows do not change repository settings. +3. Confirm the two runtime version constants and `web/latest.txt` are `0.2.0`. + Tag the reviewed commit `v0.2.0` and push that tag. Do not reuse an existing + version. For later versions, update both constants, and regenerate the + embedded installer only if `web/install.ps1` changed. A version is exactly + three numeric components with no leading zeros, except the component `0`. +4. Dispatch **Publish versioned tool** with `tag=v0.2.0` from the reviewed + default-branch workflow. It checks out and tests that tag, validates the + version constants, builds `jvm`, `jvm.ps1`, `jvm.cmd`, `SHA256SUMS`, creates + a draft with all assets, then publishes it. Existing releases are never + overwritten. If upload fails, inspect/delete the incomplete draft before + retrying; never edit assets of an already published version. +5. After all four public release assets exist, dispatch **Publish installer + site** on the current default branch. For later releases, only now advance + the reviewed `web/latest.txt` pointer and merge it; that change triggers + Pages. The preflight downloads and verifies all referenced assets before + uploading the allowlisted public files. A queued old revision cannot deploy + over a newer default-branch revision. If an unrelated commit makes a run + stale, dispatch a fresh run at the current default-branch head. +6. Verify the existing custom-domain/proxy configuration rather than replacing + it. The user reported GitHub Pages behind Cloudflare on 2026-11-03; at that + time `/` returned 404 and the concrete metadata route redirected upstream. + Preserve the Cloudflare `/index/metadata/...` routing and existing DNS/custom + domain settings. No CNAME is introduced by this change: the correct Pages + origin hostname depends on the existing proxy configuration. +7. After deployment, download `/install.sh`, `/install.ps1`, `/latest.txt` and + the referenced release checksums/payloads over HTTPS, verify exact bytes, + and review `/` plus an actual + `/index/metadata/all/linux/x86_64/jdk-latest.tsv` redirect. Installer URLs are + at the site root, **without `/web/`**. Test the README bootstrap commands + in disposable homes before announcing availability. + +After review/merge and the settings check, the first-release commands are: + +```sh +# Replace REVIEWED_COMMIT with the tested, merged commit containing these files. +git tag v0.2.0 REVIEWED_COMMIT +git push origin refs/tags/v0.2.0 +gh workflow run release.yml --repo codejive/jvm --ref main -f tag=v0.2.0 +# Wait for that run to succeed and verify all four assets are publicly available. +gh workflow run pages.yml --repo codejive/jvm --ref main +``` + +`main` was the remote default branch when inspected on 2026-11-04. If it changes, +use the new default branch. The commands above are maintainer instructions; +they have not been executed as part of implementation. + +## Offline preparation + +```sh +python tools/embed-installer.py --check +python tests/release-tooling.py +python tools/package-release.py 0.2.0 /absolute/new/assets +python tools/package-release.py 0.2.0 /absolute/new/site --site +``` + +These commands do not publish anything. Destination directories must not exist. +After editing the Windows installer, run `python tools/embed-installer.py` and +review both standalone files. The embedded Windows source is checked byte for +byte so registry mutation logic has one maintained source. + +The site staging allowlist is `install.sh`, `install.ps1`, `latest.txt`, +`index.html`, `.nojekyll`; no source tree, test fixtures or site generator is +uploaded. The Pages preflight is intentionally online and is separate from the +offline suite. Release checksums use the same HTTPS trust channel as payloads; +there is no independent signature verification or automatic updater. + +Action documentation consulted on 2026-11-04: +[custom Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages), +[upload-pages-artifact](https://github.com/actions/upload-pages-artifact), +[deploy-pages](https://github.com/actions/deploy-pages). diff --git a/SPEC.md b/SPEC.md index 091a02f..f12594f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -365,7 +365,7 @@ links and hardlinks are preserved. Unrecognized listings fail before extraction. On Windows, case aliases, reserved device names and trailing dots are rejected. -Installers use no shared lock; each stages independently. A complete, +JDK installations use no shared lock; each stages independently. A complete, compatible `` that appears during publication wins: the loser discards only its own copy and reports success. A uniquely marked nested copy left by a lost `mv` race is renamed back into the private staging directory before deletion; @@ -470,7 +470,8 @@ Lifecycle: `jvm/tmp/default.XXXXXXXX/previous`. Concurrent explicit changes: last publication wins. -Shell setup is manual; the tool never edits profiles or the registry. Unix: +Ordinary manager commands never edit profiles or the registry. The separate +[tool installer](#tool-installer) offers consented persistent setup. Manual Unix setup: ```bash export JAVA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/jvm/jdks/default" @@ -625,35 +626,192 @@ Last-Modified and last-check metadata. Windows `tar.exe` and rejects links and special entries. Both are stricter than Bash, which preserves verified internal links. +## Tool installer + +`web/install.sh` and `web/install.ps1` implement standalone user-wide tool +installation; public URLs require the maintainer publication sequence in +[RELEASING.md](RELEASING.md). They install no JDK, never select a default, never +modify project copies and introduce no `use` command. The defaults proposed in +[INSTALLER_PLAN.md](INSTALLER_PLAN.md) were approved for this implementation. + +```text +install.sh [--version major.minor.patch] + [--path | --no-path] [--java | --no-java] + [--shell bash|zsh|fish|csh|tcsh] [--profile /absolute/file] + [--non-interactive] +install.sh --help +install.ps1 [-Version major.minor.patch] [-Path | -NoPath] + [-Java | -NoJava] [-NonInteractive] +install.ps1 -Help +``` + +Options are unique; conflicting/unknown options and invalid versions exit 2 +before downloads or directory creation. Profile requires an explicit shell. +Shell/profile options are Unix-only; PowerShell is Windows-only. Help is offline. +Versions have exactly three numeric dotted components, each `0` or a positive +integer without leading zeros. Exit 0 means the tool and any requested setup +succeeded (optional setup may be declined); operational failure exits 1. If +setup fails after publication, the tool remains installed and the diagnostic +identifies this partial success. + +### Downloads and placement + +Without a version, read `https://getjvm.dev/latest.txt` once and validate its +single version (optional final LF). Pin all payload requests to +`https://github.com/codejive/jvm/releases/download/v/`. +Explicit versions bypass latest.txt. Allow only HTTPS, including redirects; +Bash supports curl or HTTPS-capable wget and sha256sum/shasum/openssl; +PowerShell uses built-in .NET with TLS 1.2 support. No helper downloads, +archive extraction, JSON parsing or execution of downloaded tool scripts is +needed. SHA256SUMS must contain exactly three unique allowlisted basenames +(`jvm`, `jvm.ps1`, `jvm.cmd`) with 64-digit SHA-256 digests. Hash downloaded +bytes before setting permissions or publishing. Checksums detect inconsistent +or corrupt release content; they share the HTTPS publisher trust channel and +are not independent signatures. + +Downloader detail verified 2026-11-04: GNU wget's +[`--https-only`](https://www.gnu.org/software/wget/manual/html_node/HTTPS-_0028SSL_002fTLS_0029-Options.html) +applies to recursive links. The tool installer instead uses disabled automatic +redirects plus validated HTTPS hops with a bounded redirect count. A wget +without the needed server-response/redirect controls fails clearly; use curl. + +The destination is resolved HOME/.local/bin, independent of XDG_DATA_HOME. +Unix requires absolute HOME; Windows uses HOME, falling back to USERPROFILE +only when HOME is absent, and requires a native absolute path. Relative home +values are errors. Data storage follows the existing absolute XDG override and +fallback policy. Newline, CR, tab and PATH-separator paths are rejected before +mutation (Unix also rejects semicolons for Windows interoperability). Unix +ships only `jvm` with executable permissions; Windows ships all three exact-byte +scripts. Windows Bash requires cygpath and Windows PowerShell and converts +paths before delegating to the embedded PowerShell installer. The Bash +bootstrap locates PowerShell on PATH or under the Windows system directory's +`WindowsPowerShell/v1.0`, allowing a restricted Cygwin PATH. The embedded +source is generated from `web/install.ps1`, not downloaded or separately +maintained registry code. No private command-evaluation interface exists. + +### Ownership, failure and recovery + +New storage: `${XDG_DATA_HOME:-$HOME/.local/share}/jvm/tool-install/`. +`record` is literal UTF-8/LF data: `jvm-tool-v1`, version, absolute destination, +then one `sha256basename` line per owned file, in `jvm`, `jvm.ps1`, +`jvm.cmd` order (only `jvm` on Unix). It is never sourced. Missing/modified +owned files, unowned destinations, directory links/reparse points, directories +in place of scripts and invalid records fail clearly. Matching reinstall is a +payload no-op; rerunning otherwise replaces only verified installer-owned +files. Moving an owned installation or changing its data root requires manual +ownership review; there is no force/adoption option. + +Downloads are staged privately before publication. An installer-only lock +serializes publication and environment setup; JDK operations remain lock-free. +Bash uses `lock/pid`; a live owner fails immediately. A leftover lock is never +guessed stale: confirm the recorded process exited, remove only `lock/pid` and +the empty `lock/` directory, then rerun. An incomplete/unknown lock needs manual +inspection. PowerShell holds an exclusive handle on the `lock` file; the OS +releases it when the process exits, leaving a harmless empty file. + +`transaction/` holds `new-record`, optional `old-record` and `old-jvm` (plus +`old-jvm.ps1`/`old-jvm.cmd` on Windows). `ready` marks the point at which all +backups exist and publication may start. Windows `stage-name` names only an +allowlisted `.jvm-next-<32 hex digits>` destination-side directory; Bash reserves +`.jvm-next` and `.jvm-recover` files. Replacement files are staged on the +destination filesystem. Checked renames/copies publish the payload and record; +catchable failures restore old bytes and ownership. A subsequent invocation +recognizes and recovers pending transactions under the lock before checking +ownership and republishing. Damaged/unrecognized recovery material is retained +with a diagnostic rather than deleted. Do not remove the ownership directory +while recovery is pending. Successful transactions discard their backups; +there is no version history or switching launcher. + +Windows publication is a sequence of file replacements, not an atomic multi-file +transaction. An uncatchable kill can leave a temporary mixed installation until +recovery. Neither implementation promises power-loss durability. On Unix, +profile edits have exact sibling `.jvm-backup.` backups. A failing or +catchably interrupted profile setup step restores that step's changed files; +completed earlier setup steps remain. Windows raw user registry values/types +are backed up to `environment-backup-<32 hex digits>.clixml` before changes; +a failed setup restores the previous values. An uncatchable kill during +persistent setup may require manual inspection of the retained backups. + +### Persistent environment setup + +Tool PATH and default-Java setup have independent consent. Positive options +consent to the corresponding changes; negative options suppress them. Otherwise +show exact target files/registry values and old/new JAVA_HOME, then ask with a +no default. Bash reads `/dev/tty`, never the piped bootstrap stdin. Without a +usable terminal, or in noninteractive mode, unspecified setup is skipped with +manual instructions. An unknown shell skips optional setup but fails explicitly +requested setup. An already-correct managed setup avoids repeated prompts and +edits. Current process PATH membership alone is not persistent Unix setup. + +Unix uses separate `# >>> jvm path >>>`/`# <<< jvm path <<<` and +`# >>> jvm java >>>`/`# <<< jvm java <<<` blocks containing literal resolved +paths and native shell syntax, with whole-entry PATH deduplication. JAVA_HOME +names the stable data-root `jvm/jdks/default`, even before a default exists; +its bin directory and the tool bin are separate entries. No GRAALVM_HOME edits, +network requests, JDK execution or inventory scans occur at startup. + +- Bash: `.bashrc` and the first existing readable `.bash_profile`, `.bash_login`, + `.profile`; absent all three, create `.profile`. Generated content is POSIX + compatible, so no higher-priority login file hides an existing profile. +- Zsh: `.zshrc` beneath exported ZDOTDIR or HOME; show the assumed path. Use + `--profile` if startup code sets an unexported/custom ZDOTDIR. +- Fish: XDG_CONFIG_HOME (fallback HOME/.config) `fish/conf.d/jvm.fish`; + unowned existing files conflict. Explicit profile overrides are supported. +- Csh/tcsh: `.cshrc`, or existing `.tcshrc` for tcsh; native `path` list and + `setenv` syntax handle initially unset variables. Literal paths use single + quotes with explicit apostrophe/history escaping; PATH-list comparisons use + quote-protected (`:q`) shell-variable expansions to preserve metacharacters. + Iterate PATH entries by index: legacy BSD csh's `foreach` re-expands its + loop-variable values, so it cannot safely carry literal backticked entries. + Legacy BSD csh expands inherited HOME before processing options or profiles; + HOME values containing backticks/glob syntax can execute text or crash that + shell before setup is read. Use tcsh for such homes. Generated setup still + treats metacharacters in tool/data paths literally. This upstream boundary + was inspected on 2026-11-04 in + [`csh.c`](https://github.com/openbsd/src/blob/master/bin/csh/csh.c) and + [`set.c`](https://github.com/openbsd/src/blob/master/bin/csh/set.c). + +Profile contents outside managed blocks and permissions are preserved where +practical (a missing trailing newline is normalized); exact original bytes are +backed up. Duplicate/corrupt markers stop setup. Symlink profiles are refused; +select the resolved target explicitly with `--shell` and `--profile`. + +Windows modifies only HKCU\Environment. Read raw values without expansion and +preserve REG_SZ/REG_EXPAND_SZ kinds and untouched PATH tokens, including empty +entries; never persist the merged process PATH or use setx. Tool setup adds the +concrete native tool directory; Java setup sets JAVA_HOME and adds the native +stable-default bin entry. Whole-entry comparison is case-insensitive and +recognizes expandable references; new entries contain no quotes. Broadcast an +environment-change notification after writes. Report observed machine Java +shadowing without changing machine PATH. No PowerShell profile edits occur. + +Open a fresh terminal application/IDE afterwards; a new tab in an old host may +still inherit stale values. Later startup statements may override setup, and +not every GUI/noninteractive process reads these profiles. Manual removal is +documented in README: remove only owned payloads/blocks/entries, preserve +JDKs/config/cache, and never restore old backups over subsequent user changes. + +### Publication + +Release and Pages workflows are checked in, not yet run or deployed. Releases +are built from an explicit existing `v` tag after the offline test +matrix passes; exact-byte assets are published via a draft and are never +overwritten. `web/latest.txt` is reviewed source and advances only after the +release exists. Pages runs installer checks for the same revision and verifies +the complete public referenced release before staging only the public-file +allowlist. It uses the `github-pages` environment and refuses stale queued +revisions. The site root has no `/web/` prefix. See RELEASING.md for first-release +ordering, settings, action documentation and post-deployment checks. + +Hosting status (2026-11-03): the user reports `https://getjvm.dev/` now +points to this repository's GitHub Pages site. An HTTP check reached a +GitHub response through Cloudflare, currently returning 404 at the root; +a working landing page is not yet verified. The concrete metadata path +`/index/metadata/all/linux/x86_64/jdk-latest.tsv` still returns a 302 to +`https://jbangdev.github.io/jdkdb-index/metadata/all/linux/x86_64/jdk-latest.tsv`. + ## Open items -- **Roadmap idea: installer for the `jvm` tool.** Provide a hosted bootstrap - installer for Bash and Windows PowerShell, plus an invocation from CMD. - Proposed commands (endpoints are not yet provided or verified): - - ```bash - curl -fsSL https://getjvm.dev/sh | bash - ``` - - ```powershell - iex (irm 'https://getjvm.dev/ps1') - ``` - - CMD could invoke the PowerShell installer through Windows PowerShell: - - ```cmd - powershell.exe -NoProfile -Command "iex (irm 'https://getjvm.dev/ps1')" - ``` - - These examples use the project's existing `getjvm.dev` domain. The installer - would install the tool itself, distinct from `jvm install` downloading a JDK. - Investigate project-local versus user-wide placement, PATH setup and the - existing manual shell-setup policy, installing `jvm.ps1` together with - `jvm.cmd` on Windows, release/version selection, updates and removal, and - download verification and interrupted-install recovery. Validate quoting and - bootstrap behavior on supported shells, including Windows PowerShell 5.1 and - CMD. This is a roadmap proposal, not an available installer or a change to - current installation and shell-setup behavior. - **Roadmap investigation: minimum-major selectors by default.** Consider changing `25` from exactly major 25 to major 25 or higher (currently `25+`), with `25!` expressing exactly major 25. The motivation is that a requested diff --git a/tests/README.md b/tests/README.md index 905cb29..c8be851 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,139 @@ # Discovery, installation and uninstallation verification +## Native C-shell repair (2026-11-04) + +The [follow-up run](https://github.com/codejive/jvm/actions/runs/41812403128) +passed every platform job, including Cygwin, and passed ShellCheck. The native +shell test then attempted to execute backticked fixture path text (`data`) +before BSD csh crashed. Native C-shell paths now use single-quoted literals; +list comparisons and the test's `source` arguments use `:q` protection. The +harness includes a command-execution sentinel and reports sourcing/startup +results for Zsh, Fish, BSD csh and tcsh before failing the hosted job, so one +failure cannot hide later shells. + +Hosted checks can select one shell with +`bash tests/tool-installer.sh --native-shells csh` (or `zsh`, `fish`, `tcsh`); +omitting the last argument still checks all four. Actual native validation for +this correction belongs to hosted CI; no local shells/platforms were added. +The [C-shell manual](https://man.openbsd.org/csh.1) documents single-quote +literals and `:q` preventing subsequent substitutions. + +Hosted follow-up isolated an upstream BSD csh initialization issue: `csh.c` +sets `home` from unquoted HOME before option processing, and `set.c` applies +glob/command substitution. BSD csh tests therefore use a separate safe +bootstrap HOME, while sourcing and starting up with the exact generated +profiles containing the original metacharacter-rich tool/data paths. The +execution sentinel, exact JAVA_HOME checks and repeated sourcing remain. +Zsh/Fish/tcsh retain the original hostile HOME. This compatibility boundary is +documented in SPEC/README; native-shell reruns verify the remaining behavior. + +The next hosted result exposed a separate BSD csh `foreach` behavior: its +internal loop-variable assignment applies `set()` expansion after stripping +quote protection. Setup now checks PATH entries by indexed `:q` expansion +inside a `while` loop, avoiding that implicit reassignment. Multiline `if` +blocks keep literal path assignments separate from expression reexecution. + +## Installer CI repair (2026-11-04) + +The [installer branch run](https://github.com/codejive/jvm/actions/runs/41808043776) +passed packaging, native PowerShell, Git Bash, MSYS2, and Linux/macOS with both +Bash 3.2 and current Bash. Cygwin passed the existing manager suites, all 23 +installer groups and native junction smoke, then failed to locate PowerShell +for delegation. The shell job stopped at ShellCheck SC1007 warnings before +running its native startup tests. + +The bootstrap now uses explicit empty-string assignments and locates native +PowerShell under the system directory when it is absent from PATH. The CI +wrapper includes the PowerShell directory for the suite's nested native calls. +The delegation regression also removes PowerShell from its Bash PATH and +requires successful fallback. Local Windows PowerShell/Git Bash checks passed +(15 groups), as did the 23 Bash installer groups, packaging, syntax and +`git diff --check`. Native Cygwin rerun and the previously blocked ShellCheck/ +Zsh/Fish/csh/tcsh job still require hosted CI; no local alternative platforms +or shells were installed or run for this repair. + +## Tool installer verification (2026-11-04) + +The new tests are distinct from `tests/install.sh`, which installs synthetic +JDKs. Installer tests download only local fixtures through fake transfers and +use temporary HOME/XDG roots, destinations and profiles. Native registry tests +replace `Open-EnvironmentKey` with a disposable key beneath +`HKCU\Software\Codejive\InstallerTests\` and delete only that key. +They never change the contributor's real user environment, profile or JDKs. +Mocks are supplied by the harness in memory or in private script copies; +shipped installers have no alternate URL/registry backend or evaluation hook. + +```sh +bash -n ./jvm +bash -n web/install.sh +bash tests/tool-installer.sh +python tests/release-tooling.py +``` + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/tool-installer.ps1 +# Also test native Bash-to-PowerShell delegation with an already installed Bash: +$env:JVM_TEST_BASH = 'C:\Program Files\Git\bin\bash.exe' +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/tool-installer.ps1 +Remove-Item Env:JVM_TEST_BASH +``` + +`tests/ci.sh` includes installer syntax and offline tests. Hosted Windows jobs +also run the delegation test through the actual current Git Bash/MSYS2/Cygwin +interpreter. The hosted Linux shell job installs Zsh/Fish/csh/tcsh and runs: + +```sh +shellcheck -S warning web/install.sh +bash tests/tool-installer.sh --native-shells +``` + +Do not run this shell/platform tooling locally without the express confirmation +required by AGENTS.md. The native-shell option starts actual fresh shells and +checks both explicit sourcing (twice) and normal startup files. Ordinary tests +simulate Unix host detection on Windows; that is not native Unix verification. +Git Bash's `ln -s` can make a copy, so its symlink-profile assertion is explicitly +skipped when no real link exists. Hosted Unix exercises the actual link case. + +Checks cover latest/pinned releases, exact-byte payloads, curl/wget paths, +available checksum alternatives, HTTPS redirect/downgrade policy for wget, +malformed/missing/checksum-failing downloads, usage without side effects, +matching reinstall, updates, modified/unowned conflicts, lock contention, +publication error/interruption rollback, interrupted-run recovery and retention +of unknown journal material. Setup checks cover separate consent, literal +metacharacter/spaced paths, native non-ASCII Windows homes, XDG fallback, +relative HOME rejection, Bash login precedence, ZDOTDIR/Fish roots, profile +backups/rollback, all shell formats, registry raw values/types, missing PATH, +failed setup rollback, CMD bootstrap quoting and Windows Bash delegation. + +Local results: 23 Bash installer groups passed under installed Git Bash 5.3.15 +(Unix host simulated); 15 installer groups passed under Windows PowerShell 5.1 +including native Git Bash delegation and CMD. Exact-byte packaging, checksum +generation, embedded-source equality and public-file staging checks passed. +All workflow YAML parsed, Bash/PowerShell syntax checks and `git diff --check` +passed. Existing regressions passed: 56 Bash discovery groups, 35 installation +groups, home/env suite and 29 PowerShell smoke groups. The runtime manager +scripts are unchanged. ShellCheck is unavailable locally. +Measured sizes after the C-shell repair: `web/install.sh` 44,068 bytes (including the +generated Windows source), `web/install.ps1` 21,023 bytes; unchanged `jvm` 68,978 bytes, +`jvm.ps1` 78,630 bytes and `jvm.cmd` 116 bytes. New installer sources use LF. + +At initial delivery, hosted Linux/macOS Bash 3.2/current, real Zsh/Fish/csh/tcsh +startup and MSYS2/Cygwin delegation were unverified; see the CI repair results +above for subsequent hosted evidence. No alternative shells, WSL or +containers were installed/run, and no hosted jobs, releases, deployment or +GitHub/Cloudflare settings were changed. An early CMD fixture mistakenly let +the built-in `irm` alias make a failed public installer request; the corrected +fixture removes that alias with `-Force` and fails closed before execution if +mock setup fails. Final installer tests are offline; no live JDK installation +or bootstrap installation against a published release was performed. + +Release/Pages files are locally reviewed and YAML-parsed, not verified hosted +runs. `tools/pages-preflight.py` intentionally needs the public release and is +not part of offline tests. Follow [RELEASING.md](../RELEASING.md) for the first +release sequence and public download/metadata-route smoke checks afterwards. + +## Earlier manager verification + CI wrapper lookup repair (2026-11-03): capture real `tar` and `sha256sum` paths before isolating PATH, avoiding cached wrapper targets in Bash 3.2. Syntax checks for `jvm` and `tests/install.sh`, all 35 offline installation diff --git a/tests/ci.sh b/tests/ci.sh index 1f22757..e825392 100644 --- a/tests/ci.sh +++ b/tests/ci.sh @@ -23,8 +23,16 @@ export PATH="$TEMP/bin:$PATH" bash -c '[ "$BASH_VERSION" = "$1" ]' bash "$BASH_VERSION" cd "$ROOT" "$BASH_BIN" -n ./jvm +"$BASH_BIN" -n web/install.sh for script in tests/*.sh; do "$BASH_BIN" -n "$script"; done "$BASH_BIN" tests/run.sh +"$BASH_BIN" tests/tool-installer.sh case "$(uname -s)" in - CYGWIN*|MSYS*|MINGW*) "$BASH_BIN" tests/default-native-windows.sh ;; + CYGWIN*|MSYS*|MINGW*) + "$BASH_BIN" tests/default-native-windows.sh + # A restricted Cygwin PATH may contain System32 without its PowerShell + # subdirectory. Include it for the suite's nested PowerShell/CMD calls. + system_directory=$(cygpath -u -S) + export PATH="$PATH:$system_directory/WindowsPowerShell/v1.0" + JVM_TEST_BASH="$(cygpath -aw "$BASH_BIN")" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$ROOT/tests/tool-installer.ps1")" ;; esac diff --git a/tests/release-tooling.py b/tests/release-tooling.py new file mode 100644 index 0000000..04812c5 --- /dev/null +++ b/tests/release-tooling.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Offline checks of exact-byte packaging and the Pages allowlist.""" +import hashlib +import pathlib +import re +import subprocess +import sys +import tempfile + +root = pathlib.Path(__file__).resolve().parent.parent +with tempfile.TemporaryDirectory(prefix='jvm-release-test-') as temp: + temp = pathlib.Path(temp) + subprocess.run([sys.executable, str(root / 'tools/embed-installer.py'), '--check'], check=True) + version = re.search(r'^VERSION=(.+)$', (root / 'jvm').read_text(), re.M)[1] + package = root / 'tools/package-release.py' + subprocess.run([sys.executable, str(package), version, str(temp / 'assets')], check=True) + expected = [] + for name in ('jvm', 'jvm.ps1', 'jvm.cmd'): + content = (root / name).read_bytes() + assert content == (temp / 'assets' / name).read_bytes() + expected.append(f'{hashlib.sha256(content).hexdigest()} {name}\n') + assert (temp / 'assets/SHA256SUMS').read_bytes() == ''.join(expected).encode() + latest = (root / 'web/latest.txt').read_text().strip() + subprocess.run([sys.executable, str(package), latest, str(temp / 'site'), '--site'], check=True) + assert {p.name for p in (temp / 'site').iterdir()} == {'install.sh', 'install.ps1', 'latest.txt', 'index.html', '.nojekyll'} + for path in (temp / 'site').iterdir(): + assert path.read_bytes() == (root / 'web' / path.name).read_bytes() + assert subprocess.run([sys.executable, str(package), 'v' + version, str(temp / 'bad')], capture_output=True).returncode == 2 +print('Exact-byte assets, checksums, embedded installer and Pages allowlist passed') diff --git a/tests/tool-installer.ps1 b/tests/tool-installer.ps1 new file mode 100644 index 0000000..a5dfc12 --- /dev/null +++ b/tests/tool-installer.ps1 @@ -0,0 +1,249 @@ +# Offline Windows PowerShell 5.1 tests; registry access is confined to a disposable key. +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$temp = Join-Path ([IO.Path]::GetTempPath()) ('jvm-installer-test-' + [Guid]::NewGuid().ToString('N')) +$savedHome = $env:HOME; $savedData = $env:XDG_DATA_HOME +$registryPath = 'Software\Codejive\InstallerTests\' + [Guid]::NewGuid().ToString('N') +$count = 0 +function Assert($Condition, [string]$Message) { if (-not $Condition) { throw $Message } } +function Pass([string]$Message) { $script:count++; Write-Output "ok $script:count - $Message" } +function Expect-Failure([scriptblock]$Action, [int]$Code = 1) { + $failed = $false + try { & $Action } catch { + $failed = $true + if ($_.Exception.Data.Contains('InstallerCode')) { Assert ($_.Exception.Data['InstallerCode'] -eq $Code) 'unexpected error code' } + } + Assert $failed 'expected failure' +} +try { + [IO.Directory]::CreateDirectory($temp) | Out-Null + $env:HOME = Join-Path $temp "home space 'quote' caf$([char]0xe9)" + $env:XDG_DATA_HOME = Join-Path $temp 'data space' + $source = [IO.File]::ReadAllText((Join-Path $root 'web\install.ps1')) + # Load functions, excluding only the public entry point; no production test hooks. + . ([scriptblock]::Create($source.Substring(0, $source.LastIndexOf('try { Invoke-JvmInstaller @($args) }')))) + function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($registryPath) } + function Send-EnvironmentChange { if ($script:failNotification) { throw 'injected registry setup failure' } } + $script:failNotification = $false + $fixture = Join-Path $temp 'fixture' + [IO.Directory]::CreateDirectory($fixture) | Out-Null + $hashes = @{} + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + [IO.File]::Copy((Join-Path $root $name), (Join-Path $fixture $name)) + $hashes[$name] = Get-ToolHash (Join-Path $fixture $name) + } + $sums = @('jvm', 'jvm.ps1', 'jvm.cmd') | ForEach-Object { "$($hashes[$_]) $_" } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), (($sums -join "`n") + "`n")) + [IO.File]::WriteAllText((Join-Path $fixture 'latest.txt'), "0.2.0`n") + $script:requests = @() + $script:downloadFailure = $false + function Get-Download([string]$Url, [string]$File) { + $script:requests += $Url + if ($script:downloadFailure) { throw 'injected HTTP failure' } + [IO.File]::Copy((Join-Path $fixture ($Url.Split('/')[-1])), $File) + } + Invoke-JvmInstaller @('-Help') | Out-Null + Expect-Failure { Invoke-JvmInstaller @('-Path', '-NoPath') } 2 + Expect-Failure { Invoke-JvmInstaller @('-Version', '01.2.3') } 2 + Expect-Failure { Invoke-JvmInstaller @('-Shell', 'bash') } 2 + Assert ($script:requests.Count -eq 0 -and -not (Test-Path -LiteralPath $env:HOME)) 'help/usage caused side effects' + Pass 'help and usage validate before networking/storage' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root 'web\install.ps1') -Help | Out-Null + Assert ($LASTEXITCODE -eq 0) 'public help exit status' + # Public invocation must report usage 2, including when launched from CMD. + $publicInvalid = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + (Join-Path $root 'web\install.ps1') + '" -Path -NoPath 2>nul' + & $env:ComSpec /d /c $publicInvalid + Assert ($LASTEXITCODE -eq 2) 'CMD public invalid argument exit status' + Pass 'public PowerShell/CMD entry points and exit statuses' + Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') + $destination = Join-Path $env:HOME '.local\bin' + $state = Join-Path $env:XDG_DATA_HOME 'jvm\tool-install' + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $destination $name)) -ceq $hashes[$name]) "payload differs: $name" } + Assert (-not (Test-Path -LiteralPath (Join-Path $env:XDG_DATA_HOME 'jvm\jdks'))) 'created JDK storage' + Assert ($script:requests.Count -eq 5) 'latest should resolve exactly once' + Pass 'latest, native paths, non-ASCII home and complete exact-byte Windows payload' + Invoke-JvmInstaller @('-NonInteractive') + $key = Open-EnvironmentKey + try { Assert (-not (Get-RegistryValue $key 'Path').Exists -and -not (Get-RegistryValue $key 'JAVA_HOME').Exists) 'unattended implicit edits' } finally { $key.Dispose() } + Pass 'unspecified unattended setup is skipped' + $script:requests = @() + Invoke-JvmInstaller @('-Version', '0.2.0', '-NonInteractive', '-Path', '-Java') + Assert (-not ($script:requests -like '*latest.txt')) 'pinned version fetched latest' + $key = Open-EnvironmentKey + try { + $before = Get-RegistryValue $key 'Path' + Assert ($before.Value.Split(';').Count -eq 2) 'expected two whole entries' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Value -ceq (Join-Path $env:XDG_DATA_HOME 'jvm\jdks\default')) 'wrong stable Java home' + } finally { $key.Dispose() } + Invoke-JvmInstaller @('-NonInteractive', '-Path', '-Java') + $key = Open-EnvironmentKey + try { Assert ((Get-RegistryValue $key 'Path').Value -ceq $before.Value) 'repeat setup duplicated PATH' } finally { $key.Dispose() } + Pass 'explicit consent, missing PATH, registry Java setup and idempotence' + $key = Open-EnvironmentKey + try { + $key.SetValue('Path', '%USERPROFILE%\custom;C:\Other Java\bin;;', [Microsoft.Win32.RegistryValueKind]::ExpandString) + $key.SetValue('JAVA_HOME', 'C:\Old JDK', [Microsoft.Win32.RegistryValueKind]::String) + } finally { $key.Dispose() } + Invoke-JvmInstaller @('-NonInteractive', '-Path', '-Java') + $key = Open-EnvironmentKey + try { + Assert ((Get-RegistryValue $key 'Path').Value.EndsWith('%USERPROFILE%\custom;C:\Other Java\bin;;')) 'raw tokens changed' + Assert ((Get-RegistryValue $key 'Path').Kind -eq 2) 'expandable type changed' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Kind -eq 1) 'string type changed' + $oldPath = Get-RegistryValue $key 'Path'; $oldJava = Get-RegistryValue $key 'JAVA_HOME' + $key.SetValue('Path', 'C:\untouched', [Microsoft.Win32.RegistryValueKind]::String) + $key.SetValue('JAVA_HOME', 'C:\untouched', [Microsoft.Win32.RegistryValueKind]::ExpandString) + } finally { $key.Dispose() } + $script:failNotification = $true + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-Path', '-Java') } + $script:failNotification = $false + $key = Open-EnvironmentKey + try { + Assert ((Get-RegistryValue $key 'Path').Value -ceq 'C:\untouched') 'PATH rollback failed' + Assert ((Get-RegistryValue $key 'Path').Kind -eq 1) 'PATH type rollback failed' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Value -ceq 'C:\untouched') 'JAVA_HOME rollback failed' + Assert ((Get-RegistryValue $key 'JAVA_HOME').Kind -eq 2) 'JAVA_HOME type rollback failed' + } finally { $key.Dispose() } + Pass 'raw registry values/types preserved and failed setup rolled back' + $file = Join-Path $destination 'jvm.cmd' + [IO.File]::AppendAllText($file, 'modified') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') } + [IO.File]::Copy((Join-Path $fixture 'jvm.cmd'), $file, $true) + Pass 'locally modified payload refused' + $goodSums = [IO.File]::ReadAllText((Join-Path $fixture 'SHA256SUMS')) + foreach ($bad in @('error', ($goodSums + $sums[0]), ($goodSums.Replace('jvm.cmd', '../jvm.cmd')))) { + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), $bad) + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), $goodSums) + [IO.File]::AppendAllText((Join-Path $fixture 'jvm'), 'corrupt') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + [IO.File]::Copy((Join-Path $root 'jvm'), (Join-Path $fixture 'jvm'), $true) + $script:downloadFailure = $true + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + $script:downloadFailure = $false + Assert ((Get-ToolHash (Join-Path $destination 'jvm')) -ceq $hashes['jvm']) 'failure damaged installed tool' + Pass 'malformed checksum data, duplicates, paths, checksum mismatch and download failure' + $lock = [IO.File]::Open((Join-Path $state 'lock'), [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + try { Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') } } finally { $lock.Dispose() } + Pass 'concurrent installer lock refuses a second publisher' + $tx = Join-Path $state 'transaction' + [IO.Directory]::CreateDirectory($tx) | Out-Null + [IO.File]::Copy((Join-Path $state 'record'), (Join-Path $tx 'old-record')) + [IO.File]::Copy((Join-Path $state 'record'), (Join-Path $tx 'new-record')) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $destination $name), (Join-Path $tx "old-$name")) } + [IO.File]::WriteAllText((Join-Path $tx 'ready'), 'ready') + [IO.File]::WriteAllText((Join-Path $destination 'jvm.ps1'), 'interrupted') + Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') + Assert ((Get-ToolHash (Join-Path $destination 'jvm.ps1')) -ceq $hashes['jvm.ps1']) 'recovery failed' + Assert (-not (Test-Path -LiteralPath $tx)) 'transaction not cleared' + Pass 'interrupted multi-file publication recovers before retrying' + $installedTime = [datetime]'2000-01-01' + [IO.File]::SetLastWriteTimeUtc((Join-Path $destination 'jvm'), $installedTime) + Invoke-JvmInstaller @('-Version', '0.2.0', '-NonInteractive', '-NoPath', '-NoJava') + Assert ([IO.File]::GetLastWriteTimeUtc((Join-Path $destination 'jvm')) -eq $installedTime) 'matching reinstall replaced bytes' + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + [IO.File]::AppendAllText((Join-Path $fixture $name), "`nnew fixture release`n") + $hashes[$name] = Get-ToolHash (Join-Path $fixture $name) + } + $newSums = @('jvm', 'jvm.ps1', 'jvm.cmd') | ForEach-Object { "$($hashes[$_]) $_" } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), (($newSums -join "`n") + "`n")) + $held = [IO.File]::Open((Join-Path $destination 'jvm.ps1'), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { Expect-Failure { Invoke-JvmInstaller @('-Version', '0.2.1', '-NonInteractive', '-NoPath', '-NoJava') } } + finally { $held.Dispose() } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $destination $name)) -ceq (Get-ToolHash (Join-Path $root $name))) 'failed replacement damaged old payload' } + Assert (-not (Test-Path -LiteralPath $tx)) 'rollback left a transaction' + Invoke-JvmInstaller @('-Version', '0.2.1', '-NonInteractive', '-NoPath', '-NoJava') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { Assert ((Get-ToolHash (Join-Path $destination $name)) -ceq $hashes[$name]) 'update did not replace full payload' } + # Reset fixture to a coherent release for subsequent integration checks. + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + [IO.File]::Copy((Join-Path $root $name), (Join-Path $fixture $name), $true) + $hashes[$name] = Get-ToolHash (Join-Path $fixture $name) + } + [IO.File]::WriteAllText((Join-Path $fixture 'SHA256SUMS'), $goodSums) + Invoke-JvmInstaller @('-Version', '0.2.0', '-NonInteractive', '-NoPath', '-NoJava') + Pass 'matching reinstall is a no-op, locked-file failure rolls back and owned update succeeds' + [IO.Directory]::CreateDirectory($tx) | Out-Null + [IO.File]::WriteAllText((Join-Path $tx 'unknown'), 'preserve') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') } + Assert ([IO.File]::ReadAllText((Join-Path $tx 'unknown')) -ceq 'preserve') 'unknown recovery material deleted' + [IO.File]::Delete((Join-Path $tx 'unknown')); [IO.Directory]::Delete($tx) + Pass 'unknown recovery material is retained' + $env:HOME = 'relative' + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + $env:HOME = Join-Path $temp 'fallback home' + $dataOverride = $env:XDG_DATA_HOME + $env:XDG_DATA_HOME = 'C:relative' + Invoke-JvmInstaller @('-NonInteractive', '-NoPath', '-NoJava') + Assert (Test-Path -LiteralPath (Join-Path $env:HOME '.local\share\jvm\tool-install\record')) 'drive-relative XDG override did not fall back' + $env:XDG_DATA_HOME = $dataOverride + $env:HOME = Join-Path $temp 'unowned' + [IO.Directory]::CreateDirectory((Join-Path $env:HOME '.local\bin')) | Out-Null + [IO.File]::WriteAllText((Join-Path $env:HOME '.local\bin\jvm'), 'unowned') + Expect-Failure { Invoke-JvmInstaller @('-NonInteractive') } + Pass 'relative HOME and unowned destination refused' + $env:JVM_TEST_FIXTURE = $fixture + $env:JVM_TEST_REGISTRY = $registryPath + $shim = @' +function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($env:JVM_TEST_REGISTRY) } +function Send-EnvironmentChange {} +function Get-Download([string]$Url, [string]$File) { [IO.File]::Copy((Join-Path $env:JVM_TEST_FIXTURE ($Url.Split('/')[-1])), $File) } +'@ + $env:HOME = Join-Path $temp 'cmd home' + $env:XDG_DATA_HOME = Join-Path $temp 'cmd data' + $env:JVM_TEST_BOOTSTRAP = Join-Path $temp 'cmd-bootstrap.ps1' + [IO.File]::WriteAllText($env:JVM_TEST_BOOTSTRAP, $source.Replace('try { Invoke-JvmInstaller @($args) }', ($shim + "`ntry { Invoke-JvmInstaller @(`$args) }"))) + # Keep the documented bootstrap expression and CMD quote boundaries exact; + # replace only the HTTP command with a local fixture inside PowerShell. + $cmdBootstrap = 'powershell.exe -NoProfile -Command "$ErrorActionPreference = ''Stop''; Remove-Item Alias:irm -Force; function irm { param($Url) if ($Url -cne ''https://getjvm.dev/install.ps1'') { throw ''bad URL'' }; [IO.File]::ReadAllText($env:JVM_TEST_BOOTSTRAP) }; iex (irm ''https://getjvm.dev/install.ps1'')"' + & $env:ComSpec /d /c $cmdBootstrap + Assert ($LASTEXITCODE -eq 0) 'documented CMD bootstrap quoting failed' + Assert ((Get-ToolHash (Join-Path $env:HOME '.local\bin\jvm.cmd')) -ceq $hashes['jvm.cmd']) 'CMD bootstrap payload differs' + Pass 'documented CMD bootstrap expression and quoting with offline HTTP fixture' + if ($env:JVM_TEST_BASH) { + # Use the real Bash-to-PowerShell path with in-file test doubles; the shipped + # bootstrap has no download/registry overrides or arbitrary setup-only API. + $env:HOME = Join-Path $temp 'bash home' + $env:XDG_DATA_HOME = Join-Path $temp 'bash data' + $bootstrap = [IO.File]::ReadAllText((Join-Path $root 'web\install.sh')) + $bootstrap = $bootstrap.Replace('try { Invoke-JvmInstaller @($args) }', ($shim + "`ntry { Invoke-JvmInstaller @(`$args) }")) + $bootstrapPath = Join-Path $temp 'bootstrap.sh' + [IO.File]::WriteAllText($bootstrapPath, $bootstrap, (New-Object Text.UTF8Encoding $false)) + & $env:JVM_TEST_BASH $bootstrapPath --version 0.2.0 --path --java --non-interactive + Assert ($LASTEXITCODE -eq 0) 'Bash-to-PowerShell bootstrap failed' + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + Assert ((Get-ToolHash (Join-Path $env:HOME ".local\bin\$name")) -ceq $hashes[$name]) 'Bash payload differs' + } + # Exercise the installed-system fallback without changing native tools + # or depending on a particular Windows Bash implementation's PATH. + $restrictedPath = Join-Path $temp 'restricted-path.sh' + $restrictedSource = @' +#!/usr/bin/env bash +set -eu +PATH="$(dirname "$(command -v cygpath)"):$(cygpath -u -S)" +export PATH +if command -v powershell.exe >/dev/null; then exit 99; fi +source "$1" --version 0.2.0 --no-path --no-java --non-interactive +'@ + [IO.File]::WriteAllText($restrictedPath, $restrictedSource.Replace("`r`n", "`n"), (New-Object Text.UTF8Encoding $false)) + & $env:JVM_TEST_BASH $restrictedPath $bootstrapPath + Assert ($LASTEXITCODE -eq 0) 'Windows Bash could not locate system PowerShell outside PATH' + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $env:JVM_TEST_BASH -c 'HOME=relative; source "$1" --version 0.2.0 --no-path --no-java --non-interactive' test $bootstrapPath 2> (Join-Path $temp 'bash-invalid.err') + $invalidStatus = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + Assert ($invalidStatus -eq 1) 'Windows Bash accepted relative HOME' + Assert ([IO.File]::ReadAllText((Join-Path $temp 'bash-invalid.err')) -like '*HOME must be absolute*') 'Windows Bash did not reject HOME before publication' + Pass 'real Windows Bash delegates publication and registry setup to embedded PowerShell' + } + Write-Output "$count PowerShell installer checks passed" +} finally { + $env:HOME = $savedHome; $env:XDG_DATA_HOME = $savedData + Remove-Item Env:JVM_TEST_FIXTURE, Env:JVM_TEST_REGISTRY, Env:JVM_TEST_BOOTSTRAP -ErrorAction SilentlyContinue + [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($registryPath, $false) + if (([IO.Path]::GetFullPath($temp)).StartsWith([IO.Path]::GetFullPath([IO.Path]::GetTempPath()), [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/tests/tool-installer.sh b/tests/tool-installer.sh new file mode 100644 index 0000000..2777fc6 --- /dev/null +++ b/tests/tool-installer.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# Deterministic Unix-contract tests; Windows runs simulate uname, not native Unix. +set -eu +ROOT=$(cd "$(dirname "$0")/.." && pwd) +TEMP=$(mktemp -d "${TMPDIR:-/tmp}/jvm-tool-test.XXXXXXXX") +trap 'status=$?; if [ "$status" != 0 ]; then cat "$TEMP/err" >&2 2>/dev/null || :; fi; rm -rf -- "$TEMP"' EXIT +trap 'exit 1' HUP INT TERM +export HOME="$TEMP/home space 'quote' !"'$literal `data`' +export XDG_DATA_HOME="$TEMP/data space 'quote' !"'$literal `data`' +export XDG_CONFIG_HOME="$TEMP/config" SHELL=/bin/bash +unset JAVA_HOME ZDOTDIR +mkdir -p "$HOME" "$TEMP/fixtures" "$TEMP/bin" +cp "$ROOT/jvm" "$ROOT/jvm.ps1" "$ROOT/jvm.cmd" "$TEMP/fixtures/" +printf '0.2.0\n' > "$TEMP/fixtures/latest.txt" +REAL_HASH=$(command -v sha256sum) +(cd "$TEMP/fixtures"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) +export FIXTURES="$TEMP/fixtures" LOG="$TEMP/requests" +export TEST_EVAL_MARKER="$TEMP/unexpected-command" +cat > "$TEMP/bin/data" <<'EOF' +#!/usr/bin/env bash +printf 'unexpected command execution\n' > "$TEST_EVAL_MARKER" +EOF +cat > "$TEMP/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -eu +out= url= +while [ "$#" -gt 0 ]; do + case $1 in -o) out=$2; shift ;; https:*) url=$1 ;; esac + shift +done +printf '%s\n' "$url" >> "$LOG" +[ "${DOWNLOAD_FAIL:-0}" = 0 ] || exit 22 +cp "$FIXTURES/${url##*/}" "$out" +EOF +cat > "$TEMP/bin/wget" <<'EOF' +#!/usr/bin/env bash +set -eu +out= url= +while [ "$#" -gt 0 ]; do + case $1 in -O) out=$2; shift ;; https:*) url=$1 ;; esac + shift +done +printf '%s\n' "$url" >> "$LOG" +[ "${DOWNLOAD_FAIL:-0}" = 0 ] || exit 8 +if [ "${WGET_REDIRECT:-}" = downgrade ]; then + printf ' HTTP/1.1 302 Found\n Location: http://insecure.example/payload\n' >&2 + exit 8 +fi +if [ "${WGET_REDIRECT:-}" = secure ]; then + case $url in + *://github.com/*) printf ' HTTP/1.1 302 Found\n Location: https://release.example/%s\n' "${url##*/}" >&2; exit 8 ;; + esac +fi +printf ' HTTP/1.1 200 OK\n' >&2 +cp "$FIXTURES/${url##*/}" "$out" +EOF +cat > "$TEMP/bin/uname" <<'EOF' +#!/usr/bin/env bash +printf 'Linux\n' +EOF +chmod +x "$TEMP/bin/"* +export PATH="$TEMP/bin:$PATH" +INSTALLER=$ROOT/web/install.sh +DEST=$HOME/.local/bin STATE=$XDG_DATA_HOME/jvm/tool-install +count=0 +ok() { count=$((count+1)); printf 'ok %s - %s\n' "$count" "$*"; } +run() { bash "$INSTALLER" "$@" > "$TEMP/out" 2> "$TEMP/err"; } +fails() { + local expected=$1 status=0; shift + run "$@" || status=$? + [ "$status" = "$expected" ] || { cat "$TEMP/err" >&2; echo "expected $expected, got $status" >&2; exit 1; } +} +run --help +fails 2 --path --no-path +fails 2 --version 01.2.3 +fails 2 --version "$(printf '0.2.0\ninvalid')" +fails 2 --profile /tmp/x +[ ! -e "$LOG" ] && [ ! -e "$HOME/.local" ] +ok 'help and invalid options have no side effects' +run --non-interactive --no-path --no-java +cmp "$DEST/jvm" "$ROOT/jvm" +[ ! -e "$HOME/.bashrc" ] && [ ! -e "$XDG_DATA_HOME/jvm/jdks" ] +[ "$(wc -l < "$LOG" | tr -d ' ')" = 3 ] +ok 'latest pins payload, exact bytes, unattended defaults preserve JDK storage' +: > "$LOG" +touch -t 200001010000 "$DEST/jvm" +touch "$TEMP/mtime-sentinel" +run --version 0.2.0 --non-interactive --no-path --no-java +! grep latest.txt "$LOG" +[ "$DEST/jvm" -ot "$TEMP/mtime-sentinel" ] +ok 'pinned matching reinstall bypasses latest' +run --path --java --shell bash --non-interactive +[ -f "$HOME/.profile" ] && [ ! -e "$HOME/.bash_profile" ] +# Start a real fresh Bash. Generated setup must treat metacharacters as data. +bash --noprofile --norc -c '. "$HOME/.profile"; . "$HOME/.bashrc"; . "$HOME/.profile"; [ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]; case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) exit 1 ;; esac' +bash --noprofile --norc -c 'PATH=; . "$HOME/.profile"; case "$PATH" in *:) ;; *) exit 1 ;; esac; unset PATH; . "$HOME/.profile"; case "$PATH" in *:) exit 1 ;; esac' +before=$(find "$HOME" -name '*.jvm-backup.*' | wc -l) +run --path --java --shell bash --non-interactive +[ "$before" = "$(find "$HOME" -name '*.jvm-backup.*' | wc -l)" ] +ok 'Bash startup executes safely, deduplicates and repeat setup preserves backups' +printf '\n# user edit\n' >> "$DEST/jvm" +fails 1 --no-path --no-java --non-interactive +grep -q 'locally modified' "$TEMP/err" +cp "$ROOT/jvm" "$DEST/jvm" +ok 'modified owned file refused' +cp "$FIXTURES/SHA256SUMS" "$TEMP/good-sums" +printf 'error\n' > "$FIXTURES/SHA256SUMS" +fails 1 --non-interactive +cp "$TEMP/good-sums" "$FIXTURES/SHA256SUMS" +printf bad >> "$FIXTURES/jvm" +fails 1 --non-interactive +cmp "$DEST/jvm" "$ROOT/jvm" +cp "$ROOT/jvm" "$FIXTURES/jvm" +DOWNLOAD_FAIL=1 fails 1 --non-interactive +printf '0.2.0\n0.3.0\n' > "$FIXTURES/latest.txt" +fails 1 --non-interactive +printf '0.2.0\ninvalid' > "$FIXTURES/latest.txt" +fails 1 --non-interactive +printf '0.2.0\n' > "$FIXTURES/latest.txt" +ok 'HTTP failures, malformed metadata and checksum mismatch preserve tool' +printf '# >>> jvm path >>>\n' >> "$HOME/.bashrc" +fails 1 --path --shell bash --non-interactive +grep -q 'Tool was installed' "$TEMP/err" +sed '$d' "$HOME/.bashrc" > "$TEMP/profile"; cp "$TEMP/profile" "$HOME/.bashrc" +ok 'corrupt markers fail setup after usable publication' +printf '# existing login\n' > "$HOME/.bash_login" +run --path --shell bash --no-java --non-interactive +grep -q '^# existing login' "$HOME/.bash_login" +[ ! -e "$HOME/.bash_profile" ] +ok 'Bash honors login precedence' +mkdir -p "$STATE/lock" +printf '%s\n' "$$" > "$STATE/lock/pid" +fails 1 --non-interactive --no-path --no-java +grep -q 'another installer' "$TEMP/err" +rm "$STATE/lock/pid"; rmdir "$STATE/lock" +ok 'live competing installer refused' +mkdir "$STATE/transaction" +cp "$STATE/record" "$STATE/transaction/old-record" +cp "$STATE/record" "$STATE/transaction/new-record" +cp -p "$DEST/jvm" "$STATE/transaction/old-jvm" +touch "$STATE/transaction/ready" +printf partial > "$DEST/jvm" +run --non-interactive --no-path --no-java +cmp "$DEST/jvm" "$ROOT/jvm" +[ ! -e "$STATE/transaction" ] +ok 'interrupted update restores prior payload and ownership before retry' +# Force failures after a replacement payload has been published, before record +# publication. Rollback must restore a prior usable version, not the new bytes. +REAL_MV=$(command -v mv) +export REAL_MV INJECT_TARGET="$STATE/record" INJECT_ONCE="$TEMP/injected" +cat > "$TEMP/bin/mv" <<'EOF' +#!/usr/bin/env bash +set -eu +last= +for arg in "$@"; do last=$arg; done +if [ -n "${INJECT_MODE:-}" ] && [ "$last" = "$INJECT_TARGET" ] && [ ! -e "$INJECT_ONCE" ]; then + : > "$INJECT_ONCE" + if [ "$INJECT_MODE" = signal ]; then kill -TERM "$PPID"; fi + exit 1 +fi +exec "$REAL_MV" "$@" +EOF +chmod +x "$TEMP/bin/mv" +printf '\n# new release fixture\n' >> "$FIXTURES/jvm" +(cd "$FIXTURES"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) +for mode in fail signal; do + rm -f "$INJECT_ONCE" + INJECT_MODE=$mode fails 1 --version 0.2.1 --non-interactive --no-path --no-java + cmp "$DEST/jvm" "$ROOT/jvm" + [ "$(sed -n '2p' "$STATE/record")" = 0.2.0 ] +done +ok 'publication error and catchable interruption restore old payload/record' +run --version 0.2.1 --non-interactive --no-path --no-java +cmp "$DEST/jvm" "$FIXTURES/jvm" +[ "$(sed -n '2p' "$STATE/record")" = 0.2.1 ] +cp "$ROOT/jvm" "$FIXTURES/jvm" +(cd "$FIXTURES"; "$REAL_HASH" -t jvm jvm.ps1 jvm.cmd > SHA256SUMS) +run --version 0.2.0 --non-interactive --no-path --no-java +ok 'owned update and explicit version replacement' +# The first profile in a two-file step must be restored if the second fails. +cp "$HOME/.bashrc" "$TEMP/original-bashrc" +cp "$HOME/.bash_login" "$TEMP/original-login" +printf '# original login before failed publication\n' > "$HOME/.bash_login" +printf '# force path regeneration\n' > "$HOME/.bashrc" +cp "$HOME/.bashrc" "$TEMP/before-rollback" +rm -f "$INJECT_ONCE" +INJECT_TARGET="$HOME/.bash_login" INJECT_MODE=fail fails 1 --path --shell bash --no-java --non-interactive +cmp "$HOME/.bashrc" "$TEMP/before-rollback" +cp "$TEMP/original-bashrc" "$HOME/.bashrc" +cp "$TEMP/original-login" "$HOME/.bash_login" +ok 'multi-profile setup step rolls back earlier edits on failure' +mkdir "$STATE/transaction" +printf 'do not delete\n' > "$STATE/transaction/unknown" +fails 1 --non-interactive --no-path --no-java +grep -q 'do not delete' "$STATE/transaction/unknown" +rm "$STATE/transaction/unknown"; rmdir "$STATE/transaction" +ok 'unknown recovery material retained for inspection' +custom="$HOME/custom-profile" +printf '# preserved\n' > "$custom" +ln -s "$custom" "$HOME/link-profile" +if [ -L "$HOME/link-profile" ]; then + fails 1 --path --shell zsh --profile "$HOME/link-profile" --non-interactive +else + printf 'Native symlink assertion requires hosted Unix (ln made a copy here).\n' +fi +run --path --shell zsh --profile "$custom" --no-java --non-interactive +grep -q '^# preserved' "$custom" +ok 'custom profile preserves content and symlink profiles are refused' +# Force capability detection to exercise wget without contacting the network. +sed '/# BEGIN GENERATED WINDOWS INSTALLER/,$d' "$INSTALLER" > "$TEMP/wget-installer" +cat >> "$TEMP/wget-installer" <<'EOF' +command() { if [ "$1" = -v ] && [ "$2" = curl ]; then return 1; fi; builtin command "$@"; } +main "$@" +EOF +INSTALLER=$TEMP/wget-installer +run --no-path --no-java --non-interactive +ok 'wget-only capability path' +WGET_REDIRECT=secure run --no-path --no-java --non-interactive +WGET_REDIRECT=downgrade fails 1 --no-path --no-java --non-interactive +! grep '^http://' "$LOG" +ok 'wget follows verified HTTPS hops and rejects downgrade before any HTTP request' +INSTALLER=$ROOT/web/install.sh +# Capability alternatives are tested without installing utilities. +for hasher in shasum openssl; do + if ! command -v "$hasher" >/dev/null 2>&1; then continue; fi + sed '/# BEGIN GENERATED WINDOWS INSTALLER/,$d' "$INSTALLER" > "$TEMP/hash-installer" + cat >> "$TEMP/hash-installer" <<'EOF' +command() { + if [ "$1" = -v ]; then + case $2 in sha256sum) return 1 ;; shasum) [ "$TEST_HASHER" != openssl ] || return 1 ;; esac + fi + builtin command "$@" +} +main "$@" +EOF + INSTALLER=$TEMP/hash-installer + TEST_HASHER=$hasher run --no-path --no-java --non-interactive + INSTALLER=$ROOT/web/install.sh +done +ok 'available SHA-256 alternatives' +for shell in zsh fish csh tcsh; do + profile="$HOME/$shell-profile" + run --path --java --shell "$shell" --profile "$profile" --non-interactive + [ "$(grep -c '^# >>> jvm ' "$profile")" = 2 ] +done +ok 'all supported shell formats generated' +ZDOTDIR="$HOME/custom-zdot" run --shell zsh --path --no-java --non-interactive +[ -f "$HOME/custom-zdot/.zshrc" ] +run --shell fish --path --java --non-interactive +[ -f "$XDG_CONFIG_HOME/fish/conf.d/jvm.fish" ] +ok 'exported ZDOTDIR and Fish XDG configuration root' +request_count=$(wc -l < "$LOG") +HOME=relative fails 1 --non-interactive --no-path --no-java +[ "$request_count" = "$(wc -l < "$LOG")" ] +mkdir -p "$TEMP/conflict-home/.local/bin" +printf unowned > "$TEMP/conflict-home/.local/bin/jvm" +HOME="$TEMP/conflict-home" XDG_DATA_HOME="$TEMP/conflict-data" fails 1 --non-interactive --no-path --no-java +[ "$(cat "$TEMP/conflict-home/.local/bin/jvm")" = unowned ] +ok 'relative HOME rejected before networking and unowned file preserved' +HOME="$TEMP/fallback-home" XDG_DATA_HOME=relative run --non-interactive --no-path --no-java +[ -f "$TEMP/fallback-home/.local/share/jvm/tool-install/record" ] +ok 'relative XDG override falls back beneath isolated HOME' +# Never depend on a contributor's controlling terminal in the offline suite. +cat "$INSTALLER" | HOME="$TEMP/piped-home" XDG_DATA_HOME="$TEMP/piped-data" bash -s -- --version 0.2.0 --non-interactive > "$TEMP/out" 2> "$TEMP/err" +[ -f "$TEMP/piped-home/.local/bin/jvm" ] && [ ! -e "$TEMP/piped-home/.bashrc" ] +ok 'piped bootstrap skips unspecified setup without consuming script stdin' +if [ "${1:-}" = --native-shells ]; then + # Hosted Unix only: use actual shells, including paths with apostrophes/spaces. + # BSD csh expands its inherited HOME before even parsing -f (csh.c/set.c). + # Keep its bootstrap HOME safe; sourced tool/data paths remain hostile. + csh_bootstrap_home=$TEMP/csh-bootstrap-home + mkdir "$csh_bootstrap_home" + native_shells='zsh fish csh tcsh' + if [ -n "${2:-}" ]; then + case $2 in zsh|fish|csh|tcsh) native_shells=$2 ;; *) echo 'Unsupported native test shell' >&2; exit 2 ;; esac + fi + native_failures=0 + for shell in $native_shells; do + command -v "$shell" >/dev/null + profile="$HOME/$shell-profile" + printf 'Checking native %s explicit sourcing\n' "$shell" + native_status=0 + if [ "$shell" = fish ]; then + "$shell" --no-config -c 'source "$argv[1]"; source "$argv[1]"; test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' "$profile" || native_status=$? + elif [ "$shell" = zsh ]; then + "$shell" -f -c '. "$1"; . "$1"; [ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' test "$profile" || native_status=$? + else + # Preserve literal words through C-shell substitution phases. The + # script path itself is simple; the sourced fixture path is not. + cat > "$TEMP/check-native.csh" <<'EOF' +unset path +source $argv[1]:q +source $argv[1]:q +set test_home = "$JAVA_HOME" +set test_expected = "$XDG_DATA_HOME/jvm/jdks/default" +if ( $test_home:q != $test_expected:q ) exit 1 +if ( $#path != 2 ) exit 1 +set test_java_bin = "$XDG_DATA_HOME/jvm/jdks/default/bin" +if ( $path[1]:q != $test_java_bin:q ) exit 1 +if ( $path[2]:q != $argv[2]:q ) exit 1 +EOF + if [ "$shell" = csh ]; then + HOME="$csh_bootstrap_home" "$shell" -f "$TEMP/check-native.csh" "$profile" "$DEST" || native_status=$? + else + "$shell" -f "$TEMP/check-native.csh" "$profile" "$DEST" || native_status=$? + fi + fi + [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 + if [ "$native_status" != 0 ]; then + printf 'FAIL: native %s sourcing (status %s); generated profile follows\n' "$shell" "$native_status" >&2 + cat "$profile" >&2 + native_failures=$((native_failures+1)) + fi + done + if [ "$native_failures" = 0 ]; then ok 'actual native shells source managed setup twice'; fi + # Also launch normal startup paths, not just explicit source commands. + run --shell zsh --path --java --non-interactive + run --shell csh --path --java --non-interactive + printf '# tcsh startup\n' > "$HOME/.tcshrc" + run --shell tcsh --path --java --non-interactive + cp "$HOME/.cshrc" "$csh_bootstrap_home/.cshrc" + bash --noprofile --rcfile "$HOME/.bashrc" -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" 2> "$TEMP/startup-err" + [ ! -s "$TEMP/startup-out" ] + for shell in $native_shells; do + printf 'Checking native %s startup\n' "$shell" + native_status=0 + case $shell in + zsh) zsh -d -i -c '[ "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default" ]' > "$TEMP/startup-out" ;; + fish) fish --private -c 'test "$JAVA_HOME" = "$XDG_DATA_HOME/jvm/jdks/default"' > "$TEMP/startup-out" ;; + csh) HOME="$csh_bootstrap_home" "$shell" -c 'set test_home = "$JAVA_HOME"; set test_expected = "$XDG_DATA_HOME/jvm/jdks/default"; if ( $test_home:q != $test_expected:q ) exit 1' > "$TEMP/startup-out" ;; + tcsh) "$shell" -c 'set test_home = "$JAVA_HOME"; set test_expected = "$XDG_DATA_HOME/jvm/jdks/default"; if ( $test_home:q != $test_expected:q ) exit 1' > "$TEMP/startup-out" ;; + esac || native_status=$? + [ ! -s "$TEMP/startup-out" ] && [ ! -e "$TEST_EVAL_MARKER" ] || native_status=1 + if [ "$native_status" != 0 ]; then + printf 'FAIL: native %s startup (status %s)\n' "$shell" "$native_status" >&2 + cat "$TEMP/startup-out" >&2 + native_failures=$((native_failures+1)) + fi + done + [ "$native_failures" = 0 ] || { printf '%s native shell checks failed\n' "$native_failures" >&2; exit 1; } + ok 'fresh shells load native startup files silently' +fi +printf '%s installer checks passed\n' "$count" diff --git a/tools/embed-installer.py b/tools/embed-installer.py new file mode 100644 index 0000000..234dccf --- /dev/null +++ b/tools/embed-installer.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Embed the single-source Windows installer in the standalone Bash bootstrap.""" +import pathlib +import sys + +root = pathlib.Path(__file__).resolve().parent.parent +target = root / 'web/install.sh' +marker = '# BEGIN GENERATED WINDOWS INSTALLER\n' +prefix = target.read_text().split(marker)[0] +result = prefix + marker + "windows_installer() {\ncat <<'JVM_POWERSHELL_INSTALLER'\n" + (root / 'web/install.ps1').read_text() + "JVM_POWERSHELL_INSTALLER\n}\nmain \"$@\"\n" +if '--check' in sys.argv: + if target.read_bytes() != result.encode(): + sys.exit('Run python tools/embed-installer.py') +else: + target.write_bytes(result.encode()) diff --git a/tools/package-release.py b/tools/package-release.py new file mode 100644 index 0000000..a820389 --- /dev/null +++ b/tools/package-release.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Offline exact-byte release packaging and public-site staging (contributor tool).""" +import argparse +import hashlib +import pathlib +import re +import shutil + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ASSETS = ('jvm', 'jvm.ps1', 'jvm.cmd') +VERSION = r'(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('version') + parser.add_argument('destination', type=pathlib.Path) + parser.add_argument('--site', action='store_true') + args = parser.parse_args() + if not re.fullmatch(VERSION, args.version): + parser.error('version must be numeric major.minor.patch without leading zeros') + if not args.site: + for name, pattern in [('jvm', r'^VERSION=([^\n]+)$'), + ('jvm.ps1', r"^\$script:Version = '([^']+)'$")]: + match = re.search(pattern, (ROOT / name).read_text(), re.M) + if not match or match[1] != args.version: + parser.error(f'{name} version does not match {args.version}') + elif (ROOT / 'web/latest.txt').read_bytes() != (args.version + '\n').encode(): + parser.error('site version must match latest.txt') + if args.destination.exists(): + parser.error('destination must not exist') + args.destination.mkdir(parents=True) + if args.site: + for name in ('install.sh', 'install.ps1', 'latest.txt', 'index.html', '.nojekyll'): + shutil.copyfile(ROOT / 'web' / name, args.destination / name) + else: + lines = [] + for name in ASSETS: + content = (ROOT / name).read_bytes() + (args.destination / name).write_bytes(content) + lines.append(f'{hashlib.sha256(content).hexdigest()} {name}\n') + (args.destination / 'SHA256SUMS').write_bytes(''.join(lines).encode('ascii')) + + +if __name__ == '__main__': + main() diff --git a/tools/pages-preflight.py b/tools/pages-preflight.py new file mode 100644 index 0000000..c5bb764 --- /dev/null +++ b/tools/pages-preflight.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Verify latest.txt against complete, public release assets before deploying Pages.""" +import hashlib +import pathlib +import re +import urllib.request + +root = pathlib.Path(__file__).resolve().parent.parent +version = (root / 'web/latest.txt').read_text() +assert re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\n', version), 'Invalid latest.txt' +base = f'https://github.com/codejive/jvm/releases/download/v{version.strip()}' + + +class HttpsRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not newurl.startswith('https://'): + raise ValueError('HTTPS downgrade') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +opener = urllib.request.build_opener(HttpsRedirect()) + + +def get(name): + with opener.open(f'{base}/{name}', timeout=60) as response: + assert response.status == 200 + return response.read() + + +lines = get('SHA256SUMS').decode('ascii').splitlines() +hashes = {} +for line in lines: + match = re.fullmatch(r'([0-9a-f]{64}) (jvm|jvm\.ps1|jvm\.cmd)', line) + assert match and match[2] not in hashes, 'Invalid SHA256SUMS' + hashes[match[2]] = match[1] +assert set(hashes) == {'jvm', 'jvm.ps1', 'jvm.cmd'}, 'Incomplete release' +for name, digest in hashes.items(): + assert hashlib.sha256(get(name)).hexdigest() == digest, f'Invalid {name}' +print(f'Complete verified public release: v{version.strip()}') diff --git a/web/.nojekyll b/web/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e196baa --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + +jvm — Java for your projects +
+

jvm

+

A small tool to download and select Java Development Kits.

+

Install the tool on Unix:

+
curl -fsSL https://getjvm.dev/install.sh | bash
+

Or in Windows PowerShell:

+
iex (irm 'https://getjvm.dev/install.ps1')
+

Then open a fresh terminal and run jvm install to install Java.

+

Instructions, options and source

+
+ diff --git a/web/install.ps1 b/web/install.ps1 new file mode 100644 index 0000000..2d1b12d --- /dev/null +++ b/web/install.ps1 @@ -0,0 +1,362 @@ +# Windows PowerShell 5.1 standalone bootstrap. No JDK commands are invoked. +$ErrorActionPreference = 'Stop' + +function Write-InstallerMessage([string]$Message) { [Console]::Error.WriteLine("jvm installer: $Message") } +function Stop-Installer([string]$Message, [int]$Code = 1) { + $exception = New-Object Exception $Message + $exception.Data['InstallerCode'] = $Code + throw $exception +} +function Test-ToolVersion([string]$Value) { return $Value -cmatch '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z' } +function Get-ToolHash([string]$File) { + $stream = [IO.File]::OpenRead($File) + $sha = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose(); $stream.Dispose() } +} +function Test-PlainFile([string]$File) { + if (-not [IO.File]::Exists($File)) { return $false } + return -not ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) +} +function Assert-SafeRoot([string]$Value) { + if ($Value -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)' -or $Value -match '[;\r\n\t]') { + Stop-Installer "Expected an absolute native path without semicolons or line breaks: $Value" + } +} +function Assert-NoReparse([string]$File) { + if (Test-Path -LiteralPath $File) { + if ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Stop-Installer "Reparse point conflict: $File" } + } +} +function Get-Download([string]$Url, [string]$File) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + for ($i = 0; $i -lt 10; $i++) { + if (([Uri]$Url).Scheme -cne 'https') { Stop-Installer 'HTTPS is required, including redirects' } + $request = [Net.HttpWebRequest]::Create($Url) + $request.AllowAutoRedirect = $false + $request.Timeout = 45000 + $request.ReadWriteTimeout = 45000 + $response = $request.GetResponse() + try { + $status = [int]$response.StatusCode + if ($status -in @(301, 302, 303, 307, 308)) { + $Url = ([Uri]::new([Uri]$Url, $response.Headers['Location'])).AbsoluteUri + continue + } + if ($status -ne 200) { Stop-Installer "HTTP $status for $Url" } + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Create($File) + try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() } + if ((Get-Item -LiteralPath $File).Length -eq 0) { Stop-Installer "Empty download: $Url" } + return + } finally { $response.Dispose() } + } + Stop-Installer 'Too many HTTPS redirects' +} +function Read-ToolChecksums([string]$File) { + $result = @{} + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 3) { Stop-Installer 'SHA256SUMS must contain exactly three entries' } + foreach ($line in $lines) { + if ($line -cnotmatch '\A([0-9a-fA-F]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z') { Stop-Installer 'Invalid SHA256SUMS entry' } + $name = $Matches[2]; $digest = $Matches[1].ToLowerInvariant() + if ($result.ContainsKey($name)) { Stop-Installer 'Duplicate SHA256SUMS entry' } + $result[$name] = $digest + } + return $result +} +function Read-ToolRecord([string]$File, [string]$Destination) { + if (-not (Test-PlainFile $File)) { Stop-Installer "Unsafe ownership record: $File" } + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 6 -or $lines[0] -cne 'jvm-tool-v1' -or -not (Test-ToolVersion $lines[1]) -or $lines[2] -cne $Destination) { + Stop-Installer 'Invalid ownership record or destination mismatch' + } + $result = @{} + foreach ($line in $lines[3..5]) { + if ($line -cnotmatch '\A([0-9a-f]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z' -or $result.ContainsKey($Matches[2])) { Stop-Installer 'Invalid ownership hashes' } + $result[$Matches[2]] = $Matches[1] + } + return $result +} +function Write-ToolRecord([string]$File, [string]$Version, [string]$Destination, [hashtable]$Hashes) { + $lines = @('jvm-tool-v1', $Version, $Destination) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { $lines += "$($Hashes[$name]) $name" } + [IO.File]::WriteAllText($File, (($lines -join "`n") + "`n"), (New-Object Text.UTF8Encoding $false)) +} +function Remove-ToolStage([string]$Stage) { + Assert-NoReparse $Stage + if (-not (Test-Path -LiteralPath $Stage)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { + if ($entry.Name -cnotin @('jvm', 'jvm.ps1', 'jvm.cmd') -or -not (Test-PlainFile $entry.FullName)) { Stop-Installer "Unrecognized staging material: $($entry.FullName)" } + } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { [IO.File]::Delete($entry.FullName) } + [IO.Directory]::Delete($Stage) +} +function Restore-ToolTransaction([string]$State, [string]$Destination) { + $tx = Join-Path $State 'transaction' + Assert-NoReparse $tx + if (-not (Test-Path -LiteralPath $tx)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $tx -Force)) { + if ($entry.Name -cnotin @('new-record', 'old-record', 'old-jvm', 'old-jvm.ps1', 'old-jvm.cmd', 'ready', 'stage-name') -or -not (Test-PlainFile $entry.FullName)) { + Stop-Installer "Unrecognized recovery material: $($entry.FullName); inspect manually" + } + } + if (Test-Path -LiteralPath (Join-Path $tx 'stage-name')) { + if (-not (Test-PlainFile (Join-Path $tx 'stage-name'))) { Stop-Installer 'Unsafe staging journal' } + $stageName = [IO.File]::ReadAllText((Join-Path $tx 'stage-name')) + if ($stageName -cnotmatch '\A\.jvm-next-[a-f0-9]{32}\z') { Stop-Installer 'Invalid staging journal' } + $stage = Join-Path $Destination $stageName + Remove-ToolStage $stage + } + if (Test-Path -LiteralPath (Join-Path $tx 'ready')) { + $new = Read-ToolRecord (Join-Path $tx 'new-record') $Destination + $old = $null + if (Test-Path -LiteralPath (Join-Path $tx 'old-record')) { $old = Read-ToolRecord (Join-Path $tx 'old-record') $Destination } + # Verify every backup before restoring any member; recovery is repeatable. + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + Assert-NoReparse $file + if ((Test-Path -LiteralPath $file) -and -not (Test-PlainFile $file)) { Stop-Installer "Unsafe recovery destination: $file" } + if ($old) { + $backup = Join-Path $tx "old-$name" + if (-not (Test-PlainFile $backup) -or (Get-ToolHash $backup) -cne $old[$name]) { Stop-Installer "Damaged recovery material at $tx; inspect manually" } + } elseif ((Test-Path -LiteralPath $file) -and (Get-ToolHash $file) -cne $new[$name]) { Stop-Installer "Unknown recovery destination: $file" } + } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { [IO.File]::Copy((Join-Path $tx "old-$name"), $file, $true) } + } + elseif (Test-Path -LiteralPath $file) { [IO.File]::Delete($file) } + } + if ($old) { [IO.File]::Copy((Join-Path $tx 'old-record'), (Join-Path $State 'record'), $true) } + elseif (Test-Path -LiteralPath (Join-Path $State 'record')) { [IO.File]::Delete((Join-Path $State 'record')) } + Write-InstallerMessage 'Recovered interrupted tool publication.' + } + Remove-Item -LiteralPath $tx -Recurse -Force +} +function Publish-Tool([string]$Work, [string]$State, [string]$Destination, [string]$Version, [hashtable]$Hashes) { + [IO.Directory]::CreateDirectory($Destination) | Out-Null + [IO.Directory]::CreateDirectory($State) | Out-Null + Assert-NoReparse $State; Assert-NoReparse $Destination + Assert-NoReparse (Join-Path $State 'lock') + try { $lock = [IO.File]::Open((Join-Path $State 'lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch { Stop-Installer 'Another installer holds the lock, or the lock is inaccessible; retry after it exits' } + $tx = Join-Path $State 'transaction' + try { + Restore-ToolTransaction $State $Destination + $record = Join-Path $State 'record' + $old = $null + Assert-NoReparse $record + if (Test-Path -LiteralPath $record) { $old = Read-ToolRecord $record $Destination } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { Stop-Installer "Owned file missing or locally modified: $file. Restore it or move the payload and ownership record aside before retrying." } + } elseif (Test-Path -LiteralPath $file) { Stop-Installer "Unowned destination conflict: $file. Move it aside before retrying." } + } + $matching = $null -ne $old + if ($old) { + $matching = ([IO.File]::ReadAllLines($record)[1] -ceq $Version) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { if ($old[$name] -cne $Hashes[$name]) { $matching = $false } } + } + if ($matching) { return } + [IO.Directory]::CreateDirectory($tx) | Out-Null + if ($old) { + [IO.File]::Copy($record, (Join-Path $tx 'old-record')) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Destination $name), (Join-Path $tx "old-$name")) } + } + Write-ToolRecord (Join-Path $tx 'new-record') $Version $Destination $Hashes + # Destination-filesystem staging, before any published file is changed. + $next = Join-Path $Destination ('.jvm-next-' + [Guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText((Join-Path $tx 'stage-name'), [IO.Path]::GetFileName($next)) + [IO.Directory]::CreateDirectory($next) | Out-Null + try { + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Work $name), (Join-Path $next $name)) } + [IO.File]::WriteAllText((Join-Path $tx 'ready'), 'ready') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) } + [IO.File]::Move((Join-Path $next $name), $file) + } + [IO.File]::Copy((Join-Path $tx 'new-record'), $record, $true) + Remove-Item -LiteralPath $tx -Recurse -Force + } finally { Remove-ToolStage $next } + } catch { + $failure = $_ + try { Restore-ToolTransaction $State $Destination } + catch { Write-InstallerMessage "Recovery retained at $tx. $($_.Exception.Message)" } + throw $failure + } finally { + # Keep serialization through consented registry edits as well. + $script:InstallerLock = $lock + } +} +function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') } +function Get-RegistryValue($Key, [string]$Name) { + if ($Key.GetValueNames() -contains $Name) { + return @{ Exists = $true; Kind = [int]$Key.GetValueKind($Name); Value = $Key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } + } + return @{ Exists = $false; Kind = [int][Microsoft.Win32.RegistryValueKind]::ExpandString; Value = '' } +} +function Set-RegistrySnapshot($Key, [string]$Name, $Snapshot) { + if ($Snapshot.Exists) { $Key.SetValue($Name, $Snapshot.Value, [Microsoft.Win32.RegistryValueKind]$Snapshot.Kind) } + else { $Key.DeleteValue($Name, $false) } +} +function Test-PathEntry([string]$Entry, [string]$Target) { + $expanded = [Environment]::ExpandEnvironmentVariables($Entry.Trim().Trim('"')).TrimEnd('\', '/') + return $expanded.Replace('/', '\') -ieq $Target.TrimEnd('\', '/').Replace('/', '\') +} +function Add-UserPath([string]$Value, [string]$Entry) { + foreach ($token in $Value.Split(';')) { if (Test-PathEntry $token $Entry) { return $Value } } + if ([string]::IsNullOrEmpty($Value)) { return $Entry } + return "$Entry;$Value" +} +function Send-EnvironmentChange { + if (-not ('JvmInstaller.Native' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace JvmInstaller { public static class Native { + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, UIntPtr w, string l, uint f, uint t, out UIntPtr r); +}} +'@ + } + $result = [UIntPtr]::Zero + [JvmInstaller.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null +} +function Get-SetupConsent([string]$Choice, [bool]$Unattended, [string]$Prompt) { + if ($Choice -eq 'yes') { return $true } + if ($Choice -eq 'no' -or $Unattended -or [Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false } + try { return (Read-Host "$Prompt [y/N]") -match '\A(?i:y|yes)\z' } catch { return $false } +} +function Set-UserEnvironment([string]$State, [string]$Destination, [string]$JavaHome, [string]$PathChoice, [string]$JavaChoice, [bool]$Unattended) { + if ($PathChoice -eq 'no' -and $JavaChoice -eq 'no') { return } + if ($Unattended -and $PathChoice -ne 'yes' -and $JavaChoice -ne 'yes') { + Write-InstallerMessage "Unspecified environment setup skipped. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds its bin directory." + return + } + $key = Open-EnvironmentKey + try { + $oldPath = Get-RegistryValue $key 'Path' + $oldJava = Get-RegistryValue $key 'JAVA_HOME' + foreach ($snapshot in @($oldPath, $oldJava)) { + if ($snapshot.Exists -and $snapshot.Kind -notin @(1, 2)) { Stop-Installer 'Environment values must be REG_SZ or REG_EXPAND_SZ' } + } + $pathValue = [string]$oldPath.Value + $toolPresent = (Add-UserPath $pathValue $Destination) -ceq $pathValue + $javaBin = Join-Path $JavaHome 'bin' + $javaPresent = $oldJava.Exists -and (Test-PathEntry ([string]$oldJava.Value) $JavaHome) -and ((Add-UserPath $pathValue $javaBin) -ceq $pathValue) + $toolYes = $false; $javaYes = $false + if (-not $toolPresent) { + Write-InstallerMessage "HKCU\Environment\Path ($($oldPath.Kind)): add $Destination; preserve raw existing value: $pathValue" + $toolYes = Get-SetupConsent $PathChoice $Unattended 'Persist tool PATH?' + } + if (-not $javaPresent) { + Write-InstallerMessage "HKCU\Environment: JAVA_HOME '$($oldJava.Value)' -> '$JavaHome'; Path adds $javaBin. A default JDK may not exist yet." + $javaYes = Get-SetupConsent $JavaChoice $Unattended 'Persist default Java?' + } + if (-not $toolYes -and -not $javaYes) { + Write-InstallerMessage "Environment unchanged. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds $javaBin." + return + } + if ($toolYes) { $pathValue = Add-UserPath $pathValue $Destination } + if ($javaYes) { $pathValue = Add-UserPath $pathValue $javaBin } + $backup = Join-Path $State ('environment-backup-' + [Guid]::NewGuid().ToString('N') + '.clixml') + @{ Path = $oldPath; JAVA_HOME = $oldJava } | Export-Clixml -LiteralPath $backup -Encoding UTF8 + try { + if ($javaYes) { $key.SetValue('JAVA_HOME', $JavaHome, [Microsoft.Win32.RegistryValueKind]$oldJava.Kind) } + $key.SetValue('Path', $pathValue, [Microsoft.Win32.RegistryValueKind]$oldPath.Kind) + $key.Flush() + Send-EnvironmentChange + } catch { + $failure = $_ + try { Set-RegistrySnapshot $key 'Path' $oldPath; Set-RegistrySnapshot $key 'JAVA_HOME' $oldJava; $key.Flush() } + catch { Write-InstallerMessage "Registry rollback failed; exact raw backup: $backup" } + throw $failure + } + Write-InstallerMessage "Updated HKCU\Environment; raw value/type backup: $backup" + $systemPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + foreach ($entry in $systemPath.Split(';')) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim('"')) + if (-not [IO.Path]::IsPathRooted($expanded)) { continue } + if ([IO.File]::Exists([IO.Path]::Combine($expanded, 'java.exe'))) { Write-InstallerMessage "System Java at $expanded may precede user PATH. Machine PATH was preserved."; break } + } + } finally { $key.Dispose() } +} +function Invoke-JvmInstaller([string[]]$Arguments) { + $version = ''; $pathChoice = 'ask'; $javaChoice = 'ask'; $unattended = $false + $seen = @{} + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $arg = $Arguments[$i] + if ($arg -ieq '-Help') { + if ($Arguments.Count -ne 1) { Stop-Installer '-Help must be used alone' 2 } + Write-Output 'Usage: install.ps1 [-Version major.minor.patch] [-Path | -NoPath] [-Java | -NoJava] [-NonInteractive]' + return + } + $group = $arg.ToLowerInvariant() + if ($group -in @('-path', '-nopath')) { $group = 'path' } + if ($group -in @('-java', '-nojava')) { $group = 'java' } + if ($seen.ContainsKey($group)) { Stop-Installer "Duplicate/conflicting option: $arg" 2 } + $seen[$group] = $true + switch ($arg.ToLowerInvariant()) { + '-version' { $i++; if ($i -ge $Arguments.Count -or -not (Test-ToolVersion $Arguments[$i])) { Stop-Installer 'Version must be numeric major.minor.patch without leading zeros' 2 }; $version = $Arguments[$i] } + '-path' { $pathChoice = 'yes' } + '-nopath' { $pathChoice = 'no' } + '-java' { $javaChoice = 'yes' } + '-nojava' { $javaChoice = 'no' } + '-noninteractive' { $unattended = $true } + default { Stop-Installer "Unknown option: $arg" 2 } + } + } + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { Stop-Installer 'The native installer supports Windows only' } + $userHome = $env:HOME + if ([string]::IsNullOrEmpty($userHome)) { $userHome = $env:USERPROFILE } + Assert-SafeRoot $userHome + $userHome = [IO.Path]::GetFullPath($userHome) + $data = $env:XDG_DATA_HOME + if ([string]::IsNullOrEmpty($data) -or $data -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)') { $data = Join-Path $userHome '.local\share' } + Assert-SafeRoot $data + $data = [IO.Path]::GetFullPath($data) + $destination = Join-Path $userHome '.local\bin' + $state = Join-Path $data 'jvm\tool-install' + $javaHome = Join-Path $data 'jvm\jdks\default' + $work = Join-Path ([IO.Path]::GetTempPath()) ('jvm-bootstrap-' + [Guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($work) | Out-Null + $published = $false + try { + if (-not $version) { + Get-Download 'https://getjvm.dev/latest.txt' (Join-Path $work 'latest.txt') + $raw = [IO.File]::ReadAllText((Join-Path $work 'latest.txt')) + if ($raw -cnotmatch '\A((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\n?\z') { Stop-Installer 'Invalid latest.txt version' } + $version = $Matches[1] + } + $base = "https://github.com/codejive/jvm/releases/download/v$version" + Get-Download "$base/SHA256SUMS" (Join-Path $work 'SHA256SUMS') + $hashes = Read-ToolChecksums (Join-Path $work 'SHA256SUMS') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + Get-Download "$base/$name" (Join-Path $work $name) + if ((Get-ToolHash (Join-Path $work $name)) -cne $hashes[$name]) { Stop-Installer "Checksum mismatch: $name" } + } + Publish-Tool $work $state $destination $version $hashes + $published = $true + Write-InstallerMessage "Installed jvm $version in $destination" + Set-UserEnvironment $state $destination $javaHome $pathChoice $javaChoice $unattended + Write-InstallerMessage 'Open a fresh terminal application/IDE; existing hosts may retain old values. Bash startup files may override inherited PATH; use cygpath -u for native paths there.' + } catch { + if ($published) { Write-InstallerMessage 'Tool was installed; persistent environment setup failed.' } + throw + } finally { + if ($script:InstallerLock) { $script:InstallerLock.Dispose(); $script:InstallerLock = $null } + Remove-Item -LiteralPath $work -Recurse -Force + } +} + +try { Invoke-JvmInstaller @($args) } +catch { + Write-InstallerMessage $_.Exception.Message + $code = 1 + if ($_.Exception.Data.Contains('InstallerCode')) { $code = [int]$_.Exception.Data['InstallerCode'] } + exit $code +} diff --git a/web/install.sh b/web/install.sh new file mode 100644 index 0000000..4d58ad4 --- /dev/null +++ b/web/install.sh @@ -0,0 +1,798 @@ +#!/usr/bin/env bash +# Standalone bootstrap. The Windows section is generated from install.ps1. +set -eu +VERSION='' PATH_CHOICE='' JAVA_CHOICE='' SHELL_CHOICE='' PROFILE='' NONINTERACTIVE=0 +WORK='' LOCKED=0 PUBLISHED=0 STATE='' DEST='' DATA='' TX='' +SETUP_FILES=() SETUP_BACKUPS=() SETUP_EXISTED=() +say() { printf '%s\n' "jvm installer: $*" >&2; } +die() { say "$*"; exit 1; } +usage_error() { say "$*"; exit 2; } +help_text() { + cat <<'EOF' +Install or update jvm in HOME/.local/bin (no JDK is installed). +Usage: install.sh [--version major.minor.patch] + [--path | --no-path] [--java | --no-java] + [--shell bash|zsh|fish|csh|tcsh] [--profile /absolute/file] + [--non-interactive] +Explicit positive options consent to persistent environment edits. +Otherwise separate prompts default to no; unattended setup is skipped. +EOF +} +valid_version() { + case $1 in ''|*[!0-9.]*) return 1 ;; esac + printf '%s\n' "$1" | LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +} +parse() { + while [ "$#" -gt 0 ]; do + case $1 in + --help) [ "$#" = 1 ] || usage_error '--help must be used alone'; help_text; exit 0 ;; + --version|--shell|--profile) + [ "$#" -ge 2 ] && [ -n "$2" ] || usage_error "$1 requires a value" + case $1 in + --version) [ -z "$VERSION" ] || usage_error 'duplicate version'; VERSION=$2 ;; + --shell) [ -z "$SHELL_CHOICE" ] || usage_error 'duplicate shell'; SHELL_CHOICE=$2 ;; + --profile) [ -z "$PROFILE" ] || usage_error 'duplicate profile'; PROFILE=$2 ;; + esac; shift ;; + --path|--no-path) [ -z "$PATH_CHOICE" ] || usage_error 'conflicting/duplicate PATH options'; PATH_CHOICE=$1 ;; + --java|--no-java) [ -z "$JAVA_CHOICE" ] || usage_error 'conflicting/duplicate Java options'; JAVA_CHOICE=$1 ;; + --non-interactive) [ "$NONINTERACTIVE" = 0 ] || usage_error 'duplicate non-interactive'; NONINTERACTIVE=1 ;; + *) usage_error "unknown option: $1" ;; + esac; shift + done + [ -z "$VERSION" ] || valid_version "$VERSION" || usage_error 'invalid version (expected major.minor.patch)' + case $SHELL_CHOICE in ''|bash|zsh|fish|csh|tcsh) ;; *) usage_error 'unsupported shell' ;; esac + if [ -n "$PROFILE" ]; then + [ -n "$SHELL_CHOICE" ] || usage_error '--profile requires --shell' + case $PROFILE in /*) ;; *) usage_error 'profile must be absolute' ;; esac + fi +} +safe_path() { + case $1 in /*) ;; *) die "absolute path required: $1" ;; esac + case $1 in *:*|*';'*|*"$(printf '\r')"*|*' +'*|*"$(printf '\t')"*) die "unrepresentable path: $1" ;; esac +} +hash() { + case $HASHER in + sha256sum) sha256sum < "$1" ;; + shasum) shasum -a 256 < "$1" ;; + openssl) openssl dgst -sha256 < "$1" ;; + esac | awk '{v=(length($1)==64 ? $1 : $NF); if(length(v)==64 && v !~ /[^0-9a-fA-F]/) {print tolower(v); found++}} END {if(found!=1) exit 1}' +} +fetch_wget() { + local url=$1 output=$2 status code location origin clean i=0 + # --https-only covers recursive links in GNU wget, not redirect targets. + # Disable automatic redirects and validate every hop before requesting it. + while [ "$i" -lt 10 ]; do + case $url in https://*) ;; *) die 'wget redirect attempted an HTTPS downgrade' ;; esac + status=0 + LC_ALL=C wget --max-redirect=0 --server-response --check-certificate --no-hsts --timeout=15 --tries=1 -O "$output" "$url" 2> "$WORK/wget-headers" || status=$? + code=$(awk '/^[ \t]+HTTP\/[0-9.]+ [0-9]+/ {code=$2} END {print code}' "$WORK/wget-headers") + if [ "$status" = 0 ] && [ "$code" = 200 ]; then return; fi + case $code in 301|302|303|307|308) ;; *) die "wget download failed: $url (requires server-response/max-redirect support)" ;; esac + location=$(awk 'tolower($0) ~ /^[ \t]+location:/ {v=$0; sub(/^[ \t]+[^:]+:[ \t]*/,"",v); sub(/\r$/,"",v)} END {print v}' "$WORK/wget-headers") + case $location in ''|*' '*|*"$(printf '\t')"*|*"$(printf '\r')"*|*' +'*) die 'invalid wget redirect location' ;; esac + clean=${url%%\#*}; clean=${clean%%\?*} + origin=${clean#https://}; origin=${origin%%/*} + case $location in + [Hh][Tt][Tt][Pp][Ss]://*) url=https://${location#*://} ;; + //*) url=https:$location ;; + /*) url=https://$origin$location ;; + \?*) url=$clean$location ;; + \#*) url=${url%%\#*}$location ;; + *:*) die 'wget redirect attempted a non-HTTPS URL' ;; + *) + case ${clean#https://} in */*) url=${clean%/*}/$location ;; *) url=$clean/$location ;; esac ;; + esac + i=$((i+1)) + done + die 'too many wget redirects' +} +fetch() { + case $DOWNLOADER in + curl) curl -q --proto '=https' --proto-redir '=https' -fLsS --connect-timeout 15 --max-time 180 -o "$2" "$1" || die "download failed: $1" ;; + wget) fetch_wget "$1" "$2" ;; + esac + [ -s "$2" ] || die "empty download: $1" +} +checksums() { + LC_ALL=C awk ' + NF!=2 || length($1)!=64 || $1 ~ /[^0-9a-fA-F]/ {exit 1} + $2!="jvm" && $2!="jvm.ps1" && $2!="jvm.cmd" {exit 1} + seen[$2]++ {exit 1} + END {if (NR!=3 || !seen["jvm"] || !seen["jvm.ps1"] || !seen["jvm.cmd"]) exit 1} + ' "$1" || die 'invalid SHA256SUMS (expected exactly the three payload basenames)' +} +verify() { + local expected actual + expected=$(awk -v n="$2" '$2==n && length($1)==64 {print tolower($1)}' "$1") + actual=$(hash "$3") || return 1 + [ "$expected" = "$actual" ] +} +plain_file() { [ -f "$1" ] && [ ! -L "$1" ]; } +read_record() { + plain_file "$1" || die "unsafe ownership record: $1" + [ "$(sed -n '1p' "$1")" = jvm-tool-v1 ] || die 'invalid ownership record' + valid_version "$(sed -n '2p' "$1")" || die 'invalid owned version' + [ "$(sed -n '3p' "$1")" = "$DEST" ] || die 'ownership destination mismatch' + [ "$(wc -l < "$1" | tr -d ' ')" = 4 ] || die 'invalid ownership record length' + tail -n 1 "$1" | LC_ALL=C grep -Eq '^[0-9a-f]{64} jvm$' || die 'invalid ownership digest' +} +recover() { + local entry + [ ! -L "$TX" ] || die 'unsafe transaction directory' + [ -d "$TX" ] || return 0 + for entry in "$TX/"* "$TX/".[!.]* "$TX/"..?*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + case ${entry##*/} in new-record|old-record|old-jvm|ready|published-record|restored-record) ;; *) die "Unrecognized recovery material: $entry; inspect manually" ;; esac + plain_file "$entry" || die "Unsafe recovery material: $entry" + done + if [ -e "$DEST/.jvm-recover" ] || [ -L "$DEST/.jvm-recover" ]; then + [ -e "$TX/ready" ] && plain_file "$DEST/.jvm-recover" || die 'recovery staging conflict; inspect .jvm-recover manually' + rm "$DEST/.jvm-recover" + fi + if [ -e "$TX/ready" ]; then + read_record "$TX/new-record" + if [ -e "$TX/old-record" ]; then + read_record "$TX/old-record" + plain_file "$TX/old-jvm" && verify "$TX/old-record" jvm "$TX/old-jvm" || die 'recovery backup is damaged; preserve transaction for manual recovery' + [ ! -e "$DEST/jvm" ] || plain_file "$DEST/jvm" || die 'unsafe recovery destination' + cp -p "$TX/old-jvm" "$DEST/.jvm-recover" && mv -f "$DEST/.jvm-recover" "$DEST/jvm" || die 'could not restore tool; preserve transaction' + cp "$TX/old-record" "$TX/restored-record" && mv -f "$TX/restored-record" "$STATE/record" || die 'could not restore ownership' + else + if [ -e "$DEST/jvm" ] || [ -L "$DEST/jvm" ]; then + plain_file "$DEST/jvm" && verify "$TX/new-record" jvm "$DEST/jvm" || die 'unrecognized file during recovery; preserve transaction' + rm "$DEST/jvm" || die 'could not remove interrupted publication' + fi + rm -f "$STATE/record" + fi + say 'Recovered interrupted tool publication.' + fi + if [ -e "$DEST/.jvm-next" ] || [ -L "$DEST/.jvm-next" ]; then + plain_file "$DEST/.jvm-next" || die 'unsafe publication staging file' + rm "$DEST/.jvm-next" + fi + rm -rf -- "$TX" +} +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if [ "$status" != 0 ]; then + local i=0 file backup + for file in ${SETUP_FILES[@]+"${SETUP_FILES[@]}"}; do + backup=${SETUP_BACKUPS[$i]} + if [ "${SETUP_EXISTED[$i]}" = 1 ]; then + cp -p "$backup" "$file" || say "Profile rollback failed: $file; exact backup: $backup" + else rm -f "$file" || say "Could not remove new profile: $file"; fi + i=$((i+1)) + done + fi + if [ "$LOCKED" = 1 ]; then + if [ "$status" != 0 ] && [ -d "$TX" ]; then (recover) || say "Recovery retained at $TX; resolve it before retrying."; fi + rm -f "$STATE/lock/pid"; rmdir "$STATE/lock" || : + fi + [ -z "$WORK" ] || rm -rf -- "$WORK" + if [ "$status" != 0 ] && [ "$PUBLISHED" = 1 ]; then say 'Tool was installed; persistent environment setup failed.'; fi + exit "$status" +} +lock() { + if ! mkdir "$STATE/lock" 2>/dev/null; then + [ -d "$STATE/lock" ] && [ ! -L "$STATE/lock" ] && plain_file "$STATE/lock/pid" || die "Unrecognized installer lock at $STATE/lock; inspect manually." + local pid + pid=$(cat "$STATE/lock/pid") + case $pid in ''|*[!0-9]*) die 'invalid installer lock owner' ;; esac + if ps -p "$pid" >/dev/null 2>&1; then die 'another installer is running; retry later'; fi + die "Interrupted installer lock at $STATE/lock (recorded PID $pid). Confirm the process has exited, remove only lock/pid and the empty lock directory, then rerun to recover the transaction." + fi + LOCKED=1 + printf '%s\n' "$$" > "$STATE/lock/pid" +} +publish() { + mkdir -p "$DEST" "$STATE" + [ ! -L "$DEST" ] && [ ! -L "$STATE" ] || die 'destination/ownership directory must not be a symlink' + lock + recover + if [ -e "$STATE/record" ] || [ -L "$STATE/record" ]; then + read_record "$STATE/record" + plain_file "$DEST/jvm" && verify "$STATE/record" jvm "$DEST/jvm" || die 'owned jvm is missing or locally modified; restore it or move it aside with the ownership record before retrying' + elif [ -e "$DEST/jvm" ] || [ -L "$DEST/jvm" ]; then + die "unowned destination conflict: $DEST/jvm; move it aside before retrying" + fi + if [ -f "$STATE/record" ] && [ "$(sed -n '2p' "$STATE/record")" = "$VERSION" ] && verify "$STATE/record" jvm "$WORK/jvm"; then + PUBLISHED=1 + say "Already installed jvm $VERSION in $DEST" + return + fi + [ ! -e "$DEST/.jvm-next" ] && [ ! -L "$DEST/.jvm-next" ] && [ ! -e "$DEST/.jvm-recover" ] && [ ! -L "$DEST/.jvm-recover" ] || die 'reserved staging filename exists in destination' + mkdir "$TX" + if [ -e "$STATE/record" ]; then + cp "$STATE/record" "$TX/old-record" + cp -p "$DEST/jvm" "$TX/old-jvm" + fi + { printf 'jvm-tool-v1\n%s\n%s\n' "$VERSION" "$DEST"; printf '%s jvm\n' "$(hash "$WORK/jvm")"; } > "$TX/new-record" + cp "$WORK/jvm" "$DEST/.jvm-next" + chmod 755 "$DEST/.jvm-next" + : > "$TX/ready" + mv -f "$DEST/.jvm-next" "$DEST/jvm" + cp "$TX/new-record" "$TX/published-record" + mv -f "$TX/published-record" "$STATE/record" + rm -rf -- "$TX" + PUBLISHED=1 + say "Installed jvm $VERSION in $DEST" +} +quote() { + case ${SHELL_CHOICE:-} in + fish) printf "'%s'" "$(printf '%s' "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")" ;; + csh|tcsh) + # Keep command/variable metacharacters inside single quotes. Use a + # double-quoted apostrophe between segments; history needs \! even + # inside single quotes in interactive C shells. + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\"'\"'/g; s/!/\\\\!/g")" ;; + *) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;; + esac +} +consent() { + [ "$1" != no ] || return 1 + [ "$1" != yes ] || return 0 + [ "$NONINTERACTIVE" = 0 ] && [ -r /dev/tty ] && [ -w /dev/tty ] || return 1 + local answer + # Opening the controlling terminal may fail even when /dev/tty exists. + { printf '%s [y/N] ' "$2" > /dev/tty; IFS= read -r answer < /dev/tty; } 2>/dev/null || return 1 + case $answer in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac +} +block() { + local kind=$1 value=$2 q + q=$(quote "$value") + printf '# >>> jvm %s >>>\n' "$kind" + case $SHELL_CHOICE in + bash|zsh) + [ "$kind" != java ] || printf 'export JAVA_HOME=%s\n' "$(quote "$JAVA_HOME_TARGET")" + printf 'case ":${PATH-}:" in *:%s:*) ;; *) export PATH=%s${PATH+:"$PATH"} ;; esac\n' "$q" "$q" ;; + fish) + if [ "$kind" = java ]; then printf 'set -gx JAVA_HOME %s\n' "$(quote "$JAVA_HOME_TARGET")"; fi + printf 'if not contains -- %s $PATH\n set -gx PATH %s $PATH\nend\n' "$q" "$q" ;; + csh|tcsh) + [ "$kind" != java ] || printf 'setenv JAVA_HOME %s\n' "$(quote "$JAVA_HOME_TARGET")" + # BSD csh's foreach stores each item with set(), which re-expands + # backticks after the list's quote flags have been stripped. Read + # existing entries by index instead, preserving :q through comparison. + printf 'if ( ! $?path ) then\n set path = ()\nendif\nset jvm_present = 0\n@ jvm_index = 1\nwhile ( $jvm_index <= $#path )\n if ( $path[$jvm_index]:q == %s ) then\n set jvm_present = 1\n endif\n @ jvm_index++\nend\nif ( $jvm_present == 0 ) then\n set path = ( %s $path:q )\nendif\nunset jvm_present jvm_index\n' "$q" "$q" ;; + esac + printf '# <<< jvm %s <<<\n' "$kind" +} +validate_markers() { + awk ' + /^# >>> jvm (path|java) >>>$/ {if (active || seen[$0]++) exit 1; active=$0; next} + /^# <<< jvm (path|java) <<<$/{expected=$0; sub("<<<",">>>",expected); sub("<<<$",">>>",expected); if(active!=expected) exit 1; active=""; next} + /jvm (path|java) (>>>|<<<)/ {exit 1} + END {if(active) exit 1} + ' "$1" +} +edit_profile() { + local file=$1 kind=$2 value=$3 backup tmp + safe_path "$file" + [ ! -L "$file" ] || die "symlinked profile: $file; use --profile with its explicit target" + if [ -e "$file" ]; then plain_file "$file" || die "profile is not a regular file: $file"; fi + if [ "${FISH_MANAGED:-0}" = 1 ] && [ -e "$file" ] && ! grep -q '^# >>> jvm ' "$file"; then die "unowned Fish configuration: $file"; fi + mkdir -p "$(dirname "$file")" + tmp=$(mktemp "$(dirname "$file")/.jvm-profile.XXXXXXXX") + if [ -e "$file" ]; then cp -p "$file" "$tmp"; else : > "$tmp"; fi + # Validate both block types before replacing either, preserving all outside bytes. + validate_markers "$tmp" || { rm -f "$tmp"; die "corrupt/duplicate managed markers in $file"; } + block "$kind" "$value" > "$WORK/block" + # Avoid rewriting a correct block (and avoid repeated prompts via profile_correct). + awk -v k="$kind" ' + $0=="# >>> jvm " k " >>>" {skip=1} + !skip {print} + $0=="# <<< jvm " k " <<<" {skip=0} + ' "$tmp" > "$WORK/profile" + cat "$WORK/block" >> "$WORK/profile" + if cmp -s "$file" "$WORK/profile" 2>/dev/null; then rm -f "$tmp"; return; fi + backup=$(mktemp "$file.jvm-backup.XXXXXXXX") + if [ -e "$file" ]; then + cp -p "$file" "$backup" + SETUP_EXISTED+=( 1 ) + else : > "$backup"; SETUP_EXISTED+=( 0 ); fi + SETUP_FILES+=( "$file" ) SETUP_BACKUPS+=( "$backup" ) + # Append/replace only the marked portion; retain original CRLF outside blocks. + cat "$WORK/profile" > "$tmp" || die 'profile staging failed' + if ! mv -f "$tmp" "$file"; then rm -f "$tmp"; die "profile publication failed; backup: $backup"; fi + say "Updated $file; exact previous contents: $backup" +} +profile_correct() { + local file=$1 kind=$2 value=$3 + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + validate_markers "$file" || return 1 + block "$kind" "$value" > "$WORK/expected" + awk -v k="$kind" '$0=="# >>> jvm " k " >>>" {on=1} on {print} $0=="# <<< jvm " k " <<<" {on=0}' "$file" > "$WORK/actual" + cmp -s "$WORK/expected" "$WORK/actual" +} +setup() { + local files file login pc jc value kind correct + if [ "$NONINTERACTIVE" = 1 ]; then + if [ -z "$PATH_CHOICE" ]; then PATH_CHOICE=--no-path; say "Tool PATH setup skipped; manually add $DEST to PATH if desired."; fi + if [ -z "$JAVA_CHOICE" ]; then JAVA_CHOICE=--no-java; say "Java setup skipped; manually set JAVA_HOME=$JAVA_HOME_TARGET and add its bin to PATH if desired."; fi + fi + if [ "$PATH_CHOICE" = --no-path ] && [ "$JAVA_CHOICE" = --no-java ]; then return; fi + if [ -z "$SHELL_CHOICE" ]; then SHELL_CHOICE=${SHELL:-}; SHELL_CHOICE=${SHELL_CHOICE##*/}; fi + files=() + if [ -n "$PROFILE" ]; then files=("$PROFILE"); else + case $SHELL_CHOICE in + bash) + login=$HOME/.profile + for file in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + if [ -f "$file" ] && [ -r "$file" ]; then login=$file; break; fi + done + files=("$HOME/.bashrc" "$login") ;; + zsh) files=("${ZDOTDIR:-$HOME}/.zshrc") ;; + fish) + file=${XDG_CONFIG_HOME:-$HOME/.config} + case $file in /*) ;; *) file=$HOME/.config ;; esac + files=("$file/fish/conf.d/jvm.fish") + FISH_MANAGED=1 ;; + csh) files=("$HOME/.cshrc") ;; + tcsh) if [ -e "$HOME/.tcshrc" ]; then files=("$HOME/.tcshrc"); else files=("$HOME/.cshrc"); fi ;; + *) + [ "$PATH_CHOICE" != --path ] && [ "$JAVA_CHOICE" != --java ] || die 'requested setup needs --shell and optionally --profile' + say "Unknown shell; add $DEST to PATH; JAVA_HOME=$JAVA_HOME_TARGET and add its bin directory to PATH."; return ;; + esac + fi + pc=ask jc=ask + case $PATH_CHOICE in --path) pc=yes ;; --no-path) pc=no ;; esac + case $JAVA_CHOICE in --java) jc=yes ;; --no-java) jc=no ;; esac + for kind in path java; do + if [ "$kind" = path ] && [ "$pc" = no ]; then continue; fi + if [ "$kind" = java ] && [ "$jc" = no ]; then continue; fi + value=$DEST; [ "$kind" != java ] || value=$JAVA_HOME_TARGET/bin + correct=1 + for file in "${files[@]}"; do profile_correct "$file" "$kind" "$value" || correct=0; done + [ "$correct" = 0 ] || continue + say "Persistent $kind setup targets: ${files[*]}" + if [ "$kind" = java ]; then say "JAVA_HOME: ${JAVA_HOME-} -> $JAVA_HOME_TARGET; PATH adds $value (a default JDK may not exist yet)."; else say "PATH adds $value (current membership does not establish persistence)."; fi + if { [ "$kind" = path ] && consent "$pc" 'Configure persistent tool PATH?'; } || { [ "$kind" = java ] && consent "$jc" 'Configure persistent default Java?'; }; then + for file in "${files[@]}"; do edit_profile "$file" "$kind" "$value"; done + SETUP_FILES=() SETUP_BACKUPS=() SETUP_EXISTED=() + else say "Skipped $kind setup. Manually configure the paths above if desired."; fi + done +} +main() { + local original host name converted powershell_bin system_directory + # Preserve the original arguments for the embedded Windows entry point. + original=("$@") + parse "$@" + host=$(uname -s) + case $host in + CYGWIN*|MSYS*|MINGW*) + [ -z "$SHELL_CHOICE$PROFILE" ] || usage_error '--shell/--profile are Unix-only' + command -v cygpath >/dev/null || die 'Windows Bash needs cygpath' + if ! powershell_bin=$(command -v powershell.exe); then + system_directory=$(cygpath -u -S) || die 'could not locate the Windows system directory' + powershell_bin=$system_directory/WindowsPowerShell/v1.0/powershell.exe + fi + [ -f "$powershell_bin" ] || die 'Windows Bash needs Windows PowerShell' + case ${HOME:-} in + '') ;; + /*|[A-Za-z]:[\\/]*|\\\\*) + converted=$(cygpath -aw "$HOME") || die 'could not convert HOME to a native path' + export HOME=$converted ;; + *) die 'HOME must be absolute' ;; + esac + if [ -n "${XDG_DATA_HOME:-}" ]; then + case $XDG_DATA_HOME in + /*|[A-Za-z]:[\\/]*|\\\\*) + converted=$(cygpath -aw "$XDG_DATA_HOME") || die 'could not convert XDG_DATA_HOME to a native path' + export XDG_DATA_HOME=$converted ;; + *) unset XDG_DATA_HOME ;; + esac + fi + WORK=$(mktemp -d "${TMPDIR:-/tmp}/jvm-bootstrap.XXXXXXXX") + trap cleanup EXIT; trap 'exit 1' HUP INT TERM + windows_installer > "$WORK/install.ps1" + local args arg + args=() + for arg in "${original[@]}"; do + case $arg in --version) args+=( -Version ) ;; --path) args+=( -Path ) ;; --no-path) args+=( -NoPath ) ;; --java) args+=( -Java ) ;; --no-java) args+=( -NoJava ) ;; --non-interactive) args+=( -NonInteractive ) ;; *) args+=( "$arg" ) ;; esac + done + MSYS2_ARG_CONV_EXCL='*' "$powershell_bin" -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -aw "$WORK/install.ps1")" "${args[@]}" + return ;; + Linux|Darwin) ;; *) die "unsupported host: $host" ;; + esac + [ -n "${HOME:-}" ] || die 'HOME is required' + safe_path "$HOME" + DATA=${XDG_DATA_HOME:-$HOME/.local/share} + case $DATA in /*) ;; *) DATA=$HOME/.local/share ;; esac + safe_path "$DATA" + DEST=$HOME/.local/bin STATE=$DATA/jvm/tool-install TX=$DATA/jvm/tool-install/transaction + JAVA_HOME_TARGET=$DATA/jvm/jdks/default + [ -z "$PROFILE" ] || safe_path "$PROFILE" + DOWNLOADER='' HASHER='' + for name in curl wget; do if command -v "$name" >/dev/null 2>&1; then DOWNLOADER=$name; break; fi; done + for name in sha256sum shasum openssl; do if command -v "$name" >/dev/null 2>&1; then HASHER=$name; break; fi; done + [ -n "$DOWNLOADER" ] && [ -n "$HASHER" ] || die 'need curl or HTTPS-capable wget and a SHA-256 utility' + umask 077 + WORK=$(mktemp -d "${TMPDIR:-/tmp}/jvm-bootstrap.XXXXXXXX") + trap cleanup EXIT; trap 'exit 1' HUP INT TERM + if [ -z "$VERSION" ]; then + fetch https://getjvm.dev/latest.txt "$WORK/latest" + [ "$(wc -l < "$WORK/latest" | tr -d ' ')" -le 1 ] || die 'invalid latest.txt' + VERSION=$(cat "$WORK/latest") + valid_version "$VERSION" || die 'invalid latest.txt version' + fi + local url + url=https://github.com/codejive/jvm/releases/download/v$VERSION + fetch "$url/SHA256SUMS" "$WORK/SHA256SUMS" + checksums "$WORK/SHA256SUMS" + fetch "$url/jvm" "$WORK/jvm" + verify "$WORK/SHA256SUMS" jvm "$WORK/jvm" || die 'jvm checksum mismatch' + publish + setup + say 'Open a fresh terminal application to inherit changes; later startup statements can override them.' +} +# BEGIN GENERATED WINDOWS INSTALLER +windows_installer() { +cat <<'JVM_POWERSHELL_INSTALLER' +# Windows PowerShell 5.1 standalone bootstrap. No JDK commands are invoked. +$ErrorActionPreference = 'Stop' + +function Write-InstallerMessage([string]$Message) { [Console]::Error.WriteLine("jvm installer: $Message") } +function Stop-Installer([string]$Message, [int]$Code = 1) { + $exception = New-Object Exception $Message + $exception.Data['InstallerCode'] = $Code + throw $exception +} +function Test-ToolVersion([string]$Value) { return $Value -cmatch '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z' } +function Get-ToolHash([string]$File) { + $stream = [IO.File]::OpenRead($File) + $sha = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose(); $stream.Dispose() } +} +function Test-PlainFile([string]$File) { + if (-not [IO.File]::Exists($File)) { return $false } + return -not ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) +} +function Assert-SafeRoot([string]$Value) { + if ($Value -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)' -or $Value -match '[;\r\n\t]') { + Stop-Installer "Expected an absolute native path without semicolons or line breaks: $Value" + } +} +function Assert-NoReparse([string]$File) { + if (Test-Path -LiteralPath $File) { + if ((Get-Item -LiteralPath $File -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { Stop-Installer "Reparse point conflict: $File" } + } +} +function Get-Download([string]$Url, [string]$File) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + for ($i = 0; $i -lt 10; $i++) { + if (([Uri]$Url).Scheme -cne 'https') { Stop-Installer 'HTTPS is required, including redirects' } + $request = [Net.HttpWebRequest]::Create($Url) + $request.AllowAutoRedirect = $false + $request.Timeout = 45000 + $request.ReadWriteTimeout = 45000 + $response = $request.GetResponse() + try { + $status = [int]$response.StatusCode + if ($status -in @(301, 302, 303, 307, 308)) { + $Url = ([Uri]::new([Uri]$Url, $response.Headers['Location'])).AbsoluteUri + continue + } + if ($status -ne 200) { Stop-Installer "HTTP $status for $Url" } + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Create($File) + try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() } + if ((Get-Item -LiteralPath $File).Length -eq 0) { Stop-Installer "Empty download: $Url" } + return + } finally { $response.Dispose() } + } + Stop-Installer 'Too many HTTPS redirects' +} +function Read-ToolChecksums([string]$File) { + $result = @{} + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 3) { Stop-Installer 'SHA256SUMS must contain exactly three entries' } + foreach ($line in $lines) { + if ($line -cnotmatch '\A([0-9a-fA-F]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z') { Stop-Installer 'Invalid SHA256SUMS entry' } + $name = $Matches[2]; $digest = $Matches[1].ToLowerInvariant() + if ($result.ContainsKey($name)) { Stop-Installer 'Duplicate SHA256SUMS entry' } + $result[$name] = $digest + } + return $result +} +function Read-ToolRecord([string]$File, [string]$Destination) { + if (-not (Test-PlainFile $File)) { Stop-Installer "Unsafe ownership record: $File" } + $lines = [IO.File]::ReadAllLines($File) + if ($lines.Count -ne 6 -or $lines[0] -cne 'jvm-tool-v1' -or -not (Test-ToolVersion $lines[1]) -or $lines[2] -cne $Destination) { + Stop-Installer 'Invalid ownership record or destination mismatch' + } + $result = @{} + foreach ($line in $lines[3..5]) { + if ($line -cnotmatch '\A([0-9a-f]{64}) (jvm|jvm\.ps1|jvm\.cmd)\z' -or $result.ContainsKey($Matches[2])) { Stop-Installer 'Invalid ownership hashes' } + $result[$Matches[2]] = $Matches[1] + } + return $result +} +function Write-ToolRecord([string]$File, [string]$Version, [string]$Destination, [hashtable]$Hashes) { + $lines = @('jvm-tool-v1', $Version, $Destination) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { $lines += "$($Hashes[$name]) $name" } + [IO.File]::WriteAllText($File, (($lines -join "`n") + "`n"), (New-Object Text.UTF8Encoding $false)) +} +function Remove-ToolStage([string]$Stage) { + Assert-NoReparse $Stage + if (-not (Test-Path -LiteralPath $Stage)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { + if ($entry.Name -cnotin @('jvm', 'jvm.ps1', 'jvm.cmd') -or -not (Test-PlainFile $entry.FullName)) { Stop-Installer "Unrecognized staging material: $($entry.FullName)" } + } + foreach ($entry in @(Get-ChildItem -LiteralPath $Stage -Force)) { [IO.File]::Delete($entry.FullName) } + [IO.Directory]::Delete($Stage) +} +function Restore-ToolTransaction([string]$State, [string]$Destination) { + $tx = Join-Path $State 'transaction' + Assert-NoReparse $tx + if (-not (Test-Path -LiteralPath $tx)) { return } + foreach ($entry in @(Get-ChildItem -LiteralPath $tx -Force)) { + if ($entry.Name -cnotin @('new-record', 'old-record', 'old-jvm', 'old-jvm.ps1', 'old-jvm.cmd', 'ready', 'stage-name') -or -not (Test-PlainFile $entry.FullName)) { + Stop-Installer "Unrecognized recovery material: $($entry.FullName); inspect manually" + } + } + if (Test-Path -LiteralPath (Join-Path $tx 'stage-name')) { + if (-not (Test-PlainFile (Join-Path $tx 'stage-name'))) { Stop-Installer 'Unsafe staging journal' } + $stageName = [IO.File]::ReadAllText((Join-Path $tx 'stage-name')) + if ($stageName -cnotmatch '\A\.jvm-next-[a-f0-9]{32}\z') { Stop-Installer 'Invalid staging journal' } + $stage = Join-Path $Destination $stageName + Remove-ToolStage $stage + } + if (Test-Path -LiteralPath (Join-Path $tx 'ready')) { + $new = Read-ToolRecord (Join-Path $tx 'new-record') $Destination + $old = $null + if (Test-Path -LiteralPath (Join-Path $tx 'old-record')) { $old = Read-ToolRecord (Join-Path $tx 'old-record') $Destination } + # Verify every backup before restoring any member; recovery is repeatable. + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + Assert-NoReparse $file + if ((Test-Path -LiteralPath $file) -and -not (Test-PlainFile $file)) { Stop-Installer "Unsafe recovery destination: $file" } + if ($old) { + $backup = Join-Path $tx "old-$name" + if (-not (Test-PlainFile $backup) -or (Get-ToolHash $backup) -cne $old[$name]) { Stop-Installer "Damaged recovery material at $tx; inspect manually" } + } elseif ((Test-Path -LiteralPath $file) -and (Get-ToolHash $file) -cne $new[$name]) { Stop-Installer "Unknown recovery destination: $file" } + } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { [IO.File]::Copy((Join-Path $tx "old-$name"), $file, $true) } + } + elseif (Test-Path -LiteralPath $file) { [IO.File]::Delete($file) } + } + if ($old) { [IO.File]::Copy((Join-Path $tx 'old-record'), (Join-Path $State 'record'), $true) } + elseif (Test-Path -LiteralPath (Join-Path $State 'record')) { [IO.File]::Delete((Join-Path $State 'record')) } + Write-InstallerMessage 'Recovered interrupted tool publication.' + } + Remove-Item -LiteralPath $tx -Recurse -Force +} +function Publish-Tool([string]$Work, [string]$State, [string]$Destination, [string]$Version, [hashtable]$Hashes) { + [IO.Directory]::CreateDirectory($Destination) | Out-Null + [IO.Directory]::CreateDirectory($State) | Out-Null + Assert-NoReparse $State; Assert-NoReparse $Destination + Assert-NoReparse (Join-Path $State 'lock') + try { $lock = [IO.File]::Open((Join-Path $State 'lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch { Stop-Installer 'Another installer holds the lock, or the lock is inaccessible; retry after it exits' } + $tx = Join-Path $State 'transaction' + try { + Restore-ToolTransaction $State $Destination + $record = Join-Path $State 'record' + $old = $null + Assert-NoReparse $record + if (Test-Path -LiteralPath $record) { $old = Read-ToolRecord $record $Destination } + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ($old) { + if (-not (Test-PlainFile $file) -or (Get-ToolHash $file) -cne $old[$name]) { Stop-Installer "Owned file missing or locally modified: $file. Restore it or move the payload and ownership record aside before retrying." } + } elseif (Test-Path -LiteralPath $file) { Stop-Installer "Unowned destination conflict: $file. Move it aside before retrying." } + } + $matching = $null -ne $old + if ($old) { + $matching = ([IO.File]::ReadAllLines($record)[1] -ceq $Version) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { if ($old[$name] -cne $Hashes[$name]) { $matching = $false } } + } + if ($matching) { return } + [IO.Directory]::CreateDirectory($tx) | Out-Null + if ($old) { + [IO.File]::Copy($record, (Join-Path $tx 'old-record')) + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Destination $name), (Join-Path $tx "old-$name")) } + } + Write-ToolRecord (Join-Path $tx 'new-record') $Version $Destination $Hashes + # Destination-filesystem staging, before any published file is changed. + $next = Join-Path $Destination ('.jvm-next-' + [Guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText((Join-Path $tx 'stage-name'), [IO.Path]::GetFileName($next)) + [IO.Directory]::CreateDirectory($next) | Out-Null + try { + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { [IO.File]::Copy((Join-Path $Work $name), (Join-Path $next $name)) } + [IO.File]::WriteAllText((Join-Path $tx 'ready'), 'ready') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + $file = Join-Path $Destination $name + if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) } + [IO.File]::Move((Join-Path $next $name), $file) + } + [IO.File]::Copy((Join-Path $tx 'new-record'), $record, $true) + Remove-Item -LiteralPath $tx -Recurse -Force + } finally { Remove-ToolStage $next } + } catch { + $failure = $_ + try { Restore-ToolTransaction $State $Destination } + catch { Write-InstallerMessage "Recovery retained at $tx. $($_.Exception.Message)" } + throw $failure + } finally { + # Keep serialization through consented registry edits as well. + $script:InstallerLock = $lock + } +} +function Open-EnvironmentKey { return [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') } +function Get-RegistryValue($Key, [string]$Name) { + if ($Key.GetValueNames() -contains $Name) { + return @{ Exists = $true; Kind = [int]$Key.GetValueKind($Name); Value = $Key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } + } + return @{ Exists = $false; Kind = [int][Microsoft.Win32.RegistryValueKind]::ExpandString; Value = '' } +} +function Set-RegistrySnapshot($Key, [string]$Name, $Snapshot) { + if ($Snapshot.Exists) { $Key.SetValue($Name, $Snapshot.Value, [Microsoft.Win32.RegistryValueKind]$Snapshot.Kind) } + else { $Key.DeleteValue($Name, $false) } +} +function Test-PathEntry([string]$Entry, [string]$Target) { + $expanded = [Environment]::ExpandEnvironmentVariables($Entry.Trim().Trim('"')).TrimEnd('\', '/') + return $expanded.Replace('/', '\') -ieq $Target.TrimEnd('\', '/').Replace('/', '\') +} +function Add-UserPath([string]$Value, [string]$Entry) { + foreach ($token in $Value.Split(';')) { if (Test-PathEntry $token $Entry) { return $Value } } + if ([string]::IsNullOrEmpty($Value)) { return $Entry } + return "$Entry;$Value" +} +function Send-EnvironmentChange { + if (-not ('JvmInstaller.Native' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace JvmInstaller { public static class Native { + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, UIntPtr w, string l, uint f, uint t, out UIntPtr r); +}} +'@ + } + $result = [UIntPtr]::Zero + [JvmInstaller.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null +} +function Get-SetupConsent([string]$Choice, [bool]$Unattended, [string]$Prompt) { + if ($Choice -eq 'yes') { return $true } + if ($Choice -eq 'no' -or $Unattended -or [Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false } + try { return (Read-Host "$Prompt [y/N]") -match '\A(?i:y|yes)\z' } catch { return $false } +} +function Set-UserEnvironment([string]$State, [string]$Destination, [string]$JavaHome, [string]$PathChoice, [string]$JavaChoice, [bool]$Unattended) { + if ($PathChoice -eq 'no' -and $JavaChoice -eq 'no') { return } + if ($Unattended -and $PathChoice -ne 'yes' -and $JavaChoice -ne 'yes') { + Write-InstallerMessage "Unspecified environment setup skipped. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds its bin directory." + return + } + $key = Open-EnvironmentKey + try { + $oldPath = Get-RegistryValue $key 'Path' + $oldJava = Get-RegistryValue $key 'JAVA_HOME' + foreach ($snapshot in @($oldPath, $oldJava)) { + if ($snapshot.Exists -and $snapshot.Kind -notin @(1, 2)) { Stop-Installer 'Environment values must be REG_SZ or REG_EXPAND_SZ' } + } + $pathValue = [string]$oldPath.Value + $toolPresent = (Add-UserPath $pathValue $Destination) -ceq $pathValue + $javaBin = Join-Path $JavaHome 'bin' + $javaPresent = $oldJava.Exists -and (Test-PathEntry ([string]$oldJava.Value) $JavaHome) -and ((Add-UserPath $pathValue $javaBin) -ceq $pathValue) + $toolYes = $false; $javaYes = $false + if (-not $toolPresent) { + Write-InstallerMessage "HKCU\Environment\Path ($($oldPath.Kind)): add $Destination; preserve raw existing value: $pathValue" + $toolYes = Get-SetupConsent $PathChoice $Unattended 'Persist tool PATH?' + } + if (-not $javaPresent) { + Write-InstallerMessage "HKCU\Environment: JAVA_HOME '$($oldJava.Value)' -> '$JavaHome'; Path adds $javaBin. A default JDK may not exist yet." + $javaYes = Get-SetupConsent $JavaChoice $Unattended 'Persist default Java?' + } + if (-not $toolYes -and -not $javaYes) { + Write-InstallerMessage "Environment unchanged. Manual setup: user PATH adds $Destination; JAVA_HOME=$JavaHome and PATH adds $javaBin." + return + } + if ($toolYes) { $pathValue = Add-UserPath $pathValue $Destination } + if ($javaYes) { $pathValue = Add-UserPath $pathValue $javaBin } + $backup = Join-Path $State ('environment-backup-' + [Guid]::NewGuid().ToString('N') + '.clixml') + @{ Path = $oldPath; JAVA_HOME = $oldJava } | Export-Clixml -LiteralPath $backup -Encoding UTF8 + try { + if ($javaYes) { $key.SetValue('JAVA_HOME', $JavaHome, [Microsoft.Win32.RegistryValueKind]$oldJava.Kind) } + $key.SetValue('Path', $pathValue, [Microsoft.Win32.RegistryValueKind]$oldPath.Kind) + $key.Flush() + Send-EnvironmentChange + } catch { + $failure = $_ + try { Set-RegistrySnapshot $key 'Path' $oldPath; Set-RegistrySnapshot $key 'JAVA_HOME' $oldJava; $key.Flush() } + catch { Write-InstallerMessage "Registry rollback failed; exact raw backup: $backup" } + throw $failure + } + Write-InstallerMessage "Updated HKCU\Environment; raw value/type backup: $backup" + $systemPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + foreach ($entry in $systemPath.Split(';')) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim('"')) + if (-not [IO.Path]::IsPathRooted($expanded)) { continue } + if ([IO.File]::Exists([IO.Path]::Combine($expanded, 'java.exe'))) { Write-InstallerMessage "System Java at $expanded may precede user PATH. Machine PATH was preserved."; break } + } + } finally { $key.Dispose() } +} +function Invoke-JvmInstaller([string[]]$Arguments) { + $version = ''; $pathChoice = 'ask'; $javaChoice = 'ask'; $unattended = $false + $seen = @{} + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $arg = $Arguments[$i] + if ($arg -ieq '-Help') { + if ($Arguments.Count -ne 1) { Stop-Installer '-Help must be used alone' 2 } + Write-Output 'Usage: install.ps1 [-Version major.minor.patch] [-Path | -NoPath] [-Java | -NoJava] [-NonInteractive]' + return + } + $group = $arg.ToLowerInvariant() + if ($group -in @('-path', '-nopath')) { $group = 'path' } + if ($group -in @('-java', '-nojava')) { $group = 'java' } + if ($seen.ContainsKey($group)) { Stop-Installer "Duplicate/conflicting option: $arg" 2 } + $seen[$group] = $true + switch ($arg.ToLowerInvariant()) { + '-version' { $i++; if ($i -ge $Arguments.Count -or -not (Test-ToolVersion $Arguments[$i])) { Stop-Installer 'Version must be numeric major.minor.patch without leading zeros' 2 }; $version = $Arguments[$i] } + '-path' { $pathChoice = 'yes' } + '-nopath' { $pathChoice = 'no' } + '-java' { $javaChoice = 'yes' } + '-nojava' { $javaChoice = 'no' } + '-noninteractive' { $unattended = $true } + default { Stop-Installer "Unknown option: $arg" 2 } + } + } + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { Stop-Installer 'The native installer supports Windows only' } + $userHome = $env:HOME + if ([string]::IsNullOrEmpty($userHome)) { $userHome = $env:USERPROFILE } + Assert-SafeRoot $userHome + $userHome = [IO.Path]::GetFullPath($userHome) + $data = $env:XDG_DATA_HOME + if ([string]::IsNullOrEmpty($data) -or $data -notmatch '\A(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)') { $data = Join-Path $userHome '.local\share' } + Assert-SafeRoot $data + $data = [IO.Path]::GetFullPath($data) + $destination = Join-Path $userHome '.local\bin' + $state = Join-Path $data 'jvm\tool-install' + $javaHome = Join-Path $data 'jvm\jdks\default' + $work = Join-Path ([IO.Path]::GetTempPath()) ('jvm-bootstrap-' + [Guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($work) | Out-Null + $published = $false + try { + if (-not $version) { + Get-Download 'https://getjvm.dev/latest.txt' (Join-Path $work 'latest.txt') + $raw = [IO.File]::ReadAllText((Join-Path $work 'latest.txt')) + if ($raw -cnotmatch '\A((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\n?\z') { Stop-Installer 'Invalid latest.txt version' } + $version = $Matches[1] + } + $base = "https://github.com/codejive/jvm/releases/download/v$version" + Get-Download "$base/SHA256SUMS" (Join-Path $work 'SHA256SUMS') + $hashes = Read-ToolChecksums (Join-Path $work 'SHA256SUMS') + foreach ($name in @('jvm', 'jvm.ps1', 'jvm.cmd')) { + Get-Download "$base/$name" (Join-Path $work $name) + if ((Get-ToolHash (Join-Path $work $name)) -cne $hashes[$name]) { Stop-Installer "Checksum mismatch: $name" } + } + Publish-Tool $work $state $destination $version $hashes + $published = $true + Write-InstallerMessage "Installed jvm $version in $destination" + Set-UserEnvironment $state $destination $javaHome $pathChoice $javaChoice $unattended + Write-InstallerMessage 'Open a fresh terminal application/IDE; existing hosts may retain old values. Bash startup files may override inherited PATH; use cygpath -u for native paths there.' + } catch { + if ($published) { Write-InstallerMessage 'Tool was installed; persistent environment setup failed.' } + throw + } finally { + if ($script:InstallerLock) { $script:InstallerLock.Dispose(); $script:InstallerLock = $null } + Remove-Item -LiteralPath $work -Recurse -Force + } +} + +try { Invoke-JvmInstaller @($args) } +catch { + Write-InstallerMessage $_.Exception.Message + $code = 1 + if ($_.Exception.Data.Contains('InstallerCode')) { $code = [int]$_.Exception.Data['InstallerCode'] } + exit $code +} +JVM_POWERSHELL_INSTALLER +} +main "$@" diff --git a/web/latest.txt b/web/latest.txt new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/web/latest.txt @@ -0,0 +1 @@ +0.2.0