Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/plugin-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ permissions:
contents: read

jobs:
sdkman-script-contracts:
name: SDKMAN JDK script contracts
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Run SDKMAN JDK contract tests
run: bash test/sdkman-switch-jdk-contract.sh

verify:
name: Verify on Node.js ${{ matrix.node-version }}
runs-on: ubuntu-latest
Expand Down
445 changes: 250 additions & 195 deletions plugins/dev/skills/sdkman-switch-jdk/SKILL.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions plugins/dev/skills/sdkman-switch-jdk/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
interface:
display_name: "Sdkman Switch Jdk"
short_description: "Sdkman switch jdk"
display_name: "SDKMAN Switch JDK"
short_description: "Safely switch SDKMAN-managed Java versions"
default_prompt: "Use $sdkman-switch-jdk to select and safely switch to the JDK required by this project."
59 changes: 59 additions & 0 deletions plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 專案範圍 JDK 變更

只有在使用者明確要求持久化 `.sdkmanrc`,或套用其中完整 SDKMAN 環境時,才讀取本參考。

## 只執行專案指定的 Java

即使 `.sdkmanrc` 另含 `maven=`、`gradle=` 或其他 candidate,也將 Java-only 要求限制在 Java:

1. 從 `.sdkmanrc` 讀出完整 `java=` identifier。
2. 必要時用 `scripts/install-java.sh` 只安裝該 Java。
3. 回到 `SKILL.md` 步驟 5 的暫時分支;default 不存在時使用其中指向的 SDK home 分支。

此分支不執行 `sdk env install`,因為它會安裝並切換 `.sdkmanrc` 中的每一個 candidate。

**完成條件:**同一 shell 中的 `java -version`、`command -v java` 與實際命令都使用該完整 identifier。

## 將 identifier 持久化至 `.sdkmanrc`

只有在使用者要求專案層級持久化時才套用:

1. 記錄 `.sdkmanrc` 原先是否存在,並保存其完整原始內容。若既有檔案未受版本控制,使用 repo 外的唯一備份,且回報實際路徑:

```bash
sdkman_switch_jdk_backup="$(mktemp "${TMPDIR:-/tmp}/sdkmanrc.XXXXXX")" && \
cp .sdkmanrc "$sdkman_switch_jdk_backup" && \
printf 'SDKMANRC backup: %s\n' "$sdkman_switch_jdk_backup"
```

2. 修改前計算有效的 `java=` 項目。沒有時新增 `java=<identifier>`;只有一個時更新該項並保留註解與其他 candidate。若超過一個,停止並列出衝突;只有在使用者明確要求修正重複項目時,才能更新第一項並移除其餘項目。
3. 修改後驗證恰好只有一個有效的 `java=` 項目,再檢查 diff,確認只包含預期的 Java 項目變更。
4. 必要時用 `scripts/install-java.sh` 安裝該 Java,再回到 `SKILL.md` 步驟 5 的暫時分支執行與驗證。

回復時,既有檔案應還原為保存的完整內容;若本次建立了新檔,先確認它仍是本次產物,再依使用者要求執行 `unlink .sdkmanrc`。不要使用會覆寫既有內容的固定 `.sdkmanrc.bak`。

**完成條件:**`.sdkmanrc` 恰好只有一個有效的 `java=` 項目、diff 只包含預期 Java 項目、實際命令成功,且回復方式能精確還原變更前狀態。

## 套用完整 SDKMAN 環境

只有在使用者明確要求套用 `.sdkmanrc` 的每個 candidate 時,才進入完整環境分支。先通過 `SKILL.md` 步驟 1 的重複 `java=` gate;有衝突時不得進入本分支。

必須從包含 `.sdkmanrc` 的目錄執行專用 runner。不要自行 source SDKMAN 後直接執行 `sdk env install`,因為該 inline 流程無法在 payload 前逐 candidate 驗證與還原 default。

runner 會以一次讀取解析並驗證 `.sdkmanrc`,把 candidate 與 version 保留在 indexed arrays;接著只以這些記憶體值明確呼叫 `sdk install <candidate> <version>` 與 `sdk use <candidate> <version>`,不會呼叫 `sdk env install` 或讓 SDKMAN 第二次讀取 `.sdkmanrc`。在共用跨程序 lock 內,runner 會記錄每個已驗證 candidate 的 raw `current` symlink。未獲准的變更只有在 current 仍精確等於該 candidate 的已驗證 version 時才會原子還原;若 fingerprint 顯示 project `.sdkmanrc` 在讀取或執行期間改變、有第三方漂移、比較失敗、還原失敗或 lock ownership 改變,runner 會保留外部的新狀態並禁止 payload。

SDKMAN 可能為原本沒有 `current` 的 candidate 建立第一個 default。逐項說明這些 candidate,並只在使用者明確同意保留該 candidate 的 `.sdkmanrc` version 時加入對應授權:

```bash
bash <skill-dir>/scripts/run-sdkman-env.sh \
[--allow-default <candidate>]... \
-- <actual-command> [args...]
```

`--allow-default` 只適用於執行前為 `absent`、且存在於 `.sdkmanrc` 的 candidate;它只允許保留 `.sdkmanrc` 指定的 exact raw version target。它不允許改寫既有 default,也不接受重複或未列於 `.sdkmanrc` 的 candidate。沒有任何建立 default 的授權時,省略所有 `--allow-default`,runner 會還原每一個新建的 default。

需要 compound command 時,將 `bash -lc 'mvn test && mvn package'` 當成 `<actual-command> [args...]` 傳入;不要把命令文字插入 `bash -c` 程式本文。

runner 只在所有 candidate 的比較、還原、最終驗證與 lock release 都成功後才以 `exec` 執行 payload。執行 SDKMAN 期間若收到 `HUP`、`INT` 或 `TERM`,會先以相同的 operation-owned 比較完成 reconciliation,再保留對應 signal status;lock metadata 初始化期間的 signal 也會延後到安全清理後處理。SDKMAN 失敗且安全 reconciliation 成功時,保留 SDKMAN 的原始非零 status;CLI 或 `.sdkmanrc` 格式錯誤回傳 `2`,其他安全拒絕回傳 `1`。

**完成條件:**回報 SDKMAN 安裝或切換的每個 candidate、明確授權的新 default、實際命令結果,且所有未獲准變更的 default symlink 與執行前逐 byte 相同。
30 changes: 30 additions & 0 deletions plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SDKMAN JDK 疑難排解

只有在核心安裝或 runner 流程失敗時,才讀取本參考。

## 驗證目前生效的 Java

以 `java -version` 與 `command -v java` 作為可觀察的真相。SDKMAN 5.20.0 在暫時 `sdk use` 後,`sdk current java` 仍可能回報 default;不要用它驗證目前 shell。

SDKMAN 輸出可能包含 ANSI escape。將 `sdk list java` 當作顯示資料,不要解析它來推論生效版本。

## 維持 default 不變

`scripts/install-java.sh` 會預先設定 `USE=n` 並對提示送入 `n`;`scripts/run-java.sh` 則在所有情況下將精確 candidate 的 `bin` 放到 `PATH` 最前方,且不會在缺少 Java default 時建立它。兩者都會比較執行前後的 `java/current`。`scripts/run-sdkman-env.sh` 會一次解析並驗證 `.sdkmanrc`,再以已驗證的記憶體 candidate 清單逐項明確執行 `sdk install` 與 `sdk use`;它不會讓 SDKMAN 重新讀取檔案,也會對每個 candidate 執行相同的 raw-state 保護。

三個 runner 共用 `${SDKMAN_CANDIDATES_DIR}/.sdkman-switch-jdk.lock`,讓 SDKMAN 操作與 reconciliation 不會彼此交錯。live owner 會使新呼叫 fail fast;metadata 完整、同一 EUID 且 PID 已不存在的 stale lock 會在重新核對後回收。metadata 不完整、ownership 不符或 PID 仍存活時一律保留 lock 並停止;先確認沒有相關 runner 後再人工檢查,不要直接遞迴刪除。

此 lock 是 bundled runner 間的合作式協調邊界。不要在 runner 執行期間以裸 `sdk default`、手動改寫 `current` 或其他不取得同一 lock 的流程並行變更 default;runner 會在每次還原寫入前重查漂移,但 portable Bash 無法把 state comparison 與 filesystem mutation 合併成單一 kernel operation。`HUP`、`INT` 與 `TERM` 會先執行相同 reconciliation 再釋放 lock;`SIGKILL` 或系統崩潰無法由 shell trap 處理,後續呼叫只會依完整 owner metadata 的 stale-lock 規則回收 lock,不會猜測或覆寫 default。

runner 已同時設定 `JAVA_HOME` 與 `PATH`。若它回報 active Java 不符,檢查 `${SDKMAN_CANDIDATES_DIR}/java/<identifier>/bin/java` 是否存在且可執行。不要將 `JAVA_HOME` 指向 `java/current`;該 symlink 代表 default,不一定是要求的 identifier。

## 失敗處理

| 觀察 | 動作 |
| --- | --- |
| SDKMAN init script 不存在 | 停止並在安裝 SDKMAN 前取得同意。 |
| 沒有唯一 identifier 符合主版本 | 列出候選並要求選定 distribution;不要取清單第一筆。 |
| `Stop! <id> is not available.` | 重新查看 `sdk list java` 並選定完整可用 identifier。 |
| runner 回報 active Java 不符 | 檢查 candidate 的 `bin/java` 與 shell 執行環境;不要繞過 runner,修正原因後重跑。 |
| runner 回報 project `.sdkmanrc` 在 activation 期間改變 | 保留並檢查並行修改;runner 只會執行已驗證的記憶體 entries,不會覆寫或第二次讀取 project 檔案,確認內容穩定後再重跑。 |
| 專案命令連帶影響 Maven、Gradle 或其他 SDK | 回到 Java-only 分支;只在明確要求完整環境時使用專用 runner。 |
125 changes: 125 additions & 0 deletions plugins/dev/skills/sdkman-switch-jdk/scripts/install-java.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env bash

set -e -o pipefail

sdkman_switch_jdk_usage() {
printf 'Usage: %s <sdkman-java-identifier>\n' "${0##*/}" >&2
}

if [[ $# -ne 1 ]]; then
sdkman_switch_jdk_usage
exit 2
fi

sdkman_switch_jdk_identifier="$1"
if [[ ! "$sdkman_switch_jdk_identifier" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then
printf 'Invalid SDKMAN Java identifier: %s\n' "$sdkman_switch_jdk_identifier" >&2
exit 2
fi
if [[ "$sdkman_switch_jdk_identifier" == "current" ]]; then
printf 'Invalid SDKMAN Java identifier (reserved name): %s\n' \
"$sdkman_switch_jdk_identifier" >&2
exit 2
fi

sdkman_switch_jdk_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sdkman_switch_jdk_state_helper="$sdkman_switch_jdk_script_dir/sdkman-current-state.sh"
if [[ ! -r "$sdkman_switch_jdk_state_helper" ]]; then
printf 'SDKMAN state helper is not readable: %s\n' \
"$sdkman_switch_jdk_state_helper" >&2
exit 1
fi
# shellcheck source=sdkman-current-state.sh
source "$sdkman_switch_jdk_state_helper"

sdkman_switch_jdk_root="${SDKMAN_DIR:-${HOME:?HOME is not set}/.sdkman}"
sdkman_switch_jdk_init="$sdkman_switch_jdk_root/bin/sdkman-init.sh"
if [[ ! -r "$sdkman_switch_jdk_init" ]]; then
printf 'SDKMAN init script is not readable: %s\n' "$sdkman_switch_jdk_init" >&2
exit 1
fi

# SDKMAN is a shell function and must be loaded into this process.
# Prevent sdkman_auto_env from applying a project file while SDKMAN initializes.
unset SDKMAN_ENV
export SDKMAN_OLD_PWD="$PWD"
# shellcheck source=/dev/null
source "$sdkman_switch_jdk_init"

if ! type sdk >/dev/null 2>&1; then
printf 'SDKMAN did not define the sdk command.\n' >&2
exit 1
fi

sdkman_switch_jdk_java_dir="${SDKMAN_CANDIDATES_DIR:?SDKMAN_CANDIDATES_DIR is not set}/java"
sdkman_switch_jdk_current="$sdkman_switch_jdk_java_dir/current"
sdkman_switch_jdk_target="$sdkman_switch_jdk_java_dir/$sdkman_switch_jdk_identifier"

sdkman_switch_jdk_install_cleanup_traps
if ! sdkman_switch_jdk_acquire_lock install-java; then
exit 1
fi

sdkman_switch_jdk_default_before="$(sdkman_switch_jdk_default_state "$sdkman_switch_jdk_current")"
if [[ "$sdkman_switch_jdk_default_before" == "unsupported" ]]; then
printf 'Refusing to continue: SDKMAN Java current is not a symlink: %s\n' \
"$sdkman_switch_jdk_current" >&2
exit 1
fi

sdkman_switch_jdk_owned_state=''
if [[ -e "$sdkman_switch_jdk_target" || -L "$sdkman_switch_jdk_target" ]]; then
if [[ ! -x "$sdkman_switch_jdk_target/bin/java" ]]; then
printf 'Refusing to use an incomplete SDKMAN Java candidate: %s\n' \
"$sdkman_switch_jdk_target" >&2
exit 1
fi
sdkman_switch_jdk_install_status=0
else
sdkman_switch_jdk_owned_state="$(sdkman_switch_jdk_target_state "$sdkman_switch_jdk_identifier")"
if ! sdkman_switch_jdk_register_default_reconciliation \
"$sdkman_switch_jdk_current" \
"$sdkman_switch_jdk_default_before" \
"$sdkman_switch_jdk_owned_state"; then
exit 1
fi
# Pre-seeding USE covers both SDKMAN auto-answer modes and the no-default case.
# The here-string supplies the same explicit answer when SDKMAN prompts.
set +e
USE=n sdk install java "$sdkman_switch_jdk_identifier" <<< 'n'
sdkman_switch_jdk_install_status=$?
set -e
fi

set +e
sdkman_switch_jdk_finish_operation "$sdkman_switch_jdk_install_status"
sdkman_switch_jdk_finish_status=$?
set -e

if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then
exit "$sdkman_switch_jdk_finish_status"
fi
if (( sdkman_switch_jdk_default_reconcile_failed != 0 )) || \
(( sdkman_switch_jdk_finish_status != sdkman_switch_jdk_install_status )); then
exit 1
fi

if (( sdkman_switch_jdk_install_status != 0 )); then
printf 'SDKMAN failed to install Java %s (status %d); the default is unchanged.\n' \
"$sdkman_switch_jdk_identifier" "$sdkman_switch_jdk_install_status" >&2
exit "$sdkman_switch_jdk_install_status"
fi

if (( sdkman_switch_jdk_default_reconcile_changed != 0 )); then
printf 'SDKMAN changed the Java default unexpectedly; the previous state was restored.\n' >&2
exit 1
fi

if [[ ! -x "$sdkman_switch_jdk_target/bin/java" ]]; then
printf 'SDKMAN reported success but Java is incomplete or not executable at: %s\n' \
"$sdkman_switch_jdk_target" >&2
exit 1
fi

printf 'Java %s is installed; SDKMAN default is unchanged.\n' \
"$sdkman_switch_jdk_identifier"
140 changes: 140 additions & 0 deletions plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env bash

set -e -o pipefail

sdkman_switch_jdk_run_usage() {
printf 'Usage: %s <sdkman-java-identifier> -- <command> [args...]\n' \
"${0##*/}" >&2
}

if [[ $# -lt 3 || "$2" != "--" ]]; then
sdkman_switch_jdk_run_usage
exit 2
fi

sdkman_switch_jdk_run_identifier="$1"
if [[ ! "$sdkman_switch_jdk_run_identifier" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then
printf 'Invalid SDKMAN Java identifier: %s\n' \
"$sdkman_switch_jdk_run_identifier" >&2
exit 2
fi
if [[ "$sdkman_switch_jdk_run_identifier" == "current" ]]; then
printf 'Invalid SDKMAN Java identifier (reserved name): %s\n' \
"$sdkman_switch_jdk_run_identifier" >&2
exit 2
fi
shift 2

sdkman_switch_jdk_run_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sdkman_switch_jdk_run_state_helper="$sdkman_switch_jdk_run_script_dir/sdkman-current-state.sh"
if [[ ! -r "$sdkman_switch_jdk_run_state_helper" ]]; then
printf 'SDKMAN state helper is not readable: %s\n' \
"$sdkman_switch_jdk_run_state_helper" >&2
exit 1
fi
# shellcheck source=sdkman-current-state.sh
source "$sdkman_switch_jdk_run_state_helper"

sdkman_switch_jdk_run_root="${SDKMAN_DIR:-${HOME:?HOME is not set}/.sdkman}"
sdkman_switch_jdk_run_init="$sdkman_switch_jdk_run_root/bin/sdkman-init.sh"
if [[ ! -r "$sdkman_switch_jdk_run_init" ]]; then
printf 'SDKMAN init script is not readable: %s\n' \
"$sdkman_switch_jdk_run_init" >&2
exit 1
fi

# SDKMAN is a shell function and must be loaded into this process.
# Prevent sdkman_auto_env from applying a project file while SDKMAN initializes.
unset SDKMAN_ENV
export SDKMAN_OLD_PWD="$PWD"
# shellcheck source=/dev/null
source "$sdkman_switch_jdk_run_init"

if ! type sdk >/dev/null 2>&1; then
printf 'SDKMAN did not define the sdk command.\n' >&2
exit 1
fi

sdkman_switch_jdk_run_java_dir="${SDKMAN_CANDIDATES_DIR:?SDKMAN_CANDIDATES_DIR is not set}/java"
sdkman_switch_jdk_run_current="$sdkman_switch_jdk_run_java_dir/current"
sdkman_switch_jdk_run_target="$sdkman_switch_jdk_run_java_dir/$sdkman_switch_jdk_run_identifier"

if [[ ! -x "$sdkman_switch_jdk_run_target/bin/java" ]]; then
printf 'Java is not installed or is incomplete: %s\n' \
"$sdkman_switch_jdk_run_identifier" >&2
exit 1
fi

sdkman_switch_jdk_install_cleanup_traps
if ! sdkman_switch_jdk_acquire_lock run-java; then
exit 1
fi

sdkman_switch_jdk_run_default_before="$(sdkman_switch_jdk_default_state "$sdkman_switch_jdk_run_current")"
if [[ "$sdkman_switch_jdk_run_default_before" == "unsupported" ]]; then
printf 'Refusing to continue: SDKMAN Java current is not a symlink: %s\n' \
"$sdkman_switch_jdk_run_current" >&2
exit 1
fi

sdkman_switch_jdk_run_use_status=0
sdkman_switch_jdk_run_owned_state=''
if [[ "$sdkman_switch_jdk_run_default_before" == link-hex:* ]]; then
sdkman_switch_jdk_run_owned_state="$(sdkman_switch_jdk_target_state "$sdkman_switch_jdk_run_identifier")"
if ! sdkman_switch_jdk_register_default_reconciliation \
"$sdkman_switch_jdk_run_current" \
"$sdkman_switch_jdk_run_default_before" \
"$sdkman_switch_jdk_run_owned_state"; then
exit 1
fi
set +e
sdk use java "$sdkman_switch_jdk_run_identifier"
sdkman_switch_jdk_run_use_status=$?
set -e
else
:
fi

set +e
sdkman_switch_jdk_finish_operation "$sdkman_switch_jdk_run_use_status"
sdkman_switch_jdk_run_finish_status=$?
set -e

if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then
exit "$sdkman_switch_jdk_run_finish_status"
fi
if (( sdkman_switch_jdk_default_reconcile_failed != 0 )) || \
(( sdkman_switch_jdk_run_finish_status != sdkman_switch_jdk_run_use_status )); then
exit 1
fi

if (( sdkman_switch_jdk_run_use_status != 0 )); then
printf 'SDKMAN failed to activate Java %s (status %d); the command was not run.\n' \
"$sdkman_switch_jdk_run_identifier" "$sdkman_switch_jdk_run_use_status" >&2
exit "$sdkman_switch_jdk_run_use_status"
fi

if (( sdkman_switch_jdk_default_reconcile_changed != 0 )); then
printf 'SDKMAN changed the Java default unexpectedly; it was restored and the command was not run.\n' >&2
exit 1
fi

# Make the requested SDK deterministic even when sdk use finds an earlier,
# non-SDKMAN java entry in PATH. This also handles an originally absent default.
export JAVA_HOME="$sdkman_switch_jdk_run_target"
export PATH="$JAVA_HOME/bin:$PATH"

hash -r
sdkman_switch_jdk_run_java="$(command -v java)"
sdkman_switch_jdk_run_expected="$sdkman_switch_jdk_run_target/bin/java"
if [[ "$sdkman_switch_jdk_run_java" != "$sdkman_switch_jdk_run_expected" ]]; then
printf 'Active java does not match the requested SDKMAN identifier.\n' >&2
printf 'Expected: %s\nActual: %s\n' \
"$sdkman_switch_jdk_run_expected" "$sdkman_switch_jdk_run_java" >&2
exit 1
fi

java -version >&2
printf 'java: %s\n' "$sdkman_switch_jdk_run_java" >&2
sdkman_switch_jdk_clear_cleanup_traps
exec "$@"
Loading