diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1e4a607f..92b89e37 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,7 +10,7 @@ Please add conscise, understandable instructions on how a reviewer can test/verify the functionality of your contribution here: -1. +1. --- @@ -20,14 +20,16 @@ Make sure everything is checked before merging this PR. For further info please our [DoD](https://github.com/devonfw/IDEasy/blob/main/documentation/contributing/DoD.adoc). - [ ] When running `mvn clean test` locally all tests pass and build is successful -- [ ] PR title is of the form `#«issue-id»: «brief summary»` (e.g. `#921: fixed setup.bat` and not `feature/921 fixed setup.bat`). If no issue ID exists, title only. -- [ ] PR top-level comment summaries what has been done and contains link to addressed issue(s) +- [ ] PR title is of the form `#«issue-id»: «brief summary»` (e.g. `#921: fixed setup.bat` and not `feature/921 fixed setup.bat`). + If no issue ID exists, title only. +- [ ] PR top-level comment summarizes what has been done and contains link to addressed issue(s) - [ ] PR and issue(s) have suitable labels - [ ] Issue is set to `In Progress` and assigned to you *or* there is no issue (might happen for very small PRs) -- [ ] You followed all [coding conventions](https://github.com/devonfw/IDEasy/blob/main/documentation/coding-conventions.adoc) +- [ ] You followed all [coding conventions](https://github.com/devonfw/IDEasy/blob/main/documentation/contributing/coding-conventions.adoc) - [ ] You have added the issue implemented by your PR in [CHANGELOG.adoc](https://github.com/devonfw/IDEasy/blob/main/CHANGELOG.adoc) unless issue is labelled with `internal` -- [ ] You have not changed any dependency in `pom.xml` files or otherwise if runtime dependencies changed, you have updated our [LICENSE.asciidoc](https://github.com/devonfw/IDEasy/blob/main/documentation/LICENSE.adoc) +- [ ] You have not changed any dependency in `pom.xml` files or otherwise if runtime dependencies changed, + you have updated our [LICENSE.adoc](https://github.com/devonfw/IDEasy/blob/main/documentation/LICENSE.adoc) - [ ] You have formulated clear instructions on how to test your contribution under "Testing instructions" ### Checklist for tool commandlets @@ -43,3 +45,10 @@ Have you added a new `«tool»` as commandlet? There are the following additiona - [ ] The variables `«TOOL»_VERSION` and `«TOOL»_EDITION` are honored by your commandlet - [ ] The new commandlet is tested on all platforms it is available for or tested on all platforms that are in scope of the linked issue +### Checklist for GUI contributions + +Did you make user-facing changes to the GUI? There are the following additional checks: + +- [ ] You followed the [ui-contribution-process.adoc](https://github.com/devonfw/IDEasy/blob/main/documentation/contributing/ui-contribution-process.adoc) and [ui-structure.adoc](https://github.com/devonfw/IDEasy/blob/main/documentation/contributing/ui-structure.adoc) +- [ ] The UI changes are designed in the Figma project and the design was approved in the team review +- [ ] You have added screenshots of the UI changes to the PR description diff --git a/.github/workflows/assign-pr-author.yml b/.github/workflows/assign-pr-author.yml index 3d72f1b9..e7b54a63 100644 --- a/.github/workflows/assign-pr-author.yml +++ b/.github/workflows/assign-pr-author.yml @@ -4,9 +4,6 @@ on: pull_request_target: types: [opened] -env: - ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true - permissions: contents: read pull-requests: write @@ -15,6 +12,9 @@ permissions: jobs: assign-author: runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true' steps: - name: Assign PR author if no assignee exists @@ -24,16 +24,26 @@ jobs: const pr = context.payload.pull_request; // Do not overwrite existing assignees - if (pr.assignees && pr.assignees.length > 0) { + if (pr.assignees?.length > 0) { console.log('PR already has assignees. Skipping.'); return; } + // Bot accounts cannot be assigned like regular contributors + if (pr.user.type === 'Bot') { + console.log(`PR author ${pr.user.login} is a bot. Skipping assignment.`); + return; + } - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - assignees: [pr.user.login] - }); - - console.log(`Assigned ${pr.user.login} to PR #${pr.number}`); \ No newline at end of file + try { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + assignees: [pr.user.login] + }); + + console.log(`Assigned ${pr.user.login} to PR #${pr.number}`); + } catch (err) { + // E.g. fork author is not a collaborator — don't fail the workflow + console.log(`Warning: could not assign ${pr.user.login}: ${err.message}`); + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 154f660c..d996ead4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,8 @@ on: jobs: build: runs-on: ubuntu-latest + if: > + ${{ github.event_name == 'workflow_dispatch' || vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true' }} steps: - name: Checkout code uses: actions/checkout@v7 diff --git a/.github/workflows/check-for-updates.yml b/.github/workflows/check-for-updates.yml index 7cc96a30..b181b947 100644 --- a/.github/workflows/check-for-updates.yml +++ b/.github/workflows/check-for-updates.yml @@ -9,6 +9,9 @@ jobs: # Checks if new commits since 24 hours of the last call of this workflow were found and adjusts the GITHUB_OUTPUT accordingly verify_commit: runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true' outputs: RUN_BUILD: ${{ steps.verify_commit.outputs.RUN_BUILD }} steps: diff --git a/.github/workflows/ci-status-watch.yml b/.github/workflows/ci-status-watch.yml new file mode 100644 index 00000000..5a44ddae --- /dev/null +++ b/.github/workflows/ci-status-watch.yml @@ -0,0 +1,126 @@ +name: CI status watch + +# Posts a Teams message listing the README badge workflows that are currently red on the +# default branch. A workflow stays in the list until it is green again, so the reminder +# repeats every weekday morning until somebody fixes it. Creates no issues, read-only. +# If a status cannot be checked, Teams receives a warning to check it manually. +# Requires the repository secret TEAMS_WEBHOOK_URL. + +on: + workflow_dispatch: + schedule: + - cron: '0 7 * * 1-5' # 07:00 UTC, Mon-Fri (= 09:00 CEST / 08:00 CET) + +permissions: + actions: read + +jobs: + watch: + if: > + github.repository == 'devonfw/IDEasy' && + (github.event_name == 'workflow_dispatch' || vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true') + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + steps: + - name: Check badge workflow status and notify Teams + run: | + set -euo pipefail + + # The workflows backing the README badges. Add a line here if a badge is added. + WORKFLOWS=" + .github/workflows/build.yml + .github/workflows/update-urls.yml + .github/workflows/nightly-build.yml + .github/workflows/integration-tests.yml + " + + echo "Checking badge workflows on branch $BRANCH" + + # Cancelled/skipped runs say nothing about the code and are ignored. A failed lookup + # is kept as an unknown status so Teams can ask the team to check it manually. + RESULTS=$(for WF_PATH in $WORKFLOWS; do + if RUNS=$(gh run list --repo "$REPO" --workflow "$WF_PATH" --branch "$BRANCH" \ + --status completed --limit 5 --json conclusion,url,updatedAt,workflowName); then + echo "$RUNS" | jq --arg workflow "$WF_PATH" ' + ([ .[] | select(.conclusion != "cancelled" and .conclusion != "skipped") ][0]) as $run + | if $run == null then + {state: "unknown", workflow: $workflow} + elif ($run.conclusion == "failure" or $run.conclusion == "timed_out" + or $run.conclusion == "startup_failure") then + {state: "red", name: $run.workflowName, url: $run.url, since: $run.updatedAt} + else + {state: "green", workflow: $workflow} + end' + else + echo "Status lookup failed for $WF_PATH." >&2 + jq -n --arg workflow "$WF_PATH" '{state: "unknown", workflow: $workflow}' + fi + done | jq -s '.') + + RED=$(echo "$RESULTS" | jq '[.[] | select(.state == "red")]') + UNKNOWN=$(echo "$RESULTS" | jq '[.[] | select(.state == "unknown")]') + RED_COUNT=$(echo "$RED" | jq 'length') + UNKNOWN_COUNT=$(echo "$UNKNOWN" | jq 'length') + if [ "$RED_COUNT" -eq 0 ] && [ "$UNKNOWN_COUNT" -eq 0 ]; then + echo "No badge workflow is red. Skipping Teams notification." + exit 0 + fi + echo "$RED_COUNT badge workflow(s) currently red; $UNKNOWN_COUNT status lookup(s) unknown." + + if [ -z "${WEBHOOK:-}" ]; then + echo "No Teams webhook set; notification cannot be delivered." + exit 1 + fi + + CARD=$(jq -n \ + --argjson red "$RED" \ + --argjson unknown "$UNKNOWN" \ + --argjson redCount "$RED_COUNT" \ + --argjson unknownCount "$UNKNOWN_COUNT" \ + --arg repo "$REPO" ' + { + type: "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body: ( + [ + { type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, + color: (if $redCount > 0 then "attention" else "warning" end), + text: (if $redCount > 0 then "🔴 CI ist rot" else "⚠️ CI-Status unklar" end) } + ] + + (if $redCount > 0 then + [ { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true, + text: (if $redCount == 1 then "1 Workflow ist rot" else "\($redCount) Workflows sind rot" end) } ] + + [ $red[] | { + type: "Container", separator: true, spacing: "Medium", + items: [ + { type: "TextBlock", weight: "Bolder", wrap: true, text: "🔴 \(.name)" }, + { type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true, + text: "rot seit \(.since) · [Run-Log öffnen](\(.url))" } + ] + } ] + else [] end) + + (if $unknownCount > 0 then + [ + { type: "TextBlock", weight: "Bolder", wrap: true, color: "warning", + spacing: "Large", text: "⚠️ Statusprüfung unvollständig" }, + { type: "TextBlock", wrap: true, + text: "Bitte den Status der folgenden Workflows manuell in GitHub prüfen." } + ] + + [ $unknown[] | { + type: "TextBlock", wrap: true, + text: "⚠️ [\(.workflow | split("/") | last)](https://github.com/\($repo)/actions/workflows/\(.workflow | split("/") | last))" + } ] + else [] end) + ) + }') + + curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK" + if [ "$UNKNOWN_COUNT" -gt 0 ]; then + echo "At least one workflow status could not be checked." + exit 1 + fi diff --git a/.github/workflows/clean-runs.yml b/.github/workflows/clean-runs.yml index 6e31e56d..085ad58b 100644 --- a/.github/workflows/clean-runs.yml +++ b/.github/workflows/clean-runs.yml @@ -6,6 +6,9 @@ on: jobs: del_runs: runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true' steps: - name: Delete cancelled workflow runs uses: Mattraks/delete-workflow-runs@v2 diff --git a/.github/workflows/issue-pr-observer.yml b/.github/workflows/issue-pr-observer.yml index ab540a85..fbe6984f 100644 --- a/.github/workflows/issue-pr-observer.yml +++ b/.github/workflows/issue-pr-observer.yml @@ -1,46 +1,86 @@ -name: External Issue & PR Observer +name: External Issue, PR & Discussion Observer -# Posts a weekday-morning digest of new EXTERNAL issues/PRs (last 24h) to a Teams -# channel so incoming reports from outside the team are noticed quickly and don't slip. -# "External" = author_association is not MEMBER, OWNER or COLLABORATOR. -# Requires the repository secret TEAMS_WEBHOOK_URL (a Teams "Workflows" incoming webhook). +# Posts a weekday-morning digest of new EXTERNAL issues, PRs and discussions (last 24h) +# to a Teams channel so incoming activity from outside the team is noticed quickly and +# "External" = author is not a member of the devonfw/ide team. +# Issues/PRs come from the REST search API, discussions from GraphQL (no REST equivalent). +# A failed API call fails the run. The next run resumes from the last successful run, +# so activity is retried instead of being silently lost. +# Requires repository secret TEAMS_WEBHOOK_URL (Teams "Workflows" incoming webhook) +# and org secret ACTION_PUSH_TOKEN (Members read; see +# documentation/contributing/update-access-token.adoc). on: - workflow_dispatch: + workflow_dispatch: schedule: - cron: '0 7 * * 1-5' # 07:00 UTC, Mon-Fri (= 09:00 CEST summer / 08:00 CET winter) permissions: + actions: read issues: read pull-requests: read + discussions: read contents: read jobs: observe: + if: > + github.repository == 'devonfw/IDEasy' && + (github.event_name == 'workflow_dispatch' || vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true') runs-on: ubuntu-latest steps: - - name: Scan for new external issues & PRs and notify Teams + - name: Scan for new external issues, PRs & discussions and notify Teams env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTION_PUSH_TOKEN: ${{ secrets.ACTION_PUSH_TOKEN }} WEBHOOK: ${{ secrets.TEAMS_WEBHOOK_URL }} REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + WORKFLOW: ".github/workflows/issue-pr-observer.yml" run: | - # Monday catches up the weekend (Fri–Sun); other weekdays look back 24h. - if [ "$(date -u +%u)" -eq 1 ]; then + set -euo pipefail + + # Resume from the previous successful observer run. If a GitHub API call or the + # Teams notification fails, that run stays failed and its activity is retried. + LAST_SUCCESS=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --branch "$BRANCH" \ + --status success --limit 1 --json createdAt --jq '.[0].createdAt // empty') \ + || { echo "Previous successful observer run fetch failed."; exit 1; } + if [ -n "$LAST_SUCCESS" ]; then + SINCE=$LAST_SUCCESS + elif [ "$(date -u +%u)" -eq 1 ]; then + # The first Monday run catches up the weekend (Fri–Sun). SINCE=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ) else + # Other first runs look back 24 hours. SINCE=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ) fi echo "Scanning $REPO for items created since $SINCE" - # Search returns both issues and PRs; keep only external authors (not on the team) - # and emit a structured object per item so the card can lay them out nicely. - ITEMS_JSON=$(gh api -X GET search/issues \ - -f q="repo:$REPO created:>=$SINCE" \ - --jq '[.items[] - | select(.author_association != "MEMBER" - and .author_association != "OWNER" - and .author_association != "COLLABORATOR") + if [ -z "$ACTION_PUSH_TOKEN" ]; then + echo "ACTION_PUSH_TOKEN is empty." + exit 1 + fi + + OWNER=${REPO%/*} + + # Include inherited child-team members, normalize logins and remove duplicates. + TEAM=$(GH_TOKEN="$ACTION_PUSH_TOKEN" gh api --method GET --paginate \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "/orgs/$OWNER/teams/ide/members?per_page=100" \ + --jq '.[].login' \ + | jq -Rsc 'split("\n") | map(select(length > 0) | ascii_downcase) | unique') \ + || { echo "Team member fetch failed."; exit 1; } + if ! echo "$TEAM" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "Team member list is empty or invalid." + exit 1 + fi + echo "Excluding $(echo "$TEAM" | jq 'length') team member(s)." + + # Issues + PRs via REST search; drop team authors, keep the rest. + ISSUE_ITEMS=$(gh api -X GET search/issues \ + -f q="repo:$REPO created:>=$SINCE" -f per_page=100 --jq '.items' \ + | jq --argjson team "$TEAM" '[.[] + | select((.user.login | ascii_downcase) as $u | $team | index($u) | not) | { kind: (if .pull_request then "Pull Request" else "Issue" end), icon: (if .pull_request then "🔵" else "🟢" end), @@ -48,40 +88,78 @@ jobs: title: .title, url: .html_url, user: .user.login - }]') + }]') \ + || { echo "Issue/PR fetch failed."; exit 1; } + + # No REST search for discussions; take newest 50 via GraphQL, filter by window + team. + NAME=${REPO#*/} + DISC_ITEMS=$(gh api graphql \ + -f owner="$OWNER" -f name="$NAME" \ + -f query=' + query($owner:String!, $name:String!) { + repository(owner:$owner, name:$name) { + discussions(first:50, orderBy:{field:CREATED_AT, direction:DESC}) { + nodes { number title url createdAt author { login } } + } + } + }' \ + --jq '.data.repository.discussions.nodes' \ + | jq --arg since "$SINCE" --argjson team "$TEAM" '[ .[] + | select(.createdAt >= $since) + | select(((.author.login // "") | ascii_downcase) as $u | $team | index($u) | not) + | { + kind: "Discussion", + icon: "💬", + number: .number, + title: .title, + url: .url, + user: (.author.login // "unknown") + } ]') \ + || { echo "Discussion fetch failed."; exit 1; } - COUNT=$(echo "$ITEMS_JSON" | jq 'length') + IP_COUNT=$(echo "$ISSUE_ITEMS" | jq 'length') + D_COUNT=$(echo "$DISC_ITEMS" | jq 'length') + COUNT=$((IP_COUNT + D_COUNT)) if [ "$COUNT" -eq 0 ]; then - echo "No new external issues or PRs. Skipping Teams-notification." + echo "No new external issues, PRs or discussions. Skipping Teams-notification." exit 0 fi - echo "$COUNT new external issue(s)/PR(s) found." + echo "$COUNT new external item(s): $IP_COUNT issue(s)/PR(s), $D_COUNT discussion(s)." - # Teams' webhook wants a bare Adaptive Card: top-level type must be - # "AdaptiveCard", not wrapped in a message/attachments envelope. - CARD=$(echo "$ITEMS_JSON" | jq --argjson count "$COUNT" '{ - type: "AdaptiveCard", - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - version: "1.4", - body: ( - [ - { type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, - text: "🆕 Neue externe Issues & Pull Requests" }, - { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true, - text: ("Letzter Zeitraum · " + (if $count == 1 then "1 neuer Eintrag" else "\($count) neue Einträge" end)) } - ] - + [ .[] | { - type: "Container", - separator: true, - spacing: "Medium", - items: [ - { type: "TextBlock", weight: "Bolder", wrap: true, - text: "\(.icon) #\(.number) · \(.title)" }, - { type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true, - text: "\(.kind) · von @\(.user) · [Öffnen](\(.url))" } - ] - } ] - ) - }') + # Bare Adaptive Card with one group per source; a group is rendered only if it has + # items, so issues/PRs and discussions stay separated and empty sections are hidden. + CARD=$(jq -n \ + --argjson ip "$ISSUE_ITEMS" \ + --argjson disc "$DISC_ITEMS" \ + --argjson count "$COUNT" ' + def section(title; items): + if (items | length) > 0 then + [ { type: "TextBlock", size: "Medium", weight: "Bolder", wrap: true, + spacing: "Large", text: title } ] + + [ items[] | { + type: "Container", separator: true, spacing: "Medium", + items: [ + { type: "TextBlock", weight: "Bolder", wrap: true, + text: "\(.icon) #\(.number) · \(.title)" }, + { type: "TextBlock", spacing: "None", isSubtle: true, size: "Small", wrap: true, + text: "\(.kind) · von @\(.user) · [Öffnen](\(.url))" } + ] + } ] + else [] end; + { + type: "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body: ( + [ + { type: "TextBlock", size: "Large", weight: "Bolder", wrap: true, + text: "🆕 Neue externe Aktivität" }, + { type: "TextBlock", spacing: "None", isSubtle: true, wrap: true, + text: ("Letzter Zeitraum · " + (if $count == 1 then "1 neuer Eintrag" else "\($count) neue Einträge" end)) } + ] + + section("📋 Issues & Pull Requests"; $ip) + + section("💬 Discussions"; $disc) + ) + }') curl -sS --fail -X POST -H "Content-Type: application/json" -d "$CARD" "$WEBHOOK" diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 6a239bbc..e4d5ecc5 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -108,7 +108,7 @@ jobs: if: runner.os == 'macOS' shell: bash run: | - ./.github/workflows/macos/generate_pkg.sh + ./macos-installer/build-pkg.sh env: RELEASE_VERSION: ${{ needs.resolve-version.outputs.snapshot_version }} - name: Upload unsigned PKG @@ -145,4 +145,4 @@ jobs: SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} RELEASE_VERSION: ${{ needs.resolve-version.outputs.snapshot_version }} - run: mvn --settings .mvn/settings.xml -DskipTests=true -Darchetype.test.skip=true -Dgpg.skip=true -Dstyle.color=always -B -ntp -pl -:ide-macos-installer -Passembly,msi,pkg,deploy deploy + run: mvn --settings .mvn/settings.xml -DskipTests=true -Darchetype.test.skip=true -Dgpg.skip=true -Dstyle.color=always -B -ntp -pl -:ide-macos-installer -pl -:ide-windows-installer -Passembly,msi,pkg,deploy deploy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6196dad2..abc52576 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: if: runner.os == 'macOS' shell: bash run: | - ./.github/workflows/macos/generate_pkg.sh + ./macos-installer/build-pkg.sh env: RELEASE_VERSION: ${{ needs.resolve-version.outputs.release_version }} - name: Upload unsigned PKG @@ -150,7 +150,7 @@ jobs: uses: actions/download-artifact@v8 with: path: ./cli/target/ - - name: Create assemblies and publish to Apache Maven Central + - name: Prepare release version run: | sed -i "s/${SNAPSHOT_VERSION}/${RELEASE_VERSION}/" .mvn/maven.config git config --global user.email ${{ secrets.BUILD_USER_EMAIL }} @@ -158,12 +158,26 @@ jobs: git add -f .mvn/maven.config git commit -m "set release version to ${RELEASE_VERSION}" git tag -a "release/${RELEASE_VERSION}" -m "tagged version ${RELEASE_VERSION}" - export GPG_TTY=$TTY mkdir -p ./cli/target/ - mvn --settings .mvn/settings.xml -B -ntp deploy -Passembly,msi,pkg,deploy -pl -:ide-macos-installer -Dgpg.pin.entry.mode=loopback -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} env: RELEASE_VERSION: ${{ needs.resolve-version.outputs.release_version }} SNAPSHOT_VERSION: ${{ needs.resolve-version.outputs.snapshot_version }} + - name: Build and test release + run: | + mvn --settings .mvn/settings.xml -B -ntp install \ + -pl -:ide-macos-installer \ + -pl -:ide-windows-installer + - name: Deploy release + run: | + export GPG_TTY=$TTY + mvn --settings .mvn/settings.xml -B -ntp deploy \ + -Passembly,msi,pkg,deploy \ + -pl -:ide-macos-installer \ + -pl -:ide-windows-installer \ + -DskipTests \ + -Dgpg.pin.entry.mode=loopback \ + -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} + env: SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.github/workflows/update-quality-status.yml b/.github/workflows/update-quality-status.yml index d5ecf8be..5890e6f9 100644 --- a/.github/workflows/update-quality-status.yml +++ b/.github/workflows/update-quality-status.yml @@ -11,6 +11,9 @@ permissions: jobs: update-quality-status: runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true' steps: - name: Check out repository diff --git a/.github/workflows/update-urls.yml b/.github/workflows/update-urls.yml index 53f0002e..5055ab6b 100644 --- a/.github/workflows/update-urls.yml +++ b/.github/workflows/update-urls.yml @@ -6,6 +6,9 @@ on: jobs: updateURLS: runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + vars.ENABLE_AUTOMATIC_WORKFLOWS == 'true' steps: - name: Checkout ide uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index dc1d7309..8c049b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,11 @@ macos-installer/Resources/LICENSE.rtf macos-installer/pkg-root macos-installer/Distribution.xml +#windows installer files +*.msi +windows-installer/msi-files +windows-installer/signed + # Package Files # *.jar *.war diff --git a/.mvn/maven.config b/.mvn/maven.config index 19698727..fd19e62f 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1 +1 @@ --Drevision=2026.09.002-SNAPSHOT +-Drevision=2026.10.001-SNAPSHOT diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 480c6a6c..5a5f51ce 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,22 +2,51 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDEasy]. +== 2026.10.001 + +Release with new features and bugfixes: + +* https://github.com/devonfw/IDEasy/issues/2546[#2546]: Include repository into git warning/errors +* https://github.com/devonfw/IDEasy/issues/2293[#2293]: Rework spyder to have same features as IdeToolCommandlets +* https://github.com/devonfw/IDEasy/issues/871[#871]: Add progressbar to *.dmg extract process +* https://github.com/devonfw/IDEasy/issues/2345[#2345]: DOCKER_EDITION ignored on Linux +* https://github.com/devonfw/IDEasy/issues/2381[#2381]: Make node and npm pristine versioned tools with per-project npm global prefix +* https://github.com/devonfw/IDEasy/issues/2607[#2607]: Align tool help for corepack +* https://github.com/devonfw/IDEasy/issues/2322[#2322]: Use a consistent IDEasy icon across all operating systems +* https://github.com/devonfw/IDEasy/issues/1209[#1209]: Improve confusing migration version message of `ide status` + +The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/51?closed=1[milestone 2026.10.001]. + == 2026.09.002 Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2206[#2206]: IDEasy title not properly shown on macOS +* https://github.com/devonfw/IDEasy/issues/377[#377]: Document Homebrew installation on MacOS +* https://github.com/devonfw/IDEasy/issues/2338[#2338]: Create first basic version of check commandlet +* https://github.com/devonfw/IDEasy/issues/2193[#2193]: Added support for conditional auto-completion +* https://github.com/devonfw/IDEasy/issues/2488[#2488]: Add compiler and SDK to MSVC installation +* https://github.com/devonfw/IDEasy/issues/2103[#2103]: Integrate Obsidian +* https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag. +* https://github.com/devonfw/IDEasy/issues/2007[#2007]: Support Docker on Arch Linux with pacman and yay * https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation * https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn * https://github.com/devonfw/IDEasy/issues/2142[#2142]: Move IDE-specific metadata (.idea, .vscode) out of workspace +* https://github.com/devonfw/IDEasy/issues/2366[#2366]: Support for variables and expressions in repository properties * https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions +* https://github.com/devonfw/IDEasy/issues/1808[#1808]: Clarify the selected workspace in `ide` and `icd` messages +* https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS +* https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs +* https://github.com/devonfw/IDEasy/issues/2271[#2271]: Fix `ide install pgadmin` on Linux +* https://github.com/devonfw/IDEasy/issues/2431[#2431]: Improve uv support to make it usable for IDE_ROOT on non-default drive The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002]. == 2026.09.001 +* https://github.com/devonfw/IDEasy/issues/2293[#2293]: Rework spyder to have same features as IdeToolCommandlets Release with new features and bugfixes: -* https://github.com/devonfw/IDEasy/issues/1628[#1628]: Support for OS-specific CVEs * https://github.com/devonfw/IDEasy/issues/1525[#1525]: Document known issue and workaround for lombok plugin in Eclipse * https://github.com/devonfw/IDEasy/issues/1031[#1031]: Added OpenRewrite commandlet * https://github.com/devonfw/IDEasy/issues/2361[#2361]: Improve dotnet installation by setting DOTNET_ROOT diff --git a/README.adoc b/README.adoc index 590a26f7..8cd44b52 100644 --- a/README.adoc +++ b/README.adoc @@ -98,3 +98,8 @@ See also our latest video https://vimeo.com/808368450/88d4af9d18[devon ide updat == License * link:./LICENSE[License] + +== Sponsors + +|=== +|image:https://signpath.org/assets/favicon-50x50.png["signpath.io",link=https://signpath.io/] |Free code signing on Windows provided by https://signpath.io/[SignPath.io], certificate by https://signpath.org/[SignPath Foundation] diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java index ab8d5220..2acdf015 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliAbortException.java @@ -3,7 +3,9 @@ import com.devonfw.tools.ide.process.ProcessResult; /** - * {@link CliException} that is thrown if the user aborted further processing due + * {@link CliException} that is thrown if the user aborted further processing (e.g. by answering a question with "no") or if a question cannot be asked because + * we are in batch mode without force mode. Since the end-user wants to stop, this exception {@link #isForceRethrowInStep() is always re-thrown} from a + * {@link com.devonfw.tools.ide.step.Step Step} so no further step is executed. */ public final class CliAbortException extends CliException { @@ -15,4 +17,10 @@ public CliAbortException() { super("Aborted by end-user.", ProcessResult.ABORT); } + @Override + public boolean isForceRethrowInStep() { + + return true; + } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java index 16cb0598..3a39a319 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java @@ -64,4 +64,15 @@ public int getExitCode() { return this.exitCode; } + /** + * @return {@code true} if this exception has to be re-thrown from a {@link com.devonfw.tools.ide.step.Step Step} even if that {@code Step} was not asked to + * re-throw errors, {@code false} otherwise (default). A regular error only makes the according {@code Step} fail while the overall process continues with + * the next step. However, if a critical guardrail was violated (e.g. no valid settings could be established) continuing makes no sense and the entire + * process has to be aborted. + */ + public boolean isForceRethrowInStep() { + + return false; + } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java new file mode 100644 index 00000000..11a153aa --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java @@ -0,0 +1,36 @@ +package com.devonfw.tools.ide.cli; + +/** + * {@link CliException} that aborts the entire CLI process when a critical guardrail fails (e.g. the settings repository could not be cloned or is not a valid + * settings repository). Unlike a regular error that only makes the current {@link com.devonfw.tools.ide.step.Step Step} fail while the overall process + * continues, this exception {@link #isForceRethrowInStep() is always re-thrown} so no further step is executed in an invalid state. + */ +public final class CliFatalException extends CliException { + + /** + * The constructor. + * + * @param message the {@link #getMessage() message}. + */ + public CliFatalException(String message) { + + super(message); + } + + /** + * The constructor. + * + * @param message the {@link #getMessage() message}. + * @param cause the {@link #getCause() cause}. + */ + public CliFatalException(String message, Throwable cause) { + + super(message, cause); + } + + @Override + public boolean isForceRethrowInStep() { + + return true; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java index 5a27631d..964e9888 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java @@ -7,7 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.property.EnumProperty; @@ -38,11 +38,11 @@ public CliSuggester(IdeContext context) { /** * Handles the case where a commandlet requires an IDEasy project context (IDE_ROOT/IDE_HOME) but the user is not inside one. * - * @param commandlet the {@link Commandlet} that the user tried to run. + * @param commandlet the {@link AbstractCommandlet} that the user tried to run. * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (message printed), {@code false} otherwise. */ - public boolean isMissingProjectContextHandled(Commandlet commandlet, StepImpl step) { + public boolean isMissingProjectContextHandled(AbstractCommandlet commandlet, StepImpl step) { if (commandlet == null) { return false; @@ -69,11 +69,11 @@ public boolean isMissingProjectContextHandled(Commandlet commandlet, StepImpl st * Handles invalid option errors and suggests corrections. * * @param result the {@link com.devonfw.tools.ide.validation.ValidationResult} from option parsing. - * @param commandlet the {@link Commandlet} that was being executed. + * @param commandlet the {@link AbstractCommandlet} that was being executed. * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean isInvalidOptionHandled(ValidationState result, Commandlet commandlet, StepImpl step) { + public boolean isInvalidOptionHandled(ValidationState result, AbstractCommandlet commandlet, StepImpl step) { if ((result == null) || (commandlet == null)) { return false; @@ -133,10 +133,10 @@ public boolean isMissingCommandletHandled(String commandKey, StepImpl step) { * Handles invalid argument value errors for properties and suggests corrections. * * @param result the {@link com.devonfw.tools.ide.validation.ValidationResult} from argument parsing. - * @param commandlet the {@link Commandlet} that was being executed. + * @param commandlet the {@link AbstractCommandlet} that was being executed. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean isInvalidArgumentHandled(ValidationState result, Commandlet commandlet) { + public boolean isInvalidArgumentHandled(ValidationState result, AbstractCommandlet commandlet) { if ((result == null) || (commandlet == null)) { return false; @@ -201,10 +201,10 @@ private List getValidValuesForProperty(Property property) { /** * Gets all option names for a commandlet. * - * @param cmd the {@link Commandlet}. + * @param cmd the {@link AbstractCommandlet}. * @return {@link List} of all option names and aliases. */ - private List getAllOptionNames(Commandlet cmd) { + private List getAllOptionNames(AbstractCommandlet cmd) { List opts = new ArrayList<>(); for (Property p : cmd.getProperties()) { @@ -230,7 +230,7 @@ private List getAllOptionNames(Commandlet cmd) { private List getAllCommandletNames() { List names = new ArrayList<>(); - for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : this.context.getCommandletManager().getCommandlets()) { names.add(cmd.getName()); } return names; @@ -244,7 +244,7 @@ private List getAllCommandletNames() { private List getAllToolNames() { List names = new ArrayList<>(); - for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : this.context.getCommandletManager().getCommandlets()) { if (cmd instanceof ToolCommandlet) { names.add(cmd.getName()); } @@ -311,4 +311,3 @@ private int levenshtein(String a, String b) { } } - diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java new file mode 100644 index 00000000..95770214 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractCommandlet.java @@ -0,0 +1,277 @@ +package com.devonfw.tools.ide.commandlet; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.devonfw.tools.ide.context.AbstractIdeContext; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.nls.NlsBundle; +import com.devonfw.tools.ide.property.KeywordProperty; +import com.devonfw.tools.ide.property.Property; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; +import com.devonfw.tools.ide.validation.ValidationResult; +import com.devonfw.tools.ide.validation.ValidationState; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * A {@link AbstractCommandlet} is a sub-command of the IDE CLI. + */ +public abstract class AbstractCommandlet implements Commandlet { + + /** The {@link IdeContext} instance. */ + protected final IdeContext context; + + private final List> propertiesList; + + private final List> properties; + + private final List> valuesList; + + private final List> values; + + private final Map> optionMap; + + private Property multiValued; + + private KeywordProperty firstKeyword; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public AbstractCommandlet(IdeContext context) { + + super(); + this.context = context; + this.propertiesList = new ArrayList<>(); + this.properties = Collections.unmodifiableList(this.propertiesList); + this.valuesList = new ArrayList<>(); + this.values = Collections.unmodifiableList(this.valuesList); + this.optionMap = new HashMap<>(); + } + + /** + * @return the {@link List} with all {@link Property properties} of this {@link AbstractCommandlet}. + */ + public List> getProperties() { + + return this.properties; + } + + /** + * @return the {@link List} of {@link Property properties} that are {@link Property#isValue() values}. + */ + public List> getValues() { + + return this.values; + } + + /** + * Clear the set values on all properties of the {@link AbstractCommandlet#propertiesList} + */ + public void reset() { + + for (Property property : this.propertiesList) { + property.clearValue(); + } + } + + /** + * @param nameOrAlias the potential {@link Property#getName() name} or {@link Property#getAlias() alias} of the requested {@link Property}. + * @return the requested {@link Property property} or {@code null} if not found. + */ + public Property getOption(String nameOrAlias) { + + return this.optionMap.get(nameOrAlias); + } + + /** + * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. + */ + protected void addKeyword(String keyword) { + + addKeyword(keyword, null); + } + + /** + * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. + * @param alias the optional {@link KeywordProperty#getAlias() alias}. + */ + protected void addKeyword(String keyword, String alias) { + + KeywordProperty property = new KeywordProperty(keyword, true, alias); + if (this.firstKeyword == null) { + if (!this.properties.isEmpty()) { + throw new IllegalStateException(property + " must be first property in " + getClass().getSimpleName()); + } + this.firstKeyword = property; + } + add(property); + } + + /** + * @param

type of the {@link Property}. + * @param property the {@link Property} to register. + * @return the given {@link Property}. + */ + protected

> P add(P property) { + + if (this.multiValued != null) { + throw new IllegalStateException("The multi-valued property " + this.multiValued + " can not be followed by " + property); + } + this.propertiesList.add(property); + if (property.isOption()) { + add(property.getName(), property, false); + add(property.getAlias(), property, true); + } + if (property.isValue()) { + this.valuesList.add(property); + } + if (property.isMultiValued()) { + this.multiValued = property; + } + return property; + } + + private void add(String name, Property property, boolean alias) { + + if (alias && (name == null)) { + return; + } + Objects.requireNonNull(name); + assert (name.equals(name.trim())); + if (name.isEmpty() && !alias) { + return; + } + Property duplicate = this.optionMap.put(name, property); + if (duplicate != null) { + throw new IllegalStateException("Duplicate name or alias " + name + " for " + property + " and " + duplicate); + } + } + + @Override + public IdeContext getContext() { + + return this.context; + } + + @Override + public abstract String getName(); + + /** + * @return the first keyword of this {@link AbstractCommandlet}. Typically, the same as {@link #getName() name} but may also differ (e.g. "set" vs. + * "set-version"). + */ + public KeywordProperty getFirstKeyword() { + + return this.firstKeyword; + } + + /** + * @param type of the {@link AbstractCommandlet}. + * @param commandletType the {@link Class} reflecting the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet}. + * @see CommandletManager#getCommandlet(Class) + */ + protected C getCommandlet(Class commandletType) { + + return this.context.getCommandletManager().getCommandlet(commandletType); + } + + /** + * @return {@code true} if {@link IdeContext#getIdeHome() IDE_HOME} is required for this commandlet, {@code false} otherwise. + */ + public boolean isIdeHomeRequired() { + + return isIdeRootRequired(); + } + + /** + * @return {@code true} if {@link IdeContext#getIdeRoot() IDE_ROOT} is required for this commandlet, {@code false} otherwise. + */ + public boolean isIdeRootRequired() { + + return true; + } + + /** + * @return {@code true} to suppress the {@link com.devonfw.tools.ide.step.StepImpl#logSummary(boolean) step summary success message}. + */ + public boolean isSuppressStepSuccess() { + + return false; + } + + /** + * @return {@code true} if the output of this commandlet is (potentially) processed automatically from outside, {@code false} otherwise. For example + * {@link CompleteCommandlet} logs the suggestions for auto-completion to a bash script. Also, the {@link EnvironmentCommandlet} logs the environment + * variables for the {@code ide} wrapper script. In such scenarios these logs shall not be spammed with warnings like "IDE_ROOT is not set" that would + * break the processing of the output. + */ + public boolean isProcessableOutput() { + + return false; + } + + /** + * @return {@code true} to write a logfile (unless disabled via {@link com.devonfw.tools.ide.variable.IdeVariables#IDE_WRITE_LOGFILE}), {@code false} + * otherwise. + */ + public boolean isWriteLogFile() { + return !isProcessableOutput(); + } + + /** + * Runs this {@link AbstractCommandlet}. + */ + public final void run() { + + if (this.context != null) { // for ContextCommandlet we do not have a context yet + ((AbstractIdeContext) this.context).configureJavaUtilLogging(this); + } + doRun(); + } + + protected abstract void doRun(); + + /** + * @return {@code true} if this {@link AbstractCommandlet} is the valid candidate to be {@link #run()}, {@code false} otherwise. + * @see Property#validate() + */ + public ValidationResult validate() { + ValidationState state = new ValidationState(null); + // avoid validation exception if not a candidate to be run. + for (Property property : this.propertiesList) { + state.add(property.validate()); + } + return state; + } + + /** + * Provide additional usage help of this {@link AbstractCommandlet} to the user. + * + * @param bundle the {@link NlsBundle} to get I18N messages from. + */ + public void printHelp(NlsBundle bundle) { + + } + + @Override + public String toString() { + + return getClass().getSimpleName() + "[" + getName() + "]"; + } + + /** + * @return the {@link AbstractToolCommandlet} set in a {@link Property} of this commandlet used for auto-completion of a {@link VersionIdentifier} or + * {@link com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor}, otherwise {@code null} if not exists or not configured. + */ + public AbstractToolCommandlet getToolForCompletion() { + return null; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java index b387e884..8dc91b6a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractVersionOrEditionGetCommandlet.java @@ -10,14 +10,14 @@ import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to get the installed version for a tool. + * An internal {@link AbstractCommandlet} to get the installed version for a tool. * - * @see ToolCommandlet#getInstalledVersion() + * @see AbstractToolCommandlet#getInstalledVersion() */ -public abstract class AbstractVersionOrEditionGetCommandlet extends Commandlet { +public abstract class AbstractVersionOrEditionGetCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(AbstractVersionOrEditionGetCommandlet.class); @@ -56,25 +56,25 @@ public boolean isProcessableOutput() { protected abstract String getPropertyToGet(); /** - * @param commandlet the {@link ToolCommandlet} to get the value from. + * @param commandlet the {@link AbstractToolCommandlet} to get the value from. * @return the configured value. - * @see ToolCommandlet#getConfiguredVersion() - * @see ToolCommandlet#getConfiguredEdition() + * @see AbstractToolCommandlet#getConfiguredVersion() + * @see AbstractToolCommandlet#getConfiguredEdition() */ - protected abstract Object getConfiguredValue(ToolCommandlet commandlet); + protected abstract Object getConfiguredValue(AbstractToolCommandlet commandlet); /** - * @param commandlet the {@link ToolCommandlet} to get the value from. + * @param commandlet the {@link AbstractToolCommandlet} to get the value from. * @return the installed value or {@code null} if the tool is not installed. - * @see ToolCommandlet#getInstalledVersion() - * @see ToolCommandlet#getInstalledEdition() + * @see AbstractToolCommandlet#getInstalledVersion() + * @see AbstractToolCommandlet#getInstalledEdition() */ - protected abstract Object getInstalledValue(ToolCommandlet commandlet); + protected abstract Object getInstalledValue(AbstractToolCommandlet commandlet); @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); IdeLogLevel level = IdeLogLevel.PROCESSABLE; Object configuredValue = getConfiguredValue(commandlet); Object installedValue = getInstalledValue(commandlet); @@ -108,7 +108,7 @@ protected void doRun() { } } - private void logToolInfo(ToolCommandlet commandlet, Object configuredValue, Object installedValue) { + private void logToolInfo(AbstractToolCommandlet commandlet, Object configuredValue, Object installedValue) { String property = getPropertyToGet(); String toolName = commandlet.getName(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java index f47cf8f0..ae57cbc2 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/BuildCommandlet.java @@ -7,12 +7,12 @@ import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.StringProperty; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * Build tool {@link Commandlet} for automatically detecting build configuration files and running the respective tool. + * Build tool {@link AbstractCommandlet} for automatically detecting build configuration files and running the respective tool. */ -public class BuildCommandlet extends Commandlet { +public class BuildCommandlet extends AbstractCommandlet { /** The explicit build options to use (if empty use defaults). */ public final StringProperty arguments; @@ -44,7 +44,7 @@ protected void doRun() { throw new CliException("Missing current working directory!"); } - LocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(buildPath); + AbstractLocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(buildPath); if (commandlet == null) { throw new CliException("Could not find a build descriptor in " + buildPath + " - no supported build tool detected."); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java index 3534e372..02c37d51 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/Commandlet.java @@ -1,272 +1,24 @@ package com.devonfw.tools.ide.commandlet; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.nls.NlsBundle; -import com.devonfw.tools.ide.property.KeywordProperty; -import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.validation.ValidationResult; -import com.devonfw.tools.ide.validation.ValidationState; -import com.devonfw.tools.ide.version.VersionIdentifier; /** - * A {@link Commandlet} is a sub-command of the IDE CLI. + * A {@link Commandlet} is a sub-command interface of the IDE CLI. */ -public abstract class Commandlet { - - /** The {@link IdeContext} instance. */ - protected final IdeContext context; - - private final List> propertiesList; - - private final List> properties; - - private final List> valuesList; - - private final List> values; - - private final Map> optionMap; - - private Property multiValued; - - private KeywordProperty firstKeyword; - - /** - * The constructor. - * - * @param context the {@link IdeContext}. - */ - public Commandlet(IdeContext context) { - - super(); - this.context = context; - this.propertiesList = new ArrayList<>(); - this.properties = Collections.unmodifiableList(this.propertiesList); - this.valuesList = new ArrayList<>(); - this.values = Collections.unmodifiableList(this.valuesList); - this.optionMap = new HashMap<>(); - } - - /** - * @return the {@link List} with all {@link Property properties} of this {@link Commandlet}. - */ - public List> getProperties() { - - return this.properties; - } - - /** - * @return the {@link List} of {@link Property properties} that are {@link Property#isValue() values}. - */ - public List> getValues() { - - return this.values; - } - - /** - * Clear the set values on all properties of the {@link Commandlet#propertiesList} - */ - public void reset() { - - for (Property property : this.propertiesList) { - property.clearValue(); - } - } - - /** - * @param nameOrAlias the potential {@link Property#getName() name} or {@link Property#getAlias() alias} of the requested {@link Property}. - * @return the requested {@link Property property} or {@code null} if not found. - */ - public Property getOption(String nameOrAlias) { - - return this.optionMap.get(nameOrAlias); - } - - /** - * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. - */ - protected void addKeyword(String keyword) { - - addKeyword(keyword, null); - } - - /** - * @param keyword the {@link KeywordProperty keyword} to {@link #add(Property) add}. - * @param alias the optional {@link KeywordProperty#getAlias() alias}. - */ - protected void addKeyword(String keyword, String alias) { - - KeywordProperty property = new KeywordProperty(keyword, true, alias); - if (this.firstKeyword == null) { - if (!this.properties.isEmpty()) { - throw new IllegalStateException(property + " must be first property in " + getClass().getSimpleName()); - } - this.firstKeyword = property; - } - add(property); - } - - /** - * @param

type of the {@link Property}. - * @param property the {@link Property} to register. - * @return the given {@link Property}. - */ - protected

> P add(P property) { - - if (this.multiValued != null) { - throw new IllegalStateException("The multi-valued property " + this.multiValued + " can not be followed by " + property); - } - this.propertiesList.add(property); - if (property.isOption()) { - add(property.getName(), property, false); - add(property.getAlias(), property, true); - } - if (property.isValue()) { - this.valuesList.add(property); - } - if (property.isMultiValued()) { - this.multiValued = property; - } - return property; - } - - private void add(String name, Property property, boolean alias) { - - if (alias && (name == null)) { - return; - } - Objects.requireNonNull(name); - assert (name.equals(name.trim())); - if (name.isEmpty() && !alias) { - return; - } - Property duplicate = this.optionMap.put(name, property); - if (duplicate != null) { - throw new IllegalStateException("Duplicate name or alias " + name + " for " + property + " and " + duplicate); - } - } +public interface Commandlet { /** - * @return the name of this {@link Commandlet} (e.g. "help"). + * @return the name of this {@link AbstractCommandlet} (e.g. "help"). */ - public abstract String getName(); + String getName(); /** - * @return the first keyword of this {@link Commandlet}. Typically, the same as {@link #getName() name} but may also differ (e.g. "set" vs. "set-version"). + * @return the {@link IdeContext} of this {@link Commandlet} or {@code null} for commandlets that are executed before the {@link IdeContext} has been + * initialized (e.g. {@code ContextCommandlet}). */ - public KeywordProperty getFirstKeyword() { + default IdeContext getContext() { - return this.firstKeyword; - } - - /** - * @param type of the {@link Commandlet}. - * @param commandletType the {@link Class} reflecting the requested {@link Commandlet}. - * @return the requested {@link Commandlet}. - * @see CommandletManager#getCommandlet(Class) - */ - protected C getCommandlet(Class commandletType) { - - return this.context.getCommandletManager().getCommandlet(commandletType); - } - - /** - * @return {@code true} if {@link IdeContext#getIdeHome() IDE_HOME} is required for this commandlet, {@code false} otherwise. - */ - public boolean isIdeHomeRequired() { - - return isIdeRootRequired(); - } - - /** - * @return {@code true} if {@link IdeContext#getIdeRoot() IDE_ROOT} is required for this commandlet, {@code false} otherwise. - */ - public boolean isIdeRootRequired() { - - return true; - } - - /** - * @return {@code true} to suppress the {@link com.devonfw.tools.ide.step.StepImpl#logSummary(boolean) step summary success message}. - */ - public boolean isSuppressStepSuccess() { - - return false; - } - - /** - * @return {@code true} if the output of this commandlet is (potentially) processed automatically from outside, {@code false} otherwise. For example - * {@link CompleteCommandlet} logs the suggestions for auto-completion to a bash script. Also, the {@link EnvironmentCommandlet} logs the environment - * variables for the {@code ide} wrapper script. In such scenarios these logs shall not be spammed with warnings like "IDE_ROOT is not set" that would - * break the processing of the output. - */ - public boolean isProcessableOutput() { - - return false; - } - - /** - * @return {@code true} to write a logfile (unless disabled via {@link com.devonfw.tools.ide.variable.IdeVariables#IDE_WRITE_LOGFILE}), {@code false} - * otherwise. - */ - public boolean isWriteLogFile() { - return !isProcessableOutput(); - } - - /** - * Runs this {@link Commandlet}. - */ - public final void run() { - - if (this.context != null) { // for ContextCommandlet we do not have a context yet - ((AbstractIdeContext) this.context).configureJavaUtilLogging(this); - } - doRun(); - } - - protected abstract void doRun(); - - /** - * @return {@code true} if this {@link Commandlet} is the valid candidate to be {@link #run()}, {@code false} otherwise. - * @see Property#validate() - */ - public ValidationResult validate() { - ValidationState state = new ValidationState(null); - // avoid validation exception if not a candidate to be run. - for (Property property : this.propertiesList) { - state.add(property.validate()); - } - return state; - } - - /** - * Provide additional usage help of this {@link Commandlet} to the user. - * - * @param bundle the {@link NlsBundle} to get I18N messages from. - */ - public void printHelp(NlsBundle bundle) { - - } - - @Override - public String toString() { - - return getClass().getSimpleName() + "[" + getName() + "]"; - } - - /** - * @return the {@link ToolCommandlet} set in a {@link Property} of this commandlet used for auto-completion of a {@link VersionIdentifier} or - * {@link com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor}, otherwise {@code null} if not exists or not configured. - */ - public ToolCommandlet getToolForCompletion() { return null; } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java index dd6cc327..0b62ae29 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManager.java @@ -8,47 +8,47 @@ import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.property.KeywordProperty; import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * Interface to {@link #getCommandlet(Class) get} a {@link Commandlet} instance that is properly initialized. + * Interface to {@link #getCommandlet(Class) get} a {@link AbstractCommandlet} instance that is properly initialized. */ public interface CommandletManager { /** - * @param type of the {@link Commandlet}. - * @param commandletType the {@link Class} reflecting the requested {@link Commandlet}. - * @return the requested {@link Commandlet}. + * @param type of the {@link AbstractCommandlet}. + * @param commandletType the {@link Class} reflecting the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet}. */ - C getCommandlet(Class commandletType); + C getCommandlet(Class commandletType); /** - * @param name the {@link Commandlet#getName() name} of the requested {@link Commandlet}. - * @return the requested {@link Commandlet} or {@code null} if not found. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet} or {@code null} if not found. */ - Commandlet getCommandlet(String name); + AbstractCommandlet getCommandlet(String name); /** * @param keyword the first keyword argument. - * @return a {@link Commandlet} having the first {@link Property} {@link Property#isRequired() required} and a {@link KeywordProperty} with the given - * {@link Property#getName() name} or {@code null} if no such {@link Commandlet} is registered. + * @return a {@link AbstractCommandlet} having the first {@link Property} {@link Property#isRequired() required} and a {@link KeywordProperty} with the given + * {@link Property#getName() name} or {@code null} if no such {@link AbstractCommandlet} is registered. */ - Commandlet getCommandletByFirstKeyword(String keyword); + AbstractCommandlet getCommandletByFirstKeyword(String keyword); /** - * @return the {@link Collection} of all registered {@link Commandlet}s. + * @return the {@link Collection} of all registered {@link AbstractCommandlet}s. */ - Collection getCommandlets(); + Collection getCommandlets(); /** - * @param name the {@link Commandlet#getName() name} of the requested {@link Commandlet}. - * @return the requested {@link Commandlet}. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractCommandlet}. + * @return the requested {@link AbstractCommandlet}. * @throws IllegalArgumentException if not found. */ - default Commandlet getRequiredCommandlet(String name) { + default AbstractCommandlet getRequiredCommandlet(String name) { - Commandlet commandlet = getCommandlet(name); + AbstractCommandlet commandlet = getCommandlet(name); if (commandlet == null) { throw new IllegalArgumentException("The commandlet " + name + " could not be found!"); } @@ -56,41 +56,41 @@ default Commandlet getRequiredCommandlet(String name) { } /** - * @param name the {@link Commandlet#getName() name} of the requested {@link ToolCommandlet}. - * @return the requested {@link ToolCommandlet} or {@code null} if not found. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractToolCommandlet}. + * @return the requested {@link AbstractToolCommandlet} or {@code null} if not found. */ - default ToolCommandlet getToolCommandlet(String name) { + default AbstractToolCommandlet getToolCommandlet(String name) { - Commandlet commandlet = getCommandlet(name); - if (commandlet instanceof ToolCommandlet tc) { + AbstractCommandlet commandlet = getCommandlet(name); + if (commandlet instanceof AbstractToolCommandlet tc) { return tc; } return null; } /** - * @param name the {@link Commandlet#getName() name} of the requested {@link ToolCommandlet}. - * @return the requested {@link ToolCommandlet}. - * @throws IllegalArgumentException if no {@link ToolCommandlet} exists with the given {@code name}. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractToolCommandlet}. + * @return the requested {@link AbstractToolCommandlet}. + * @throws IllegalArgumentException if no {@link AbstractToolCommandlet} exists with the given {@code name}. */ - default ToolCommandlet getRequiredToolCommandlet(String name) { + default AbstractToolCommandlet getRequiredToolCommandlet(String name) { - Commandlet commandlet = getRequiredCommandlet(name); - if (commandlet instanceof ToolCommandlet tc) { + AbstractCommandlet commandlet = getRequiredCommandlet(name); + if (commandlet instanceof AbstractToolCommandlet tc) { return tc; } throw new IllegalArgumentException("The commandlet " + name + " is not a ToolCommandlet!"); } /** - * @param name the {@link Commandlet#getName() name} of the requested {@link LocalToolCommandlet}. - * @return the requested {@link LocalToolCommandlet}. - * @throws IllegalArgumentException if no {@link LocalToolCommandlet} exists with the given {@code name}. + * @param name the {@link AbstractCommandlet#getName() name} of the requested {@link AbstractLocalToolCommandlet}. + * @return the requested {@link AbstractLocalToolCommandlet}. + * @throws IllegalArgumentException if no {@link AbstractLocalToolCommandlet} exists with the given {@code name}. */ - default LocalToolCommandlet getRequiredLocalToolCommandlet(String name) { + default AbstractLocalToolCommandlet getRequiredLocalToolCommandlet(String name) { - Commandlet commandlet = getRequiredCommandlet(name); - if (commandlet instanceof LocalToolCommandlet ltc) { + AbstractCommandlet commandlet = getRequiredCommandlet(name); + if (commandlet instanceof AbstractLocalToolCommandlet ltc) { return ltc; } throw new IllegalArgumentException("The commandlet " + name + " is not a LocalToolCommandlet!"); @@ -100,20 +100,20 @@ default LocalToolCommandlet getRequiredLocalToolCommandlet(String name) { * @param arguments the {@link CliArguments}. * @param collector the optional {@link CompletionCandidateCollector}. Will be {@code null} if no argument {@link CliArguments#isCompletion() completion} * shall be performed. - * @return an {@link Iterator} of the matching {@link Commandlet}(s). Typically empty or containing a single {@link Commandlet}. Only in edge-cases multiple - * {@link Commandlet}s could be found (e.g. if two {@link Commandlet}s exist with the same keyword but with different mandatory properties such as in our - * legacy devonfw-ide "ide get version ..." and "ide get edition ..." - however, we redesigned our CLI to "ide get-version ..." and "ide get-edition ..." - * to simplify this). + * @return an {@link Iterator} of the matching {@link AbstractCommandlet}(s). Typically empty or containing a single {@link AbstractCommandlet}. Only in + * edge-cases multiple {@link AbstractCommandlet}s could be found (e.g. if two {@link AbstractCommandlet}s exist with the same keyword but with different + * mandatory properties such as in our legacy devonfw-ide "ide get version ..." and "ide get edition ..." - however, we redesigned our CLI to "ide + * get-version ..." and "ide get-edition ..." to simplify this). */ - Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector); + Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector); /** - * Detects the applicable build tool for the given {@code buildPath} by {@link LocalToolCommandlet#findBuildDescriptor(Path) querying} the registered build - * commandlets (in order of priority) for a matching build descriptor (e.g. {@code pom.xml}, {@code build.gradle} or {@code package.json}). + * Detects the applicable build tool for the given {@code buildPath} by {@link AbstractLocalToolCommandlet#findBuildDescriptor(Path) querying} the registered + * build commandlets (in order of priority) for a matching build descriptor (e.g. {@code pom.xml}, {@code build.gradle} or {@code package.json}). * * @param buildPath the {@link Path} to the directory to build. - * @return the applicable build {@link LocalToolCommandlet} or {@code null} if no build descriptor was found or {@code buildPath} was {@code null}. + * @return the applicable build {@link AbstractLocalToolCommandlet} or {@code null} if no build descriptor was found or {@code buildPath} was {@code null}. */ - LocalToolCommandlet findBuildTool(Path buildPath); + AbstractLocalToolCommandlet findBuildTool(Path buildPath); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index c42d462b..a3c308c6 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -14,13 +14,15 @@ import com.devonfw.tools.ide.cli.CliArgument; import com.devonfw.tools.ide.cli.CliArguments; +import com.devonfw.tools.ide.commandlet.check.CheckCommandlet; import com.devonfw.tools.ide.commandlet.cleanup.CleanupCommandlet; +import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; import com.devonfw.tools.ide.property.KeywordProperty; import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.androidstudio.AndroidStudio; import com.devonfw.tools.ide.tool.aws.Aws; import com.devonfw.tools.ide.tool.az.Azure; @@ -56,6 +58,7 @@ import com.devonfw.tools.ide.tool.ng.Ng; import com.devonfw.tools.ide.tool.node.Node; import com.devonfw.tools.ide.tool.npm.Npm; +import com.devonfw.tools.ide.tool.obsidian.Obsidian; import com.devonfw.tools.ide.tool.oc.Oc; import com.devonfw.tools.ide.tool.pgadmin.PgAdmin; import com.devonfw.tools.ide.tool.pip.Pip; @@ -85,17 +88,17 @@ public class CommandletManagerImpl implements CommandletManager { private static final Logger LOG = LoggerFactory.getLogger(CommandletManagerImpl.class); /** The build commandlets in order of priority - the first one with a matching build descriptor wins. */ - private static final List> BUILD_TOOLS = List.of(Mvn.class, Gradle.class, Yarn.class, Npm.class); + private static final List> BUILD_TOOLS = List.of(Mvn.class, Gradle.class, Yarn.class, Npm.class); private final IdeContext context; - private final Map, Commandlet> commandletTypeMap; + private final Map, AbstractCommandlet> commandletTypeMap; - private final Map commandletNameMap; + private final Map commandletNameMap; - private final Map firstKeywordMap; + private final Map firstKeywordMap; - private final Collection commandlets; + private final Collection commandlets; /** * The constructor. @@ -193,12 +196,14 @@ public CommandletManagerImpl(IdeContext context) { add(new Just(context)); add(new SoapUi(context)); add(new Ruff(context)); + add(new CheckCommandlet(context)); + add(new Obsidian(context)); } /** - * @param commandlet the {@link Commandlet} to add. + * @param commandlet the {@link AbstractCommandlet} to add. */ - protected void add(Commandlet commandlet) { + protected void add(AbstractCommandlet commandlet) { boolean hasRequiredProperty = false; List> properties = commandlet.getProperties(); @@ -227,30 +232,30 @@ protected void add(Commandlet commandlet) { throw new IllegalStateException("Commandlet " + commandlet + " must have at least one mandatory property!"); } this.commandletTypeMap.put(commandlet.getClass(), commandlet); - Commandlet duplicate = this.commandletNameMap.put(commandlet.getName(), commandlet); + AbstractCommandlet duplicate = this.commandletNameMap.put(commandlet.getName(), commandlet); if (duplicate != null) { throw new IllegalStateException("Commandlet " + commandlet + " has the same name as " + duplicate); } } - private void registerKeyword(String keyword, Commandlet commandlet) { + private void registerKeyword(String keyword, AbstractCommandlet commandlet) { - Commandlet duplicate = this.firstKeywordMap.putIfAbsent(keyword, commandlet); + AbstractCommandlet duplicate = this.firstKeywordMap.putIfAbsent(keyword, commandlet); if (duplicate != null) { LOG.debug("Duplicate keyword {} already used by {} so it cannot be associated also with {}", keyword, duplicate, commandlet); } } @Override - public Collection getCommandlets() { + public Collection getCommandlets() { return this.commandlets; } @Override - public C getCommandlet(Class commandletType) { + public C getCommandlet(Class commandletType) { - Commandlet commandlet = this.commandletTypeMap.get(commandletType); + AbstractCommandlet commandlet = this.commandletTypeMap.get(commandletType); if (commandlet == null) { throw new IllegalStateException("Commandlet for type " + commandletType + " is not registered!"); } @@ -258,26 +263,26 @@ public C getCommandlet(Class commandletType) { } @Override - public Commandlet getCommandlet(String name) { + public AbstractCommandlet getCommandlet(String name) { return this.commandletNameMap.get(name); } @Override - public Commandlet getCommandletByFirstKeyword(String keyword) { + public AbstractCommandlet getCommandletByFirstKeyword(String keyword) { return this.firstKeywordMap.get(keyword); } @Override - public Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector) { + public Iterator findCommandlet(CliArguments arguments, CompletionCandidateCollector collector) { CliArgument current = arguments.current(); if (current.isEnd()) { return Collections.emptyIterator(); } String keyword = current.get(); - Commandlet commandlet = getCommandletByFirstKeyword(keyword); + AbstractCommandlet commandlet = getCommandletByFirstKeyword(keyword); if ((commandlet == null) && (collector == null)) { return Collections.emptyIterator(); } @@ -285,13 +290,13 @@ public Iterator findCommandlet(CliArguments arguments, CompletionCan } @Override - public LocalToolCommandlet findBuildTool(Path buildPath) { + public AbstractLocalToolCommandlet findBuildTool(Path buildPath) { if (buildPath == null) { return null; } - for (Class toolClass : BUILD_TOOLS) { - LocalToolCommandlet toolCommandlet = getCommandlet(toolClass); + for (Class toolClass : BUILD_TOOLS) { + AbstractLocalToolCommandlet toolCommandlet = getCommandlet(toolClass); if (toolCommandlet.findBuildDescriptor(buildPath) != null) { return toolCommandlet; } @@ -299,19 +304,19 @@ public LocalToolCommandlet findBuildTool(Path buildPath) { return null; } - private final class CommandletFinder implements Iterator { + private final class CommandletFinder implements Iterator { - private final Commandlet firstCandidate; + private final AbstractCommandlet firstCandidate; - private final Iterator commandletIterator; + private final Iterator commandletIterator; private final CliArguments arguments; private final CompletionCandidateCollector collector; - private Commandlet next; + private AbstractCommandlet next; - private CommandletFinder(Commandlet firstCandidate, CliArguments arguments, CompletionCandidateCollector collector) { + private CommandletFinder(AbstractCommandlet firstCandidate, CliArguments arguments, CompletionCandidateCollector collector) { this.firstCandidate = firstCandidate; this.commandletIterator = getCommandlets().iterator(); @@ -331,25 +336,25 @@ public boolean hasNext() { } @Override - public Commandlet next() { + public AbstractCommandlet next() { if (this.next == null) { throw new NoSuchElementException(); } - Commandlet result = this.next; + AbstractCommandlet result = this.next; this.next = findNext(); return result; } - private boolean isSuitable(Commandlet commandlet) { + private boolean isSuitable(AbstractCommandlet commandlet) { return (commandlet != null) && (!commandlet.isIdeHomeRequired() || (context.getIdeHome() != null)); } - private Commandlet findNext() { + private AbstractCommandlet findNext() { while (this.commandletIterator.hasNext()) { - Commandlet cmd = this.commandletIterator.next(); + AbstractCommandlet cmd = this.commandletIterator.next(); if ((cmd != this.firstCandidate) && isSuitable(cmd)) { List> properties = cmd.getProperties(); // validation should already be done in add method and could be removed here... diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java index 1655c272..bebd6023 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CompleteCommandlet.java @@ -13,9 +13,9 @@ import com.devonfw.tools.ide.property.StringProperty; /** - * {@link Commandlet} for auto-completion. + * {@link AbstractCommandlet} for auto-completion. */ -public final class CompleteCommandlet extends Commandlet { +public final class CompleteCommandlet extends AbstractCommandlet { /** The name of the complete commandlet. */ public static final String NAME = "complete"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java index da9903c3..95328434 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ContextCommandlet.java @@ -11,7 +11,7 @@ /** * An internal pseudo-commandlet to create the {@link IdeContext}. It shall not be registered in {@link CommandletManager}. */ -public class ContextCommandlet extends Commandlet { +public class ContextCommandlet extends AbstractCommandlet { private final FlagProperty batch; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java index a68d6768..189b6b5f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java @@ -7,15 +7,15 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.StringProperty; import com.devonfw.tools.ide.version.IdeVersion; /** - * {@link Commandlet} to create a new IDEasy instance + * {@link AbstractCommandlet} to create a new IDEasy instance */ public class CreateCommandlet extends AbstractUpdateCommandlet { @@ -24,9 +24,6 @@ public class CreateCommandlet extends AbstractUpdateCommandlet { /** {@link StringProperty} for the name of the new project */ public final StringProperty newProject; - /** {@link FlagProperty} for creating a project with settings inside a code repository */ - public final FlagProperty codeRepositoryFlag; - /** * The constructor. * @@ -36,7 +33,6 @@ public CreateCommandlet(IdeContext context) { super(context); this.newProject = add(new StringProperty("", true, "project")); - this.codeRepositoryFlag = add(new FlagProperty("--code")); add(this.settingsRepo); } @@ -55,42 +51,51 @@ public boolean isIdeHomeRequired() { @Override protected void doRun() { - String newProjectName = this.newProject.getValue(); - Path newProjectPath = this.context.getIdeRoot().resolve(newProjectName); - + Path newProjectPath = getNewProjectPath(); LOG.info("Creating new IDEasy project in {}", newProjectPath); - if (!this.context.getFileAccess().isEmptyDir(newProjectPath)) { - this.context.askToContinue("Directory {} already exists. Do you want to continue?", newProjectPath); - } else { - this.context.getFileAccess().mkdirs(newProjectPath); + FileAccess fileAccess = this.context.getFileAccess(); + if (!fileAccess.isEmptyDir(newProjectPath)) { + this.context.askToContinue("Directory {} already exists. Do you want to move the project to the backup and create it from scratch?", newProjectPath); + fileAccess.backup(newProjectPath); } - initializeProject(newProjectPath); - this.context.setIdeHome(newProjectPath); super.doRun(); this.context.getFileAccess().writeFileContent(IdeVersion.getVersionString(), newProjectPath.resolve(IdeContext.FILE_SOFTWARE_VERSION)); - IdeLogLevel.SUCCESS.log(LOG, "Successfully created new project '{}'.", newProjectName); - + IdeLogLevel.SUCCESS.log(LOG, "Successfully created new project '{}'.", this.newProject.getValue()); logWelcomeMessage(); } - private void initializeProject(Path newInstancePath) { + @Override + protected void onSettingHealthCheckFinished() { + // only called after the settings passed the health check + Path newProjectPath = getNewProjectPath(); + this.context.setIdeHome(newProjectPath); FileAccess fileAccess = this.context.getFileAccess(); - fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_SOFTWARE)); - fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_PLUGINS)); - fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)); + fileAccess.mkdirs(newProjectPath); + fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)); + fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)); + fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)); } + /** + * @return The path to the settings folder in the new project. + */ @Override - protected boolean isCodeRepository() { - return this.codeRepositoryFlag.isTrue(); + protected Path getSettingsPathForSettingsUpdate() { + + return getNewProjectPath().resolve(IdeContext.FOLDER_SETTINGS); + } + + private Path getNewProjectPath() { + + return this.context.getIdeRoot().resolve(this.newProject.getValue()); } @Override protected String getStepMessage() { - return "Create (clone) " + (isCodeRepository() ? "code" : "settings") + " repository"; + return "Create (clone) repository"; } private void logWelcomeMessage() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java index 981e6f66..c1b316c3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionGetCommandlet.java @@ -1,12 +1,12 @@ package com.devonfw.tools.ide.commandlet; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to get the installed edition for a tool. + * An internal {@link AbstractCommandlet} to get the installed edition for a tool. * - * @see ToolCommandlet#getInstalledEdition() + * @see AbstractToolCommandlet#getInstalledEdition() */ public class EditionGetCommandlet extends AbstractVersionOrEditionGetCommandlet { @@ -33,13 +33,13 @@ protected String getPropertyToGet() { } @Override - protected Object getConfiguredValue(ToolCommandlet commandlet) { + protected Object getConfiguredValue(AbstractToolCommandlet commandlet) { return commandlet.getConfiguredEdition(); } @Override - protected Object getInstalledValue(ToolCommandlet commandlet) { + protected Object getInstalledValue(AbstractToolCommandlet commandlet) { return commandlet.getInstalledEdition(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java index 43853171..49eb0ec7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionListCommandlet.java @@ -2,14 +2,14 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to list editions for a tool. + * An internal {@link AbstractCommandlet} to list editions for a tool. * - * @see ToolCommandlet#listEditions() + * @see AbstractToolCommandlet#listEditions() */ -public class EditionListCommandlet extends Commandlet { +public class EditionListCommandlet extends AbstractCommandlet { /** The tool to list the editions of. */ public final ToolProperty tool; @@ -41,7 +41,7 @@ public boolean isWriteLogFile() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); commandlet.listEditions(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java index 08cbfdea..1ce50245 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EditionSetCommandlet.java @@ -5,12 +5,12 @@ import com.devonfw.tools.ide.property.EditionProperty; import com.devonfw.tools.ide.property.EnumProperty; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to set a tool edition. + * An internal {@link AbstractCommandlet} to set a tool edition. */ -public class EditionSetCommandlet extends Commandlet { +public class EditionSetCommandlet extends AbstractCommandlet { /** The tool to set the edition of. */ public final ToolProperty tool; @@ -43,7 +43,7 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); String edition = this.edition.getValue(); EnvironmentVariablesFiles env = this.cfg.getValue(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java index e4afe06b..ab31d4bb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandlet.java @@ -18,12 +18,12 @@ import com.devonfw.tools.ide.os.WindowsPathSyntax; import com.devonfw.tools.ide.process.EnvironmentVariableCollectorContext; import com.devonfw.tools.ide.property.FlagProperty; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link Commandlet} to print the environment variables. + * {@link AbstractCommandlet} to print the environment variables. */ -public final class EnvironmentCommandlet extends Commandlet { +public final class EnvironmentCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(EnvironmentCommandlet.class); @@ -144,8 +144,8 @@ private String format(VariableLine line, boolean winCmd) { */ private void printBashCompletions() { - for (Commandlet commandlet : this.context.getCommandletManager().getCommandlets()) { - if (commandlet instanceof LocalToolCommandlet tool) { + for (AbstractCommandlet commandlet : this.context.getCommandletManager().getCommandlets()) { + if (commandlet instanceof AbstractLocalToolCommandlet tool) { try { if (tool.isInstalled()) { String bashCompletion = tool.getBashCompletion(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java index 1ffd249f..66a8a71e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/HelpCommandlet.java @@ -18,9 +18,9 @@ import com.devonfw.tools.ide.version.IdeVersion; /** - * {@link Commandlet} to print the environment variables. + * {@link AbstractCommandlet} to print the environment variables. */ -public final class HelpCommandlet extends Commandlet { +public final class HelpCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(HelpCommandlet.class); @@ -63,7 +63,7 @@ protected void doRun() { this.context.printLogo(); NlsBundle bundle = NlsBundle.of(this.context); IdeLogLevel.SUCCESS.log(LOG, bundle.get("version-banner"), IdeVersion.getVersionString()); - Commandlet cmd = this.commandlet.getValue(); + AbstractCommandlet cmd = this.commandlet.getValue(); printCommandletHelp(bundle, cmd); if (cmd == null) { LOG.info(""); @@ -74,7 +74,7 @@ protected void doRun() { LOG.info(bundle.get("icd-hint")); } - private void printCommandletHelp(NlsBundle bundle, Commandlet cmd) { + private void printCommandletHelp(NlsBundle bundle, AbstractCommandlet cmd) { Args values = null; Args options = null; @@ -154,7 +154,7 @@ private void printCommandlets(NlsBundle bundle) { Args commandlets = new Args(); Args toolcommandlets = new Args(); - for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : this.context.getCommandletManager().getCommandlets()) { String key = cmd.getName(); KeywordProperty keyword = cmd.getFirstKeyword(); if (keyword != null) { @@ -177,7 +177,7 @@ private void printCommandlets(NlsBundle bundle) { toolcommandlets.print(IdeLogLevel.INTERACTION); } - private void collectOptions(Args options, Commandlet cmd, NlsBundle bundle) { + private void collectOptions(Args options, AbstractCommandlet cmd, NlsBundle bundle) { for (Property property : cmd.getProperties()) { if (property.isOption() && !property.isRequired()) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java index 57ba14ba..568cc114 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallCommandlet.java @@ -9,19 +9,19 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.property.VersionProperty; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.IdeasyCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to install a tool. + * {@link AbstractCommandlet} to install a tool. * - * @see ToolCommandlet#install() + * @see AbstractToolCommandlet#install() */ -public class InstallCommandlet extends Commandlet { +public class InstallCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(InstallCommandlet.class); @@ -78,7 +78,7 @@ protected void doRun() { ideasy.setupWindowsTerminal(); return; } - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); VersionIdentifier versionIdentifier = this.version.getValue(); VersionIdentifier version = versionIdentifier; if (version == null) { @@ -97,7 +97,7 @@ protected void doRun() { } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java index ac4b4662..616c300c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/InstallPluginCommandlet.java @@ -7,15 +7,15 @@ import com.devonfw.tools.ide.property.PluginProperty; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; /** - * {@link Commandlet} to install a tool. + * {@link AbstractCommandlet} to install a tool. * - * @see ToolCommandlet#install() + * @see AbstractToolCommandlet#install() */ -public class InstallPluginCommandlet extends Commandlet { +public class InstallPluginCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(InstallPluginCommandlet.class); @@ -46,7 +46,7 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); String plugin = this.plugin.getValue(); if (commandlet instanceof PluginBasedCommandlet cmd) { @@ -59,7 +59,7 @@ protected void doRun() { } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java index dd400be2..b78be129 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java @@ -13,12 +13,12 @@ import com.devonfw.tools.ide.property.PathProperty; /** - * // * Link creation {@link Commandlet} similar to {@code ln -s}. + * // * Link creation {@link AbstractCommandlet} similar to {@code ln -s}. *

* It tries to create a true symbolic link first. On Windows, symlink creation may be restricted due to missing privileges. In that case, IDEasy will create a * hard link as an alternative (file-only, same volume) to avoid the Git-Bash behavior of silently copying files. */ -public final class LnCommandlet extends Commandlet { +public final class LnCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(LnCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java index 2c14eff0..31791878 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ReleaseCommandlet.java @@ -10,14 +10,14 @@ import com.devonfw.tools.ide.git.GitContext; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.property.StringProperty; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.BuildTool; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to build and deploy a release of the current project. + * {@link AbstractCommandlet} to build and deploy a release of the current project. */ -public class ReleaseCommandlet extends Commandlet { +public class ReleaseCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(ReleaseCommandlet.class); @@ -42,7 +42,7 @@ protected void doRun() { Path projectPath = this.context.getCwd(); GitContext git = this.context.getGitContext(); - LocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(projectPath); + AbstractLocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildTool(projectPath); if (commandlet == null) { throw new CliException("Could not find a build descriptor in " + projectPath + ". There is nothing to release here."); } @@ -102,7 +102,7 @@ private boolean warnIfFork(GitContext git, Path projectPath) { return false; } - private boolean isTopLevelProject(LocalToolCommandlet buildCommandlet, Path projectPath) { + private boolean isTopLevelProject(AbstractLocalToolCommandlet buildCommandlet, Path projectPath) { // top-level if the build descriptor found here is not also present in the parent directory Path parent = projectPath.getParent(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java index 7ece1bb5..1150f62a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/RewriteCommandlet.java @@ -17,9 +17,9 @@ import com.devonfw.tools.ide.tool.openrewrite.RewriteRecipeEnum; /** - * {@link Commandlet} for OpenRewrite refactoring. + * {@link AbstractCommandlet} for OpenRewrite refactoring. */ -public class RewriteCommandlet extends Commandlet { +public class RewriteCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(RewriteCommandlet.class); @@ -93,7 +93,7 @@ private boolean confirmApplyChange() { String input = this.context.askForInput( "***Before making actual changes to the code, please confirm it seriously." + " It is strongly recommended to perform a DRY-RUN first***\n" + - "Type yes to apply changes, or press other keys to perform DRY-RUN: "); + "Type yes to apply changes, or press other keys to perform DRY-RUN: "); return input.equalsIgnoreCase("yes"); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java index 01e93054..a4bffa90 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/ShellCommandlet.java @@ -30,9 +30,9 @@ import com.devonfw.tools.ide.property.Property; /** - * {@link Commandlet} for internal interactive shell with build-in auto-completion and help. + * {@link AbstractCommandlet} for internal interactive shell with build-in auto-completion and help. */ -public final class ShellCommandlet extends Commandlet { +public final class ShellCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(ShellCommandlet.class); @@ -126,8 +126,8 @@ protected void doRun() { /** * Strips the pre-filled {@link #IDE_PREFIX} from the given raw input line, so that a command entered after leaving the prefix untouched (e.g. - * {@code ide status}) behaves the same as if only {@code status} was entered. This allows the user to remove the prefix via backspace to enter a - * non-IDEasy command (e.g. {@code cd}) without the misleading {@code ide} prefix. + * {@code ide status}) behaves the same as if only {@code status} was entered. This allows the user to remove the prefix via backspace to enter a non-IDEasy + * command (e.g. {@code cd}) without the misleading {@code ide} prefix. * * @param rawLine the raw line as read from the {@link LineReader}. * @return the normalized line ready to be passed to {@link #runCommand(String)}. @@ -190,11 +190,12 @@ private int changeDirectory(CliArguments cliArgs) { /** * @param argument the current {@link CliArgument} (position) to match. - * @param commandlet the potential {@link Commandlet} to match. - * @return {@code true} if the given {@link Commandlet} matches to the given {@link CliArgument}(s) and those have been applied (set in the {@link Commandlet} - * and {@link Commandlet#validate() validated}), {@code false} otherwise (the {@link Commandlet} did not match and we have to try a different candidate). + * @param commandlet the potential {@link AbstractCommandlet} to match. + * @return {@code true} if the given {@link AbstractCommandlet} matches to the given {@link CliArgument}(s) and those have been applied (set in the + * {@link AbstractCommandlet} and {@link AbstractCommandlet#validate() validated}), {@code false} otherwise (the {@link AbstractCommandlet} did not match + * and we have to try a different candidate). */ - private boolean apply(CliArgument argument, Commandlet commandlet) { + private boolean apply(CliArgument argument, AbstractCommandlet commandlet) { LOG.trace("Trying to match arguments to commandlet {}", commandlet.getName()); CliArgument currentArgument = argument; diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java index 39a5131f..efbbad92 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java @@ -9,6 +9,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.git.repository.RepositoryType; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.migration.IdeMigrator; import com.devonfw.tools.ide.os.SystemInfo; @@ -17,9 +18,9 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to print a status report about IDEasy. + * {@link AbstractCommandlet} to print a status report about IDEasy. */ -public class StatusCommandlet extends Commandlet { +public class StatusCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(StatusCommandlet.class); @@ -91,7 +92,8 @@ private void logSettingsLegacyStatus() { } if (hasLegacyProperties) { LOG.warn( - "Your settings are outdated and contain legacy configurations. Please consider upgrading your settings:\nhttps://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc#upgrade"); + "Your settings are outdated and contain legacy configurations. " + + "Please consider upgrading your settings:\nhttps://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc#upgrade"); } } @@ -104,7 +106,7 @@ private void logSettingsGitStatus() { } else { GitContext gitContext = this.context.getGitContext(); if (gitContext.isRepositoryUpdateAvailable(settingsPath, this.context.getSettingsCommitIdPath())) { - if (!this.context.isSettingsCodeRepository()) { + if (RepositoryType.ofSettingsPath(this.context.getSettingsPath(), context) != RepositoryType.CODE_SETTINGS_COMBINED) { LOG.warn("Your settings are not up-to-date, please run 'ide update'."); } } else { @@ -126,11 +128,10 @@ private void logMigrationStatus() { IdeMigrator migrator = new IdeMigrator(); VersionIdentifier projectVersion = this.context.getProjectVersion(); - VersionIdentifier targetVersion = migrator.getTargetVersion(); - if (projectVersion.isLess(targetVersion)) { + if (projectVersion.isLess(migrator.getTargetVersion())) { IdeLogLevel.INTERACTION.log(LOG, - "Your project is on IDEasy version {} and needs an update to version {}!\nPlease run 'ide update' to migrate your project", - projectVersion, targetVersion); + "Your project '{}' was created or last updated with IDEasy version {} and needs an update.\nPlease run 'ide update' to migrate your project.", + this.context.getProjectName(), projectVersion); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java index d7121a54..d8ac1f21 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java @@ -15,9 +15,9 @@ import com.devonfw.tools.ide.util.TruststoreUtil; /** - * {@link Commandlet} to fix the TLS problem for VPN users. + * {@link AbstractCommandlet} to fix the TLS problem for VPN users. */ -public class TruststoreCommandlet extends Commandlet { +public class TruststoreCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(TruststoreCommandlet.class); @@ -99,7 +99,8 @@ protected void doRun() { LOG.info("No truststore update is required for the given address."); if (defaultUrlUsed) { LOG.info( - "If the issue still occurs try to call the command again and add the url that is causing the problem to the command: \n ide fix-vpn-tls-problem "); + "If the issue still occurs try to call the command again and add the url that is causing the problem to the command: \n" + + " ide fix-vpn-tls-problem "); } return; @@ -116,7 +117,8 @@ protected void doRun() { } catch (Exception e) { LOG.error("Failed to capture certificate from {}:{}.", effectiveEndpoint.host(), effectiveEndpoint.port(), e); IdeLogLevel.INTERACTION.log(LOG, - "Please check proxy/VPN and retry. You can also follow: https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); + "Please check proxy/VPN and retry. You can also follow: " + + "https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); return; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java index 3871d7da..a18a0114 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallCommandlet.java @@ -5,13 +5,13 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.IdeasyCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; /** - * An internal {@link Commandlet} to uninstall a tool. + * An internal {@link AbstractCommandlet} to uninstall a tool. */ -public class UninstallCommandlet extends Commandlet { +public class UninstallCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UninstallCommandlet.class); @@ -57,7 +57,7 @@ protected void doRun() { return; } for (int i = 0; i < valueCount; i++) { - ToolCommandlet toolCommandlet = this.tools.getValue(i); + AbstractToolCommandlet toolCommandlet = this.tools.getValue(i); if (toolCommandlet.isInstalled()) { toolCommandlet.uninstall(); } else { diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java index fc9bf3b4..b38110cc 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UninstallPluginCommandlet.java @@ -6,15 +6,15 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.PluginProperty; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; /** - * {@link Commandlet} to install a tool. + * {@link AbstractCommandlet} to install a tool. * - * @see ToolCommandlet#install() + * @see AbstractToolCommandlet#install() */ -public class UninstallPluginCommandlet extends Commandlet { +public class UninstallPluginCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UninstallPluginCommandlet.class); @@ -45,7 +45,7 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); String plugin = this.plugin.getValue(); if (commandlet instanceof PluginBasedCommandlet cmd) { @@ -56,7 +56,7 @@ protected void doRun() { } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java index cf3251fe..7f6b6dc0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java @@ -12,12 +12,12 @@ import com.devonfw.tools.ide.util.FilenameUtil; /** - * {@link Commandlet} to extract an archive file to a target directory. + * {@link AbstractCommandlet} to extract an archive file to a target directory. *

* Supports ZIP, TAR, TAR.GZ, TAR.BZ2, 7Z, JAR archives (cross-platform), as well as MSI (Windows) and DMG/PKG (Mac). *

*/ -public final class UnpackCommandlet extends Commandlet { +public final class UnpackCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UnpackCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java index d33d44af..31406c25 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeCommandlet.java @@ -10,9 +10,9 @@ import com.devonfw.tools.ide.tool.ToolInstallation; /** - * {@link Commandlet} to upgrade the version of IDEasy + * {@link AbstractCommandlet} to upgrade the version of IDEasy */ -public class UpgradeCommandlet extends Commandlet { +public class UpgradeCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UpgradeCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java index 46914bb2..84c6b514 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpgradeSettingsCommandlet.java @@ -25,9 +25,9 @@ import com.devonfw.tools.ide.variable.VariableDefinition; /** - * {@link Commandlet} to upgrade settings after a migration from devonfw-ide to IDEasy. + * {@link AbstractCommandlet} to upgrade settings after a migration from devonfw-ide to IDEasy. */ -public class UpgradeSettingsCommandlet extends Commandlet { +public class UpgradeSettingsCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(UpgradeSettingsCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java index da3ecb2a..e2995343 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionCommandlet.java @@ -10,7 +10,7 @@ /** * Prints the IDEasy version and exits */ -public class VersionCommandlet extends Commandlet { +public class VersionCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(VersionCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java index 8b9ddeea..c70a0618 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionGetCommandlet.java @@ -1,12 +1,12 @@ package com.devonfw.tools.ide.commandlet; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to get the installed version for a tool. + * An internal {@link AbstractCommandlet} to get the installed version for a tool. * - * @see ToolCommandlet#getInstalledVersion() + * @see AbstractToolCommandlet#getInstalledVersion() */ public class VersionGetCommandlet extends AbstractVersionOrEditionGetCommandlet { @@ -33,13 +33,13 @@ protected String getPropertyToGet() { } @Override - protected Object getConfiguredValue(ToolCommandlet commandlet) { + protected Object getConfiguredValue(AbstractToolCommandlet commandlet) { return commandlet.getConfiguredVersion(); } @Override - protected Object getInstalledValue(ToolCommandlet commandlet) { + protected Object getInstalledValue(AbstractToolCommandlet commandlet) { return commandlet.getInstalledVersion(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java index abbb6fc7..359b19c4 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionListCommandlet.java @@ -2,14 +2,14 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.ToolProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * An internal {@link Commandlet} to list versions for a tool. + * An internal {@link AbstractCommandlet} to list versions for a tool. * - * @see ToolCommandlet#listVersions() + * @see AbstractToolCommandlet#listVersions() */ -public class VersionListCommandlet extends Commandlet { +public class VersionListCommandlet extends AbstractCommandlet { /** The tool to list the versions of. */ public final ToolProperty tool; @@ -41,7 +41,7 @@ public boolean isWriteLogFile() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); commandlet.listVersions(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java index 1c12d1f4..6eb40932 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/VersionSetCommandlet.java @@ -5,15 +5,15 @@ import com.devonfw.tools.ide.property.EnumProperty; import com.devonfw.tools.ide.property.ToolProperty; import com.devonfw.tools.ide.property.VersionProperty; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * An internal {@link Commandlet} to set a tool version. + * An internal {@link AbstractCommandlet} to set a tool version. * - * @see ToolCommandlet#setVersion(VersionIdentifier, boolean) + * @see AbstractToolCommandlet#setVersion(VersionIdentifier, boolean) */ -public class VersionSetCommandlet extends Commandlet { +public class VersionSetCommandlet extends AbstractCommandlet { /** The tool to set the version of. */ public final ToolProperty tool; @@ -46,14 +46,14 @@ public String getName() { @Override protected void doRun() { - ToolCommandlet commandlet = this.tool.getValue(); + AbstractToolCommandlet commandlet = this.tool.getValue(); VersionIdentifier versionIdentifier = this.version.getValue(); EnvironmentVariablesFiles env = this.cfg.getValue(); commandlet.setVersion(versionIdentifier, true, env); } @Override - public ToolCommandlet getToolForCompletion() { + public AbstractToolCommandlet getToolForCompletion() { return this.tool.getValue(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java new file mode 100644 index 00000000..c8c378ef --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckCommandlet.java @@ -0,0 +1,137 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.property.FlagProperty; + +/** + * {@link AbstractCommandlet} to check the current repository for best-practices, starting from the current working directory (CWD). + */ +public class CheckCommandlet extends AbstractCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(CheckCommandlet.class); + + /** + * rule to exclude all hidden files and folders. + */ + public static final String RULE_IGNORE_HIDDEN = ".*"; + + /** + * rule to ensure that the .gitignore file is committed. + */ + public static final String RULE_UNIGNORE_GITIGNORE = "!" + MissingGitignoreIssue.GITIGNORE; + + /** + * Flag to fix issues + */ + public final FlagProperty fix; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public CheckCommandlet(IdeContext context) { + super(context); + addKeyword(getName()); + this.fix = add(new FlagProperty("--fix")); + + } + + @Override + public String getName() { + + return "check"; + } + + @Override + public boolean isIdeRootRequired() { + return false; + } + + @Override + protected void doRun() { + + List issues = new ArrayList<>(); + + Path repositoryRoot = findRepositoryRoot(); + if (repositoryRoot == null) { + LOG.warn("This does not seem to be a git repository (no .git folder found in {} or any parent directory).", this.context.getCwd()); + return; + } + + LOG.info("Found repository root at {}.", repositoryRoot); + checkGitIgnore(repositoryRoot, issues); + report(issues); + } + + private Path findRepositoryRoot() { + GitContext gitContext = this.context.getGitContext(); + return gitContext.findRepositoryRoot(this.context.getCwd()); + } + + private void checkGitIgnore(Path repositoryRoot, List issues) { + + Path gitignore = repositoryRoot.resolve(MissingGitignoreIssue.GITIGNORE); + if (!context.getFileAccess().isFile(gitignore)) { + issues.add(new MissingGitignoreIssue(repositoryRoot)); + return; + } + + List lines; + + lines = context.getFileAccess().readFileLines(gitignore); + + if (!containsRule(lines, RULE_IGNORE_HIDDEN)) { + issues.add(new MissingGitignoreRuleIssue(gitignore, RULE_IGNORE_HIDDEN)); + } + + if (!containsRule(lines, RULE_UNIGNORE_GITIGNORE)) { + issues.add(new MissingGitignoreRuleIssue(gitignore, RULE_UNIGNORE_GITIGNORE)); + } + } + + private boolean containsRule(List lines, String rule) { + + return lines.stream().anyMatch(line -> line.strip().equals(rule)); + } + + private void report(List issues) { + + if (issues.isEmpty()) { + IdeLogLevel.SUCCESS.log(LOG, "No issues found."); + return; + } + + LOG.warn("Found {} issue(s)", issues.size()); + + boolean open = false; + for (CheckIssue issue : issues) { + if (this.fix.isTrue() && issue.isFixable()) { + if (issue.fix(this.context)) { + IdeLogLevel.SUCCESS.log(LOG, "Fixed: {}", issue); + continue; + } + LOG.warn("Failed to fix {}", issue); + open = true; + } else { + LOG.warn("{}", issue); + open = true; + } + } + + if (open) { + throw new CliException("ide check found issues - see issues above.", 1); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java new file mode 100644 index 00000000..37e3c924 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/CheckIssue.java @@ -0,0 +1,92 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * Represents an issue found by {@code ide check}. + */ +public class CheckIssue { + + private final Path path; + + private final Integer line; + + private final String message; + + /** + * The constructor for an issue not tied to a specific line. + * + * @param path the {@link Path} to the affected file. May point to a file that does not exist. + * @param message a description of the problem. + */ + protected CheckIssue(Path path, String message) { + + this.path = path; + this.line = null; + this.message = message; + } + + /** + * The constructor. + * + * @param path the {@link Path} to the affected file. May point to a file that does not exist. + * @param line the line number within {@code path} that this issue refers to. + * @param message a description of the problem. + */ + protected CheckIssue(Path path, Integer line, String message) { + this.path = path; + this.line = line; + this.message = message; + } + + /** + * @return the {@link Path} to the affected file. + */ + public Path getPath() { + + return this.path; + } + + /** + * @return the line number this issue refers to, or {@code null} if not applicable. + */ + public Integer getLineNumber() { + + return this.line; + } + + /** + * @return a description of the problem. + */ + public String getMessage() { + + return this.message; + } + + /** + * @return {@code true} if this issue can be automatically fixed via {@link #fix(IdeContext)}, {@code false} otherwise. + */ + public boolean isFixable() { + return false; + } + + /** + * Attempts to fix the problem that caused this issue. + * + * @param context the {@link IdeContext}. + * @return {@code true} if the fix was applied successfully, {@code false} otherwise. + */ + public boolean fix(IdeContext context) { + return false; + } + + @Override + public String toString() { + if (this.line == null) { + return this.path + ": " + this.message; + } + return this.path + ":" + this.line + ": " + this.message; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java new file mode 100644 index 00000000..1709ebfd --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreIssue.java @@ -0,0 +1,37 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; +import java.util.List; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * A {@link CheckIssue} reporting that the repository root has no {@code .gitignore} file. When fixed, creates + * a minimal {@code .gitignore} containing the rules ({@code .*} and {@code !.gitignore}). + */ +public class MissingGitignoreIssue extends CheckIssue { + + /** The file name of the git-ignore file. */ + public static final String GITIGNORE = ".gitignore"; + + /** + * The constructor. + * + * @param repositoryRoot the {@link Path} to the (missing) {@code .gitignore} file in the repository root. + */ + public MissingGitignoreIssue(Path repositoryRoot) { + super(repositoryRoot.resolve(GITIGNORE), "No " + GITIGNORE + " found in repository root."); + } + + @Override + public boolean isFixable() { + return true; + } + + @Override + public boolean fix(IdeContext context) { + + context.getFileAccess().writeFileLines(List.of(".*", "!" + GITIGNORE), getPath()); + return true; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java new file mode 100644 index 00000000..3eea7061 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/check/MissingGitignoreRuleIssue.java @@ -0,0 +1,46 @@ +package com.devonfw.tools.ide.commandlet.check; + +import java.nio.file.Path; +import java.util.List; + +import com.devonfw.tools.ide.context.IdeContext; + +/** + * A {@link CheckIssue} reporting that a {@code .gitignore} file exists but is missing expected rules. When fixed, appends the missing rule as a new line at the + * end of the file. + */ +public class MissingGitignoreRuleIssue extends CheckIssue { + + private final String rule; + + /** + * The constructor. + * + * @param gitignore the {@link Path} to the existing {@code .gitignore} file. + * @param rule the missing rule (e.g. {@code ".*"} or {@code "!.gitignore"}). + */ + public MissingGitignoreRuleIssue(Path gitignore, String rule) { + super(gitignore, "Missing rule \"" + rule + "\" in " + gitignore.getFileName() + "."); + this.rule = rule; + } + + @Override + public boolean isFixable() { + + return true; + } + + @Override + public boolean fix(IdeContext context) { + + List lines = context.getFileAccess().readFileLines(getPath()); + + if (lines == null) { + return false; + } + + lines.add(this.rule); + context.getFileAccess().writeFileLines(lines, getPath()); + return true; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java index d086693a..7cb813bb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java @@ -7,7 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.step.Step; @@ -17,7 +17,7 @@ /** * Commandlet which scans your IDE installation for unused software (tools not currently used by any project) and removes them. */ -public class CleanupCommandlet extends Commandlet { +public class CleanupCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(CleanupCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java similarity index 61% rename from cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java rename to cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java index c034ddcf..0e3dfb7f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java @@ -1,4 +1,4 @@ -package com.devonfw.tools.ide.commandlet; +package com.devonfw.tools.ide.commandlet.update; import java.io.IOException; import java.nio.file.Files; @@ -6,6 +6,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -13,18 +14,25 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.cli.CliFatalException; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; +import com.devonfw.tools.ide.commandlet.CommandletManager; +import com.devonfw.tools.ide.commandlet.CreateCommandlet; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsHealthCheckResult; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateResult; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateStatus; +import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdater; import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeStartContextImpl; -import com.devonfw.tools.ide.git.GitContext; -import com.devonfw.tools.ide.git.GitUrl; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; +import com.devonfw.tools.ide.git.repository.RepositoryType; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.property.StringProperty; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.ToolEdition; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; import com.devonfw.tools.ide.tool.ToolInstallRequest; @@ -37,23 +45,12 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * Abstract {@link Commandlet} base-class for both {@link UpdateCommandlet} and {@link CreateCommandlet}. + * Abstract {@link AbstractCommandlet} base-class for both {@link UpdateCommandlet} and {@link CreateCommandlet}. */ -public abstract class AbstractUpdateCommandlet extends Commandlet { +public abstract class AbstractUpdateCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(AbstractUpdateCommandlet.class); - private static final String MESSAGE_CODE_REPO_URL = """ - No code repository was given after '--code'. - Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc - Please enter the code repository below that includes your settings folder."""; - - private static final String MESSAGE_SETTINGS_REPO_URL = """ - No settings found at {} and no SETTINGS_URL is defined. - Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc - Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter. - In case you just want to test IDEasy you may simply hit return to install the default settings."""; - /** {@link StringProperty} for the settings repository URL. */ public final StringProperty settingsRepo; @@ -107,6 +104,15 @@ protected void doRun() { createStartScripts(); } + /** + * Hook that is called after the settings passed the health check but before they are moved to their final location. Does nothing by default and is overridden + * by {@link CreateCommandlet} to create the project structure so that no project is created at all if the health check failed. + */ + protected void onSettingHealthCheckFinished() { + + // nothing to do by default + } + private void reloadContext() { ((AbstractIdeContext) this.context).reload(); @@ -158,138 +164,83 @@ private void setupConf(Path template, Path conf) { /** * Updates the settings repository in IDE_HOME/settings by either cloning if no such repository exists or pulling if the repository exists then saves the - * latest current commit ID in the file ".commit.id". + * latest current commit ID in the file ".commit.id". The settings are always cloned into a temporary directory first where a health check is performed. Only + * if that health check succeeded the settings are pulled or the verified clone is moved to its final location. */ protected void updateSettings() { - boolean codeRepository = this.context.isSettingsCodeRepository(); - if (codeRepository && !(this.context.isForceMode() || forcePull.isTrue())) { + RepositoryType repositoryType = RepositoryType.ofSettingsPath(getSettingsPathForSettingsUpdate(), context); + if (repositoryType == RepositoryType.CODE_SETTINGS_COMBINED && !(this.context.isForceMode() || this.forcePull.isTrue())) { LOG.info("Skipping git pull in settings due to code repository. Use --force-pull to enforce pulling."); return; } - this.context.newStep(getStepMessage()).run(() -> updateSettingsInStep(codeRepository)); + Step step = this.context.newStep(getStepMessage()); + step.run(this::updateSettingsInStep); } protected String getStepMessage() { - return "update (pull) settings repository"; + return "Update settings repository"; } - private void updateSettingsInStep(boolean codeRepository) { - Path settingsPath = this.context.getSettingsPath(); - if (!codeRepository) { - boolean settingsRepository = this.context.getGitContext().isGitRepo(settingsPath); - if (!settingsRepository) { - if (Files.exists(settingsPath)) { - if (!this.context.getFileAccess().isEmptyDir(settingsPath)) { - this.context.askToContinue( - "Your settings repository seems to be broken ('.git' folder not present). " - + "We can fix this by moving your settings the backed up. " - + "You will be asked for the settings git URL and your settings will be cloned from scratch. " - + "Do you want to proceed?" - ); - } - this.context.getFileAccess().backup(settingsPath); - } - GitUrl gitUrl = getOrAskSettingsUrl(); - checkProjectNameConvention(gitUrl.getProjectName()); - initializeRepository(gitUrl); - return; - } - } - GitContext gitContext = this.context.getGitContext(); - if (gitContext.hasUntrackedFiles(settingsPath)) { - gitContext.pullSafelyWithStash(settingsPath); - } else { - gitContext.pull(settingsPath); - } - this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath()); - } + private void updateSettingsInStep() { - private GitUrl getOrAskSettingsUrl() { + SettingsUpdater settingsUpdater = new SettingsUpdater(this.context, this.settingsRepo); + try { + //Step 1: Perform health check + Step healthCheckStep = this.context.newStep("Performing settings health check"); + SettingsHealthCheckResult healthCheckResult = healthCheckStep.call(() -> checkSettingsInStep(settingsUpdater), () -> null); - String repository = this.settingsRepo.getValue(); - repository = handleDefaultRepository(repository); - String userPromt; - String defaultUrl; - if (isCodeRepository()) { - userPromt = "Code repository URL:"; - defaultUrl = null; - LOG.info(MESSAGE_CODE_REPO_URL); - } else { - userPromt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:"; - defaultUrl = IdeContext.DEFAULT_SETTINGS_REPO_URL; - LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath()); - } - GitUrl gitUrl = null; - if (repository != null) { - gitUrl = GitUrl.of(repository); - } - while ((gitUrl == null) || !gitUrl.isValid()) { - repository = this.context.askForInput(userPromt, defaultUrl); - repository = handleDefaultRepository(repository); - gitUrl = GitUrl.of(repository); - if (!gitUrl.isValid()) { - LOG.warn("The input URL is not valid, please try again."); + // If the health check failed (healthCheckResult is null) the settings have not been verified, so skip applying them and fail the "Update settings" + // step. A non-null result is only produced when the health check passed or the user explicitly chose to continue anyway (force mode), so this never + // aborts in force mode. + if (healthCheckResult == null) { + throw new CliException("Settings update aborted due to error in health check"); } - } - return gitUrl; - } - private String handleDefaultRepository(String repository) { - if ("-".equals(repository)) { - if (isCodeRepository()) { - LOG.warn("'-' is found after '--code'. This is invalid."); - repository = null; - } else { - LOG.info("'-' was found for settings repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL); - repository = IdeContext.DEFAULT_SETTINGS_REPO_URL; + //Step 2: Let create/update commandlets prepare themselves for the settings update. + onSettingHealthCheckFinished(); + + //Step 3: Apply (move/pull newest version) settings + Step applySettingsStep = this.context.newStep("Applying settings"); + applySettingsStep.run(() -> applySettingsUpdateInStep(settingsUpdater, healthCheckResult)); + + //Make sure to always fail the parent step if the "Apply settings" step fails. + if (applySettingsStep.isFailure()) { + throw new CliException("Settings update failed due to error while applying the settings update"); } + } finally { + // the verified clone lives across both steps and the prepareProject hook so it is only here that its lifetime ends + settingsUpdater.cleanup(); } - return repository; } - private void checkProjectNameConvention(String projectName) { - boolean isSettingsRepo = projectName.contains(IdeContext.SETTINGS_REPOSITORY_KEYWORD); - boolean codeRepository = isCodeRepository(); - if (isSettingsRepo == codeRepository) { - String warningTemplate; - if (codeRepository) { - warningTemplate = """ - Your git URL is pointing to the project name {} that contains the keyword '{}'. - Therefore we assume that you did a mistake by adding the '--code' option to the ide project creation. - Do you really want to create the project?"""; - } else { - warningTemplate = """ - Your git URL is pointing to the project name {} that does not contain the keyword ''{}''. - Therefore we assume that you forgot to add the '--code' option to the ide project creation. - Do you really want to create the project?"""; - } - this.context.askToContinue(warningTemplate, projectName, IdeContext.SETTINGS_REPOSITORY_KEYWORD); + private SettingsHealthCheckResult checkSettingsInStep(SettingsUpdater settingsUpdater) { + SettingsHealthCheckResult healthCheckResult = settingsUpdater.checkSettings(getSettingsPathForSettingsUpdate()); + RepositoryType repositoryType = healthCheckResult.repositoryType(); + + if ((repositoryType == null || !repositoryType.isValid()) && !healthCheckResult.isExistingProject()) { + throw new CliFatalException("Fatal error while cloning settings: The settings health check failed: " + healthCheckResult.errorMessage()); + } else if ((repositoryType == null || !repositoryType.isValid())) { + throw new CliException("The settings health check failed: " + healthCheckResult.errorMessage()); } + return healthCheckResult; } - private void initializeRepository(GitUrl gitUrl) { + private void applySettingsUpdateInStep(SettingsUpdater settingsUpdater, SettingsHealthCheckResult healthCheckResult) { + SettingsUpdateResult settingsUpdateResult = settingsUpdater.applySettings(healthCheckResult); + if (settingsUpdateResult == null) { - GitContext gitContext = this.context.getGitContext(); - Path settingsPath = this.context.getSettingsPath(); - Path repoPath = settingsPath; - boolean codeRepository = isCodeRepository(); - if (codeRepository) { - // clone the given code repository into IDE_HOME/workspaces/main - repoPath = context.getWorkspacePath().resolve(gitUrl.getProjectName()); + throw new CliException("Failed to apply the settings update due to unknown error."); } - gitContext.pullOrClone(gitUrl, repoPath); - if (codeRepository) { - // check for settings folder and create symlink to IDE_HOME/settings - Path settingsFolder = repoPath.resolve(IdeContext.FOLDER_SETTINGS); - if (Files.exists(settingsFolder)) { - context.getFileAccess().symlink(settingsFolder, settingsPath); - } else { - throw new CliException("Invalid code repository " + gitUrl + ": missing a settings folder at " + settingsFolder); + + if (Objects.requireNonNull(settingsUpdateResult.updateStatus()) == SettingsUpdateStatus.SETTINGS_UPDATE_FAILED) { + String errorMessage = "The settings update could not be applied: " + settingsUpdateResult.errorMessage(); + if (!healthCheckResult.isExistingProject()) { + throw new CliFatalException(errorMessage); } + throw new CliException(errorMessage); } - this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath()); } private void updateSoftware() { @@ -304,13 +255,13 @@ private void updateSoftware() { private void doUpdateSoftwareStep(Step step) { - Set toolCommandlets = new HashSet<>(); + Set toolCommandlets = new HashSet<>(); CommandletManager commandletManager = this.context.getCommandletManager(); // installed tools in IDE_HOME/software List softwarePaths = this.context.getFileAccess().listChildren(this.context.getSoftwarePath(), Files::isDirectory); for (Path softwarePath : softwarePaths) { String toolName = softwarePath.getFileName().toString(); - ToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(toolName); + AbstractToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(toolName); if (toolCommandlet != null) { toolCommandlets.add(toolCommandlet); } @@ -320,7 +271,7 @@ private void doUpdateSoftwareStep(Step step) { List regularTools = IdeVariables.IDE_TOOLS.get(this.context); if (regularTools != null) { for (String regularTool : regularTools) { - ToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(regularTool); + AbstractToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(regularTool); if (toolCommandlet == null) { String displayName = (regularTool == null || regularTool.isBlank()) ? "" : "'" + regularTool + "'"; LOG.error("Cannot install or update tool '{}''. No matching commandlet found. Please check your IDE_TOOLS configuration.", displayName); @@ -337,7 +288,7 @@ private void doUpdateSoftwareStep(Step step) { } // update/install the toolCommandlets - for (ToolCommandlet toolCommandlet : toolCommandlets) { + for (AbstractToolCommandlet toolCommandlet : toolCommandlets) { this.context.newStep("Install " + toolCommandlet.getName()).run(() -> toolCommandlet.install(false)); } @@ -358,7 +309,7 @@ private void installExtraToolInstallations(String tool, List + *
  • {@link #checkSettings(Path)} health check: the settings are always cloned into a temporary directory + * first where it is verified that the git URL is valid, that cloning succeeded, + * and that the repository actually is a settings or a combined code and settings repository.
  • + *
  • {@link #applySettings(SettingsHealthCheckResult)} apply: Settings are pulled in place (if they were already + * present)
  • + * + */ +public class SettingsUpdater { + + private static final Logger LOG = LoggerFactory.getLogger(SettingsUpdater.class); + + private static final String MESSAGE_SETTINGS_REPO_URL = """ + No settings found at {} and no SETTINGS_URL is defined. + Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc + Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter. + In case you just want to test IDEasy you may simply hit return to install the default settings."""; + + private static final String MESSAGE_INVALID_REPOSITORY = "The given git repository URL does not point to a valid settings or code-settings repository. " + + "Please verify and try again."; + + private final IdeContext context; + + private final FileAccess fileAccess; + + private final StringProperty settingsRepoProperty; + + /** The temporary directory holding the verified clone or {@code null} if there is nothing to move. */ + private Path tempRepoDir; + + /** The name of the git project - required to place a combined code and settings repository into the workspace. */ + private String gitProjectName; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param settingsRepoProperty the {@link StringProperty} with the settings repository URL from the update commandlet. + */ + public SettingsUpdater(IdeContext context, StringProperty settingsRepoProperty) { + + super(); + this.context = context; + this.settingsRepoProperty = settingsRepoProperty; + this.fileAccess = context.getFileAccess(); + } + + /** + * Performs the health check on the settings repository. Nothing is changed in {@link IdeContext#getIdeHome() IDE_HOME} except that a broken settings folder + * is backed up. Whether the settings are pulled or cloned is decided solely by the state of {@link IdeContext#getSettingsPath() IDE_HOME/settings} so that + * {@code ide create} and {@code ide update} share the very same logic. + * + * @param settingsPath the path to the (code-)settings directory which the health check should be performed on. + * @return the {@link SettingsHealthCheckResult}. + */ + public SettingsHealthCheckResult checkSettings(Path settingsPath) { + + if (settingsPath != null && !fileAccess.isEmptyDir(settingsPath)) { + // for a combined code and settings repository IDE_HOME/settings is a symlink into the code repository whose '.git' folder is one level above, + // so isGitRepo would report it as broken settings + return checkSettingsPresent(settingsPath); + } + return cloneAndCheckSettings(settingsPath); + } + + /** + * Health check for settings that are already present. As the project keeps working with these settings, a failure is only fatal if the user explicitly + * aborted. Here, if a new version is available, we clone the new version into a temporary folder and perform health checks. If the cloned, new version is + * valid, we call git update in the existing settings folder. + */ + private SettingsHealthCheckResult checkSettingsPresent(Path settingsPath) { + + RepositoryType settingsRepoType = RepositoryType.ofSettingsPath(settingsPath, this.context); + try { + + //If cloned repo is not (code-)settings repo, let the user re-clone the settings. + if (!settingsRepoType.isValid()) { + return cloneAndCheckSettings(settingsPath); + } + + //Otherwise, (e.g. user overrides), return valid. + return SettingsHealthCheckResult.ofSuccess(settingsRepoType, settingsPath, true); + } catch (RuntimeException e) { + cleanup(); + if (e instanceof CliAbortException) { + // the user answered "no" so we must not silently carry on + return SettingsHealthCheckResult.ofFailed(settingsRepoType, "Settings update aborted by end-user", settingsPath, true); + } + return SettingsHealthCheckResult.ofFailed(settingsRepoType, e.getMessage(), settingsPath, true); + } + } + + /** + * Health check for missing or broken settings (e.g. {@code ide create}). Without valid settings there is nothing to continue with, so every failure is fatal + * here. + */ + private SettingsHealthCheckResult cloneAndCheckSettings(Path settingsPath) { + + try { + if (settingsPath != null) { + backupBrokenSettings(settingsPath); + } + GitUrl gitUrl = getOrAskSettingsUrl(); + + Path tempCloneDir = cloneRepoToTempDir(gitUrl); + RepositoryType repositoryType = RepositoryType.ofGitRoot(tempCloneDir, this.context); + + if (!repositoryType.isValid()) { + + return SettingsHealthCheckResult.ofFailed(repositoryType, MESSAGE_INVALID_REPOSITORY, tempCloneDir, false); + } + return SettingsHealthCheckResult.ofSuccess(repositoryType, tempCloneDir, false); + } catch (RuntimeException e) { + cleanup(); + throw createGuaranteedFatalException(e); + } + } + + /** + * @param error the {@link RuntimeException} that made the settings setup fail. + * @return a {@link CliFatalException} that aborts the entire process. An existing {@link CliException} keeps its message and + * {@link CliException#getExitCode() exit code} so that e.g. an abort by the user is still reported as such. + */ + private static CliFatalException createGuaranteedFatalException(RuntimeException error) { + + if (error instanceof CliFatalException rethrow) { + return rethrow; + } else if (error instanceof CliException) { + return new CliFatalException("Error occurred during settings update: " + error.getMessage(), error); + } + return new CliFatalException("Error occurred during settings update: " + error.getClass() + ": " + error.getMessage(), error); + } + + private void pullSettingsAndSaveCommitId(Path settingsPath) { + + GitContext gitContext = this.context.getGitContext(); + if (gitContext.hasUntrackedFiles(settingsPath)) { + gitContext.pullSafelyWithStash(settingsPath); + } else { + gitContext.pull(settingsPath); + } + gitContext.saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath()); + } + + /** + * Clone a settings repository into a temporary directory. + * + * @param gitUrl {@link GitUrl} of the (code-)settings repository. + * @return {@link Path} of the temporary directory. + */ + private Path cloneRepoToTempDir(GitUrl gitUrl) { + + this.gitProjectName = gitUrl.getProjectName(); + + // createTempDir guarantees a unique and empty directory so no leftovers of a previous attempt can interfere and we can clone directly + this.tempRepoDir = this.context.getFileAccess().createTempDir("project-" + this.gitProjectName); + this.context.getGitContext().clone(gitUrl, this.tempRepoDir); + return this.tempRepoDir; + } + + private void backupBrokenSettings(Path settingsPath) { + + if ((settingsPath == null) || !Files.exists(settingsPath)) { + return; + } + + if (!fileAccess.isEmptyDir(settingsPath)) { + this.context.askToContinue(""" + Your settings repository seems to be broken ('.git' folder not present). + We can fix this by moving your settings to the backup. + You will be asked for the settings git URL and your settings will be cloned from scratch. + Do you want to proceed?"""); + } + fileAccess.backup(settingsPath); + } + + /** + * Applies the result of the {@link #checkSettings(Path)} health check by either pulling the settings in place or moving the verified clone to its final + * location. + * + * @param healthCheckResult {@link SettingsHealthCheckResult} health check result to use for applying the settings + * @return a {@link SettingsUpdateResult} representing the state, whether moving/pulling the newest settings was successful. + */ + public SettingsUpdateResult applySettings(SettingsHealthCheckResult healthCheckResult) { + + Path sourcePath = healthCheckResult.settingsDirectory(); + Path settingsPath = this.context.getSettingsPath(); + + RepositoryType repositoryType = RepositoryType.ofGitRoot(sourcePath, context); + + boolean onlyPull = healthCheckResult.repositoryType().isValid() && healthCheckResult.isExistingProject(); + + // Case 1: We performed "ide update"; so settings already existed and we just need to perform a git pull in the existing repo. + if (onlyPull) { + pullSettingsAndSaveCommitId(settingsPath); + + repositoryType = RepositoryType.ofSettingsPath(context.getSettingsPath(), this.context); + if (repositoryType != RepositoryType.SETTINGS) { + return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATE_FAILED, + repositoryType, + "Update done, but the updated settings repository seems to be of an invalid type: " + repositoryType); + } + + return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATED, repositoryType, null); + } + + String errorMessage = null; + Path gitRootDir = settingsPath; + SettingsUpdateStatus resultStatus = SettingsUpdateStatus.SETTINGS_UPDATE_FAILED; + + // Case 2: We freshly cloned the settings repo and need to move it to a target directory. + switch (repositoryType) { + case UNKNOWN -> errorMessage = "Cannot apply settings as type of the settings repo is incorrect"; + case SETTINGS -> { + + //move to IDE_HOME/SETTINGS + moveProject(sourcePath, settingsPath); + + resultStatus = SettingsUpdateStatus.SETTINGS_CLONED; + } + case CODE_SETTINGS_COMBINED -> { + + //this is a special case - here we need to symlink from IDE_HOME/settings to IDE_HOME/workspaces/main/repo_name/settings. + // (Formerly managed by the obsolete "--code" flag) + Path repoMoveTargetDirectory = this.context.getWorkspacePath().resolve(gitProjectName); + Path symlinkPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_SETTINGS); + Path repoSettingsDirectory = repoMoveTargetDirectory.resolve(IdeContext.FOLDER_SETTINGS); + + moveProject(sourcePath, repoMoveTargetDirectory); + + context.getFileAccess().symlink(repoSettingsDirectory, symlinkPath); + + gitRootDir = repoMoveTargetDirectory; + resultStatus = SettingsUpdateStatus.SETTINGS_CLONED; + } + default -> errorMessage = "Unknown error during settings"; + } + + if (errorMessage == null) { + this.context.getGitContext().saveCurrentCommitId(gitRootDir, this.context.getSettingsCommitIdPath()); + } + return new SettingsUpdateResult(resultStatus, repositoryType, errorMessage); + } + + /** + * Removes the temporary clone. It is deleted and not backed up since it only contains a fresh clone without any user data and a backup would be created + * inside {@link IdeContext#getIdeHome() IDE_HOME} that may not even exist yet. Failures are only logged so that the actual error never gets masked. + */ + public void cleanup() { + + if (this.tempRepoDir == null) { + return; + } + try { + this.context.getFileAccess().delete(this.tempRepoDir); + } catch (RuntimeException e) { + LOG.warn("Failed to delete temporary directory {}", this.tempRepoDir, e); + } + this.tempRepoDir = null; + } + + private GitUrl getOrAskSettingsUrl() { + + String repository = handleDefaultRepository(this.settingsRepoProperty.getValue()); + GitUrl gitUrl = null; + if (repository != null) { + gitUrl = GitUrl.of(repository); + } + if ((gitUrl == null) || !gitUrl.isValid()) { + LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath()); + } + String userPrompt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:"; + while ((gitUrl == null) || !gitUrl.isValid()) { + repository = handleDefaultRepository(this.context.askForInput(userPrompt, IdeContext.DEFAULT_SETTINGS_REPO_URL)); + gitUrl = GitUrl.of(repository); + if (!gitUrl.isValid()) { + LOG.warn("The input URL is not valid, please try again."); + } + } + return gitUrl; + } + + private String handleDefaultRepository(String repository) { + + if ("-".equals(repository)) { + LOG.info("'-' was found for the repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL); + repository = IdeContext.DEFAULT_SETTINGS_REPO_URL; + } + return repository; + } + + private void moveProject(Path from, Path to) { + + try { + this.context.getFileAccess().move(from, to); + } catch (RuntimeException e) { + // FileAccess already reports source, target and the Windows file-lock hint so we only escalate to a fatal error here + throw new CliFatalException(e.getMessage(), e); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java b/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java index d3215a5c..b4ff2e97 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java +++ b/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java @@ -50,6 +50,16 @@ public class SystemPath { private final IdeContext context; + /** + * Tools whose binaries are also bundled inside another (runtime) tool's install folder and would therefore be shadowed by it on the PATH. + *

    + * {@code node} ships its own {@code npm}/{@code npx}/{@code corepack} inside its (flat, no {@code bin/}) install folder, so without an explicit order the + * pristine, independently versioned {@code npm} install and the node-bundled one would compete for the same binary names in an arbitrary order + * ({@code tool2pathMap} is a {@link HashMap}). Ordering these tools first guarantees the pristine installation deterministically wins. If another tool ever + * becomes independently versioned the same way, it is added here. + */ + private static final List PATH_PRECEDENCE_TOOLS = List.of("npm"); + private static final List EXTENSION_PRIORITY = List.of(".exe", ".cmd", ".bat", ".msi", ".ps1", ""); /** @@ -205,6 +215,28 @@ private void collectToolPath(Path softwarePath) { } } + /** + * @return the tool bin {@link Path}s in the order in which they must be searched and placed on the PATH. The {@link #PATH_PRECEDENCE_TOOLS} are placed + * first so that their executables deterministically take precedence over the copies bundled with the runtime that also ships them (e.g. the pristine + * {@code npm} over the {@code npm}/{@code npx} bundled with {@code node}); the remaining tools are returned in their map order. + */ + private List getToolPathsInResolutionOrder() { + + List orderedPaths = new ArrayList<>(this.tool2pathMap.size()); + for (String tool : PATH_PRECEDENCE_TOOLS) { + Path toolPath = this.tool2pathMap.get(tool); + if (toolPath != null) { + orderedPaths.add(toolPath); + } + } + for (Map.Entry entry : this.tool2pathMap.entrySet()) { + if (!PATH_PRECEDENCE_TOOLS.contains(entry.getKey())) { + orderedPaths.add(entry.getValue()); + } + } + return orderedPaths; + } + private static String getTool(Path path, Path ideRoot) { if (ideRoot == null) { @@ -290,7 +322,7 @@ public Path findBinary(Path toolPath, Predicate filter) { return binaryPath; } } - for (Path path : this.tool2pathMap.values()) { + for (Path path : getToolPathsInResolutionOrder()) { Path binaryPath = findBinaryInOrder(path, fileName); if (binaryPath != null && filter.test(binaryPath)) { return binaryPath; @@ -352,7 +384,7 @@ public String toString(WindowsPathSyntax pathSyntax) { for (Path path : this.extraPathEntries) { appendPath(path, sb, separator, pathSyntax); } - for (Path path : this.tool2pathMap.values()) { + for (Path path : getToolPathsInResolutionOrder()) { appendPath(path, sb, separator, pathSyntax); } for (Path path : this.paths) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java b/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java index 7948f360..15dd3511 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/AutoCompletionRegistry.java @@ -1,9 +1,12 @@ package com.devonfw.tools.ide.completion; -import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** @@ -11,34 +14,36 @@ */ public class AutoCompletionRegistry { - /** - * The registered completion candidates. + * The registered completion entries mapped by their candidate names. */ - private final List entries = new ArrayList<>(); - + private final Map entryMap = new LinkedHashMap<>(); /** - * Adds a new completion candidate. + * Adds a completion candidate together with its alternatives. * * @param candidate the candidate to add. + * @param alternatives to add a long with the candidate + * @return the {@link CompletionEntry} created for {@code candidate} for configuration. */ - public void add(String candidate) { - this.entries.add(new CompletionEntry(candidate)); - } + public CompletionEntry add(String candidate, String... alternatives) { - /** - * Adds a new completion candidate together with a synonym. For now this adds both values. - * - * @param candidate the candidate to add. - * @param synonym to add a long with the candidate - */ - public void add(String candidate, String synonym) { - CompletionEntry entry = new CompletionEntry(candidate); - entry.addSynonym(synonym); - this.entries.add(entry); - } + Set names = new LinkedHashSet<>(); + names.add(candidate); + names.addAll(List.of(alternatives)); + + Set immutableNames = Set.copyOf(names); + CompletionEntry entry = new CompletionEntry(candidate, names); + this.entryMap.put(candidate, entry); + + for (String alternative : immutableNames) { + if (!alternative.equals(candidate)) { + this.entryMap.put(alternative, new CompletionEntry(alternative, immutableNames)); + } + } + return entry; + } /** * Adds all candidates matching the given argument to the collector. @@ -46,15 +51,13 @@ public void add(String candidate, String synonym) { * @param arg the current argument to complete. * @param collector the {@link CompletionCandidateCollector}. * @param property the {@link Property} that triggered completion. - * @param commandlet the {@link Commandlet} owning the property. + * @param commandlet the {@link AbstractCommandlet} owning the property. */ public void complete(String arg, CompletionCandidateCollector collector, - Property property, Commandlet commandlet) { + Property property, AbstractCommandlet commandlet) { - for (CompletionEntry entry : this.entries) { + for (CompletionEntry entry : this.entryMap.values()) { entry.complete(arg, collector, property, commandlet); } } - - } diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java index b5e01c25..c64fc18b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollector.java @@ -5,7 +5,7 @@ import java.util.Set; import java.util.stream.Collectors; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** @@ -17,18 +17,18 @@ public interface CompletionCandidateCollector { * @param text the suggested word to add to auto-completion. * @param description the description of the suggestion candidate or {@code null} to determine automatically form the given parameters. * @param property the {@link Property} that triggered this suggestion. - * @param commandlet the {@link Commandlet} owning the {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning the {@link Property}. */ - void add(String text, String description, Property property, Commandlet commandlet); + void add(String text, String description, Property property, AbstractCommandlet commandlet); /** * @param text the suggested word to add to auto-completion. * @param description the description of the suggestion candidate or {@code null} to determine automatically form the given parameters. * @param property the {@link Property} that triggered this suggestion. - * @param commandlet the {@link Commandlet} owning the {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning the {@link Property}. * @return the {@link CompletionCandidate} for the given parameters. */ - default CompletionCandidate createCandidate(String text, String description, Property property, Commandlet commandlet) { + default CompletionCandidate createCandidate(String text, String description, Property property, AbstractCommandlet commandlet) { if (description == null) { // compute description from property + commandlet like in HelpCommandlet? @@ -41,10 +41,10 @@ default CompletionCandidate createCandidate(String text, String description, Pro * @param text the suggested word to add to auto-completion. * @param sortedCandidates the array of candidates sorted in ascending order. * @param property the {@link Property} that triggered this suggestion. - * @param commandlet the {@link Commandlet} owning the {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning the {@link Property}. * @return the number of {@link CompletionCandidate}s that have been added. */ - default int addAllMatches(String text, String[] sortedCandidates, Property property, Commandlet commandlet) { + default int addAllMatches(String text, String[] sortedCandidates, Property property, AbstractCommandlet commandlet) { if (text.isEmpty()) { for (String candidate : sortedCandidates) { @@ -64,7 +64,7 @@ default int addAllMatches(String text, String[] sortedCandidates, Property pr } /** - * Resets this {@link CompletionCandidateCollector} to reuse trying the next {@link Commandlet}. + * Resets this {@link CompletionCandidateCollector} to reuse trying the next {@link AbstractCommandlet}. */ default void clear() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java index 1e28cb60..d3446796 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorAdapter.java @@ -3,7 +3,7 @@ import java.util.List; import java.util.Set; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** @@ -29,7 +29,7 @@ public CompletionCandidateCollectorAdapter(String prefix, CompletionCandidateCol } @Override - public void add(String text, String description, Property property, Commandlet commandlet) { + public void add(String text, String description, Property property, AbstractCommandlet commandlet) { this.delegate.add(this.prefix + text, description, property, commandlet); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java index 4636f1b7..1e98a96a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefault.java @@ -9,7 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.Property; @@ -56,7 +56,7 @@ public CompletionCandidateCollectorDefault(IdeContext context, Set alrea } @Override - public void add(String text, String description, Property property, Commandlet commandlet) { + public void add(String text, String description, Property property, AbstractCommandlet commandlet) { // Check if this candidate already exists to avoid duplicates for (CompletionCandidate existing : this.candidates) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java index 9df9cc74..b68166fc 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java +++ b/cli/src/main/java/com/devonfw/tools/ide/completion/CompletionEntry.java @@ -1,66 +1,97 @@ package com.devonfw.tools.ide.completion; -import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.property.Property; /** - * A completion candidate that may have one or more synonyms. When any one of the candidate or its synonyms has already been provided on the command line, none - * of them will be suggested again. + * A completion candidate with optional alternatives and dependencies. The candidate and all of its alternatives represent completion options. Therefore, this + * entry is not suggested if the candidate or any of its alternatives has already been provided. */ public class CompletionEntry { - /** The primary candidate string. */ - private String candidate; + /** The candidate to suggest. */ + private final String candidate; - /** List of synonym strings for this candidate. */ - private List synonyms = new ArrayList<>(); + /** The names of this entry, including the candidate itself. */ + private final Set alternatives; + + /** The candidates of which at least one has to be provided before this entry may be suggested. */ + private final Set dependencies = new HashSet<>(); /** - * The constructor. + * The constructor for a completion entry with alternatives. * - * @param candidate the primary candidate to add. + * @param candidate the candidate to suggest. + * @param alternatives names of this entry, including {@code candidate}. */ - public CompletionEntry(String candidate) { + public CompletionEntry(String candidate, Set alternatives) { this.candidate = candidate; + this.alternatives = alternatives; } /** - * Adds a synonym for this candidate. - * - * @param synonym the synonym to add. + * @return the primary candidate string. */ - public void addSynonym(String synonym) { - this.synonyms.add(synonym); + public String getCandidate() { + return candidate; } /** - * Performs auto-completion for this entry, skipping it entirely if the candidate or any of its synonyms has already been provided on the command line. + * Performs auto-completion for this entry, skipping it entirely if the candidate or any of its alternatives has already been provided on the command line. * * @param arg the current argument being completed. * @param collector the {@link CompletionCandidateCollector} to add matching candidates to. * @param property the {@link Property} that triggered completion. - * @param commandlet the {@link Commandlet} owning the property. + * @param commandlet the {@link AbstractCommandlet} owning the property. */ - public void complete(String arg, CompletionCandidateCollector collector, Property property, Commandlet commandlet) { + public void complete(String arg, CompletionCandidateCollector collector, Property property, AbstractCommandlet commandlet) { Set alreadyProvided = collector.getAlreadyProvided(); - if (alreadyProvided != null && (alreadyProvided.contains(this.candidate) || synonyms.stream().anyMatch(alreadyProvided::contains))) { + if (!isDependencySatisfied(alreadyProvided)) { + return; + } + + if (isProvided(alreadyProvided)) { return; } if (candidate.startsWith(arg)) { collector.add(candidate, "", property, commandlet); } + } - for (String synonym : synonyms) { - if (synonym.startsWith(arg)) { - collector.add(synonym, "", property, commandlet); - } - } + /** + * Checks whether the dependencies of this entry are satisfied. + * + * @param alreadyProvided the already provided arguments. + * @return {@code true} if no dependency is configured or at least one dependency has been provided, {@code false} otherwise. + */ + private boolean isDependencySatisfied(Set alreadyProvided) { + + return this.dependencies.isEmpty() || this.dependencies.stream().anyMatch(alreadyProvided::contains); } + /** + * Checks if this candidate or any of its alternatives was already provided. + * + * @param alreadyProvided the set of already provided arguments. + * @return {@code true} if already provided, {@code false} otherwise. + */ + public boolean isProvided(Set alreadyProvided) { + return this.alternatives.stream().anyMatch(alreadyProvided::contains); + } + + /** + * Adds candidates of which at least one has to be provided before this entry may be suggested. + * + * @param entries array of {@link CompletionEntry} objects of which at least one must be provided. + */ + public void addDependency(String... entries) { + + this.dependencies.addAll(List.of(entries)); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 7e3632cb..23bf4d83 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -32,13 +32,13 @@ import com.devonfw.tools.ide.cli.CliArguments; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.cli.CliSuggester; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; import com.devonfw.tools.ide.commandlet.CommandletManagerImpl; import com.devonfw.tools.ide.commandlet.ContextCommandlet; import com.devonfw.tools.ide.commandlet.EnvironmentCommandlet; -import com.devonfw.tools.ide.commandlet.UpdateCommandlet; import com.devonfw.tools.ide.commandlet.UpgradeCommandlet; +import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.common.SystemPath; import com.devonfw.tools.ide.completion.CompletionCandidate; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; @@ -50,6 +50,7 @@ import com.devonfw.tools.ide.git.GitContext; import com.devonfw.tools.ide.git.GitContextImpl; import com.devonfw.tools.ide.git.GitUrl; +import com.devonfw.tools.ide.git.repository.RepositoryType; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.io.FileAccessImpl; import com.devonfw.tools.ide.log.IdeLogArgFormatter; @@ -73,7 +74,7 @@ import com.devonfw.tools.ide.property.Property; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.step.StepImpl; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.custom.CustomToolRepository; import com.devonfw.tools.ide.tool.custom.CustomToolRepositoryImpl; @@ -440,7 +441,11 @@ private String getMessageIdeHomeFound() { if (isPrivacyMode() && !WORKSPACE_MAIN.equals(wks)) { wks = "*".repeat(wks.length()); } - return "IDE environment variables have been set for " + formatArgument(this.ideHome) + " in workspace " + wks; + String workspaceMessage = " with workspace set to " + wks; + if (!this.cwd.startsWith(this.workspacePath)) { + workspaceMessage += " (fallback to default)"; + } + return "IDE environment variables have been set for " + formatArgument(this.ideHome) + workspaceMessage; } private String getMessageNotInsideIdeProject() { @@ -699,31 +704,15 @@ public Path getSettingsPath() { public Path getSettingsGitRepository() { Path settingsPath = getSettingsPath(); + RepositoryType settingsRepositoryType = RepositoryType.ofSettingsPath(settingsPath, this); // check whether the settings path has a .git folder only if its not a symbolic link or junction - if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !isSettingsCodeRepository()) { + if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !(settingsRepositoryType == RepositoryType.CODE_SETTINGS_COMBINED)) { LOG.error("Settings repository exists but is not a git repository."); return null; } return settingsPath; } - @Override - public boolean isSettingsCodeRepository() { - - Path settingsPath = getSettingsPath(); - if (settingsPath != null) { - boolean settingsIsLink = Files.isSymbolicLink(settingsPath) || getFileAccess().isJunction(settingsPath); - if (settingsIsLink) { - Path realPath = getFileAccess().toRealPath(this.settingsPath); - if (realPath != null) { - return getGitContext().isGitRepo(realPath.getParent()); - } - return true; - } - } - return false; - } - @Override public Path getSettingsCommitIdPath() { @@ -988,8 +977,8 @@ public void setEnvironmentOfInstalledTools(EnvironmentContext environmentContext if (getSoftwarePath() == null) { return; } - for (Commandlet commandlet : getCommandletManager().getCommandlets()) { - if (commandlet instanceof LocalToolCommandlet tool) { + for (AbstractCommandlet commandlet : getCommandletManager().getCommandlets()) { + if (commandlet instanceof AbstractLocalToolCommandlet tool) { Path toolPath = tool.getToolPath(); // we cannot use isInstalled() here since it may spawn processes (e.g. "npm --version") what would be way too expensive. if ((toolPath != null) && Files.isDirectory(toolPath)) { @@ -1143,8 +1132,8 @@ public String askForSecret(String message, String defaultValue) { * @param message the question to ask. * @param defaultValue the value to return if the user accepts the default (by entering an empty value) or {@code null} to re-ask until a value is * entered. - * @param secret - {@code true} to read the input in a masked way (see {@link #readSecretLine()}) and to mask it in the log output, {@code false} to - * read it as plain text. + * @param secret - {@code true} to read the input in a masked way (see {@link #readSecretLine()}) and to mask it in the log output, {@code false} to read + * it as plain text. * @return the entered value or the default value. */ private String ask(String message, String defaultValue, boolean secret) { @@ -1294,7 +1283,8 @@ public void endStep(StepImpl step) { } /** - * Finds the matching {@link Commandlet} to run, applies {@link CliArguments} to its {@link Commandlet#getProperties() properties} and will execute it. + * Finds the matching {@link AbstractCommandlet} to run, applies {@link CliArguments} to its {@link AbstractCommandlet#getProperties() properties} and will + * execute it. * * @param arguments the {@link CliArgument}. * @return the return code of the execution. @@ -1309,8 +1299,8 @@ public int run(CliArguments arguments) { assert (this.currentStep == null); boolean supressStepSuccess = false; StepImpl step = newStep(true, "ide", (Object[]) current.asArray()); - Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, null); - Commandlet cmd = null; + Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, null); + AbstractCommandlet cmd = null; ValidationResult result = null; try { while (commandletIterator.hasNext()) { @@ -1328,7 +1318,7 @@ public int run(CliArguments arguments) { if (commandKey == null || commandKey.isBlank()) { return 0; } - Commandlet commandletByName = this.commandletManager.getCommandlet(commandKey); + AbstractCommandlet commandletByName = this.commandletManager.getCommandlet(commandKey); // Missing commandlet if (commandletByName == null) { if (getCliSuggester().isMissingCommandletHandled(commandKey, step)) { @@ -1395,7 +1385,7 @@ private CliSuggester getCliSuggester() { /** * Ensure the logging system is initialized. */ - private void activateLogging(Commandlet cmd) { + private void activateLogging(AbstractCommandlet cmd) { configureJavaUtilLogging(cmd); this.startContext.activateLogging(); @@ -1404,9 +1394,9 @@ private void activateLogging(Commandlet cmd) { /** * Configures the logging system (JUL). * - * @param cmd the {@link Commandlet} to be called. May be {@code null}. + * @param cmd the {@link AbstractCommandlet} to be called. May be {@code null}. */ - public void configureJavaUtilLogging(Commandlet cmd) { + public void configureJavaUtilLogging(AbstractCommandlet cmd) { if (this.julConfigured) { return; @@ -1427,7 +1417,7 @@ public void configureJavaUtilLogging(Commandlet cmd) { } } - protected boolean isWriteLogfile(Commandlet cmd) { + protected boolean isWriteLogfile(AbstractCommandlet cmd) { if ((cmd == null) || !cmd.isWriteLogFile()) { return false; } @@ -1435,7 +1425,7 @@ protected boolean isWriteLogfile(Commandlet cmd) { return Boolean.TRUE.equals(writeLogfile); } - private Properties createJavaUtilLoggingProperties(boolean writeLogfile, Commandlet cmd) { + private Properties createJavaUtilLoggingProperties(boolean writeLogfile, AbstractCommandlet cmd) { Path idePath = getIdePath(); if (writeLogfile && (idePath == null)) { @@ -1462,7 +1452,7 @@ private Properties createJavaUtilLoggingProperties(boolean writeLogfile, Command return properties; } - private Path createLogfilePath(Path idePath, Commandlet cmd) { + private Path createLogfilePath(Path idePath, AbstractCommandlet cmd) { LocalDateTime now = LocalDateTime.now(); Path logsPath = idePath.resolve(FOLDER_LOGS).resolve(DateTimeUtil.formatDate(now, true)); StringBuilder sb = new StringBuilder(32); @@ -1491,11 +1481,12 @@ public void runWithoutLogging(Runnable lambda, IdeLogLevel threshold) { } /** - * @param cmd the potential {@link Commandlet} to {@link #apply(CliArguments, Commandlet) apply} and {@link Commandlet#run() run}. - * @return {@code true} if the given {@link Commandlet} matched and did {@link Commandlet#run() run} successfully, {@code false} otherwise (the - * {@link Commandlet} did not match and we have to try a different candidate). + * @param cmd the potential {@link AbstractCommandlet} to {@link #apply(CliArguments, AbstractCommandlet) apply} and + * {@link AbstractCommandlet#run() run}. + * @return {@code true} if the given {@link AbstractCommandlet} matched and did {@link AbstractCommandlet#run() run} successfully, {@code false} otherwise + * (the {@link AbstractCommandlet} did not match and we have to try a different candidate). */ - private ValidationResult applyAndRun(CliArguments arguments, Commandlet cmd) { + private ValidationResult applyAndRun(CliArguments arguments, AbstractCommandlet cmd) { IdeLogLevel previousLogLevel = null; cmd.reset(); @@ -1559,12 +1550,13 @@ settingsRepository, getSettingsCommitIdPath()))) { * determines the correct message to log, depending on whether the settings repository is a symlink/junction, or not. Should the user already be running the * appropriate {@code ide update} command, the message is suppressed to avoid confusion. * - * @param cmd the {@link Commandlet}. + * @param cmd the {@link AbstractCommandlet}. * @return {@code msg} to log to the console. {@code null} if the message is suppressed. */ - private String determineSettingsUpdateMessage(Commandlet cmd) { + private String determineSettingsUpdateMessage(AbstractCommandlet cmd) { boolean update = cmd instanceof UpdateCommandlet; - if (isSettingsCodeRepository()) { + RepositoryType settingsRepositoryType = RepositoryType.ofSettingsPath(getSettingsPath(), this); + if (settingsRepositoryType == RepositoryType.CODE_SETTINGS_COMBINED) { if (update && (isForceMode() || isForcePull())) { return null; } @@ -1577,7 +1569,7 @@ private String determineSettingsUpdateMessage(Commandlet cmd) { } } - private boolean ensureLicenseAgreement(Commandlet cmd) { + private boolean ensureLicenseAgreement(AbstractCommandlet cmd) { if (isTest()) { return true; // ignore for tests @@ -1672,14 +1664,14 @@ public List complete(CliArguments arguments, CompletionCand property.apply(arguments, this, cc, collector); } } - Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, collector); + Iterator commandletIterator = this.commandletManager.findCommandlet(arguments, collector); CliArgument current = arguments.current(); if (current.isCompletion() && current.isCombinedShortOption()) { collector.add(current.get(), null, null, null); } arguments.next(); while (commandletIterator.hasNext()) { - Commandlet cmd = commandletIterator.next(); + AbstractCommandlet cmd = commandletIterator.next(); if (!arguments.current().isEnd()) { completeCommandlet(arguments.copy(), cmd, collector); } @@ -1708,7 +1700,7 @@ private Property nextValueProperty(Iterator> valueIterator, CliAr return valueProperty; } - private void completeCommandlet(CliArguments arguments, Commandlet cmd, CompletionCandidateCollector collector) { + private void completeCommandlet(CliArguments arguments, AbstractCommandlet cmd, CompletionCandidateCollector collector) { LOG.trace("Trying to match arguments for auto-completion for commandlet {}", cmd.getName()); @@ -1773,10 +1765,10 @@ private void completeCommandlet(CliArguments arguments, Commandlet cmd, Completi /** * @param arguments the {@link CliArguments} to apply. Will be {@link CliArguments#next() consumed} as they are matched. Consider passing a * {@link CliArguments#copy() copy} as needed. - * @param cmd the potential {@link Commandlet} to match. + * @param cmd the potential {@link AbstractCommandlet} to match. * @return the {@link ValidationResult} telling if the {@link CliArguments} can be applied successfully or if validation errors ocurred. */ - public ValidationResult apply(CliArguments arguments, Commandlet cmd) { + public ValidationResult apply(CliArguments arguments, AbstractCommandlet cmd) { LOG.trace("Trying to match arguments to commandlet {}", cmd.getName()); CliArgument currentArgument = arguments.current(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index b141c847..d848aef2 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -10,7 +10,9 @@ import com.devonfw.tools.ide.cli.CliAbortException; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.cli.CliOfflineException; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; +import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet; import com.devonfw.tools.ide.common.SystemPath; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; @@ -30,6 +32,7 @@ import com.devonfw.tools.ide.tool.corepack.Corepack; import com.devonfw.tools.ide.tool.custom.CustomToolRepository; import com.devonfw.tools.ide.tool.gradle.Gradle; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.tool.npm.Npm; @@ -48,7 +51,7 @@ * referenced instead of duplicating such string literals across the code-base. All central components can be accessed from here such as: *

      *
    • {@link #getPath() system path} (abstraction of PATH environment variable)
    • - *
    • {@link #getCommandletManager() commandlet manager} (access {@link com.devonfw.tools.ide.commandlet.Commandlet}s)
    • + *
    • {@link #getCommandletManager() commandlet manager} (access {@link AbstractCommandlet}s)
    • *
    • {@link #getFileAccess() file access} (file and I/O operations on a higher level of abstraction)
    • *
    • {@link #getNetworkStatus() network status} (determine if we are online or offline)
    • *
    • {@link #newProcess() process context} (start external programs as process including logging, error handling, background and output processing)
    • @@ -69,7 +72,7 @@ public interface IdeContext extends IdeStartContext { /** * The default settings URL. * - * @see com.devonfw.tools.ide.commandlet.AbstractUpdateCommandlet + * @see AbstractUpdateCommandlet */ String DEFAULT_SETTINGS_REPO_URL = "https://github.com/devonfw/ide-settings.git"; @@ -309,7 +312,8 @@ default String askForSecret(String message) { void addSecretVariable(String name); /** - * Registers the value of a variable as secret if the variable was marked via {@link #addSecretVariable(String)}. Has to be called before the value is logged. + * Registers the value of a variable as secret if the variable was marked via {@link #addSecretVariable(String)}. Has to be called before the value is + * logged. * * @param name the name of the variable. * @param value the value of the variable. @@ -629,11 +633,6 @@ default Path getRepositoriesPath() { */ Path getSettingsGitRepository(); - /** - * @return {@code true} if the settings repository is a symlink or a junction to a code-repository. - */ - boolean isSettingsCodeRepository(); - /** * @return the {@link Path} to the file containing the last tracked commit Id of the settings repository. */ @@ -766,7 +765,7 @@ default IdeProgressBar newProgressBarForPlugins(long size) { } /** - * @return the {@link DirectoryMerger} used to configure and merge the workspace for an {@link com.devonfw.tools.ide.tool.ide.IdeToolCommandlet IDE}. + * @return the {@link DirectoryMerger} used to configure and merge the workspace for an {@link AbstractIdeToolCommandlet IDE}. */ DirectoryMerger getWorkspaceMerger(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java b/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java index d591f0d1..6d4d1003 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/AbstractEnvironmentVariables.java @@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.event.Level; +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.expression.ExpressionContext; import com.devonfw.tools.ide.expression.ExpressionFunctionManager; @@ -206,7 +207,9 @@ private String resolveRecursive(String value, Object source, int recursion, Abst return null; } if (recursion > MAX_RECURSION) { - throw new IllegalStateException( + // a circular variable reference is a configuration error of the user and therefore reported as CliException and not as IllegalStateException that + // would indicate a programming error and reach the end-user as a stacktrace + throw new CliException( "Reached maximum recursion resolving " + value + " for root variable " + context.rootSrc + " with value '" + context.rootValue + "'."); } recursion++; @@ -331,7 +334,9 @@ public String inverseResolve(String string, Object src, VariableSyntax syntax) { if (value == null) { value = variable.getDefaultValueAsString(this.context); } - if (value != null) { + // skip empty values: they carry nothing to inverse-resolve and String.replace("", token) would + // insert the variable expression between every character, corrupting the value + if ((value != null) && !value.isEmpty()) { result = result.replace(value, syntax.create(name)); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariables.java b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariables.java index a60553f9..76960e37 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariables.java @@ -6,6 +6,7 @@ import java.util.Locale; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; import com.devonfw.tools.ide.variable.VariableDefinition; import com.devonfw.tools.ide.variable.VariableSyntax; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -202,7 +203,7 @@ default EnvironmentVariables findVariable(String name) { * @param source the source where the {@link String} to resolve originates from. Should have a reasonable {@link Object#toString() string representation} * that will be used in error or log messages if a variable could not be resolved. * @return the given {@link String} with the variables resolved. - * @see com.devonfw.tools.ide.tool.ide.IdeToolCommandlet + * @see AbstractIdeToolCommandlet */ String resolve(String string, Object source); @@ -213,7 +214,7 @@ default EnvironmentVariables findVariable(String name) { * that will be used in error or log messages if a variable could not be resolved. * @param legacySupport - {@code true} for legacy support with {@link VariableSyntax#CURLY} as fallback, {@code false} otherwise. * @return the given {@link String} with the variables resolved. - * @see com.devonfw.tools.ide.tool.ide.IdeToolCommandlet + * @see AbstractIdeToolCommandlet */ String resolve(String string, Object source, boolean legacySupport); @@ -228,7 +229,7 @@ default EnvironmentVariables findVariable(String name) { * {@link Object#toString() string representation} that will be used in error or log messages if the inverse resolving was not working as expected. * @return the given {@link String} with {@link #get(String) variable values} replaced with according {@link com.devonfw.tools.ide.variable.VariableSyntax} * expressions. - * @see com.devonfw.tools.ide.tool.ide.IdeToolCommandlet + * @see AbstractIdeToolCommandlet */ default String inverseResolve(String string, Object source) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java index 9546c6cc..94612adf 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesSystem.java @@ -42,7 +42,9 @@ public Map getVariables() { public String getFlat(String name) { for (VariableDefinition variable : IdeVariables.VARIABLES) { - if ((variable != IdeVariables.PATH) && (variable != IdeVariables.IDE_ROOT) && (variable != IdeVariables.HOME) && name.equals(variable.getName())) { + // USER is like PATH, IDE_ROOT and HOME an environment variable provided by the operating system and not invented by IDEasy, so it is not blocked here + if ((variable != IdeVariables.PATH) && (variable != IdeVariables.IDE_ROOT) && (variable != IdeVariables.HOME) && (variable != IdeVariables.USER) + && name.equals(variable.getName())) { return null; } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesType.java b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesType.java index 37ae0a56..f6838268 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesType.java +++ b/cli/src/main/java/com/devonfw/tools/ide/environment/EnvironmentVariablesType.java @@ -1,6 +1,7 @@ package com.devonfw.tools.ide.environment; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; /** @@ -43,7 +44,7 @@ public enum EnvironmentVariablesType { /** * Type of {@link EnvironmentVariables} from the virtual - * {@link com.devonfw.tools.ide.tool.LocalToolCommandlet#setEnvironment(EnvironmentContext, ToolInstallation, boolean) tool environment. + * {@link AbstractLocalToolCommandlet#setEnvironment(EnvironmentContext, ToolInstallation, boolean) tool environment}. */ TOOL diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java index e77957d2..a2727031 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContext.java @@ -240,10 +240,13 @@ default void reset(Path repository, String branch) { String determineCurrentBranch(Path repository); /** + * Determines the remote that the branch of the given repository tracks (as configured via {@code branch..remote}). This is the same remote that + * {@link #isRepositoryUpdateAvailable(Path)} reads via {@code git rev-parse @{u}}. + * * @param repository the {@link Path} to the folder where the git repository is located. - * @return the name of the default origin. + * @return the name of the tracked remote (e.g. "origin") or {@code null} if the current branch has no upstream configured or no branch is checked out. */ - String determineRemote(Path repository); + String determineTrackedRemote(Path repository); /** * Saves the current git commit ID of a repository to a file given as an argument. @@ -296,7 +299,8 @@ default void push(Path repository) { * Pushes the local commits of the given repository to the remote repository. * * @param repository the {@link Path} to the git repository. - * @param followTags {@code true} to also push annotated tags reachable from the pushed commits (git push --follow-tags), {@code false} to push commits only. + * @param followTags {@code true} to also push annotated tags reachable from the pushed commits (git push --follow-tags), {@code false} to push commits + * only. */ void push(Path repository, boolean followTags); @@ -310,9 +314,8 @@ default void push(Path repository) { void addRemote(Path repository, String name, String url); /** - * Adds a new git remote to the given repository using {@code git remote add}. - * If the remote already exists with a different URL, an {@link IllegalStateException} is thrown - * instead of silently overriding it. + * Adds a new git remote to the given repository using {@code git remote add}. If the remote already exists with a different URL, an + * {@link IllegalStateException} is thrown instead of silently overriding it. * * @param repository the {@link Path} to the git repository. * @param name the name of the remote to add (e.g. "upstream"). @@ -320,4 +323,12 @@ default void push(Path repository) { * @throws IllegalStateException if the remote already exists with a different URL. */ void addRemoteOrFail(Path repository, String name, String url); + + /** + * Find the root of the git repository + * + * @param repository the {@link Path} to the git repository. + * @return the {@link Path} to the repository root + */ + Path findRepositoryRoot(Path repository); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java index 49adbb59..7e86b16c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java @@ -170,18 +170,19 @@ public void pullOrClone(GitUrl gitUrl, Path repository) { Objects.requireNonNull(repository); Objects.requireNonNull(gitUrl); - if (Files.isDirectory(repository.resolve(GIT_FOLDER))) { - // checks for remotes - String remote = determineRemote(repository); - if (remote == null) { - String message = repository + " is a local git repository with no remote - if you did this for testing, you may continue...\n" - + "Do you want to ignore the problem and continue anyhow?"; - this.context.askToContinue(message); - } else { - pull(repository); - } - } else { + if (!Files.isDirectory(repository.resolve(GIT_FOLDER))) { clone(gitUrl, repository); + return; + } + // the remote to pull from is the one the current branch is tracking (see also "git rev-parse @{u}") + String effectiveRemote = determineTrackedRemote(repository); + if (effectiveRemote == null) { + String message = repository + + " is a local git repository whose current branch does not track a remote - if you did this for testing, you may continue...\n" + + "Do you want to ignore the problem and continue anyhow?"; + this.context.askToContinue(message); + } else { + pull(repository); } } @@ -259,13 +260,19 @@ public void fetch(Path repository, String remote, String branch) { branch = determineCurrentBranch(repository); } if (remote == null) { - remote = determineRemote(repository); + // the remote to fetch from is the one the current branch is tracking (see also "git rev-parse @{u}") + remote = determineTrackedRemote(repository); + } + if (remote == null) { + // we are on a local branch without a configured upstream, there is no remote to fetch from + LOG.info("Skipping git fetch on {} because no remote is configured for branch {}.", repository, branch); + return; } - ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "fetch", Objects.requireNonNullElse(remote, "origin"), branch); + ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "fetch", remote, branch); if (!result.isSuccessful()) { - LOG.warn("Git fetch for '{}/{} failed.'.", remote, branch); + LOG.warn("Git fetch for '{}/{}' in repository '{}' failed.", remote, branch, repository); } } @@ -276,9 +283,13 @@ public String determineCurrentBranch(Path repository) { } @Override - public String determineRemote(Path repository) { + public String determineTrackedRemote(Path repository) { - return runGitCommandAndGetSingleOutput("Failed to determine current origin of git repository.", repository, "remote"); + String branch = determineCurrentBranch(repository); + if ((branch == null) || branch.isBlank()) { + return null; + } + return getOptionalGitConfigValue(repository, "branch." + branch + ".remote"); } @Override @@ -539,8 +550,8 @@ public void addRemoteOrFail(Path repository, String name, String url) { * @param repository the {@link Path} to the git repository. * @param name the name of the remote. * @param url the URL of the remote. - * @param failOnOverride {@code true} to throw an {@link IllegalStateException} if the remote already exists - * with a different URL, {@code false} to silently update it. + * @param failOnOverride {@code true} to throw an {@link IllegalStateException} if the remote already exists with a different URL, {@code false} to + * silently update it. */ protected void addRemote(Path repository, String name, String url, boolean failOnOverride) { @@ -606,4 +617,20 @@ public void push(Path repository, boolean followTags) { runGitCommand(repository, "push"); } } + + @Override + public Path findRepositoryRoot(Path directory) { + if (directory == null) { + return null; + } + + Path dir = this.context.getFileAccess().toRealPath(directory); + while (dir != null) { + if (isGitRepo(dir)) { + return dir; + } + dir = dir.getParent(); + } + return null; + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java index b89a3715..b09c3063 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryCommandlet.java @@ -11,20 +11,21 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.cli.CliAbortException; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.GitContext; import com.devonfw.tools.ide.git.GitUrl; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.property.RepositoryProperty; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; /** - * {@link Commandlet} to setup one or multiple GIT repositories for development. + * {@link AbstractCommandlet} to setup one or multiple GIT repositories for development. */ -public class RepositoryCommandlet extends Commandlet { +public class RepositoryCommandlet extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(RepositoryCommandlet.class); private static final String REPOSITORY = "repository"; @@ -74,7 +75,7 @@ protected void doRun() { boolean forceMode = this.context.isForceMode() || this.context.isForceRepositories(); Map repositoryConfigMap = new HashMap<>(propertiesFiles.size()); for (Path propertiesFile : propertiesFiles) { - RepositoryConfig config = prepareActiveRepository(propertiesFile, forceMode); + RepositoryConfig config = prepareActiveRepositoryIgnoringInvalid(propertiesFile, forceMode); if (config != null) { repositoryConfigMap.put(propertiesFile, config); } @@ -88,20 +89,39 @@ protected void doRun() { } } + /** + * Like {@link #prepareActiveRepository(Path, boolean)} but ignores a single invalid repository instead of aborting the setup of all others. Resolving a + * property value may fail for a malformed expression, and one broken file in the repositories folder must not prevent every other repository from being + * set up. Only used when iterating all repositories - if the user explicitly requested a single repository, the failure is reported instead. A + * {@link CliAbortException} is still propagated as the {@link Step} always re-throws it. + * + * @param repositoryFile the {@link Path} to the repository properties file. + * @param forceMode - {@code true} to setup the repository even if it is not active, {@code false} otherwise. + * @return the {@link RepositoryConfig} or {@code null} if the repository shall be skipped. + */ + private RepositoryConfig prepareActiveRepositoryIgnoringInvalid(Path repositoryFile, boolean forceMode) { + + Step step = this.context.newStep("Prepare repository", repositoryFile); + return step.call(() -> prepareActiveRepository(repositoryFile, forceMode), () -> null); + } + private RepositoryConfig prepareActiveRepository(Path repositoryFile, boolean forceMode) { - RepositoryConfig config = RepositoryConfig.loadProperties(repositoryFile, this.context); - if (config == null) { - return null; - } - if (!config.active()) { + // the active flag is evaluated before the remaining properties are read: reading a property resolves variables and expressions in its value, so an + // expression like @ask-variable would otherwise ask the user for a repository that is skipped anyway + RepositoryProperties properties = new RepositoryProperties(repositoryFile, this.context); + if (!properties.isActive()) { if (forceMode) { - LOG.info("Setup of repository {} is forced, hence proceeding ...", config.id()); + LOG.info("Setup of repository {} is forced, hence proceeding ...", properties.getId()); } else { - LOG.info("Skipping repository {} because it is not active, use --force-repositories to setup all repositories ...", config.id()); + LOG.info("Skipping repository {} because it is not active, use --force-repositories to setup all repositories ...", properties.getId()); return null; } } + RepositoryConfig config = RepositoryConfig.loadProperties(properties); + if (config == null) { + return null; + } // prepare workspace creation for correct resolution of * List workspaces = config.workspaces(); for (String workspace : workspaces) { @@ -238,7 +258,7 @@ private boolean buildRepository(RepositoryConfig repositoryConfig, Path reposito if (buildCmd != null && !buildCmd.isEmpty()) { return this.context.newStep("Build repository via: " + buildCmd).run(() -> { String[] command = buildCmd.split("\\s+"); - ToolCommandlet commandlet = this.context.getCommandletManager().getToolCommandlet(command[0]); + AbstractToolCommandlet commandlet = this.context.getCommandletManager().getToolCommandlet(command[0]); if (commandlet == null) { String displayName = (command[0] == null || command[0].isBlank()) ? "" : "'" + command[0] + "'"; LOG.error("Cannot build repository. Required tool '{}' not found. Please check your repository's build_cmd configuration value.", @@ -273,13 +293,13 @@ private void importRepository(RepositoryConfig repositoryConfig, Path repository for (String ide : imports) { Step step = this.context.newStep("Importing repository " + repositoryId + " into " + ide); step.run(() -> { - ToolCommandlet commandlet = this.context.getCommandletManager().getToolCommandlet(ide); + AbstractToolCommandlet commandlet = this.context.getCommandletManager().getToolCommandlet(ide); if (commandlet == null) { String displayName = (ide == null || ide.isBlank()) ? "" : "'" + ide + "'"; step.error("Cannot import repository '{}'. Required IDE '{}' not found. Please check your repository's imports configuration.", repositoryId, displayName); - } else if (commandlet instanceof IdeToolCommandlet ideCommandlet) { - ideCommandlet.importRepository(repositoryPath); + } else if (commandlet instanceof IdeToolCommandlet ideToolCommandlet) { + ideToolCommandlet.importRepository(repositoryPath); } else { step.error("Repository {} has import {} configured that is not an IDE!", repositoryId, ide); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java index 6b327476..b8e19054 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryConfig.java @@ -1,8 +1,6 @@ package com.devonfw.tools.ide.git.repository; -import java.nio.file.Path; import java.util.List; -import java.util.Properties; import java.util.Set; import com.devonfw.tools.ide.context.IdeContext; @@ -58,13 +56,12 @@ public GitUrl asGitUrl() { } /** - * @param filePath the {@link Path} to the {@link Properties} to load. - * @param context the {@link IdeContext}. - * @return the parsed {@link RepositoryConfig}. + * @param properties the {@link RepositoryProperties} to parse. The caller loads them so it can inspect e.g. the + * {@link RepositoryProperties#isActive() active flag} before the remaining properties are read and thereby resolved. + * @return the parsed {@link RepositoryConfig} or {@code null} if the properties are {@link RepositoryProperties#isInvalid() invalid}. */ - public static RepositoryConfig loadProperties(Path filePath, IdeContext context) { + static RepositoryConfig loadProperties(RepositoryProperties properties) { - RepositoryProperties properties = new RepositoryProperties(filePath, context); String id = properties.getId(); RepositoryConfig config = new RepositoryConfig(id, properties.getPath(), properties.getWorkingSets(), properties.getWorkspaces(), properties.getGitUrl(), properties.getGitBranch(), properties.getBuildPath(), properties.getBuildCmd(), properties.getImports(), properties.getLinks(), properties.getRemotes(), diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java index 27d3969b..a36a48dd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryProperties.java @@ -4,8 +4,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.regex.Pattern; @@ -47,6 +49,11 @@ final class RepositoryProperties { private final Properties properties; + private final IdeContext context; + + /** Cache of already resolved property values so that resolving never happens twice - see {@link #doGetProperty(String, String)}. */ + private final Map resolvedProperties; + private boolean invalid; /** @@ -56,7 +63,7 @@ final class RepositoryProperties { * @param context the {@link IdeContext}. */ public RepositoryProperties(Path file, IdeContext context) { - this(file, context.getFileAccess().readProperties(file)); + this(file, context.getFileAccess().readProperties(file), context); } /** @@ -64,9 +71,20 @@ public RepositoryProperties(Path file, IdeContext context) { * @param properties the actual {@link Properties} loaded from the file. */ RepositoryProperties(Path file, Properties properties) { + this(file, properties, null); + } + + /** + * @param file the {@link Path} to the properties file. + * @param properties the actual {@link Properties} loaded from the file. + * @param context the {@link IdeContext} used to resolve variables and expressions or {@code null} to disable resolution. + */ + RepositoryProperties(Path file, Properties properties, IdeContext context) { super(); this.file = file; this.properties = properties; + this.context = context; + this.resolvedProperties = new HashMap<>(); } /** @@ -120,6 +138,18 @@ public String getProperty(String name, String legacyName, boolean required) { private String doGetProperty(String name, String legacyName) { + // the result is cached since resolving may have side-effects: an expression like @ask-variable asks the user, so reading the same property twice + // (e.g. the active flag, that is read by RepositoryCommandlet and again by RepositoryConfig) must not ask twice. + if (this.resolvedProperties.containsKey(name)) { + return this.resolvedProperties.get(name); + } + String value = resolve(doGetRawProperty(name, legacyName)); + this.resolvedProperties.put(name, value); + return value; + } + + private String doGetRawProperty(String name, String legacyName) { + String value = this.properties.getProperty(name); if (value != null) { return value; @@ -142,6 +172,23 @@ private String doGetProperty(String name, String legacyName) { return value; } + /** + * Resolves {@link com.devonfw.tools.ide.variable.VariableSyntax#SQUARE variables} and expressions in the given property value. This is done centrally here so + * that it applies to every property and happens before any validation (e.g. of a {@link #getPath() path}) that would otherwise reject the syntax of an + * unresolved value. + * + * @param value the raw property value or {@code null}. + * @return the given value with variables and expressions resolved. + */ + private String resolve(String value) { + + if ((value == null) || (this.context == null)) { + return value; + } + // legacy support is disabled on purpose: the ${...} syntax is common in build commands (e.g. maven properties in build_cmd) and must not be touched + return this.context.getVariables().resolve(value, this.file, false); + } + private static boolean isEmpty(String value) { return (value == null) || value.isBlank(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java new file mode 100644 index 00000000..7268317e --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java @@ -0,0 +1,98 @@ +package com.devonfw.tools.ide.git.repository; + +import java.nio.file.Files; +import java.nio.file.Path; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; + +/** + * Enum representation of a detected {@link RepositoryType}. + */ +public enum RepositoryType { + + /** Git Repository is a settings repository. */ + SETTINGS, + + /** A combined code & settings repository contains both the settings-folder and the code within the workspace folder. */ + CODE_SETTINGS_COMBINED, + + /** The type of the repository could not be determined. */ + UNKNOWN; + + /** + * Checks whether the given git repository is a settings repository, a combined settings and code repository, or a typical code repository. A combined code + * and settings repository is detected by a top-level {@code settings} folder that itself is a valid settings folder. + * + * @param repositoryPath the {@link Path} to the repository to check. + * @param ideContext a {@link IdeContext} + * @return the {@link RepositoryType} of the repository. + */ + public static RepositoryType ofGitRoot(Path repositoryPath, IdeContext ideContext) { + + if (repositoryPath == null || !Files.isDirectory(repositoryPath)) { + return RepositoryType.UNKNOWN; + } + if (isSettingsFolder(repositoryPath) && ideContext.getGitContext().isGitRepo(repositoryPath)) { + return RepositoryType.SETTINGS; + } + Path settingsFolder = repositoryPath.resolve(IdeContext.FOLDER_SETTINGS); + if (isSettingsFolder(settingsFolder)) { + return RepositoryType.CODE_SETTINGS_COMBINED; + } + // there is no valid settings folder to be found. + return RepositoryType.UNKNOWN; + } + + /** + * @param settingsPath + * @param context + * @return + */ + public static RepositoryType ofSettingsPath(Path settingsPath, IdeContext context) { + + if (settingsPath == null || context == null) { + return RepositoryType.UNKNOWN; + } + if (context.getGitContext().isGitRepo(settingsPath)) { + return RepositoryType.SETTINGS; + } else if (isCombinedSettingsCodeRepository(settingsPath, context)) { + return RepositoryType.CODE_SETTINGS_COMBINED; + } + return RepositoryType.UNKNOWN; + } + + /** + * @return true if repository is either of type {@code SETTINGS} or {@code CODE_SETTINGS_COMBINED} + */ + public boolean isValid() { + return this == SETTINGS || this == CODE_SETTINGS_COMBINED; + } + + /** + * @param folder the {@link Path} to check. + * @return {@code true} if the given {@code folder} is the root of a settings repository, {@code false} otherwise. + */ + private static boolean isSettingsFolder(Path folder) { + + return (Files.exists(folder.resolve(EnvironmentVariables.DEFAULT_PROPERTIES)) + || Files.exists(folder.resolve(EnvironmentVariables.LEGACY_PROPERTIES))); + } + + private static boolean isCombinedSettingsCodeRepository(Path settingsPath, IdeContext context) { + + FileAccess fileAccess = context.getFileAccess(); + if (settingsPath != null) { + boolean settingsIsLink = Files.isSymbolicLink(settingsPath) || fileAccess.isJunction(settingsPath); + if (settingsIsLink) { + Path realPath = fileAccess.toRealPath(settingsPath); + if (realPath != null) { + return context.getGitContext().isGitRepo(realPath.getParent()); + } + return true; + } + } + return false; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java index 931c59a5..c4db00f3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java @@ -16,6 +16,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.DosFileAttributeView; import java.nio.file.attribute.FileTime; @@ -192,24 +193,18 @@ private void downloadFileWithProgressBar(String url, Path target, HttpResponse 0) { - out.write(buf, 0, readBytes); - pb.stepBy(readBytes); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } catch (IOException e) { - throw new RuntimeException("Failed to copy from " + source + " to " + target, e); + try (IdeProgressBar pb = this.context.newProgressbarForCopying(size)) { + copy(source, target, mode, PathCopyListener.NONE, pb); } } @@ -359,6 +354,11 @@ public void move(Path source, Path targetDir, StandardCopyOption... copyOptions) @Override public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) { + copy(source, target, mode, listener, null); + } + + private void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener, IdeProgressBar progressBar) { + if (mode.isUseSourceFilename()) { // if we want to copy the file or folder "source" to the existing folder "target" in a shell this will copy // source into that folder so that we as a result have a copy in "target/source". @@ -395,13 +395,13 @@ public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener l delete(target); } try { - copyRecursive(source, target, mode, listener); + copyRecursive(source, target, mode, listener, progressBar); } catch (IOException e) { throw new IllegalStateException("Failed to " + operation + " " + source + " to " + target, e); } } - private void copyRecursive(Path source, Path target, FileCopyMode mode, PathCopyListener listener) throws IOException { + private void copyRecursive(Path source, Path target, FileCopyMode mode, PathCopyListener listener, IdeProgressBar progressBar) throws IOException { if (Files.isDirectory(source)) { mkdirs(target); @@ -409,7 +409,7 @@ private void copyRecursive(Path source, Path target, FileCopyMode mode, PathCopy Iterator iterator = childStream.iterator(); while (iterator.hasNext()) { Path child = iterator.next(); - copyRecursive(child, target.resolve(child.getFileName().toString()), mode, listener); + copyRecursive(child, target.resolve(child.getFileName().toString()), mode, listener, progressBar); } } listener.onCopy(source, target, true); @@ -418,13 +418,33 @@ private void copyRecursive(Path source, Path target, FileCopyMode mode, PathCopy delete(target); } LOG.trace("Starting to {} {} to {}", mode.getOperation(), source, target); - Files.copy(source, target); + if (progressBar == null) { + Files.copy(source, target); + } else { + copyFileBytes(source, target, progressBar); + } listener.onCopy(source, target, false); } else { throw new IOException("Path " + source + " does not exist."); } } + private void copyFileBytes(Path source, Path target, IdeProgressBar progressBar) throws IOException { + + try (InputStream in = Files.newInputStream(source); OutputStream out = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW)) { + byte[] buf = new byte[1024]; + int readBytes; + while ((readBytes = in.read(buf)) > 0) { + out.write(buf, 0, readBytes); + progressBar.stepBy(readBytes); + } + } + // unlike Files.copy streaming does not carry over the file mode so e.g. executables inside a *.app would break + if (!skipPermissionsIfWindows(target)) { + Files.setPosixFilePermissions(target, Files.getPosixFilePermissions(source)); + } + } + /** * Deletes the given {@link Path} if it is a symbolic link or a Windows junction or hard link. And throws an {@link IllegalStateException} if it fails to * delete the file at the given {@link Path}. @@ -1009,7 +1029,7 @@ public void extractDmg(Path file, Path targetDir) { throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file); } - copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE); + copyWithProgressBar(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE); pc.addArgs("detach", "-force", mountPath); pc.run(); } @@ -1417,6 +1437,13 @@ public boolean isNonEmptyFile(Path file) { private long getFileSize(Path file) { + if (Files.isDirectory(file)) { + long size = 0; + for (Path child : listChildren(file, f -> true)) { + size += getFileSize(child); + } + return size; + } try { return Files.size(file); } catch (IOException e) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/log/IdeLogLevel.java b/cli/src/main/java/com/devonfw/tools/ide/log/IdeLogLevel.java index 523bcf76..6d38711f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/log/IdeLogLevel.java +++ b/cli/src/main/java/com/devonfw/tools/ide/log/IdeLogLevel.java @@ -6,6 +6,7 @@ import org.slf4j.event.Level; import org.slf4j.spi.LoggingEventBuilder; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeStartContextImpl; /** @@ -42,7 +43,7 @@ public enum IdeLogLevel { */ ERROR("\033[91m", Level.ERROR, null, JulLogLevel.ERROR), - /** {@link IdeLogLevel} for {@link com.devonfw.tools.ide.commandlet.Commandlet#isProcessableOutput() processable output} */ + /** {@link IdeLogLevel} for {@link AbstractCommandlet#isProcessableOutput() processable output} */ PROCESSABLE(null, Level.INFO, MarkerFactory.getMarker("PROCESSABLE"), JulLogLevel.PROCESSABLE); private final String color; diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java index 5108a85c..312cb6fd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java @@ -53,6 +53,8 @@ public DirectoryMerger(IdeContext context) { this.extension2mergerMap.put("launch", xmlMerger); // Eclipse specific JsonMerger jsonMerger = new JsonMerger(context); this.extension2mergerMap.put("json", jsonMerger); + IniMerger iniMerger = new IniMerger(context); + this.extension2mergerMap.put("ini", iniMerger); TextMerger textMerger = new TextMerger(context); this.extension2mergerMap.put("name", textMerger); // intellij specific this.extension2mergerMap.put("editorconfig", textMerger); @@ -63,6 +65,23 @@ public DirectoryMerger(IdeContext context) { @Override public int merge(Path setup, Path update, EnvironmentVariables variables, Path workspace) { + return merge(setup, update, variables, workspace, Map.of()); + } + + /** + * Same as {@link #merge(Path, Path, EnvironmentVariables, Path)} but allows to merge templates of specific sub-folders to a different location outside the + * {@code workspace}. + * + * @param setup the setup {@link Path} for creation. + * @param update the update {@link Path} for creation and update. + * @param variables the {@link EnvironmentVariables} to {@link EnvironmentVariables#resolve(String, Object) resolve variables}. + * @param workspace the workspace {@link Path} to create or update. + * @param redirects the {@link Map} with the {@link Path}s inside the {@code workspace} as keys and the {@link Path}s where to merge them instead as + * values. + * @return the number of errors that occurred. Should be {@code 0} for success. + */ + public int merge(Path setup, Path update, EnvironmentVariables variables, Path workspace, Map redirects) { + int errors = 0; Set children = null; children = addChildren(setup, children); @@ -74,7 +93,9 @@ public int merge(Path setup, Path update, EnvironmentVariables variables, Path w } else { // directory scan for (String filename : children) { - errors += merge(setup.resolve(filename), update.resolve(filename), variables, workspace.resolve(filename)); + Path target = workspace.resolve(filename); + target = redirects.getOrDefault(target, target); + errors += merge(setup.resolve(filename), update.resolve(filename), variables, target, redirects); } } return errors; diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java new file mode 100644 index 00000000..df3b8795 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/IniMerger.java @@ -0,0 +1,201 @@ +package com.devonfw.tools.ide.merge; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.io.ini.IniFile; +import com.devonfw.tools.ide.io.ini.IniFileImpl; +import com.devonfw.tools.ide.io.ini.IniSection; + +/** + * Implementation of {@link FileMerger} for {@code .ini} files. + */ +public class IniMerger extends FileMerger { + + private static final Logger LOG = LoggerFactory.getLogger(IniMerger.class); + + /** + * The constructor. + * + * @param context the {@link #context}. + */ + public IniMerger(IdeContext context) { + super(context); + } + + @Override + protected void doMerge(Path setup, Path update, EnvironmentVariables resolver, Path workspace) { + + FileAccess fileAccess = this.context.getFileAccess(); + IniFile mergedIni = new IniFileImpl(); + boolean updateFileExists = Files.exists(update); + Path template = setup; + if (Files.exists(workspace)) { + if (!updateFileExists) { + LOG.trace("Nothing to do as update file does not exist: {}", update); + return; // nothing to do ... + } + fileAccess.readIniFile(workspace, mergedIni); + } else if (Files.exists(setup)) { + fileAccess.readIniFile(setup, mergedIni); + } + if (updateFileExists) { + IniFile updateIni = new IniFileImpl(); + fileAccess.readIniFile(update, updateIni); + mergeIniInto(updateIni, mergedIni); + template = update; + } + + resolve(mergedIni, resolver, template.toString()); + fileAccess.writeIniFile(mergedIni, workspace, true); + LOG.trace("Saved merged ini to: {}", workspace); + } + + /** + * Merge the properties from {@code source} into {@code target}. Keys that exist in both: target gets overwritten with source. Keys that exist only in target: + * preserved (user modifications). + * + * @param source the source INI (update template). + * @param target the target INI (workspace or setup base). + */ + private void mergeIniInto(IniFile source, IniFile target) { + for (String sectionName : source.getSectionNames()) { + IniSection srcSection = source.getSection(sectionName); + IniSection tgtSection = target.getOrCreateSection(sectionName); + for (String key : srcSection.getPropertyKeys()) { + tgtSection.setProperty(key, srcSection.getPropertyValue(key)); + } + } + IniSection srcInitial = source.getInitialSection(); + IniSection tgtInitial = target.getOrCreateSection(""); + for (String key : srcInitial.getPropertyKeys()) { + tgtInitial.setProperty(key, srcInitial.getPropertyValue(key)); + } + } + + private void resolve(IniFile iniFile, EnvironmentVariables resolver, String src) { + + for (String sectionName : iniFile.getSectionNames()) { + IniSection section = iniFile.getSection(sectionName); + for (String key : section.getPropertyKeys()) { + String value = section.getPropertyValue(key); + String resolved = resolver.resolve(value, src, this.legacySupport); + section.setProperty(key, resolved); + } + } + IniSection initial = iniFile.getInitialSection(); + for (String key : initial.getPropertyKeys()) { + String value = initial.getPropertyValue(key); + String resolved = resolver.resolve(value, src, this.legacySupport); + initial.setProperty(key, resolved); + } + } + + @Override + public void inverseMerge(Path workspace, EnvironmentVariables variables, boolean addNewProperties, Path update) { + + if (!Files.exists(workspace)) { + LOG.trace("Workspace file does not exist: {}", workspace); + return; + } + if (!Files.exists(update)) { + LOG.trace("Update file does not exist: {}", update); + return; + } + Object src = workspace.getFileName(); + FileAccess fileAccess = this.context.getFileAccess(); + IniFile updateIni = fileAccess.readIniFile(update); + IniFile workspaceIni = fileAccess.readIniFile(workspace); + IniFile mergedIni = new IniFileImpl(); + copyIniInto(updateIni, mergedIni); + boolean updated = false; + + for (String sectionName : workspaceIni.getSectionNames()) { + IniSection wsSection = workspaceIni.getSection(sectionName); + IniSection mergedSection = mergedIni.getOrCreateSection(sectionName); + for (String key : wsSection.getPropertyKeys()) { + String wsValue = wsSection.getPropertyValue(key); + String updateValue = getPropertyValue(mergedSection, key); + if ((updateValue != null) || addNewProperties) { + String updateValueResolved = updateValue != null + ? variables.resolve(updateValue, src, this.legacySupport) + : null; + if (!wsValue.equals(updateValueResolved)) { + String wsValueInverseResolved = variables.inverseResolve(wsValue, src); + mergedSection.setProperty(key, wsValueInverseResolved); + updated = true; + } + } + } + } + + IniSection wsInitial = workspaceIni.getInitialSection(); + IniSection mergedInitial = mergedIni.getOrCreateSection(""); + for (String key : wsInitial.getPropertyKeys()) { + String wsValue = wsInitial.getPropertyValue(key); + String updateValue = getPropertyValue(mergedInitial, key); + if ((updateValue != null) || addNewProperties) { + String updateValueResolved = updateValue != null + ? variables.resolve(updateValue, src, this.legacySupport) + : null; + if (!wsValue.equals(updateValueResolved)) { + String wsValueInverseResolved = variables.inverseResolve(wsValue, src); + mergedInitial.setProperty(key, wsValueInverseResolved); + updated = true; + } + } + } + + if (updated) { + fileAccess.writeIniFile(mergedIni, update, true); + LOG.debug("Saved changes from: {} to: {}", workspace.getFileName(), update); + } else { + LOG.trace("No changes for: {}", update); + } + } + + @Override + protected boolean doUpgrade(Path workspaceFile) throws Exception { + + return doUpgradeTextContent(workspaceFile); + } + + /** + * Returns the value of the given property key in the given {@link IniSection} or {@code null} if the section does not contain the key. + * + * @param section the {@link IniSection} to look up in. + * @param key the property key. + * @return the value or {@code null} if the key is not present. + */ + private static String getPropertyValue(IniSection section, String key) { + + return (section != null) && section.getPropertyKeys().contains(key) ? section.getPropertyValue(key) : null; + } + + /** + * Copy all sections and properties from source INI to target INI. + * + * @param src the source INI. + * @param tgt the target INI. + */ + private void copyIniInto(IniFile src, IniFile tgt) { + for (String sectionName : src.getSectionNames()) { + IniSection srcSection = src.getSection(sectionName); + IniSection tgtSection = tgt.getOrCreateSection(sectionName); + for (String key : srcSection.getPropertyKeys()) { + tgtSection.setProperty(key, srcSection.getPropertyValue(key)); + } + } + IniSection srcInit = src.getInitialSection(); + IniSection tgtInit = tgt.getOrCreateSection(""); + for (String key : srcInit.getPropertyKeys()) { + tgtInit.setProperty(key, srcInit.getPropertyValue(key)); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/WorkspaceMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/WorkspaceMerger.java index dfee5ed1..4b606a92 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/WorkspaceMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/WorkspaceMerger.java @@ -3,9 +3,10 @@ import java.nio.file.Path; import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; /** - * Interface for a merger responsible for merging {@link com.devonfw.tools.ide.tool.ide.IdeToolCommandlet IDE} configuration files into the workspace. + * Interface for a merger responsible for merging {@link AbstractIdeToolCommandlet IDE} configuration files into the workspace. */ public interface WorkspaceMerger { diff --git a/cli/src/main/java/com/devonfw/tools/ide/nls/NlsBundle.java b/cli/src/main/java/com/devonfw/tools/ide/nls/NlsBundle.java index 006ff936..b0204b80 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/nls/NlsBundle.java +++ b/cli/src/main/java/com/devonfw/tools/ide/nls/NlsBundle.java @@ -6,7 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.property.Property; @@ -86,29 +86,29 @@ public String getOrNull(String key) { } /** - * @param commandlet the {@link com.devonfw.tools.ide.commandlet.Commandlet} to get the help summary for. + * @param commandlet the {@link AbstractCommandlet} to get the help summary for. * @return the localized message (translated to the users language). */ - public String get(Commandlet commandlet) { + public String get(AbstractCommandlet commandlet) { return get("cmd." + commandlet.getName()); } /** - * @param commandlet the {@link com.devonfw.tools.ide.commandlet.Commandlet} to get the help detail for. + * @param commandlet the {@link AbstractCommandlet} to get the help detail for. * @return the localized message (translated to the users language). */ - public String getDetail(Commandlet commandlet) { + public String getDetail(AbstractCommandlet commandlet) { return get("cmd." + commandlet.getName() + ".detail"); } /** - * @param commandlet the {@link Commandlet} {@link Commandlet#getProperties() owning} the given {@link Property}. + * @param commandlet the {@link AbstractCommandlet} {@link AbstractCommandlet#getProperties() owning} the given {@link Property}. * @param property the {@link Property} to the the description of. * @return the localized message describing the property. */ - public String get(Commandlet commandlet, Property property) { + public String get(AbstractCommandlet commandlet, Property property) { String prefix = "opt."; if (property.isValue()) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java b/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java index 80d07de1..a096173f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java +++ b/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java @@ -15,7 +15,7 @@ import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; /** @@ -147,11 +147,11 @@ public Path findLinkDir(Path rootDir, String tool) { /** * Finds the root tool path of a tool in MacOS * - * @param commandlet the {@link ToolCommandlet} + * @param commandlet the {@link AbstractToolCommandlet} * @param context the {@link IdeContext} * @return a {@link String} */ - public Path findRootToolPath(ToolCommandlet commandlet, IdeContext context) { + public Path findRootToolPath(AbstractToolCommandlet commandlet, IdeContext context) { return context.getSoftwareRepositoryPath().resolve(ToolRepository.ID_DEFAULT).resolve(commandlet.getName()) .resolve(commandlet.getInstalledEdition()) .resolve(commandlet.getInstalledVersion().toString()); diff --git a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java index 9ef23ebf..3869d3f2 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java @@ -49,6 +49,8 @@ public class ProcessContextImpl implements ProcessContext { protected Path executable; + private Path workingDirectory; + private String overriddenPath; private final List extraPathEntries; @@ -104,6 +106,7 @@ public ProcessContext errorHandling(ProcessErrorHandling handling) { public ProcessContext directory(Path directory) { if (directory != null) { + this.workingDirectory = directory; this.processBuilder.directory(directory.toFile()); } else { LOG.debug( @@ -315,6 +318,11 @@ private String createCommandMessage(String interpreter, String suffix) { sb.append("Running command '"); sb.append(this.executable); sb.append("'"); + if (this.workingDirectory != null) { + sb.append(" in '"); + sb.append(this.workingDirectory); + sb.append("'"); + } if (interpreter != null) { sb.append(" using "); sb.append(interpreter); diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/BooleanProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/BooleanProperty.java index 680b62b1..1fb88edf 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/BooleanProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/BooleanProperty.java @@ -1,7 +1,7 @@ package com.devonfw.tools.ide.property; import com.devonfw.tools.ide.cli.CliArguments; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; @@ -82,7 +82,7 @@ public void setValueAsString(String valueAsString, IdeContext context) { } @Override - protected boolean applyValue(String argValue, boolean lookahead, CliArguments args, IdeContext context, Commandlet commandlet, + protected boolean applyValue(String argValue, boolean lookahead, CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { if (lookahead) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/CommandletProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/CommandletProperty.java index 76b42d0a..696b01ae 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/CommandletProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/CommandletProperty.java @@ -1,14 +1,14 @@ package com.devonfw.tools.ide.property; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.validation.PropertyValidator; /** - * {@link Property} with {@link #getValueType() value type} {@link Commandlet}. + * {@link Property} with {@link #getValueType() value type} {@link AbstractCommandlet}. */ -public class CommandletProperty extends Property { +public class CommandletProperty extends Property { /** * The constructor. @@ -30,27 +30,27 @@ public CommandletProperty(String name, boolean required, String alias) { * @param alias the {@link #getAlias() property alias}. * @param validator the {@link PropertyValidator} used to {@link #validate() validate} the {@link #getValue() value}. */ - public CommandletProperty(String name, boolean required, String alias, PropertyValidator validator) { + public CommandletProperty(String name, boolean required, String alias, PropertyValidator validator) { super(name, required, alias, false, validator); } @Override - public Class getValueType() { + public Class getValueType() { - return Commandlet.class; + return AbstractCommandlet.class; } @Override - protected String format(Commandlet valueToFormat) { + protected String format(AbstractCommandlet valueToFormat) { return valueToFormat.getName(); } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { - for (Commandlet cmd : context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : context.getCommandletManager().getCommandlets()) { String cmdName = cmd.getName(); if (cmdName.startsWith(arg)) { collector.add(cmdName, null, null, cmd); @@ -59,9 +59,9 @@ protected void completeValue(String arg, IdeContext context, Commandlet commandl } @Override - public Commandlet parse(String valueAsString, IdeContext context) { + public AbstractCommandlet parse(String valueAsString, IdeContext context) { - Commandlet commandlet = context.getCommandletManager().getCommandlet(valueAsString); + AbstractCommandlet commandlet = context.getCommandletManager().getCommandlet(valueAsString); if (commandlet == null) { throw new IllegalArgumentException(valueAsString); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/EnumProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/EnumProperty.java index fd3d0ec2..42f4626b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/EnumProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/EnumProperty.java @@ -2,7 +2,7 @@ import java.util.Locale; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; @@ -49,7 +49,7 @@ public V parse(String valueAsString, IdeContext context) { } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { for (V enumConstant : this.valueType.getEnumConstants()) { String name = enumConstant.name().toLowerCase(Locale.ROOT); diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/KeywordProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/KeywordProperty.java index eb67cae1..5121f4db 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/KeywordProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/KeywordProperty.java @@ -1,7 +1,7 @@ package com.devonfw.tools.ide.property; import com.devonfw.tools.ide.cli.CliArguments; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; @@ -64,7 +64,7 @@ public boolean matches(String nameOrAlias) { } @Override - public boolean apply(CliArguments args, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + public boolean apply(CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { String normalizedName = this.name; if (args.current().getKey().equals(this.alias)) { @@ -76,7 +76,7 @@ public boolean apply(CliArguments args, IdeContext context, Commandlet commandle } @Override - protected boolean applyValue(String argValue, boolean lookahead, CliArguments args, IdeContext context, Commandlet commandlet, + protected boolean applyValue(String argValue, boolean lookahead, CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { setValue(true); diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/LocaleProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/LocaleProperty.java index 304fc1e0..e8dbeb5f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/LocaleProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/LocaleProperty.java @@ -3,7 +3,7 @@ import java.util.Arrays; import java.util.Locale; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.validation.PropertyValidator; @@ -53,7 +53,7 @@ public Locale parse(String valueAsString, IdeContext context) { } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { collector.addAllMatches(arg, getAvailableLocales(), this, commandlet); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/PathProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/PathProperty.java index 1fa1ffcc..7b946fd5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/PathProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/PathProperty.java @@ -5,7 +5,7 @@ import java.nio.file.Path; import java.util.stream.Stream; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.validation.PropertyValidator; @@ -105,7 +105,7 @@ protected boolean isPathRequiredToBeFile() { } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { Path path = Path.of(arg); Path parent = path.getParent(); @@ -121,10 +121,11 @@ protected void completeValue(String arg, IdeContext context, Commandlet commandl * @param folder the {@link Path} to the directory where to search for the file. * @param filename the filename (prefix) to complete. * @param context the {@link IdeContext}. - * @param commandlet the owning {@link Commandlet}. + * @param commandlet the owning {@link AbstractCommandlet}. * @param collector the {@link CompletionCandidateCollector}. */ - protected void completeValuesFromFolder(Path folder, String filename, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValuesFromFolder(Path folder, String filename, IdeContext context, AbstractCommandlet commandlet, + CompletionCandidateCollector collector) { if (Files.isDirectory(folder)) { try (Stream children = Files.list(folder)) { @@ -139,10 +140,10 @@ protected void completeValuesFromFolder(Path folder, String filename, IdeContext /** * @param path the {@link Path} that has been found via completion. * @param context the {@link IdeContext}. - * @param commandlet the owning {@link Commandlet}. - * @return the {@link String} to {@link CompletionCandidateCollector#add(String, String, Property, Commandlet) add} as completion candidate. + * @param commandlet the owning {@link AbstractCommandlet}. + * @return the {@link String} to {@link CompletionCandidateCollector#add(String, String, Property, AbstractCommandlet) add} as completion candidate. */ - protected String getPathForCompletion(Path path, IdeContext context, Commandlet commandlet) { + protected String getPathForCompletion(Path path, IdeContext context, AbstractCommandlet commandlet) { return path.toString(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java index 23dd5302..2f5d2a4e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/PluginProperty.java @@ -1,16 +1,16 @@ package com.devonfw.tools.ide.property; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; import com.devonfw.tools.ide.tool.plugin.ToolPlugins; import com.devonfw.tools.ide.validation.PropertyValidator; /** - * {@link Property} representing the plugin of a {@link PluginBasedCommandlet}. + * {@link Property} representing the plugin of a {@link PluginBasedCommandlet tool that supports plugins}. */ public class PluginProperty extends Property { @@ -53,9 +53,9 @@ public String parse(String valueAsString, IdeContext context) { } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { - ToolCommandlet cmd = commandlet.getToolForCompletion(); + AbstractToolCommandlet cmd = commandlet.getToolForCompletion(); if (cmd instanceof PluginBasedCommandlet pbc) { ToolPlugins plugins = pbc.getPlugins(); for (ToolPluginDescriptor pluginDescriptor : plugins.getPlugins()) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/Property.java b/cli/src/main/java/com/devonfw/tools/ide/property/Property.java index 7e6f3f1a..0c1fdea6 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/Property.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/Property.java @@ -10,7 +10,7 @@ import com.devonfw.tools.ide.cli.CliArgument; import com.devonfw.tools.ide.cli.CliArguments; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.completion.CompletionCandidateCollectorAdapter; import com.devonfw.tools.ide.context.IdeContext; @@ -121,8 +121,8 @@ public String getNameOrAlias() { } /** - * @return {@code true} if this property is required (if argument is not present the {@link Commandlet} cannot be invoked), {@code false} otherwise (if - * optional). + * @return {@code true} if this property is required (if argument is not present the {@link AbstractCommandlet} cannot be invoked), {@code false} otherwise + * (if optional). */ public boolean isRequired() { @@ -159,7 +159,7 @@ public boolean isEndOptions() { /** * Determines if this {@link Property} is multi-valued and accepts any number of values. A multi-valued {@link Property} needs to be the last {@link Property} - * of a {@link Commandlet}. + * of a {@link AbstractCommandlet}. * * @return {@code true} if multi-valued, {@code false} otherwise. */ @@ -304,10 +304,10 @@ public void setValueAsString(String valueAsString, IdeContext context) { * * @param valueAsString the new {@link #getValue() value} as {@link String}. * @param context the {@link IdeContext} - * @param commandlet the {@link Commandlet} owning this property. + * @param commandlet the {@link AbstractCommandlet} owning this property. * @return {@code true} if the value has been assigned successfully, {@code false} otherwise (an error occurred). */ - public final boolean assignValueAsString(String valueAsString, IdeContext context, Commandlet commandlet) { + public final boolean assignValueAsString(String valueAsString, IdeContext context, AbstractCommandlet commandlet) { try { setValueAsString(valueAsString, context); @@ -356,11 +356,11 @@ protected V getNullValue() { /** * @param args the {@link CliArguments} already {@link CliArguments#current() pointing} the {@link CliArgument} to apply. * @param context the {@link IdeContext}. - * @param commandlet the {@link Commandlet} owning this property. + * @param commandlet the {@link AbstractCommandlet} owning this property. * @param collector the {@link CompletionCandidateCollector}. * @return {@code true} if it matches, {@code false} otherwise. */ - public boolean apply(CliArguments args, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + public boolean apply(CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { return apply(this.name, args, context, commandlet, collector); } @@ -369,11 +369,11 @@ public boolean apply(CliArguments args, IdeContext context, Commandlet commandle * @param normalizedName the {@link #getName() name} or potentially a normalized form of it (see {@link KeywordProperty}). * @param args the {@link CliArguments} already {@link CliArguments#current() pointing} the {@link CliArgument} to apply. * @param context the {@link IdeContext}. - * @param commandlet the {@link Commandlet} owning this property. + * @param commandlet the {@link AbstractCommandlet} owning this property. * @param collector the {@link CompletionCandidateCollector}. * @return {@code true} if it matches, {@code false} otherwise. */ - protected boolean apply(String normalizedName, CliArguments args, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected boolean apply(String normalizedName, CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { CliArgument argument = args.current(); if (argument.isCompletion()) { @@ -418,11 +418,11 @@ protected boolean apply(String normalizedName, CliArguments args, IdeContext con * @param lookahead - {@code true} if the given {@code argValue} is taken as lookahead from the next value, {@code false} otherwise. * @param args the {@link CliArguments}. * @param context the {@link IdeContext}. - * @param commandlet the {@link Commandlet} owning this {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning this {@link Property}. * @param collector the {@link CompletionCandidateCollector}. * @return {@code true} if it matches, {@code false} otherwise. */ - protected boolean applyValue(String argValue, boolean lookahead, CliArguments args, IdeContext context, Commandlet commandlet, + protected boolean applyValue(String argValue, boolean lookahead, CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { boolean success = assignValueAsString(argValue, context, commandlet); @@ -450,10 +450,10 @@ protected boolean applyValue(String argValue, boolean lookahead, CliArguments ar * @param argument the {@link CliArgument CLI argument}. * @param args the {@link CliArguments}. * @param context the {@link IdeContext}. - * @param commandlet the {@link Commandlet} owning this {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning this {@link Property}. * @param collector the {@link CompletionCandidateCollector}. */ - protected void complete(String normalizedName, CliArgument argument, CliArguments args, IdeContext context, Commandlet commandlet, + protected void complete(String normalizedName, CliArgument argument, CliArguments args, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { String arg = argument.get(); @@ -492,10 +492,10 @@ protected void complete(String normalizedName, CliArgument argument, CliArgument * * @param arg the {@link CliArgument#get() CLI argument}. * @param context the {@link IdeContext}. - * @param commandlet the {@link Commandlet} owning this {@link Property}. + * @param commandlet the {@link AbstractCommandlet} owning this {@link Property}. * @param collector the {@link CompletionCandidateCollector}. */ - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { } diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/RepositoryProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/RepositoryProperty.java index 909b16c6..c07224d9 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/RepositoryProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/RepositoryProperty.java @@ -3,7 +3,7 @@ import java.nio.file.Files; import java.nio.file.Path; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.validation.PropertyValidator; @@ -69,7 +69,7 @@ public Path parse(String valueAsString, IdeContext context) { } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { Path repositoriesPath = context.getRepositoriesPath(); if (repositoriesPath != null) { @@ -78,7 +78,7 @@ protected void completeValue(String arg, IdeContext context, Commandlet commandl } @Override - protected String getPathForCompletion(Path path, IdeContext context, Commandlet commandlet) { + protected String getPathForCompletion(Path path, IdeContext context, AbstractCommandlet commandlet) { String filename = path.getFileName().toString(); if (filename.endsWith(EXTENSION_PROPERTIES)) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/ToolArgumentsProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/ToolArgumentsProperty.java index 7bc6ba84..35673082 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/ToolArgumentsProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/ToolArgumentsProperty.java @@ -1,9 +1,9 @@ package com.devonfw.tools.ide.property; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** * {@link StringProperty} for tool arguments with support for tool-specific auto-completion. @@ -25,17 +25,17 @@ public ToolArgumentsProperty(String name, boolean required, boolean multivalued, /** - * Completes a tool argument by delegating to the owning {@link ToolCommandlet}. + * Completes a tool argument by delegating to the owning {@link AbstractToolCommandlet}. * * @param arg the current argument to complete. * @param context the {@link IdeContext}. - * @param commandlet the owning {@link Commandlet}. + * @param commandlet the owning {@link AbstractCommandlet}. * @param collector the {@link CompletionCandidateCollector}. */ @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { - if (commandlet instanceof ToolCommandlet toolCommandlet) { + if (commandlet instanceof AbstractToolCommandlet toolCommandlet) { toolCommandlet.completeToolArguments(arg, collector, this); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/ToolProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/ToolProperty.java index 5cf4f200..d3a9235e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/ToolProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/ToolProperty.java @@ -1,15 +1,16 @@ package com.devonfw.tools.ide.property; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.validation.PropertyValidator; /** - * {@link Property} with {@link #getValueType() value type} {@link ToolCommandlet}. + * {@link Property} with {@link #getValueType() value type} {@link AbstractToolCommandlet}. */ -public class ToolProperty extends Property { +public class ToolProperty extends Property { /** * The constructor. @@ -45,33 +46,33 @@ public ToolProperty(String name, boolean required, boolean multivalued, String a * @param multivalued the boolean flag about multiple arguments * @param validator the {@link PropertyValidator} used to {@link #validate() validate} the {@link #getValue() value}. */ - public ToolProperty(String name, boolean required, String alias, boolean multivalued, PropertyValidator validator) { + public ToolProperty(String name, boolean required, String alias, boolean multivalued, PropertyValidator validator) { super(name, required, alias, multivalued, validator); } @Override - public Class getValueType() { + public Class getValueType() { - return ToolCommandlet.class; + return AbstractToolCommandlet.class; } @Override - protected String format(ToolCommandlet valueToFormat) { + protected String format(AbstractToolCommandlet valueToFormat) { return valueToFormat.getName(); } @Override - public ToolCommandlet parse(String valueAsString, IdeContext context) { + public AbstractToolCommandlet parse(String valueAsString, IdeContext context) { return context.getCommandletManager().getRequiredToolCommandlet(valueAsString); } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { - for (Commandlet cmd : context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : context.getCommandletManager().getCommandlets()) { if (cmd instanceof ToolCommandlet) { String cmdName = cmd.getName(); if (cmdName.startsWith(arg)) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/VersionProperty.java b/cli/src/main/java/com/devonfw/tools/ide/property/VersionProperty.java index 759512ad..8e99a6c5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/VersionProperty.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/VersionProperty.java @@ -4,11 +4,11 @@ import java.util.List; import java.util.stream.IntStream; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidate; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.validation.PropertyValidator; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -57,15 +57,15 @@ public VersionIdentifier parse(String valueAsString, IdeContext context) { } @Override - protected void completeValue(String arg, IdeContext context, Commandlet commandlet, CompletionCandidateCollector collector) { + protected void completeValue(String arg, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { - ToolCommandlet tool = commandlet.getToolForCompletion(); + AbstractToolCommandlet tool = commandlet.getToolForCompletion(); if (tool != null) { completeVersion(VersionIdentifier.of(arg), tool, context, commandlet, collector); } } - private void completeVersion(VersionIdentifier version2complete, ToolCommandlet tool, IdeContext context, Commandlet commandlet, + private void completeVersion(VersionIdentifier version2complete, AbstractToolCommandlet tool, IdeContext context, AbstractCommandlet commandlet, CompletionCandidateCollector collector) { collector.disableSorting(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/step/Step.java b/cli/src/main/java/com/devonfw/tools/ide/step/Step.java index 19760d97..800caa95 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/step/Step.java +++ b/cli/src/main/java/com/devonfw/tools/ide/step/Step.java @@ -3,6 +3,8 @@ import java.util.concurrent.Callable; import java.util.function.Supplier; +import com.devonfw.tools.ide.cli.CliException; + /** * Interface for a {@link Step} of the process. Allows to split larger processes into smaller steps that are traced and measured. Also prevents that if one step * fails, the overall process can still continue so a sub-step (e.g. "plugin installation" or "git update") does not automatically block the entire process. At @@ -219,7 +221,8 @@ default boolean run(Runnable stepCode) { /** * @param stepCode the {@link Runnable} to {@link Runnable#run() execute} for this {@link Step}. - * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. + * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. Independent of this flag an error is always rethrown if it + * {@link CliException#isForceRethrowInStep() forces} it. * @return {@code true} on success, {@code false} on error (if {@code rethrow} is {@code false}). */ default boolean run(Runnable stepCode, boolean rethrow) { @@ -231,8 +234,10 @@ default boolean run(Runnable stepCode, boolean rethrow) { } return true; } catch (RuntimeException | Error e) { - error(e); - if (rethrow) { + boolean forceRethrow = isForceRethrow(e); + // if the error is rethrown it gets logged by the caller so we suppress duplicated error messages here + error(e, forceRethrow); + if (rethrow || forceRethrow) { throw e; } return false; @@ -264,7 +269,8 @@ default R call(Callable stepCode, Supplier resultOnErrorSupplier) { /** * @param stepCode the {@link Callable} to {@link Callable#call() execute} for this {@link Step}. - * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. + * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. Independent of this flag an error is always rethrown if it + * {@link CliException#isForceRethrowInStep() forces} it. * @param resultOnErrorSupplier the {@link Supplier} {@link Supplier#get() providing} the result to be returned in case of a {@link Throwable error}. * @param type of the return value. * @return the value returned from {@link Callable#call()}. @@ -278,8 +284,10 @@ default R call(Callable stepCode, boolean rethrow, Supplier resultOnEr } return result; } catch (Throwable e) { - error(e); - if (rethrow) { + boolean forceRethrow = isForceRethrow(e); + // if the error is rethrown it gets logged by the caller so we suppress duplicated error messages here + error(e, forceRethrow); + if (rethrow || forceRethrow) { if (e instanceof RuntimeException re) { throw re; } else if (e instanceof Error error) { @@ -294,4 +302,13 @@ default R call(Callable stepCode, boolean rethrow, Supplier resultOnEr } } + /** + * @param error the {@link Throwable} that occurred inside a {@link Step}. + * @return {@code true} if the given {@code error} has to be rethrown even if the {@link Step} was not asked to rethrow errors, {@code false} otherwise. + */ + private static boolean isForceRethrow(Throwable error) { + + return (error instanceof CliException cliException) && cliException.isForceRethrowInStep(); + } + } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/AbstractLocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/AbstractLocalToolCommandlet.java new file mode 100644 index 00000000..f5e607d0 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/AbstractLocalToolCommandlet.java @@ -0,0 +1,658 @@ +package com.devonfw.tools.ide.tool; + +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.cli.CliOfflineException; +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.url.model.file.json.ToolDependency; +import com.devonfw.tools.ide.version.GenericVersionRange; +import com.devonfw.tools.ide.version.VersionIdentifier; +import com.devonfw.tools.ide.version.VersionRange; + +/** + * {@link AbstractToolCommandlet} that is installed locally into the IDEasy. + */ +public abstract class AbstractLocalToolCommandlet extends AbstractToolCommandlet implements LocalToolCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractLocalToolCommandlet.class); + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param tool the {@link #getName() tool name}. + * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. + */ + public AbstractLocalToolCommandlet(IdeContext context, String tool, Set tags) { + + super(context, tool, tags); + } + + @Override + public Path getToolPath() { + if (this.context.getSoftwarePath() == null) { + return null; + } + return this.context.getSoftwarePath().resolve(getName()); + } + + /** + * @return the {@link Path} where the installed artifact of this tool actually lives, as reported in the install success message. Defaults to + * {@link #getToolPath() tool path}, which for package-manager based tools is the runtime tool's path and not the location where the managed package + * is actually installed (e.g. a global npm package lives in the per-project npm prefix, not in {@code software/node}). + */ + protected Path getInstalledLocation() { + + return getToolPath(); + } + + /** + * @return the {@link Path} where the executables of the tool can be found. Typically, a "bin" folder inside {@link #getToolPath() tool path}. + */ + public Path getToolBinPath() { + + Path toolPath = getToolPath(); + if (toolPath == null) { + return null; + } + Path binPath = toolPath.resolve(IdeContext.FOLDER_BIN); + if (Files.isDirectory(binPath)) { + return binPath; + } + return toolPath; + } + + /** + * @return {@code true} to ignore a missing {@link IdeContext#FILE_SOFTWARE_VERSION software version file} in an installation, {@code false} delete the broken + * installation (default). + */ + protected boolean isIgnoreMissingSoftwareVersionFile() { + + return false; + } + + @Override + protected ToolInstallation doInstall(ToolInstallRequest request) { + + Step step = request.getStep(); + if (step == null) { + return doInstallStep(request); + } else { + return step.call(() -> doInstallStep(request), + () -> createExistingToolInstallation(request)); + } + } + + private ToolInstallation doInstallStep(ToolInstallRequest request) { + + if (request.isIgnoreProject() && isIgnoreSoftwareRepo()) { + throw new CliException( + "The tool " + this.tool + + " does not support the software repository and therefore cannot be installed while ignoring the project."); + } + + // install configured version of our tool in the software repository if not already installed + ToolInstallation installation = installTool(request); + + // check if we already have this version installed (linked) locally in IDE_HOME/software + VersionIdentifier resolvedVersion = installation.resolvedVersion(); + if (request.isAlreadyInstalled()) { + return installation; + } else { + LOG.debug("Installation from {} to {}.", request.getInstalled(), request.getRequested()); + } + FileAccess fileAccess = this.context.getFileAccess(); + boolean ignoreSoftwareRepo = isIgnoreSoftwareRepo(); + Path toolPath = request.getToolPath(); + if (!ignoreSoftwareRepo && !request.isIgnoreProject() && toolPath != null) { + // we need to link the version or update the link. + if (Files.exists(toolPath, LinkOption.NOFOLLOW_LINKS)) { + fileAccess.backup(toolPath); + } + fileAccess.mkdirs(toolPath.getParent()); + fileAccess.symlink(installation.linkDir(), toolPath); + } else { + LOG.debug("Skipping symlink creation for tool {} as it is installed in standalone/global mode (ignoreSoftwareRepo={}, ignoreProject={}, toolPath={}).", + this.tool, ignoreSoftwareRepo, request.isIgnoreProject(), toolPath); + } + if (!request.isExtraInstallation() && (installation.binDir() != null)) { + this.context.getPath().setPath(this.tool, installation.binDir()); + } + postInstall(request); + ToolEditionAndVersion installed = request.getInstalled(); + GenericVersionRange installedVersion = null; + if (installed != null) { + installedVersion = installed.getVersion(); + } + ToolEditionAndVersion requested = request.getRequested(); + ToolEdition toolEdition = requested.getEdition(); + Step step = request.getStep(); + Path installedLocation = getInstalledLocation(); + if (installedVersion == null) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully installed {} in version {} at {}", toolEdition, resolvedVersion, installedLocation); + } else { + IdeLogLevel.SUCCESS.log(LOG, "Successfully installed {} in version {} replacing previous version {} of {} at {}", toolEdition, + resolvedVersion, installedVersion, installed.getEdition(), installedLocation); + } + if (step != null) { + step.success(true); + } + return installation; + } + + /** + * Determines whether this tool should be installed directly in the software folder or in the software repository. + * + * @return {@code true} if the tool should be installed directly in the software folder, ignoring the central software repository; {@code false} if the tool + * should be installed in the central software repository (default behavior). + */ + protected boolean isIgnoreSoftwareRepo() { + + return false; + } + + /** + * Performs the installation of the {@link #getName() tool} together with the environment context managed by this + * {@link com.devonfw.tools.ide.commandlet.Commandlet}. + * + * @param request the {@link ToolInstallRequest}. + * @return the resulting {@link ToolInstallation}. + */ + public ToolInstallation installTool(ToolInstallRequest request) { + + completeRequest(request); // most likely already done, but if installTool was called directly and not from install + if (request.isInstallLoop()) { + return toolAlreadyInstalled(request); + } + ToolEditionAndVersion requested = request.getRequested(); + ToolEdition toolEdition = requested.getEdition(); + assert (toolEdition.tool().equals(this.tool)) : "Mismatch " + this.tool + " != " + toolEdition.tool(); + String edition = toolEdition.edition(); + VersionIdentifier resolvedVersion = cveCheck(request); + installToolDependencies(request); + // cveCheck might have changed resolvedVersion so let us re-check... + if (request.isAlreadyInstalled()) { + return toolAlreadyInstalled(request); + } else { + ToolEditionAndVersion installed = request.getInstalled(); + LOG.debug("Installation from {} to {}.", installed, requested); + } + Path installationPath = getInstallationPath(edition, resolvedVersion); + + ProcessContext processContext = request.getProcessContext(); + boolean additionalInstallation = request.isAdditionalInstallation(); + boolean ignoreSoftwareRepo = isIgnoreSoftwareRepo(); + Path toolVersionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + FileAccess fileAccess = this.context.getFileAccess(); + if (Files.isDirectory(installationPath)) { + if (Files.exists(toolVersionFile)) { + if (!ignoreSoftwareRepo) { + assert resolvedVersion.equals(getInstalledVersion(installationPath)) : + "Found version " + getInstalledVersion(installationPath) + " in " + toolVersionFile + " but expected " + resolvedVersion; + LOG.debug("Version {} of tool {} is already installed at {}", resolvedVersion, toolEdition, installationPath); + return createToolInstallation(installationPath, resolvedVersion, false, processContext, additionalInstallation); + } + } else { + // Makes sure that IDEasy will not delete itself + if (this.tool.equals(IdeasyCommandlet.TOOL_NAME)) { + LOG.warn("Your IDEasy installation is missing the version file at {}", toolVersionFile); + return createToolInstallation(installationPath, resolvedVersion, false, processContext, additionalInstallation); + } else if (!isIgnoreMissingSoftwareVersionFile()) { + LOG.warn("Deleting corrupted installation at {}", installationPath); + fileAccess.delete(installationPath); + } else { + // the version file is missing but the tool allows this (e.g. because the version is determined from the installation itself). If + // the installed version could be determined, it was already restored to the version file during the determination of the installed + // edition and version (see computeInstalledEditionAndVersionFromLocalSoftwareFolder) and the installation above is kept. Reaching + // this point means the installed version could not be determined, so the installation is treated as broken and reinstalled. + LOG.warn("Could not determine the installed version of {} at {}, the installation is considered broken and will be reinstalled.", this.tool, + installationPath); + } + } + } + VersionIdentifier actualInstalledVersion = resolvedVersion; + try { + performToolInstallation(request, installationPath); + } catch (CliOfflineException e) { + // If we are offline and cannot download, check if we can continue with an existing installation + ToolEditionAndVersion installed = request.getInstalled(); + if ((installed != null) && (installed.getResolvedVersion() != null)) { + LOG.warn("Cannot download {} in version {} because we are offline. Continuing with already installed version {}.", this.tool, + resolvedVersion, installed.getResolvedVersion()); + // If offline and could not download, actualInstalledVersion will be the old version, not resolvedVersion + // In that case, we need to recalculate the installation path for the actually installed version + actualInstalledVersion = installed.getResolvedVersion(); + installationPath = getInstallationPath(edition, actualInstalledVersion); + } else { + // No existing installation available, re-throw the exception + throw e; + } + } + return createToolInstallation(installationPath, actualInstalledVersion, true, processContext, additionalInstallation); + } + + /** + * Performs the installation of the {@link #getName() tool} by using {@link #installDownloadedToolPayload(ToolInstallRequest, Path, Path)} for tool-specific + * logic, backing up any existing installation, and writing the version file. + *

      + * This method assumes that the version has already been resolved and dependencies installed. It handles the final steps of placing the tool into the + * appropriate installation directory. + * + * @param request the {@link ToolInstallRequest}. + * @param installationPath the target {@link Path} where the {@link #getName() tool} should be installed. + */ + protected void performToolInstallation(ToolInstallRequest request, Path installationPath) { + + FileAccess fileAccess = this.context.getFileAccess(); + ToolEditionAndVersion requested = request.getRequested(); + VersionIdentifier resolvedVersion = requested.getResolvedVersion(); + Path downloadedToolFile = downloadTool(requested.getEdition().edition(), resolvedVersion); + + if (Files.isDirectory(installationPath)) { + if (this.tool.equals(IdeasyCommandlet.TOOL_NAME)) { + LOG.warn("Your IDEasy installation is missing the version file."); + } else { + fileAccess.backup(installationPath); + } + } + fileAccess.mkdirs(installationPath.getParent()); + + installDownloadedToolPayload(request, installationPath, downloadedToolFile); + + this.context.writeVersionFile(resolvedVersion, installationPath); + // fix macOS Gatekeeper blocking - must run after version file is written but before any executables are launched + getMacOsHelper().removeQuarantineAttribute(installationPath); + LOG.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath); + } + + /** + * Performs the actual installation of the tool bits. + * + * @param request the {@link ToolInstallRequest}. + * @param installationPath the target {@link Path} where the tool should be installed. + * @param downloadedToolFile the {@link Path} to the downloaded tool file. + */ + protected void installDownloadedToolPayload(ToolInstallRequest request, Path installationPath, Path downloadedToolFile) { + + boolean extract = isExtract(); + if (!extract) { + LOG.trace("Extraction is disabled for '{}' hence just moving the downloaded file {}.", this.tool, downloadedToolFile); + } + this.context.getFileAccess().extract(downloadedToolFile, installationPath, this::postExtract, extract); + } + + /** + * @param edition the {@link #getConfiguredEdition() tool edition} to download. + * @param resolvedVersion the resolved {@link VersionIdentifier version} to download. + * @return the {@link Path} to the downloaded release file. + */ + protected Path downloadTool(String edition, VersionIdentifier resolvedVersion) { + return getToolRepository().download(this.tool, edition, resolvedVersion, this); + } + + /** + * Install this tool as dependency of another tool. + * + * @param versionRange the required {@link VersionRange}. See {@link ToolDependency#versionRange()}. + * @param parentRequest the {@link ToolInstallRequest} of the tool causing this dependency. + * @return the corresponding {@link ToolInstallation}. + */ + public ToolInstallation installAsDependency(VersionRange versionRange, ToolInstallRequest parentRequest) { + ToolInstallRequest request = new ToolInstallRequest(parentRequest); + ToolEditionAndVersion requested = new ToolEditionAndVersion(getToolWithConfiguredEdition()); + request.setRequested(requested); + + // If we are not inside a project or if we should ignore the project, do not try to use the configured version + // but instead just use the version range from the dependency. + boolean ignoreProject = request.isIgnoreProject() || this.context.getIdeHome() == null; + VersionIdentifier configuredVersion = ignoreProject ? null : getConfiguredVersion(); + if (!ignoreProject && (configuredVersion != null) && versionRange.contains(configuredVersion)) { + // prefer configured version if contained in version range + requested.setVersion(configuredVersion); + // return install(true, configuredVersion, processContext, null); + return install(request); + } else { + if (ignoreProject) { + LOG.debug("Ignoring project for dependency {} of tool {}, using version range {}", this.tool, parentRequest.getRequested(), versionRange); + } else if (isIgnoreSoftwareRepo()) { + throw new IllegalStateException( + "Cannot satisfy dependency to " + this.tool + " in version " + versionRange + " for " + parentRequest.getRequested() + + " since it is conflicting with configured version " + + configuredVersion + + " and this tool does not support the software repository."); + } else { + LOG.info( + "The tool {} requires {} in the version range {}, but your project uses version {}, which does not match." + + " Therefore, we install a compatible version in that range.", + parentRequest.getRequested().getEdition(), this.tool, versionRange, configuredVersion); + } + requested.setVersion(versionRange); + return installTool(request); + } + } + + /** + * Installs the tool dependencies for the current tool. + * + * @param request the {@link ToolInstallRequest}. + */ + protected void installToolDependencies(ToolInstallRequest request) { + + ToolEditionAndVersion requested = request.getRequested(); + VersionIdentifier version = requested.getResolvedVersion(); + ToolEdition toolEdition = requested.getEdition(); + Collection dependencies = getToolRepository().findDependencies(this.tool, toolEdition.edition(), version); + int size = dependencies.size(); + LOG.debug("Tool {} has {} other tool(s) as dependency", toolEdition, size); + for (ToolDependency dependency : dependencies) { + LOG.trace("Ensuring dependency {} for tool {}", dependency.tool(), toolEdition); + AbstractLocalToolCommandlet dependencyTool = this.context.getCommandletManager().getRequiredLocalToolCommandlet(dependency.tool()); + dependencyTool.installAsDependency(dependency.versionRange(), request); + } + } + + /** + * Post-extraction hook that can be overridden to add custom processing after unpacking and before moving to the final destination folder. + * + * @param extractedDir the {@link Path} to the folder with the unpacked tool. + */ + protected void postExtract(Path extractedDir) { + + } + + /** + * Restores the {@link IdeContext#FILE_SOFTWARE_VERSION version file} of an existing installation in case it got lost (e.g. because {@code uv} recreated the + * python virtual environment). Does nothing if the file is present or the version is unknown. + * + * @param installationPath the {@link Path} to the installation of this tool. + * @param version the {@link VersionIdentifier} that is actually installed at the given {@code installationPath}. + */ + protected void restoreMissingVersionFile(Path installationPath, VersionIdentifier version) { + + if ((version == null) || !Files.isDirectory(installationPath)) { + return; + } + Path toolVersionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + if (Files.exists(toolVersionFile)) { + return; + } + LOG.warn("Version file is missing at {} - restoring it with version {} for tool {}", toolVersionFile, version, this.tool); + this.context.writeVersionFile(version, installationPath); + // the installation on disk changed so a previously cached result must not survive + invalidateInstalledEditionAndVersion(); + } + + @Override + protected EditionAndVersion computeInstalledEditionAndVersion() { + + Path toolPath = getToolPath(); + if (isToolNotInstalled(toolPath)) { + return null; + } + // Resolve edition and version from a single tool-path lookup (one pass) instead of two separate lookups. + Path repoPath = getInstalledSoftwareRepoPath(toolPath, true); + + if (repoPath != null) { + VersionIdentifier version = VersionIdentifier.of(repoPath.getFileName().toString()); + return new EditionAndVersion(getEditionFromSoftwareRepoPath(repoPath), version); + } + return computeInstalledEditionAndVersionFromLocalSoftwareFolder(); + } + + /** + * Determines the installed edition and version of a tool that is not installed via the software repository. + * + * @return the installed {@link EditionAndVersion} or {@code null} if the version cannot be determined. + */ + protected EditionAndVersion computeInstalledEditionAndVersionFromLocalSoftwareFolder() { + + Path toolPath = getToolPath(); + VersionIdentifier version = getInstalledVersion(toolPath); + if (version == null) { + version = computeInstalledVersionFromLocalSoftwareFolder(); + if (version == null) { + return null; + } + restoreMissingVersionFile(toolPath, version); + } + return new EditionAndVersion(computeInstalledEditionFromLocalSoftwareFolder(), version); + } + + /** + * Determines the installed edition of a tool that is not installed via the software repository. + * + * @return the installed edition of this tool. + */ + protected String computeInstalledEditionFromLocalSoftwareFolder() { + return this.tool; + } + + /** + * Determines the installed version of a tool that is not installed via the software repository, in case the + * {@link IdeContext#FILE_SOFTWARE_VERSION version file} is missing. + *

      + * By default {@code null} is returned since there is no tool-specific way to determine the version without the version file. Subclasses may override this to + * determine the version from the installation itself. + * + * @return the installed {@link VersionIdentifier version} or {@code null} if it cannot be determined. + */ + protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() { + return null; + } + + /** + * @param toolPath the installation {@link Path} where to find the version file. + * @return the currently installed {@link VersionIdentifier version} of this tool or {@code null} if not installed. + */ + protected VersionIdentifier getInstalledVersion(Path toolPath) { + + if (isToolNotInstalled(toolPath)) { + return null; + } + Path versionLookupPath = getInstalledSoftwareRepoPath(toolPath, false); + if (versionLookupPath == null) { + versionLookupPath = toolPath; + } + Path toolVersionFile = versionLookupPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + if (!Files.exists(toolVersionFile)) { + Path legacyToolVersionFile = versionLookupPath.resolve(IdeContext.FILE_LEGACY_SOFTWARE_VERSION); + if (Files.exists(legacyToolVersionFile)) { + toolVersionFile = legacyToolVersionFile; + } else { + LOG.warn("Tool {} is missing version file in {}", getName(), toolVersionFile); + return null; + } + } + String version = this.context.getFileAccess().readFileContent(toolVersionFile).trim(); + return VersionIdentifier.of(version); + } + + /** + * @param toolPath the installation {@link Path} where to find currently installed tool. The name of the parent directory of the real path corresponding + * to the passed {@link Path path} must be the name of the edition. + * @return the installed edition of this tool or {@code null} if not installed. + */ + protected String getInstalledEdition(Path toolPath) { + + if (isToolNotInstalled(toolPath)) { + return null; + } + Path repoPath = getInstalledSoftwareRepoPath(toolPath, true); + if (repoPath == null) { + return computeInstalledEditionFromLocalSoftwareFolder(); + } + return getEditionFromSoftwareRepoPath(repoPath); + } + + private String getEditionFromSoftwareRepoPath(Path repoPath) { + + String edition = repoPath.getParent().getFileName().toString(); + if (!getToolRepository().getSortedEditions(this.tool).contains(edition)) { + LOG.warn("Undefined edition {} of tool {}", edition, this.tool); + } + return edition; + } + + private Path getInstalledSoftwareRepoPath(Path toolPath, boolean logIfNotSoftwareRepo) { + if (isToolNotInstalled(toolPath)) { + return null; + } + Path installPath = this.context.getFileAccess().toRealPath(toolPath); + // if the installPath changed, a link has been resolved + if (installPath.equals(toolPath)) { + if (logIfNotSoftwareRepo && !isIgnoreSoftwareRepo()) { + LOG.warn("Tool {} is not installed via software repository (maybe from devonfw-ide). Please consider reinstalling it.", this.tool); + } + // I do not see any reliable way how we could determine the edition of a tool that does not use software repo or that was installed by devonfw-ide + return null; + } + installPath = getValidInstalledSoftwareRepoPath(installPath, context.getSoftwareRepositoryPath()); + return installPath; + } + + Path getValidInstalledSoftwareRepoPath(Path installPath, Path softwareRepoPath) { + + Path result = this.context.getFileAccess().findAncestor(installPath, softwareRepoPath, 4); + if (result == null) { + LOG.warn("The installation path is not located within the software repository {}.", installPath); + } + return result; + } + + private boolean isToolNotInstalled(Path toolPath) { + + if ((toolPath == null) || !Files.isDirectory(toolPath)) { + LOG.debug("Tool {} not installed in {}", this.tool, toolPath); + return true; + } + return false; + } + + @Override + public void uninstall() { + try { + Path toolPath = getToolPath(); + if (!Files.exists(toolPath)) { + LOG.warn("An installed version of {} does not exist.", this.tool); + return; + } + if (this.context.isForceMode() && !isIgnoreSoftwareRepo()) { + LOG.warn( + "You triggered an uninstall of {} in version {} with force mode!\n" + + "This will physically delete the currently installed version including its plugins from the machine.\n" + + "This may cause issues with other projects, that use the same version of that tool." + , this.tool, getInstalledVersion()); + uninstallPluginsOfTool(); + uninstallFromSoftwareRepository(toolPath); + } + performUninstall(toolPath); + IdeLogLevel.SUCCESS.log(LOG, "Successfully uninstalled {}", this.tool); + } catch (Exception e) { + LOG.error("Failed to uninstall {}", this.tool, e); + } + } + + /** + * Performs the actual uninstallation of this tool. + * + * @param toolPath the current {@link #getToolPath() tool path}. + */ + protected void performUninstall(Path toolPath) { + this.context.getFileAccess().delete(toolPath); + } + + /** + * Deletes the installed version of the tool from the shared software repository. + */ + private void uninstallFromSoftwareRepository(Path toolPath) { + Path repoPath = getInstalledSoftwareRepoPath(toolPath, true); + if ((repoPath == null) || !Files.exists(repoPath)) { + LOG.warn("An installed version of {} does not exist in software repository.", this.tool); + return; + } + LOG.info("Physically deleting {} as requested by the user via force mode.", repoPath); + this.context.getFileAccess().delete(repoPath); + IdeLogLevel.SUCCESS.log(LOG, "Successfully deleted {} from your computer.", repoPath); + } + + /** + * Deletes the installed plugins of the tool from the plugins path. + */ + private void uninstallPluginsOfTool() { + + Path toolPluginsPath = this.context.getPluginsPath().resolve(this.tool); + if (!Files.exists(toolPluginsPath)) { + LOG.info("There are no plugins of {} present to delete.", this.tool); + return; + } + LOG.info("Physically deleting {} as requested by the user via force mode.", toolPluginsPath); + this.context.getFileAccess().delete(toolPluginsPath); + IdeLogLevel.SUCCESS.log(LOG, "Successfully deleted {} from your computer.", toolPluginsPath); + } + + @Override + protected Path getInstallationPath(String edition, VersionIdentifier resolvedVersion) { + Path installationPath; + if (isIgnoreSoftwareRepo()) { + installationPath = getToolPath(); + } else { + Path softwareRepositoryPath = this.context.getSoftwareRepositoryPath(); + if (softwareRepositoryPath == null) { + return null; + } + Path softwareRepoPath = softwareRepositoryPath.resolve(getToolRepository().getId()).resolve(this.tool).resolve(edition); + installationPath = softwareRepoPath.resolve(resolvedVersion.toString()); + } + return installationPath; + } + + + /** + * Searches for a wrapper file in valid projects (containing a build file f.e. build.gradle or pom.xml) and returns its path. + * + * @param wrapperFileName the name of the wrapper file + * @return Path of the wrapper file or {@code null} if none was found. + */ + protected Path findWrapper(String wrapperFileName) { + Path dir = this.context.getCwd(); + // traverse the cwd directory containing a build descriptor up till a wrapper file was found + while ((dir != null) && (findBuildDescriptor(dir) != null)) { + Path wrapper = dir.resolve(wrapperFileName); + if (Files.exists(wrapper)) { + LOG.debug("Using wrapper: {}", wrapper); + return wrapper; + } + dir = dir.getParent(); + } + return null; + } + + /** + * @param directory the {@link Path} to the build directory. + * @return the build configuration file for this tool or {@code null} if not found (or this is not a build tool). + */ + public Path findBuildDescriptor(Path directory) { + return null; + } + + /** + * @return Bash completion command for this tool or {@code null} if this tool does not provide Bash completion. + */ + public String getBashCompletion() { + + return null; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/AbstractToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/AbstractToolCommandlet.java new file mode 100644 index 00000000..298b08ee --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/AbstractToolCommandlet.java @@ -0,0 +1,1169 @@ +package com.devonfw.tools.ide.tool; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import com.devonfw.tools.ide.cache.CachedValue; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.completion.AutoCompletionRegistry; +import com.devonfw.tools.ide.completion.CompletionCandidateCollector; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.environment.EnvironmentVariablesFiles; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.nls.NlsBundle; +import com.devonfw.tools.ide.os.MacOsHelper; +import com.devonfw.tools.ide.os.OperatingSystem; +import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.process.ProcessResultImpl; +import com.devonfw.tools.ide.property.Property; +import com.devonfw.tools.ide.property.ToolArgumentsProperty; +import com.devonfw.tools.ide.security.ToolVersionChoice; +import com.devonfw.tools.ide.security.ToolVulnerabilities; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.repository.ToolRepository; +import com.devonfw.tools.ide.url.model.file.json.Cve; +import com.devonfw.tools.ide.url.model.file.json.ToolDependency; +import com.devonfw.tools.ide.url.model.file.json.ToolSecurity; +import com.devonfw.tools.ide.variable.IdeVariables; +import com.devonfw.tools.ide.version.GenericVersionRange; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * {@link AbstractCommandlet} for a tool integrated into the IDE. + */ +public abstract class AbstractToolCommandlet extends AbstractCommandlet implements ToolCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractToolCommandlet.class); + + /** @see #getName() */ + protected final String tool; + + private final Set tags; + + /** The commandline arguments to pass to the tool. */ + public final ToolArgumentsProperty arguments; + + private Path executionDirectory; + + private MacOsHelper macOsHelper; + + /** Cached result for {@link #getInstalledEditionAndVersion()}. */ + private final CachedValue installedEditionAndVersion; + + /** + * Registry for tool-specific auto-completion candidates. + */ + private AutoCompletionRegistry autoCompletionRegistry; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param tool the {@link #getName() tool name}. + * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. + */ + public AbstractToolCommandlet(IdeContext context, String tool, Set tags) { + + super(context); + this.tool = tool; + this.tags = tags; + this.installedEditionAndVersion = new CachedValue<>(this::computeInstalledEditionAndVersion); + addKeyword(tool); + this.arguments = new ToolArgumentsProperty("", false, true, "args"); + initProperties(); + } + + /** + * Gets the auto-completion registry for this tool. + * + * @return the {@link AutoCompletionRegistry}. + */ + protected AutoCompletionRegistry getAutoCompletionRegistry() { + + if (this.autoCompletionRegistry == null) { + this.autoCompletionRegistry = new AutoCompletionRegistry(); + initAutoCompletionRegistry(this.autoCompletionRegistry); + } + + return this.autoCompletionRegistry; + } + + + /** + * Initializes the auto-completion registry for this tool. + * + * @param registry the {@link AutoCompletionRegistry} to initialize. + */ + protected void initAutoCompletionRegistry(AutoCompletionRegistry registry) { + // default empty + } + + /** + * Completes tool-specific arguments. + * + * @param arg the current argument to complete. + * @param collector the {@link CompletionCandidateCollector}. + * @param property the {@link Property} that triggered completion. + */ + public void completeToolArguments(String arg, CompletionCandidateCollector collector, Property property) { + + getAutoCompletionRegistry().complete(arg, collector, property, this); + } + + /** + * Add initial Properties to the tool + */ + protected void initProperties() { + + add(this.arguments); + } + + @Override + public final String getName() { + + return this.tool; + } + + /** + * @return the name of the binary executable for this tool. + */ + protected String getBinaryName() { + + return this.tool; + } + + /** + * @return the {@link Path} to the installed {@link #getBinaryName() binary} or {@code null} if not found on the + * {@link com.devonfw.tools.ide.common.SystemPath}. + */ + protected Path getBinaryExecutable() { + + Path binary = this.context.getPath().findBinaryPathByName(getBinaryName()); + if (binary.getParent() == null) { + return null; + } + return binary; + } + + @Override + public final Set getTags() { + + return this.tags; + } + + /** + * @return the execution directory where the tool will be executed. Will be {@code null} by default leading to execution in the users current working + * directory where IDEasy was called. + * @see #setExecutionDirectory(Path) + */ + public Path getExecutionDirectory() { + return this.executionDirectory; + } + + /** + * @param executionDirectory the new value of {@link #getExecutionDirectory()}. + */ + public void setExecutionDirectory(Path executionDirectory) { + this.executionDirectory = executionDirectory; + } + + /** + * @return the {@link EnvironmentVariables#getToolVersion(String) tool version}. + */ + public VersionIdentifier getConfiguredVersion() { + + return this.context.getVariables().getToolVersion(getName()); + } + + /** + * @return the {@link EnvironmentVariables#getToolEdition(String) tool edition}. + */ + public String getConfiguredEdition() { + + return this.context.getVariables().getToolEdition(getName()); + } + + /** + * @return the {@link ToolEdition} with {@link #getName() tool} with its {@link #getConfiguredEdition() edition}. + */ + protected final ToolEdition getToolWithConfiguredEdition() { + + return new ToolEdition(this.tool, getConfiguredEdition()); + } + + @Override + protected void doRun() { + + runTool(this.arguments.asList()); + } + + /** + * @param args the command-line arguments to run the tool. + * @return the {@link ProcessResult result}. + * @see AbstractToolCommandlet#runTool(ProcessMode, GenericVersionRange, List) + */ + public ProcessResult runTool(List args) { + + return runTool(ProcessMode.DEFAULT, null, args); + } + + /** + * Ensures the tool is installed and then runs this tool with the given arguments. + * + * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. + * @param toolVersion the explicit {@link GenericVersionRange version} to run. Typically {@code null} to run the + * {@link #getConfiguredVersion() configured version}. Otherwise, the specified version will be used (from the software repository, if not compatible). + * @param args the command-line arguments to run the tool. + * @return the {@link ProcessResult result}. + */ + public final ProcessResult runTool(ProcessMode processMode, GenericVersionRange toolVersion, List args) { + + return runTool(processMode, toolVersion, ProcessErrorHandling.THROW_CLI, args); + } + + /** + * Ensures the tool is installed and then runs this tool with the given arguments. + * + * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. + * @param toolVersion the explicit {@link GenericVersionRange version} to run. Typically {@code null} to run the + * {@link #getConfiguredVersion() configured version}. Otherwise, the specified version will be used (from the software repository, if not compatible). + * @param errorHandling the {@link ProcessErrorHandling}. + * @param args the command-line arguments to run the tool. + * @return the {@link ProcessResult result}. + */ + public ProcessResult runTool(ProcessMode processMode, GenericVersionRange toolVersion, ProcessErrorHandling errorHandling, List args) { + + ProcessContext pc = newToolProcess(errorHandling); + ToolInstallRequest request = new ToolInstallRequest(true); + if (toolVersion != null) { + request.setRequested(new ToolEditionAndVersion(toolVersion)); + } + request.setProcessContext(pc); + return runTool(request, processMode, args); + } + + /** + * Ensures the tool is installed and then runs this tool with the given arguments. + * + * @param request the {@link ToolInstallRequest}. + * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. + * @param args the command-line arguments to run the tool. + * @return the {@link ProcessResult result}. + */ + public ProcessResult runTool(ToolInstallRequest request, ProcessMode processMode, List args) { + + if (request.isCveCheckDone()) { + // if the CVE check has already been done, we can assume that the install(request) has already been called before + // most likely a postInstall* method was overridden calling this method with the same request what is a programming error + // we render this warning so the error gets detected and can be fixed but we do not block the user by skipping the installation. + LOG.warn("Preventing infinity loop during installation of {}", request.getRequested(), new RuntimeException()); + } else { + ToolInstallation installation = install(request); + if (installation != null && installation.installedAsynchronously()) { + return new ProcessResultImpl(this.tool, this.tool, 0, List.of()); + } + } + return runTool(request.getProcessContext(), processMode, args); + } + + /** + * @param pc the {@link ProcessContext}. + * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. + * @param args the command-line arguments to run the tool. + * @return the {@link ProcessResult result}. + */ + public ProcessResult runTool(ProcessContext pc, ProcessMode processMode, List args) { + + if (this.executionDirectory != null) { + pc.directory(this.executionDirectory); + } + configureToolBinary(pc, processMode); + configureToolArgs(pc, processMode, args); + return pc.run(processMode); + } + + /** + * @param pc the {@link ProcessContext}. + * @param processMode the {@link ProcessMode}. + */ + protected void configureToolBinary(ProcessContext pc, ProcessMode processMode) { + + pc.executable(Path.of(getBinaryName())); + } + + /** + * @param pc the {@link ProcessContext}. + * @param processMode the {@link ProcessMode}. + * @param args the command-line arguments to {@link ProcessContext#addArgs(List) add}. + */ + protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { + + pc.addArgs(args); + } + + /** + * Installs or updates the managed {@link #getName() tool}. + * + * @return the {@link ToolInstallation}. + */ + public ToolInstallation install() { + + return install(true); + } + + /** + * Performs the installation of the {@link #getName() tool} managed by this {@link AbstractCommandlet}. + * + * @param silent - {@code true} if called recursively to suppress verbose logging, {@code false} otherwise. + * @return the {@link ToolInstallation}. + */ + public ToolInstallation install(boolean silent) { + return install(new ToolInstallRequest(silent)); + } + + /** + * Performs the installation (install, update, downgrade) of the {@link #getName() tool} managed by this {@link AbstractToolCommandlet}. + * + * @param request the {@link ToolInstallRequest}. + * @return the {@link ToolInstallation}. + */ + public ToolInstallation install(ToolInstallRequest request) { + + completeRequest(request); + if (request.isInstallLoop()) { + return toolAlreadyInstalled(request); + } + ToolInstallation installation = doInstall(request); + if (installation != null && installation.installedAsynchronously()) { + LOG.warn( + "The installation of {} is currently running in the background!\n" + + "You need to complete the installation, potentially reboot and rerun your 'ide' command in a new terminal session" + + " after the installation has completed.", + request.getRequested()); + } + return installation; + } + + /** + * Performs the installation (install, update, downgrade) of the {@link #getName() tool} managed by this {@link AbstractToolCommandlet}. + * + * @param request the {@link ToolInstallRequest}. + * @return the {@link ToolInstallation}. + */ + protected abstract ToolInstallation doInstall(ToolInstallRequest request); + + /** + * @param request the {@link ToolInstallRequest} to complete (fill values that are currently {@code null}). + */ + protected void completeRequest(ToolInstallRequest request) { + + completeRequestInstalled(request); + completeRequestRequested(request); // depends on completeRequestInstalled + completeRequestProcessContext(request); + completeRequestToolPath(request); + } + + private void completeRequestProcessContext(ToolInstallRequest request) { + if (request.getProcessContext() == null) { + request.setProcessContext(newToolProcess(ProcessErrorHandling.THROW_CLI)); + } + } + + /** + * @param errorHandling the {@link ProcessErrorHandling}. + * @return a new {@link ProcessContext} initialized with the environment variables of all installed tools of the project. This tool and its + * {@link ToolDependency dependencies} will override those variables later when their + * {@link #setEnvironment(EnvironmentContext, ToolInstallation, boolean) environment} is set. + */ + private ProcessContext newToolProcess(ProcessErrorHandling errorHandling) { + + ProcessContext pc = this.context.newProcess().errorHandling(errorHandling); + this.context.setEnvironmentOfInstalledTools(pc); + return pc; + } + + private void completeRequestInstalled(ToolInstallRequest request) { + + ToolEditionAndVersion installedToolVersion = request.getInstalled(); + if (installedToolVersion == null) { + installedToolVersion = new ToolEditionAndVersion((GenericVersionRange) null); + request.setInstalled(installedToolVersion); + } + Path toolPath = request.getToolPath(); + if (installedToolVersion.getVersion() == null) { + VersionIdentifier installedVersion; + if ((toolPath != null) && (this instanceof AbstractLocalToolCommandlet ltc)) { + installedVersion = ltc.getInstalledVersion(toolPath); + } else { + installedVersion = getInstalledVersion(); + } + if (installedVersion == null) { + return; + } + installedToolVersion.setVersion(installedVersion); + } + if (installedToolVersion.getEdition() == null) { + String installedEdition; + if ((toolPath != null) && (this instanceof AbstractLocalToolCommandlet ltc)) { + installedEdition = ltc.getInstalledEdition(toolPath); + } else { + installedEdition = getInstalledEdition(); + } + installedToolVersion.setEdition(new ToolEdition(this.tool, installedEdition)); + } + assert installedToolVersion.getResolvedVersion() != null; + } + + private void completeRequestRequested(ToolInstallRequest request) { + + ToolEdition edition; + ToolEditionAndVersion requested = request.getRequested(); + if (requested == null) { + edition = new ToolEdition(this.tool, getConfiguredEdition()); + requested = new ToolEditionAndVersion(edition); + request.setRequested(requested); + + } else { + edition = requested.getEdition(); + if (edition == null) { + // If no edition was specified, set it to the configured one + edition = new ToolEdition(this.tool, getConfiguredEdition()); + requested.setEdition(edition); + } + } + + // Adjust edition if necessary based on requested version. This is needed for tools like IntelliJ where we may need to automatically switch editions + requested = adjustRequestedEdition(requested); + edition = requested.getEdition(); + + GenericVersionRange version = requested.getVersion(); + if (version == null) { + version = request.isIgnoreProject() ? VersionIdentifier.LATEST : getConfiguredVersion(); + requested.setVersion(version); + } + VersionIdentifier resolvedVersion = requested.getResolvedVersion(); + if (resolvedVersion == null) { + if (this.context.isSkipUpdatesMode()) { + ToolEditionAndVersion installed = request.getInstalled(); + if (installed != null) { + VersionIdentifier installedVersion = installed.getResolvedVersion(); + if (version.contains(installedVersion)) { + resolvedVersion = installedVersion; + } + } + } + if (resolvedVersion == null) { + resolvedVersion = resolveVersionForInstall(edition.edition(), version); + } + + if (resolvedVersion != null) { + requested.setResolvedVersion(resolvedVersion); + } + } + } + + /** + * Resolves the requested version for installation. + * + * @param edition the requested tool edition. + * @param version the requested version or version range. + * @return the resolved {@link VersionIdentifier} or {@code null} if version resolution is delegated to another installation mechanism. + */ + protected VersionIdentifier resolveVersionForInstall(String edition, GenericVersionRange version) { + + return getToolRepository().resolveVersion(this.tool, edition, version, this); + } + + /** + * Hook for subclasses to adjust the requested tool edition before the version is finalized. + * + * @param requested the requested {@link ToolEditionAndVersion} + * @return the given or transformed {@link ToolEditionAndVersion} + */ + protected ToolEditionAndVersion adjustRequestedEdition(ToolEditionAndVersion requested) { + + // default no-op + return requested; + } + + + private void completeRequestToolPath(ToolInstallRequest request) { + + Path toolPath = request.getToolPath(); + if (toolPath == null) { + toolPath = getToolPath(); + request.setToolPath(toolPath); + } + } + + /** + * @return the {@link Path} where the tool is located (installed). Will be {@code null} for global tools that do not know the {@link Path} since it is + * determined by the installer. + */ + public Path getToolPath() { + return null; + } + + /** + * This method is called after a tool was requested to be installed or updated. + * + * @param request {@code true} the {@link ToolInstallRequest}. + */ + protected void postInstall(ToolInstallRequest request) { + + if (!request.isAlreadyInstalled()) { + postInstallOnNewInstallation(request); + } + } + + /** + * This method is called after a tool was requested to be installed or updated and a new installation was performed. + * + * @param request {@code true} the {@link ToolInstallRequest}. + */ + protected void postInstallOnNewInstallation(ToolInstallRequest request) { + + // nothing to do by default + } + + /** + * @param edition the {@link #getInstalledEdition() edition}. + * @param version the {@link #getInstalledVersion() version}. + * @return the {@link Path} where this tool is installed (physically) or {@code null} if not available. + */ + protected abstract Path getInstallationPath(String edition, VersionIdentifier version); + + /** + * @param request the {@link ToolInstallRequest}. + * @return the existing {@link ToolInstallation}. + */ + protected ToolInstallation createExistingToolInstallation(ToolInstallRequest request) { + + ToolEditionAndVersion installed = request.getInstalled(); + + String edition = this.tool; + VersionIdentifier resolvedVersion = VersionIdentifier.LATEST; + + if (installed != null) { + if (installed.getEdition() != null) { + edition = installed.getEdition().edition(); + } + if (installed.getResolvedVersion() != null) { + resolvedVersion = installed.getResolvedVersion(); + } + } + + return createExistingToolInstallation(edition, resolvedVersion, request.getProcessContext(), + request.isAdditionalInstallation()); + } + + /** + * @param edition the {@link #getConfiguredEdition() edition}. + * @param installedVersion the {@link #getConfiguredVersion() version}. + * @param environmentContext the {@link EnvironmentContext}. + * @param extraInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the + * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. + * @return the {@link ToolInstallation}. + */ + protected ToolInstallation createExistingToolInstallation(String edition, VersionIdentifier installedVersion, EnvironmentContext environmentContext, + boolean extraInstallation) { + + Path installationPath = getInstallationPath(edition, installedVersion); + return createToolInstallation(installationPath, installedVersion, false, environmentContext, extraInstallation); + } + + /** + * @param rootDir the {@link ToolInstallation#rootDir() top-level installation directory}. + * @param version the installed {@link VersionIdentifier}. + * @param newInstallation {@link ToolInstallation#newInstallation() new installation} flag. + * @param environmentContext the {@link EnvironmentContext}. + * @param additionalInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the + * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. + * @return the {@link ToolInstallation}. + */ + protected ToolInstallation createToolInstallation(Path rootDir, VersionIdentifier version, boolean newInstallation, + EnvironmentContext environmentContext, boolean additionalInstallation) { + + Path linkDir = rootDir; + Path binDir = rootDir; + if (rootDir != null) { + // on MacOS applications have a very strange structure - see JavaDoc of findLinkDir and ToolInstallation.linkDir for details. + linkDir = getMacOsHelper().findLinkDir(rootDir, getBinaryName()); + binDir = this.context.getFileAccess().getBinPath(linkDir); + } + return createToolInstallation(rootDir, linkDir, binDir, version, newInstallation, environmentContext, additionalInstallation); + } + + /** + * @param rootDir the {@link ToolInstallation#rootDir() top-level installation directory}. + * @param linkDir the {@link ToolInstallation#linkDir() link directory}. + * @param binDir the {@link ToolInstallation#binDir() bin directory}. + * @param version the installed {@link VersionIdentifier}. + * @param newInstallation {@link ToolInstallation#newInstallation() new installation} flag. + * @param environmentContext the {@link EnvironmentContext}. + * @param additionalInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the + * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. + * @return the {@link ToolInstallation}. + */ + protected ToolInstallation createToolInstallation(Path rootDir, Path linkDir, Path binDir, VersionIdentifier version, boolean newInstallation, + EnvironmentContext environmentContext, boolean additionalInstallation) { + + // Invalidate cached edition/version so that subsequent calls reflect the new installation + invalidateInstalledEditionAndVersion(); + // do not copy the version file into macOS .app bundles: changing the bundle after codesigning breaks the seal. + ToolInstallation toolInstallation = new ToolInstallation(rootDir, linkDir, binDir, version, newInstallation); + setEnvironment(environmentContext, toolInstallation, additionalInstallation); + return toolInstallation; + } + + /** + * Called if the tool {@link ToolInstallRequest#isAlreadyInstalled() is already installed in the correct edition and version} so we can skip the + * installation. + * + * @param request the {@link ToolInstallRequest}. + * @return the {@link ToolInstallation}. + */ + protected ToolInstallation toolAlreadyInstalled(ToolInstallRequest request) { + + logToolAlreadyInstalled(request); + cveCheck(request); + postInstall(request); + return createExistingToolInstallation(request); + } + + /** + * Log that the tool is already installed. + * + * @param request the {@link ToolInstallRequest}. + */ + protected void logToolAlreadyInstalled(ToolInstallRequest request) { + Level level; + if (request.isSilent()) { + level = Level.DEBUG; + } else { + level = Level.INFO; + } + ToolEditionAndVersion installed = request.getInstalled(); + LOG.atLevel(level).log("Version {} of tool {} is already installed", installed.getVersion(), installed.getEdition()); + } + + /** + * Method to get the home path of the given {@link ToolInstallation}. + * + * @param toolInstallation the {@link ToolInstallation}. + * @return the Path to the home of the tool + */ + protected Path getToolHomePath(ToolInstallation toolInstallation) { + return toolInstallation.linkDir(); + } + + /** + * Method to set environment variables for the process context. + * + * @param environmentContext the {@link EnvironmentContext} where to {@link EnvironmentContext#withEnvVar(String, String) set environment variables} for + * this tool. + * @param toolInstallation the {@link ToolInstallation}. + * @param additionalInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the + * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. + */ + public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { + + String pathVariable = EnvironmentVariables.getToolVariablePrefix(this.tool) + "_HOME"; + Path toolHomePath = getToolHomePath(toolInstallation); + if (toolHomePath != null) { + environmentContext.withEnvVar(pathVariable, toolHomePath.toString()); + } + if (additionalInstallation) { + environmentContext.withPathEntry(toolInstallation.binDir()); + } + } + + /** + * @return {@code true} to extract (unpack) the downloaded binary file, {@code false} otherwise. + */ + protected boolean isExtract() { + + return true; + } + + /** + * Checks a version to be installed for {@link Cve}s. If at least one {@link Cve} is found, we try to find better/safer versions as alternative. If we find + * something better, we will suggest this to the user and ask him to make his choice. + * + * @param request the {@link ToolInstallRequest}. + * @return the {@link VersionIdentifier} to install. The will may be asked (unless {@code skipSuggestions} is {@code true}) and might choose a different + * version than the originally requested one. + */ + protected VersionIdentifier cveCheck(ToolInstallRequest request) { + + ToolEditionAndVersion requested = request.getRequested(); + VersionIdentifier resolvedVersion = requested.getResolvedVersion(); + if (request.isCveCheckDone()) { + return resolvedVersion; + } + ToolEdition toolEdition = requested.getEdition(); + GenericVersionRange allowedVersions = requested.getVersion(); + boolean requireStableVersion = true; + if (allowedVersions instanceof VersionIdentifier vi) { + requireStableVersion = vi.isStable(); + } + ToolSecurity toolSecurity = this.context.getDefaultToolRepository().findSecurity(this.tool, toolEdition.edition()); + double minSeverity = IdeVariables.CVE_MIN_SEVERITY.get(context); + OperatingSystem os = this.context.getSystemInfo().getOs(); + ToolVulnerabilities currentVulnerabilities = toolSecurity.findCves(resolvedVersion, os, minSeverity); + ToolVersionChoice currentChoice = ToolVersionChoice.ofCurrent(requested, currentVulnerabilities); + request.setCveCheckDone(); + if (currentChoice.logAndCheckIfEmpty()) { + return resolvedVersion; + } + boolean alreadyInstalled = request.isAlreadyInstalled(); + boolean directForceInstall = this.context.isForceMode() && request.isDirect(); + if (alreadyInstalled && !directForceInstall) { + // currently for a transitive dependency it does not make sense to suggest alternative versions, since the choice is not stored anywhere, + // and we then would ask the user again every time the tool having this dependency is started. So we only log the problem and the user needs to react + // (e.g. upgrade the tool with the dependency that is causing this). + IdeLogLevel.INTERACTION.log(LOG, "Please run 'ide -f install {}' to check for update suggestions!", this.tool); + return resolvedVersion; + } + ToolVersionChoice latest = null; + ToolVulnerabilities latestVulnerabilities = currentVulnerabilities; + ToolVersionChoice nearest = null; + ToolVulnerabilities nearestVulnerabilities = currentVulnerabilities; + List toolVersions = getVersions(); + for (VersionIdentifier version : toolVersions) { + + if (Objects.equals(version, resolvedVersion)) { + continue; // Skip the entire iteration for resolvedVersion + } + + if (acceptVersion(version, allowedVersions, requireStableVersion)) { + ToolVulnerabilities newVulnerabilities = toolSecurity.findCves(version, os, minSeverity); + if (newVulnerabilities.isSafer(latestVulnerabilities)) { + // we found a better/safer version + ToolEditionAndVersion toolEditionAndVersion = new ToolEditionAndVersion(toolEdition, version); + if (version.isGreater(resolvedVersion)) { + latestVulnerabilities = newVulnerabilities; + latest = ToolVersionChoice.ofLatest(toolEditionAndVersion, latestVulnerabilities); + nearest = null; + } else { + nearestVulnerabilities = newVulnerabilities; + nearest = ToolVersionChoice.ofNearest(toolEditionAndVersion, nearestVulnerabilities); + } + } else if (newVulnerabilities.isSaferOrEqual(nearestVulnerabilities)) { + if (newVulnerabilities.isSafer(nearestVulnerabilities) || version.isGreater(resolvedVersion)) { + nearest = ToolVersionChoice.ofNearest(new ToolEditionAndVersion(toolEdition, version), newVulnerabilities); + } + nearestVulnerabilities = newVulnerabilities; + } + } + } + if ((nearest != null) && !nearest.vulnerabilities().isSafer(currentVulnerabilities)) { + nearest = null; // never suggest a version that does not reduce the CVEs + } + if ((latest == null) && (nearest == null)) { + LOG.warn("Could not find any other version resolving your CVEs.\n" + + "Please keep attention to this tool and consider updating as soon as security fixes are available."); + if (alreadyInstalled) { + // we came here via "ide -f install ..." but no alternative is available + return resolvedVersion; + } + } + List choices = new ArrayList<>(); + choices.add(currentChoice); + boolean addSuggestions; + if (this.context.isForceMode() && request.isDirect()) { + addSuggestions = true; + } else { + List skipCveFixTools = IdeVariables.SKIP_CVE_FIX.get(this.context); + addSuggestions = !skipCveFixTools.contains(this.tool); + } + if (nearest != null) { + if (addSuggestions) { + choices.add(nearest); + } + nearest.logAndCheckIfEmpty(); + } + if (latest != null) { + if (addSuggestions) { + choices.add(latest); + } + latest.logAndCheckIfEmpty(); + } + ToolVersionChoice[] choicesArray = choices.toArray(ToolVersionChoice[]::new); + LOG.warn("Please note that by selecting an unsafe version to install, you accept the risk to be attacked."); + ToolVersionChoice answer = this.context.question(choicesArray, "Which version do you want to install?"); + VersionIdentifier version = answer.toolEditionAndVersion().getResolvedVersion(); + requested.setResolvedVersion(version); + return version; + } + + private static boolean acceptVersion(VersionIdentifier version, GenericVersionRange allowedVersions, boolean requireStableVersion) { + if (allowedVersions.isPattern() && !allowedVersions.contains(version)) { + return false; + } else if (requireStableVersion && !version.isStable()) { + return false; + } + return true; + } + + /** + * @return the {@link MacOsHelper} instance. + */ + protected MacOsHelper getMacOsHelper() { + + if (this.macOsHelper == null) { + this.macOsHelper = new MacOsHelper(this.context); + } + return this.macOsHelper; + } + + /** + * Gets the installed edition and version together, resolving both in a single operation. This method is final: tool-specific logic belongs in the + * {@link #computeInstalledEditionAndVersion()} hook so that the cache here always applies. + * + * @return the {@link EditionAndVersion} or {@code null} if not installed. + */ + public final EditionAndVersion getInstalledEditionAndVersion() { + + return this.installedEditionAndVersion.get(); + } + + /** + * Hook to compute the installed edition and version together. Override this method in subclasses to resolve both edition and version in a single operation, + * avoiding redundant expensive lookups. + * + * @return the {@link EditionAndVersion} or {@code null} if not installed. + */ + protected EditionAndVersion computeInstalledEditionAndVersion() { + + return null; + } + + /** + * @return the currently installed {@link VersionIdentifier version} of this tool or {@code null} if not installed. + */ + public final VersionIdentifier getInstalledVersion() { + + EditionAndVersion ev = getInstalledEditionAndVersion(); + return (ev != null) ? ev.version() : null; + } + + /** + * @return {@code true} if this tool is installed, {@code false} otherwise. + */ + public boolean isInstalled() { + + return getInstalledVersion() != null; + } + + /** + * @return the installed edition of this tool or {@code null} if not installed. + */ + public final String getInstalledEdition() { + + EditionAndVersion ev = getInstalledEditionAndVersion(); + return (ev != null) ? ev.edition() : null; + } + + /** Invalidates the cached installed edition and version so the next call to {@link #getInstalledEditionAndVersion()} recomputes the result. */ + protected void invalidateInstalledEditionAndVersion() { + + this.installedEditionAndVersion.invalidate(); + } + + /** + * Uninstalls the {@link #getName() tool}. + */ + public abstract void uninstall(); + + /** + * @return the {@link ToolRepository}. + */ + public ToolRepository getToolRepository() { + + return this.context.getDefaultToolRepository(); + } + + /** + * List the available editions of this tool. + */ + public void listEditions() { + + List editions = getToolRepository().getSortedEditions(getName()); + for (String edition : editions) { + LOG.info(edition); + } + } + + /** + * List the available versions of this tool. + */ + public void listVersions() { + + List versions = getToolRepository().getSortedVersions(getName(), getConfiguredEdition(), this); + for (VersionIdentifier vi : versions) { + LOG.info(vi.toString()); + } + } + + /** + * @return the {@link com.devonfw.tools.ide.tool.repository.DefaultToolRepository#getSortedVersions(String, String, AbstractToolCommandlet) sorted versions} + * of this tool. + */ + public List getVersions() { + return getToolRepository().getSortedVersions(getName(), getConfiguredEdition(), this); + } + + /** + * Sets the tool version in the environment variable configuration file. + * + * @param version the version (pattern) to set. + */ + public void setVersion(String version) { + + if ((version == null) || version.isBlank()) { + throw new IllegalStateException("Version has to be specified!"); + } + VersionIdentifier configuredVersion = VersionIdentifier.of(version); + if (!configuredVersion.isPattern() && !configuredVersion.isValid()) { + LOG.warn("Version {} seems to be invalid", version); + } + setVersion(configuredVersion, true); + } + + /** + * Sets the tool version in the environment variable configuration file. + * + * @param version the version to set. May also be a {@link VersionIdentifier#isPattern() version pattern}. + * @param hint - {@code true} to print the installation hint, {@code false} otherwise. + */ + public void setVersion(VersionIdentifier version, boolean hint) { + + setVersion(version, hint, null); + } + + /** + * Sets the tool version in the environment variable configuration file. + * + * @param version the version to set. May also be a {@link VersionIdentifier#isPattern() version pattern}. + * @param hint - {@code true} to print the installation hint, {@code false} otherwise. + * @param destination - the destination for the property to be set + */ + public void setVersion(VersionIdentifier version, boolean hint, EnvironmentVariablesFiles destination) { + + String edition = getConfiguredEdition(); + ToolRepository toolRepository = getToolRepository(); + + EnvironmentVariables variables = this.context.getVariables(); + if (destination == null) { + //use default location + destination = EnvironmentVariablesFiles.SETTINGS; + } + EnvironmentVariables settingsVariables = variables.getByType(destination.toType()); + String variableName = EnvironmentVariables.getToolVersionVariable(this.tool); + + VersionIdentifier resolvedVersion = toolRepository.resolveVersion(this.tool, edition, version, this); // verify that the version actually exists + settingsVariables.set(variableName, version.toString(), false); + settingsVariables.save(); + EnvironmentVariables declaringVariables = variables.findVariable(variableName); + if ((declaringVariables != null) && (declaringVariables != settingsVariables)) { + LOG.warn("The variable {} is overridden in {}. Please remove the overridden declaration in order to make the change affect.", variableName, + declaringVariables.getSource()); + } + LOG.info("Version of tool {} has been set to {} ({}={})", this.tool, version, variableName, version); + if (hint && !resolvedVersion.equals(getInstalledVersion())) { + IdeLogLevel.INTERACTION.log(LOG, "To install that version call the following command:\nide install {}", this.tool); + } + } + + /** + * Sets the tool edition in the environment variable configuration file. + * + * @param edition the edition to set. + */ + public void setEdition(String edition) { + + setEdition(edition, true); + } + + /** + * Sets the tool edition in the environment variable configuration file. + * + * @param edition the edition to set + * @param hint - {@code true} to print the installation hint, {@code false} otherwise. + */ + public void setEdition(String edition, boolean hint) { + + setEdition(edition, hint, null); + } + + /** + * Sets the tool edition in the environment variable configuration file. + * + * @param edition the edition to set + * @param hint - {@code true} to print the installation hint, {@code false} otherwise. + * @param destination - the destination for the property to be set + */ + public void setEdition(String edition, boolean hint, EnvironmentVariablesFiles destination) { + + if ((edition == null) || edition.isBlank()) { + throw new IllegalStateException("Edition has to be specified!"); + } + + if (destination == null) { + //use default location + destination = EnvironmentVariablesFiles.SETTINGS; + } + + if (!getToolRepository().getSortedEditions(this.tool).contains(edition)) { + LOG.warn("Edition {} seems to be invalid", edition); + } + EnvironmentVariables variables = this.context.getVariables(); + EnvironmentVariables settingsVariables = variables.getByType(destination.toType()); + String name = EnvironmentVariables.getToolEditionVariable(this.tool); + settingsVariables.set(name, edition, false); + settingsVariables.save(); + + LOG.info("{}={} has been set in {}", name, edition, settingsVariables.getSource()); + EnvironmentVariables declaringVariables = variables.findVariable(name); + if ((declaringVariables != null) && (declaringVariables != settingsVariables)) { + LOG.warn("The variable {} is overridden in {}. Please remove the overridden declaration in order to make the change affect.", name, + declaringVariables.getSource()); + } + if (hint) { + LOG.info("To install that edition call the following command:"); + LOG.info("ide install {}", this.tool); + } + } + + /** + * Runs the tool's help command to provide the user with usage information. + */ + @Override + public void printHelp(NlsBundle bundle) { + + super.printHelp(bundle); + String toolHelpArgs = getToolHelpArguments(); + if (toolHelpArgs != null && getInstalledVersion() != null) { + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING) + .executable(Path.of(getBinaryName())).addArgs(toolHelpArgs); + pc.run(ProcessMode.DEFAULT); + } + } + + /** + * @return the tool's specific help command. Usually help, --help or -h. Return null if not applicable. + */ + public String getToolHelpArguments() { + + return null; + } + + /** + * Creates a start script for the tool using the tool name. + * + * @param targetDir the {@link Path} of the installation where to create the script. If a "bin" sub-folder is present, the script will be created there + * instead. + * @param binary name of the binary to execute from the start script. + */ + protected void createStartScript(Path targetDir, String binary) { + + createStartScript(targetDir, binary, false); + } + + /** + * Creates a start script for the tool using the tool name. + * + * @param targetDir the {@link Path} of the installation where to create the script. If a "bin" sub-folder is present, the script will be created there + * instead. + * @param binary name of the binary to execute from the start script. + * @param background {@code true} to run the {@code binary} in background, {@code false} otherwise (foreground). + */ + protected void createStartScript(Path targetDir, String binary, boolean background) { + + Path binFolder = targetDir.resolve("bin"); + if (!Files.exists(binFolder)) { + if (this.context.getSystemInfo().isMac()) { + MacOsHelper macOsHelper = getMacOsHelper(); + Path appDir = macOsHelper.findAppDir(targetDir); + binFolder = macOsHelper.findLinkDir(appDir, binary); + } else { + binFolder = targetDir; + } + assert (Files.exists(binFolder)); + } + Path bashFile = binFolder.resolve(getName()); + String bashFileContentStart = "#!/usr/bin/env bash\n\"$(dirname \"$0\")/"; + String bashFileContentEnd = "\" $@"; + if (background) { + bashFileContentEnd += " &"; + } + try { + Files.writeString(bashFile, bashFileContentStart + binary + bashFileContentEnd); + } catch (IOException e) { + throw new RuntimeException(e); + } + assert (Files.exists(bashFile)); + context.getFileAccess().makeExecutable(bashFile); + } + + @Override + public void reset() { + super.reset(); + this.executionDirectory = null; + } + + /** + * @param command the binary that will be searched in the PATH e.g. docker + * @return true if the command is available to use + */ + protected boolean isCommandAvailable(String command) { + return this.context.getPath().hasBinaryOnPath(command); + } + + /** + * @param output the raw output string from executed command e.g. 'docker version' + * @param pattern Regular Expression pattern that filters out the unnecessary texts. + * @return version that has been processed. + */ + protected VersionIdentifier resolveVersionWithPattern(String output, Pattern pattern) { + Matcher matcher = pattern.matcher(output); + + if (matcher.find()) { + return VersionIdentifier.of(matcher.group(1)); + } else { + return null; + } + } + + /** + * @deprecated directly log success message and then report success on step if not null. + */ + @Deprecated + protected void success(Step step, String message, Object... args) { + + if (step == null) { + IdeLogLevel.SUCCESS.log(LOG, message, args); + } else { + step.success(message, args); + } + } + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java index d51223c3..bc846523 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java @@ -9,11 +9,11 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link ToolCommandlet} that delegates to another ToolCommandlet. + * {@link AbstractToolCommandlet} that delegates to another ToolCommandlet. * - * @param type of the {@link ToolCommandlet} to delegate to. + * @param type of the {@link AbstractToolCommandlet} to delegate to. */ -public abstract class DelegatingToolCommandlet extends ToolCommandlet { +public abstract class DelegatingToolCommandlet extends AbstractToolCommandlet { private final Class delegateClass; @@ -23,7 +23,7 @@ public abstract class DelegatingToolCommandlet extends * @param context the {@link IdeContext}. * @param tool the {@link #getName() tool name}. * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. - * @param delegateClass the {@link ToolCommandlet}. + * @param delegateClass the {@link AbstractToolCommandlet}. */ public DelegatingToolCommandlet(IdeContext context, String tool, Set tags, Class delegateClass) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java index fb276ce3..15ebcf59 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java @@ -23,15 +23,20 @@ import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.repository.ToolRepository; +import com.devonfw.tools.ide.version.GenericVersionRange; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link ToolCommandlet} that is installed globally. + * {@link AbstractToolCommandlet} that is installed globally. */ -public abstract class GlobalToolCommandlet extends ToolCommandlet { +public abstract class GlobalToolCommandlet extends AbstractToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(GlobalToolCommandlet.class); + private static final String MAC_APPLICATIONS_FOLDER_NAME = "Applications"; + + private static final Path MAC_SYSTEM_APPLICATIONS_DIR = Path.of("/" + MAC_APPLICATIONS_FOLDER_NAME); + /** * The constructor. * @@ -85,11 +90,13 @@ protected boolean runWithPackageManager(boolean silent, List commands = getInstallPackageManagerCommands(resolvedVersion); if (!commands.isEmpty()) { boolean newInstallation = runWithPackageManager(request.isSilent(), commands, NativePackageAction.INSTALL); - Path rootDir = getInstallationPath(getConfiguredEdition(), resolvedVersion); - return createToolInstallation(rootDir, resolvedVersion, newInstallation, request.getProcessContext(), request.isAdditionalInstallation()); + + VersionIdentifier installedVersion = getInstalledVersion(); + Path rootDir = getInstallationPath(getConfiguredEdition(), installedVersion); + + return createToolInstallation(rootDir, installedVersion, newInstallation, request.getProcessContext(), + request.isAdditionalInstallation()); } } @@ -185,6 +196,20 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { return createToolInstallation(installationPath, resolvedVersion, true, pc, false); } + @Override + protected VersionIdentifier resolveVersionForInstall(String edition, GenericVersionRange version) { + + if (this.context.getSystemInfo().isLinux() && !getNativePackages().isEmpty()) { + if (version instanceof VersionIdentifier versionIdentifier && !versionIdentifier.isPattern()) { + return versionIdentifier; + } + + return null; + } + + return super.resolveVersionForInstall(edition, version); + } + /** * @return the {@link List} of {@link NativePackage}s this tool consists of on Linux - one per supported {@link NativePackageManager}. Override this method * instead of {@link #getInstallPackageManagerCommands} so that the commands for installation and uninstallation can both be derived from this @@ -262,8 +287,8 @@ public String getWindowsRegistryAppName() { } /** - * @return a {@link Map} that maps edition names to the app name to look for in the Windows registry. Default - * returns a single entry with {@code tool -> tool}. Override for tools with multiple editions on Windows. + * @return a {@link Map} that maps edition names to the app name to look for in the Windows registry. Default returns a single entry with + * {@code tool -> tool}. Override for tools with multiple editions on Windows. */ public Map getWindowsRegistryAppNames() { @@ -308,10 +333,78 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer public void uninstall() { if (this.context.getSystemInfo().isWindows()) { WindowsHelper.get(this.context).uninstallApplication(getWindowsRegistryAppName()); - } else if (this.context.getSystemInfo().isLinux()) { + } else if (this.context.getSystemInfo().isLinux() && !getNativePackages().isEmpty()) { runWithPackageManager(false, getUninstallPackageManagerCommands(), NativePackageAction.UNINSTALL); + } else if (this.context.getSystemInfo().isMac()) { + uninstallMac(); } else { LOG.error("Couldn't uninstall {} on this OS. Please uninstall manually.", this.getName()); } } + + /** + * Uninstalls this tool on macOS. Unlike Linux, macOS has no single standardized package manager, so we try the best-effort options in order and finally fall + * back to giving the user actionable guidance if nothing could be done automatically. + */ + private void uninstallMac() { + if (runWithPackageManager(false, getUninstallPackageManagerCommands(), NativePackageAction.UNINSTALL)) { + return; + } + Path appBundle = findMacApplicationBundle(); + if ((appBundle != null) && deleteMacApplicationBundle(appBundle)) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully uninstalled {} by removing {}", this.tool, appBundle); + return; + } + String brewHint = ""; + if (isPackageManagerAvailable(NativePackageManager.BREW_CASK)) { + brewHint = " or via Homebrew (e.g. 'brew uninstall " + this.tool + "' or 'brew uninstall --cask " + this.tool + "')"; + } + LOG.error("Couldn't automatically uninstall {} on macOS. Please uninstall it manually, e.g. by moving it from the Applications folder to the Trash{}.", + this.getName(), brewHint); + } + + /** + * @param appBundle the *.app bundle to delete. + * @return {@code true} if {@code appBundle} was successfully deleted, {@code false} if deletion failed - e.g. because since macOS Monterey the Applications + * folder is protected and a regular process may not be allowed to delete from it. Callers must not assume this always succeeds and should fall back to + * manual-uninstall guidance if it returns {@code false} rather than letting the exception propagate. + */ + private boolean deleteMacApplicationBundle(Path appBundle) { + try { + this.context.getFileAccess().delete(appBundle); + return true; + } catch (IllegalStateException e) { + LOG.warn("Could not automatically remove {}: {}", appBundle, e.getMessage(), e); + return false; + } + } + + /** + * @return the {@link Path} to the *.app bundle of this tool as found in one of the well-known macOS application folders, or {@code null} if + * {@link #getMacApplicationName() unknown} or not found there. + */ + private Path findMacApplicationBundle() { + String appName = getMacApplicationName(); + if (appName == null) { + return null; + } + String bundleFileName = appName + ".app"; + List applicationsDirs = List.of(MAC_SYSTEM_APPLICATIONS_DIR, this.context.getUserHome().resolve(MAC_APPLICATIONS_FOLDER_NAME)); + for (Path applicationsDir : applicationsDirs) { + Path candidate = applicationsDir.resolve(bundleFileName); + if (Files.isDirectory(candidate)) { + return candidate; + } + } + return null; + } + + /** + * @return the name (without the ".app" suffix) of this tool's application bundle as it appears in the macOS Applications folder, or {@code null} if unknown + * so that {@link #uninstall() uninstall} cannot try to automatically remove it and instead gives the user manual guidance. Override this in subclasses + * that know their application bundle name (which may differ from {@link #getName() the tool name}, e.g. "Docker" for the tool "docker"). + */ + public String getMacApplicationName() { + return null; + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java index f1cbbece..0b7cc4f7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java @@ -1,681 +1,8 @@ package com.devonfw.tools.ide.tool; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.Path; -import java.util.Collection; -import java.util.Set; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.devonfw.tools.ide.cli.CliException; -import com.devonfw.tools.ide.cli.CliOfflineException; -import com.devonfw.tools.ide.common.Tag; -import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.io.FileAccess; -import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.process.ProcessContext; -import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.repository.ToolRepository; -import com.devonfw.tools.ide.url.model.file.json.ToolDependency; -import com.devonfw.tools.ide.version.GenericVersionRange; -import com.devonfw.tools.ide.version.VersionIdentifier; -import com.devonfw.tools.ide.version.VersionRange; - /** - * {@link ToolCommandlet} that is installed locally into the IDEasy. + * {@link ToolCommandlet} that is installed locally (inside {@code $IDE_HOME/tools}) as opposed to globally (e.g. via the OS package manager). */ -public abstract class LocalToolCommandlet extends ToolCommandlet { - - private static final Logger LOG = LoggerFactory.getLogger(LocalToolCommandlet.class); - - /** - * The constructor. - * - * @param context the {@link IdeContext}. - * @param tool the {@link #getName() tool name}. - * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. - */ - public LocalToolCommandlet(IdeContext context, String tool, Set tags) { - - super(context, tool, tags); - } - - /** - * @return the {@link Path} where the tool is located (installed). - */ - @Override - public Path getToolPath() { - if (this.context.getSoftwarePath() == null) { - return null; - } - return this.context.getSoftwarePath().resolve(getName()); - } - - /** - * @return the {@link Path} where the executables of the tool can be found. Typically, a "bin" folder inside {@link #getToolPath() tool path}. - */ - public Path getToolBinPath() { - - Path toolPath = getToolPath(); - if (toolPath == null) { - return null; - } - Path binPath = toolPath.resolve(IdeContext.FOLDER_BIN); - if (Files.isDirectory(binPath)) { - return binPath; - } - return toolPath; - } - - /** - * @return {@code true} to ignore a missing {@link IdeContext#FILE_SOFTWARE_VERSION software version file} in an installation, {@code false} delete the broken - * installation (default). - */ - protected boolean isIgnoreMissingSoftwareVersionFile() { - - return false; - } - - @Override - protected ToolInstallation doInstall(ToolInstallRequest request) { - - Step step = request.getStep(); - if (step == null) { - return doInstallStep(request); - } else { - return step.call(() -> doInstallStep(request), - () -> createExistingToolInstallation(request)); - } - } - - private ToolInstallation doInstallStep(ToolInstallRequest request) { - - if (request.isIgnoreProject() && isIgnoreSoftwareRepo()) { - throw new CliException( - "The tool " + this.tool - + " does not support the software repository and therefore cannot be installed while ignoring the project."); - } - - // install configured version of our tool in the software repository if not already installed - ToolInstallation installation = installTool(request); - - // check if we already have this version installed (linked) locally in IDE_HOME/software - VersionIdentifier resolvedVersion = installation.resolvedVersion(); - if (request.isAlreadyInstalled()) { - return installation; - } else { - LOG.debug("Installation from {} to {}.", request.getInstalled(), request.getRequested()); - } - FileAccess fileAccess = this.context.getFileAccess(); - boolean ignoreSoftwareRepo = isIgnoreSoftwareRepo(); - Path toolPath = request.getToolPath(); - if (!ignoreSoftwareRepo && !request.isIgnoreProject() && toolPath != null) { - // we need to link the version or update the link. - if (Files.exists(toolPath, LinkOption.NOFOLLOW_LINKS)) { - fileAccess.backup(toolPath); - } - fileAccess.mkdirs(toolPath.getParent()); - fileAccess.symlink(installation.linkDir(), toolPath); - } else { - LOG.debug("Skipping symlink creation for tool {} as it is installed in standalone/global mode (ignoreSoftwareRepo={}, ignoreProject={}, toolPath={}).", - this.tool, ignoreSoftwareRepo, request.isIgnoreProject(), toolPath); - } - if (!request.isExtraInstallation() && (installation.binDir() != null)) { - this.context.getPath().setPath(this.tool, installation.binDir()); - } - postInstall(request); - ToolEditionAndVersion installed = request.getInstalled(); - GenericVersionRange installedVersion = null; - if (installed != null) { - installedVersion = installed.getVersion(); - } - ToolEditionAndVersion requested = request.getRequested(); - ToolEdition toolEdition = requested.getEdition(); - Step step = request.getStep(); - if (installedVersion == null) { - IdeLogLevel.SUCCESS.log(LOG, "Successfully installed {} in version {} at {}", toolEdition, resolvedVersion, toolPath); - } else { - IdeLogLevel.SUCCESS.log(LOG, "Successfully installed {} in version {} replacing previous version {} of {} at {}", toolEdition, - resolvedVersion, installedVersion, installed.getEdition(), toolPath); - } - if (step != null) { - step.success(true); - } - return installation; - } - - /** - * Determines whether this tool should be installed directly in the software folder or in the software repository. - * - * @return {@code true} if the tool should be installed directly in the software folder, ignoring the central software repository; {@code false} if the tool - * should be installed in the central software repository (default behavior). - */ - protected boolean isIgnoreSoftwareRepo() { - - return false; - } - - /** - * Performs the installation of the {@link #getName() tool} together with the environment context managed by this - * {@link com.devonfw.tools.ide.commandlet.Commandlet}. - * - * @param request the {@link ToolInstallRequest}. - * @return the resulting {@link ToolInstallation}. - */ - public ToolInstallation installTool(ToolInstallRequest request) { - - completeRequest(request); // most likely already done, but if installTool was called directly and not from install - if (request.isInstallLoop()) { - return toolAlreadyInstalled(request); - } - ToolEditionAndVersion requested = request.getRequested(); - ToolEdition toolEdition = requested.getEdition(); - assert (toolEdition.tool().equals(this.tool)) : "Mismatch " + this.tool + " != " + toolEdition.tool(); - String edition = toolEdition.edition(); - VersionIdentifier resolvedVersion = cveCheck(request); - installToolDependencies(request); - // cveCheck might have changed resolvedVersion so let us re-check... - if (request.isAlreadyInstalled()) { - return toolAlreadyInstalled(request); - } else { - ToolEditionAndVersion installed = request.getInstalled(); - LOG.debug("Installation from {} to {}.", installed, requested); - } - Path installationPath = getInstallationPath(edition, resolvedVersion); - - ProcessContext processContext = request.getProcessContext(); - boolean additionalInstallation = request.isAdditionalInstallation(); - boolean ignoreSoftwareRepo = isIgnoreSoftwareRepo(); - Path toolVersionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); - FileAccess fileAccess = this.context.getFileAccess(); - if (Files.isDirectory(installationPath)) { - if (Files.exists(toolVersionFile)) { - if (!ignoreSoftwareRepo) { - assert resolvedVersion.equals(getInstalledVersion(installationPath)) : - "Found version " + getInstalledVersion(installationPath) + " in " + toolVersionFile + " but expected " + resolvedVersion; - LOG.debug("Version {} of tool {} is already installed at {}", resolvedVersion, toolEdition, installationPath); - return createToolInstallation(installationPath, resolvedVersion, false, processContext, additionalInstallation); - } - } else { - // Makes sure that IDEasy will not delete itself - if (this.tool.equals(IdeasyCommandlet.TOOL_NAME)) { - LOG.warn("Your IDEasy installation is missing the version file at {}", toolVersionFile); - return createToolInstallation(installationPath, resolvedVersion, false, processContext, additionalInstallation); - } else if (!isIgnoreMissingSoftwareVersionFile()) { - LOG.warn("Deleting corrupted installation at {}", installationPath); - fileAccess.delete(installationPath); - } else { - // version file is missing but tool allows this - restore the file and preserve the installation - VersionIdentifier installedVersion = getInstalledVersion(installationPath); - if (installedVersion == null) { - installedVersion = resolvedVersion; - } - restoreMissingVersionFile(installationPath, installedVersion); - if (installedVersion.equals(resolvedVersion)) { - return createToolInstallation(installationPath, installedVersion, false, processContext, additionalInstallation); - } - // the installation on disk is a different version than requested so we continue with the regular installation - // that will backup the existing installation before installing the requested version. - } - } - } - VersionIdentifier actualInstalledVersion = resolvedVersion; - try { - performToolInstallation(request, installationPath); - } catch (CliOfflineException e) { - // If we are offline and cannot download, check if we can continue with an existing installation - ToolEditionAndVersion installed = request.getInstalled(); - if ((installed != null) && (installed.getResolvedVersion() != null)) { - LOG.warn("Cannot download {} in version {} because we are offline. Continuing with already installed version {}.", this.tool, - resolvedVersion, installed.getResolvedVersion()); - // If offline and could not download, actualInstalledVersion will be the old version, not resolvedVersion - // In that case, we need to recalculate the installation path for the actually installed version - actualInstalledVersion = installed.getResolvedVersion(); - installationPath = getInstallationPath(edition, actualInstalledVersion); - } else { - // No existing installation available, re-throw the exception - throw e; - } - } - return createToolInstallation(installationPath, actualInstalledVersion, true, processContext, additionalInstallation); - } - - /** - * Performs the installation of the {@link #getName() tool} by using {@link #installDownloadedToolPayload(ToolInstallRequest, Path, Path)} for tool-specific - * logic, backing up any existing installation, and writing the version file. - *

      - * This method assumes that the version has already been resolved and dependencies installed. It handles the final steps of placing the tool into the - * appropriate installation directory. - * - * @param request the {@link ToolInstallRequest}. - * @param installationPath the target {@link Path} where the {@link #getName() tool} should be installed. - */ - protected void performToolInstallation(ToolInstallRequest request, Path installationPath) { - - FileAccess fileAccess = this.context.getFileAccess(); - ToolEditionAndVersion requested = request.getRequested(); - VersionIdentifier resolvedVersion = requested.getResolvedVersion(); - Path downloadedToolFile = downloadTool(requested.getEdition().edition(), resolvedVersion); - - if (Files.isDirectory(installationPath)) { - if (this.tool.equals(IdeasyCommandlet.TOOL_NAME)) { - LOG.warn("Your IDEasy installation is missing the version file."); - } else { - fileAccess.backup(installationPath); - } - } - fileAccess.mkdirs(installationPath.getParent()); - - installDownloadedToolPayload(request, installationPath, downloadedToolFile); - - this.context.writeVersionFile(resolvedVersion, installationPath); - // fix macOS Gatekeeper blocking - must run after version file is written but before any executables are launched - getMacOsHelper().removeQuarantineAttribute(installationPath); - LOG.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath); - } - - /** - * Performs the actual installation of the tool bits. - * - * @param request the {@link ToolInstallRequest}. - * @param installationPath the target {@link Path} where the tool should be installed. - * @param downloadedToolFile the {@link Path} to the downloaded tool file. - */ - protected void installDownloadedToolPayload(ToolInstallRequest request, Path installationPath, Path downloadedToolFile) { - - boolean extract = isExtract(); - if (!extract) { - LOG.trace("Extraction is disabled for '{}' hence just moving the downloaded file {}.", this.tool, downloadedToolFile); - } - this.context.getFileAccess().extract(downloadedToolFile, installationPath, this::postExtract, extract); - } - - /** - * @param edition the {@link #getConfiguredEdition() tool edition} to download. - * @param resolvedVersion the resolved {@link VersionIdentifier version} to download. - * @return the {@link Path} to the downloaded release file. - */ - protected Path downloadTool(String edition, VersionIdentifier resolvedVersion) { - return getToolRepository().download(this.tool, edition, resolvedVersion, this); - } - - /** - * Install this tool as dependency of another tool. - * - * @param versionRange the required {@link VersionRange}. See {@link ToolDependency#versionRange()}. - * @param parentRequest the {@link ToolInstallRequest} of the tool causing this dependency. - * @return the corresponding {@link ToolInstallation}. - */ - public ToolInstallation installAsDependency(VersionRange versionRange, ToolInstallRequest parentRequest) { - ToolInstallRequest request = new ToolInstallRequest(parentRequest); - ToolEditionAndVersion requested = new ToolEditionAndVersion(getToolWithConfiguredEdition()); - request.setRequested(requested); - - // If we are not inside a project or if we should ignore the project, do not try to use the configured version - // but instead just use the version range from the dependency. - boolean ignoreProject = request.isIgnoreProject() || this.context.getIdeHome() == null; - VersionIdentifier configuredVersion = ignoreProject ? null : getConfiguredVersion(); - if (!ignoreProject && (configuredVersion != null) && versionRange.contains(configuredVersion)) { - // prefer configured version if contained in version range - requested.setVersion(configuredVersion); - // return install(true, configuredVersion, processContext, null); - return install(request); - } else { - if (ignoreProject) { - LOG.debug("Ignoring project for dependency {} of tool {}, using version range {}", this.tool, parentRequest.getRequested(), versionRange); - } else if (isIgnoreSoftwareRepo()) { - throw new IllegalStateException( - "Cannot satisfy dependency to " + this.tool + " in version " + versionRange + " for " + parentRequest.getRequested() - + " since it is conflicting with configured version " - + configuredVersion - + " and this tool does not support the software repository."); - } else { - LOG.info( - "The tool {} requires {} in the version range {}, but your project uses version {}, which does not match." - + " Therefore, we install a compatible version in that range.", - parentRequest.getRequested().getEdition(), this.tool, versionRange, configuredVersion); - } - requested.setVersion(versionRange); - return installTool(request); - } - } - - /** - * Installs the tool dependencies for the current tool. - * - * @param request the {@link ToolInstallRequest}. - */ - protected void installToolDependencies(ToolInstallRequest request) { - - ToolEditionAndVersion requested = request.getRequested(); - VersionIdentifier version = requested.getResolvedVersion(); - ToolEdition toolEdition = requested.getEdition(); - Collection dependencies = getToolRepository().findDependencies(this.tool, toolEdition.edition(), version); - int size = dependencies.size(); - LOG.debug("Tool {} has {} other tool(s) as dependency", toolEdition, size); - for (ToolDependency dependency : dependencies) { - LOG.trace("Ensuring dependency {} for tool {}", dependency.tool(), toolEdition); - LocalToolCommandlet dependencyTool = this.context.getCommandletManager().getRequiredLocalToolCommandlet(dependency.tool()); - dependencyTool.installAsDependency(dependency.versionRange(), request); - } - } - - /** - * Post-extraction hook that can be overridden to add custom processing after unpacking and before moving to the final destination folder. - * - * @param extractedDir the {@link Path} to the folder with the unpacked tool. - */ - protected void postExtract(Path extractedDir) { - - } - - @Override - protected ToolInstallation toolAlreadyInstalled(ToolInstallRequest request) { - - if (isIgnoreMissingSoftwareVersionFile()) { - // the installed version was determined from the installation itself so we can heal the missing version file - ToolEditionAndVersion installed = request.getInstalled(); - if (installed != null) { - restoreMissingVersionFile(getToolPath(), installed.getResolvedVersion()); - } - } - return super.toolAlreadyInstalled(request); - } - - /** - * Restores the {@link IdeContext#FILE_SOFTWARE_VERSION version file} of an existing installation in case it got lost (e.g. because {@code uv} recreated the - * python virtual environment). Does nothing if the file is present or the version is unknown. - * - * @param installationPath the {@link Path} to the installation of this tool. - * @param version the {@link VersionIdentifier} that is actually installed at the given {@code installationPath}. - */ - protected void restoreMissingVersionFile(Path installationPath, VersionIdentifier version) { - - if ((version == null) || !Files.isDirectory(installationPath)) { - return; - } - Path toolVersionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); - if (Files.exists(toolVersionFile)) { - return; - } - LOG.warn("Version file is missing at {} - restoring it with version {} for tool {}", toolVersionFile, version, this.tool); - this.context.writeVersionFile(version, installationPath); - // the installation on disk changed so a previously cached result must not survive - invalidateInstalledEditionAndVersion(); - } - - @Override - protected EditionAndVersion computeInstalledEditionAndVersion() { - - Path toolPath = getToolPath(); - if (isToolNotInstalled(toolPath)) { - return null; - } - // Resolve edition and version from a single tool-path lookup (one pass) instead of two separate lookups. - String edition = getInstalledEdition(toolPath); - VersionIdentifier version = getInstalledVersion(toolPath); - if (version == null) { - return null; - } - return new EditionAndVersion(edition, version); - } - - /** - * @param toolPath the installation {@link Path} where to find the version file. - * @return the currently installed {@link VersionIdentifier version} of this tool or {@code null} if not installed. - */ - protected VersionIdentifier getInstalledVersion(Path toolPath) { - - if (isToolNotInstalled(toolPath)) { - return null; - } - Path versionLookupPath = getInstalledSoftwareRepoPath(toolPath, false); - if (versionLookupPath == null) { - versionLookupPath = toolPath; - } - Path toolVersionFile = versionLookupPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); - if (!Files.exists(toolVersionFile)) { - Path legacyToolVersionFile = versionLookupPath.resolve(IdeContext.FILE_LEGACY_SOFTWARE_VERSION); - if (Files.exists(legacyToolVersionFile)) { - toolVersionFile = legacyToolVersionFile; - } else { - LOG.warn("Tool {} is missing version file in {}", getName(), toolVersionFile); - return null; - } - } - String version = this.context.getFileAccess().readFileContent(toolVersionFile).trim(); - return VersionIdentifier.of(version); - } - - /** - * @param toolPath the installation {@link Path} where to find currently installed tool. The name of the parent directory of the real path corresponding - * to the passed {@link Path path} must be the name of the edition. - * @return the installed edition of this tool or {@code null} if not installed. - */ - protected String getInstalledEdition(Path toolPath) { - if (isToolNotInstalled(toolPath)) { - return null; - } - Path realPath = this.context.getFileAccess().toRealPath(toolPath); - // if the realPath changed, a link has been resolved - if (realPath.equals(toolPath)) { - if (!isIgnoreSoftwareRepo()) { - LOG.warn("Tool {} is not installed via software repository (maybe from devonfw-ide). Please consider reinstalling it.", this.tool); - } - // I do not see any reliable way how we could determine the edition of a tool that does not use software repo or that was installed by devonfw-ide - return getConfiguredEdition(); - } - Path toolRepoFolder = context.getSoftwareRepositoryPath().resolve(ToolRepository.ID_DEFAULT).resolve(this.tool); - String edition = getEdition(toolRepoFolder, realPath); - if (edition == null) { - edition = this.tool; - } - if (!getToolRepository().getSortedEditions(this.tool).contains(edition)) { - LOG.warn("Undefined edition {} of tool {}", edition, this.tool); - } - return edition; - } - - private String getEdition(Path toolRepoFolder, Path toolInstallFolder) { - - int toolRepoNameCount = toolRepoFolder.getNameCount(); - int toolInstallNameCount = toolInstallFolder.getNameCount(); - if (toolRepoNameCount < toolInstallNameCount) { - // ensure toolInstallFolder starts with $IDE_ROOT/_ide/software/default/«tool» - for (int i = 0; i < toolRepoNameCount; i++) { - if (!toolRepoFolder.getName(i).toString().equals(toolInstallFolder.getName(i).toString())) { - return null; - } - } - return toolInstallFolder.getName(toolRepoNameCount).toString(); - } - return null; - } - - private Path getInstalledSoftwareRepoPath(Path toolPath) { - return getInstalledSoftwareRepoPath(toolPath, false); - } - - private Path getInstalledSoftwareRepoPath(Path toolPath, boolean logIfNotSoftwareRepo) { - if (isToolNotInstalled(toolPath)) { - return null; - } - Path installPath = this.context.getFileAccess().toRealPath(toolPath); - // if the installPath changed, a link has been resolved - if (installPath.equals(toolPath)) { - if (logIfNotSoftwareRepo && !isIgnoreSoftwareRepo()) { - LOG.warn("Tool {} is not installed via software repository (maybe from devonfw-ide). Please consider reinstalling it.", this.tool); - } - // I do not see any reliable way how we could determine the edition of a tool that does not use software repo or that was installed by devonfw-ide - return null; - } - installPath = getValidInstalledSoftwareRepoPath(installPath, context.getSoftwareRepositoryPath()); - return installPath; - } - - Path getValidInstalledSoftwareRepoPath(Path installPath, Path softwareRepoPath) { - int softwareRepoNameCount = softwareRepoPath.getNameCount(); - int toolInstallNameCount = installPath.getNameCount(); - int targetToolInstallNameCount = softwareRepoNameCount + 4; - - // installPath can't be shorter than softwareRepoPath - if (toolInstallNameCount < softwareRepoNameCount) { - LOG.warn("The installation path is not located within the software repository {}.", installPath); - return null; - } - // ensure installPath starts with $IDE_ROOT/_ide/software/ - for (int i = 0; i < softwareRepoNameCount; i++) { - if (!softwareRepoPath.getName(i).toString().equals(installPath.getName(i).toString())) { - LOG.warn("The installation path is not located within the software repository {}.", installPath); - return null; - } - } - // return $IDE_ROOT/_ide/software/«id»/«tool»/«edition»/«version» - if (toolInstallNameCount == targetToolInstallNameCount) { - return installPath; - } else if (toolInstallNameCount > targetToolInstallNameCount) { - Path validInstallPath = installPath; - for (int i = 0; i < toolInstallNameCount - targetToolInstallNameCount; i++) { - validInstallPath = validInstallPath.getParent(); - } - return validInstallPath; - } else { - LOG.warn("The installation path is faulty {}.", installPath); - return null; - } - } - - private boolean isToolNotInstalled(Path toolPath) { - - if ((toolPath == null) || !Files.isDirectory(toolPath)) { - LOG.debug("Tool {} not installed in {}", this.tool, toolPath); - return true; - } - return false; - } - - @Override - public void uninstall() { - try { - Path toolPath = getToolPath(); - if (!Files.exists(toolPath)) { - LOG.warn("An installed version of {} does not exist.", this.tool); - return; - } - if (this.context.isForceMode() && !isIgnoreSoftwareRepo()) { - LOG.warn( - "You triggered an uninstall of {} in version {} with force mode!\n" - + "This will physically delete the currently installed version including its plugins from the machine.\n" - + "This may cause issues with other projects, that use the same version of that tool." - , this.tool, getInstalledVersion()); - uninstallPluginsOfTool(); - uninstallFromSoftwareRepository(toolPath); - } - performUninstall(toolPath); - IdeLogLevel.SUCCESS.log(LOG, "Successfully uninstalled {}", this.tool); - } catch (Exception e) { - LOG.error("Failed to uninstall {}", this.tool, e); - } - } - - /** - * Performs the actual uninstallation of this tool. - * - * @param toolPath the current {@link #getToolPath() tool path}. - */ - protected void performUninstall(Path toolPath) { - this.context.getFileAccess().delete(toolPath); - } - - /** - * Deletes the installed version of the tool from the shared software repository. - */ - private void uninstallFromSoftwareRepository(Path toolPath) { - Path repoPath = getInstalledSoftwareRepoPath(toolPath, true); - if ((repoPath == null) || !Files.exists(repoPath)) { - LOG.warn("An installed version of {} does not exist in software repository.", this.tool); - return; - } - LOG.info("Physically deleting {} as requested by the user via force mode.", repoPath); - this.context.getFileAccess().delete(repoPath); - IdeLogLevel.SUCCESS.log(LOG, "Successfully deleted {} from your computer.", repoPath); - } - - /** - * Deletes the installed plugins of the tool from the plugins path. - */ - private void uninstallPluginsOfTool() { - - Path toolPluginsPath = this.context.getPluginsPath().resolve(this.tool); - if (!Files.exists(toolPluginsPath)) { - LOG.info("There are no plugins of {} present to delete.", this.tool); - return; - } - LOG.info("Physically deleting {} as requested by the user via force mode.", toolPluginsPath); - this.context.getFileAccess().delete(toolPluginsPath); - IdeLogLevel.SUCCESS.log(LOG, "Successfully deleted {} from your computer.", toolPluginsPath); - } - - @Override - protected Path getInstallationPath(String edition, VersionIdentifier resolvedVersion) { - Path installationPath; - if (isIgnoreSoftwareRepo()) { - installationPath = getToolPath(); - } else { - Path softwareRepositoryPath = this.context.getSoftwareRepositoryPath(); - if (softwareRepositoryPath == null) { - return null; - } - Path softwareRepoPath = softwareRepositoryPath.resolve(getToolRepository().getId()).resolve(this.tool).resolve(edition); - installationPath = softwareRepoPath.resolve(resolvedVersion.toString()); - } - return installationPath; - } - - /** - * @return {@link VersionIdentifier} with latest version of the tool}. - */ - public VersionIdentifier getLatestToolVersion() { - - return this.context.getDefaultToolRepository().resolveVersion(this.tool, getConfiguredEdition(), VersionIdentifier.LATEST, this); - } - - - /** - * Searches for a wrapper file in valid projects (containing a build file f.e. build.gradle or pom.xml) and returns its path. - * - * @param wrapperFileName the name of the wrapper file - * @return Path of the wrapper file or {@code null} if none was found. - */ - protected Path findWrapper(String wrapperFileName) { - Path dir = this.context.getCwd(); - // traverse the cwd directory containing a build descriptor up till a wrapper file was found - while ((dir != null) && (findBuildDescriptor(dir) != null)) { - Path wrapper = dir.resolve(wrapperFileName); - if (Files.exists(wrapper)) { - LOG.debug("Using wrapper: {}", wrapper); - return wrapper; - } - dir = dir.getParent(); - } - return null; - } - - /** - * @param directory the {@link Path} to the build directory. - * @return the build configuration file for this tool or {@code null} if not found (or this is not a build tool). - */ - public Path findBuildDescriptor(Path directory) { - return null; - } - - /** - * @return Bash completion command for this tool or {@code null} if this tool does not provide Bash completion. - */ - public String getBashCompletion() { +public interface LocalToolCommandlet extends ToolCommandlet { - return null; - } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackage.java b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackage.java index 8156b976..d8509191 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackage.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackage.java @@ -1,5 +1,6 @@ package com.devonfw.tools.ide.tool; +import java.nio.file.Path; import java.util.List; import java.util.Objects; @@ -13,6 +14,7 @@ public class NativePackage { private final List extraInstallOptions; private final List setupCommands; private final List cleanupCommands; + private final List optionalNativePackageArtifactPaths; /** * Creates a new {@link NativePackage} with optional fields defaulting to empty lists. @@ -25,11 +27,27 @@ public class NativePackage { */ public NativePackage(NativePackageManager pm, List packages, List extraInstallOptions, List setupCommands, List cleanupCommands) { + this(pm, packages, extraInstallOptions, setupCommands, cleanupCommands, null); + } + + /** + * Creates a new {@link NativePackage} with optional fields defaulting to empty lists. + * + * @param pm the specific {@link NativePackageManager} + * @param packages the packages that need to be handled. + * @param extraInstallOptions extra install options (optional) + * @param setupCommands commands to run before install (optional) + * @param cleanupCommands commands to run after uninstall (optional) + * @param optionalNativePackageArtifactPaths paths to local package artifacts (optional) + */ + public NativePackage(NativePackageManager pm, List packages, + List extraInstallOptions, List setupCommands, List cleanupCommands, List optionalNativePackageArtifactPaths) { this.packageManager = Objects.requireNonNull(pm, "package manager must not be null"); this.packages = List.copyOf(Objects.requireNonNull(packages, "packages must not be null")); this.extraInstallOptions = extraInstallOptions != null ? List.copyOf(extraInstallOptions) : List.of(); this.setupCommands = setupCommands != null ? List.copyOf(setupCommands) : List.of(); this.cleanupCommands = cleanupCommands != null ? List.copyOf(cleanupCommands) : List.of(); + this.optionalNativePackageArtifactPaths = optionalNativePackageArtifactPaths != null ? List.copyOf(optionalNativePackageArtifactPaths) : List.of(); } /** @@ -39,7 +57,7 @@ public NativePackage(NativePackageManager pm, List packages, * @param packages the packages that need to be handled */ public NativePackage(NativePackageManager pm, List packages) { - this(pm, packages, null, null, null); + this(pm, packages, null, null, null, null); } /** @@ -88,6 +106,13 @@ public List getCleanupCommands() { return cleanupCommands; } + /** + * @return set {@link optionalNativePackageArtifactPaths}. + */ + public List getOptionalNativePackageArtifactPaths() { + return optionalNativePackageArtifactPaths; + } + /** * @param version the version to pin the {@link #getPackages()} to or {@code null} to install the latest available version. * @return {@link PackageManagerCommand} for installation. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java index a742228c..869a6385 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java @@ -1,5 +1,6 @@ package com.devonfw.tools.ide.tool; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -8,30 +9,47 @@ */ public enum NativePackageManager { /** Advanced Package Tool (APT) is the package manager of Debian based Linux distributions. */ - APT("install -y", "-y autoremove --purge", "=", "*"), + APT("apt", "install -y", "-y autoremove --purge", "=", "*", true), /** Zypper is the package manager of SUSE based Linux distributions. */ - ZYPPER("--non-interactive install", "remove", "=", ""), + ZYPPER("zypper", "--non-interactive install", "remove", "=", "", true), /** Yellowdog Updater Modified (YUM) is the package manager of RPM package based Linux distributions like Fedora, Red Hat, or CentOS. */ - YUM("install -y", "remove -y", "-", "*"), + YUM("yum", "install -y", "remove -y", "-", "*", true), /** DaNdiFied yum (DNF) is the package manager of RPM package based Linux distributions like Fedora. It is the successor of {@link #YUM}. */ - DNF("install -y", "remove -y", "-", "*"); + DNF("dnf", "install -y", "remove -y", "-", "*", true), + + /** Pacman is the package manager of Arch Linux based distributions. */ + PACMAN("pacman", "-S --needed --noconfirm", "-Rs --noconfirm", null, "", true), + + /** Yay is an AUR helper for packages that are not in the official Arch Linux repositories. */ + YAY("yay", "-S --needed --noconfirm", "-Rs --noconfirm", null, "", false), + + /** Homebrew formula installation, the closest thing macOS has to a standard package manager. */ + BREW("brew", "install", "uninstall", "@", "", false), + + /** Homebrew cask installation, used for macOS GUI applications distributed as *.app bundles. */ + BREW_CASK("brew", "install --cask", "uninstall --cask", "@", "", false); private static final String DPKG_STATUS_INSTALLED = "installed"; private static final String SUDO = "sudo"; + private final String binaryName; private final String installCommand; private final String uninstallCommand; private final String versionSeparator; private final String versionWildCard; + private final boolean needSudo; - NativePackageManager(String installCommand, String uninstallCommand, String versionSeparator, String versionWildCard) { + NativePackageManager(String binaryName, String installCommand, String uninstallCommand, String versionSeparator, String versionWildCard, + boolean needSudo) { + this.binaryName = binaryName; this.installCommand = installCommand; this.uninstallCommand = uninstallCommand; this.versionSeparator = versionSeparator; this.versionWildCard = versionWildCard; + this.needSudo = needSudo; } /** @@ -55,13 +73,28 @@ public static NativePackageManager extractPackageManager(String command) { if (command.contains("dnf")) { return DNF; } + if (command.contains("yay")) { + return YAY; + } + if (command.contains("pacman")) { + return PACMAN; + } throw new IllegalArgumentException("Unknown package manager in command: " + command); } public String getBinaryName() { - return name().toLowerCase(); + return this.binaryName; + } + + /** + * @return {@code true} if commands of this {@link NativePackageManager} need to be run with {@code sudo} (root permissions), + * {@code false} otherwise (e.g. for {@link #YAY} and {@link #BREW}/{@link #BREW_CASK} that must never be run as root). + */ + public boolean isNeedSudo() { + + return this.needSudo; } /** @@ -73,7 +106,7 @@ public String getBinaryName() { */ public String getPackageSpec(String pkg, String version) { - if ((version == null) || version.isBlank()) { + if ((version == null) || version.isBlank() || (this.versionSeparator == null)) { return pkg; } String spec = pkg + this.versionSeparator + version + this.versionWildCard; @@ -91,6 +124,9 @@ public List getVersionQueryCommand(String pkg) { List command = new ArrayList<>(switch (this) { case APT -> List.of("dpkg-query", "-W", "-f=${db:Status-Status}|${Version}"); case ZYPPER, YUM, DNF -> List.of("rpm", "-q", "--queryformat", "%{VERSION}"); + case PACMAN, YAY -> List.of("pacman", "-Q"); + case BREW -> List.of(getBinaryName(), "list", "--versions"); + case BREW_CASK -> List.of(getBinaryName(), "list", "--cask", "--versions"); }); command.add(pkg); return command; @@ -111,6 +147,22 @@ public String parseVersionQueryOutput(String output) { return null; } version = parts[1].trim(); + } else if ((this == PACMAN) || (this == YAY)) { + String[] parts = version.split("\\s+"); + if (parts.length != 2) { + return null; + } + version = parts[1]; + int pkgRelIndex = version.lastIndexOf('-'); + if (pkgRelIndex > 0) { + version = version.substring(0, pkgRelIndex); + } + } else if ((this == BREW) || (this == BREW_CASK)) { + // output of "brew list --versions " is " " (possibly multiple space-separated versions, we take the last/newest one) + int lastSpace = version.lastIndexOf(' '); + if (lastSpace >= 0) { + version = version.substring(lastSpace + 1).trim(); + } } return version.isEmpty() ? null : version; } @@ -123,13 +175,23 @@ public String parseVersionQueryOutput(String output) { public PackageManagerCommand install(NativePackage nativePackage, String version) { verifyPackageManager(nativePackage); List commands = new ArrayList<>(nativePackage.getSetupCommands()); - StringBuilder command = new StringBuilder(SUDO).append(' ').append(getBinaryName()); + StringBuilder command = new StringBuilder(); + if (this.needSudo) { + command.append(SUDO).append(' '); + } + command.append(getBinaryName()); for (String option : nativePackage.getExtraInstallOptions()) { command.append(' ').append(option); } command.append(' ').append(this.installCommand); - for (String pkg : nativePackage.getPackages()) { - command.append(' ').append(getPackageSpec(pkg, version)); + if (nativePackage.getOptionalNativePackageArtifactPaths().isEmpty()) { + for (String pkg : nativePackage.getPackages()) { + command.append(' ').append(getPackageSpec(pkg, version)); + } + } else { + for (Path nativePackageArtifactPath : nativePackage.getOptionalNativePackageArtifactPaths()) { + command.append(' ').append(nativePackageArtifactPath); + } } commands.add(command.toString()); return new PackageManagerCommand(this, commands); @@ -142,7 +204,11 @@ public PackageManagerCommand install(NativePackage nativePackage, String version */ public PackageManagerCommand uninstall(NativePackage nativePackage) { verifyPackageManager(nativePackage); - StringBuilder command = new StringBuilder(SUDO).append(' ').append(getBinaryName()).append(' ').append(this.uninstallCommand); + StringBuilder command = new StringBuilder(); + if (this.needSudo) { + command.append(SUDO).append(' '); + } + command.append(getBinaryName()).append(' ').append(this.uninstallCommand); for (String pkg : nativePackage.getPackages()) { command.append(' ').append(pkg); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java index ead22223..92fbef8d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java @@ -16,11 +16,11 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link LocalToolCommandlet} for tools that have their own {@link #getToolRepository() repository} and do not follow standard installation mechanism. + * {@link AbstractLocalToolCommandlet} for tools that have their own {@link #getToolRepository() repository} and do not follow standard installation mechanism. * - * @param

      type of the {@link ToolCommandlet} acting as {@link #getPackageManagerClass() package manager}. + * @param

      type of the {@link AbstractToolCommandlet} acting as {@link #getPackageManagerClass() package manager}. */ -public abstract class PackageManagerBasedLocalToolCommandlet

      extends LocalToolCommandlet { +public abstract class PackageManagerBasedLocalToolCommandlet

      extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(PackageManagerBasedLocalToolCommandlet.class); @@ -47,7 +47,7 @@ protected boolean isIgnoreSoftwareRepo() { public boolean isInstalled() { // Check if parent tool is installed first - if not, this tool cannot be installed - LocalToolCommandlet parentTool = getParentTool(); + AbstractLocalToolCommandlet parentTool = getParentTool(); if (!parentTool.isInstalled()) { return false; } @@ -85,7 +85,7 @@ public ProcessResult runPackageManager(PackageManagerRequest request, boolean sk completeRequest(request); ProcessContext pc = request.getProcessContext(); - ToolCommandlet pm = request.getPackageManager(); + AbstractToolCommandlet pm = request.getPackageManager(); if (!skipInstallation) { // See Node.postInstallOnNewInstallation ToolInstallRequest installRequest = new ToolInstallRequest(true); installRequest.setProcessContext(pc.createChild()); @@ -223,7 +223,7 @@ protected final void performUninstall(Path toolPath) { } } - protected abstract LocalToolCommandlet getParentTool(); + protected abstract AbstractLocalToolCommandlet getParentTool(); @Override public Path getToolPath() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerRequest.java b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerRequest.java index 4372bee4..392cb0eb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerRequest.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerRequest.java @@ -26,7 +26,7 @@ public final class PackageManagerRequest { private VersionIdentifier version; - private ToolCommandlet packageManager; + private AbstractToolCommandlet packageManager; private ProcessContext processContext; @@ -56,7 +56,7 @@ public String getType() { } /** - * @return the CLI args used to {@link ToolCommandlet#runTool(List) run} the {@link #getPackageManager() package manager}. E.g. + * @return the CLI args used to {@link AbstractToolCommandlet#runTool(List) run} the {@link #getPackageManager() package manager}. E.g. * List.of("install", "-gf", "@angular/cli"). */ public List getArgs() { @@ -75,7 +75,7 @@ public PackageManagerRequest addArg(String arg) { /** * @return the tool to manage (e.g. install) via this request. Will be in the syntax and terminology of the package-manager that can differ from - * {@link ToolCommandlet#getName() tool names} in IDEasy. + * {@link AbstractToolCommandlet#getName() tool names} in IDEasy. * @see PackageManagerBasedLocalToolCommandlet#getPackageName() */ public String getTool() { @@ -104,9 +104,9 @@ public PackageManagerRequest setVersion(VersionIdentifier version) { } /** - * @return the {@link ToolCommandlet} acting as package manager. + * @return the {@link AbstractToolCommandlet} acting as package manager. */ - public ToolCommandlet getPackageManager() { + public AbstractToolCommandlet getPackageManager() { return packageManager; } @@ -115,7 +115,7 @@ public ToolCommandlet getPackageManager() { * @param packageManager new value of {@link #getPackageManager()}. * @return this {@link PackageManagerRequest} for fluent API calls. */ - public PackageManagerRequest setPackageManager(ToolCommandlet packageManager) { + public PackageManagerRequest setPackageManager(AbstractToolCommandlet packageManager) { if (this.packageManager != null) { throw new IllegalStateException(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java index 8df3ac81..a3a6f6cd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java @@ -1,1159 +1,11 @@ package com.devonfw.tools.ide.tool; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.event.Level; - -import com.devonfw.tools.ide.cache.CachedValue; import com.devonfw.tools.ide.commandlet.Commandlet; -import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.common.Tags; -import com.devonfw.tools.ide.completion.AutoCompletionRegistry; -import com.devonfw.tools.ide.completion.CompletionCandidateCollector; -import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.environment.EnvironmentVariables; -import com.devonfw.tools.ide.environment.EnvironmentVariablesFiles; -import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.nls.NlsBundle; -import com.devonfw.tools.ide.os.MacOsHelper; -import com.devonfw.tools.ide.os.OperatingSystem; -import com.devonfw.tools.ide.process.EnvironmentContext; -import com.devonfw.tools.ide.process.ProcessContext; -import com.devonfw.tools.ide.process.ProcessErrorHandling; -import com.devonfw.tools.ide.process.ProcessMode; -import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.process.ProcessResultImpl; -import com.devonfw.tools.ide.property.Property; -import com.devonfw.tools.ide.property.ToolArgumentsProperty; -import com.devonfw.tools.ide.security.ToolVersionChoice; -import com.devonfw.tools.ide.security.ToolVulnerabilities; -import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.repository.ToolRepository; -import com.devonfw.tools.ide.url.model.file.json.Cve; -import com.devonfw.tools.ide.url.model.file.json.ToolDependency; -import com.devonfw.tools.ide.url.model.file.json.ToolSecurity; -import com.devonfw.tools.ide.variable.IdeVariables; -import com.devonfw.tools.ide.version.GenericVersionRange; -import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} for a tool integrated into the IDE. + * {@link Commandlet} for a tool managed by IDEasy. */ -public abstract class ToolCommandlet extends Commandlet implements Tags { - - private static final Logger LOG = LoggerFactory.getLogger(ToolCommandlet.class); - - /** @see #getName() */ - protected final String tool; - - private final Set tags; - - /** The commandline arguments to pass to the tool. */ - public final ToolArgumentsProperty arguments; - - private Path executionDirectory; - - private MacOsHelper macOsHelper; - - /** Cached result for {@link #getInstalledEditionAndVersion()}. */ - private final CachedValue installedEditionAndVersion; - - /** - * Registry for tool-specific auto-completion candidates. - */ - private AutoCompletionRegistry autoCompletionRegistry; - - /** - * The constructor. - * - * @param context the {@link IdeContext}. - * @param tool the {@link #getName() tool name}. - * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. - */ - public ToolCommandlet(IdeContext context, String tool, Set tags) { - - super(context); - this.tool = tool; - this.tags = tags; - this.installedEditionAndVersion = new CachedValue<>(this::computeInstalledEditionAndVersion); - addKeyword(tool); - this.arguments = new ToolArgumentsProperty("", false, true, "args"); - initProperties(); - } - - /** - * Gets the auto-completion registry for this tool. - * - * @return the {@link AutoCompletionRegistry}. - */ - protected AutoCompletionRegistry getAutoCompletionRegistry() { - - if (this.autoCompletionRegistry == null) { - this.autoCompletionRegistry = new AutoCompletionRegistry(); - initAutoCompletionRegistry(this.autoCompletionRegistry); - } - - return this.autoCompletionRegistry; - } - - - /** - * Initializes the auto-completion registry for this tool. - * - * @param registry the {@link AutoCompletionRegistry} to initialize. - */ - protected void initAutoCompletionRegistry(AutoCompletionRegistry registry) { - // default empty - } - - /** - * Completes tool-specific arguments. - * - * @param arg the current argument to complete. - * @param collector the {@link CompletionCandidateCollector}. - * @param property the {@link Property} that triggered completion. - */ - public void completeToolArguments(String arg, CompletionCandidateCollector collector, Property property) { - - getAutoCompletionRegistry().complete(arg, collector, property, this); - } - - /** - * Add initial Properties to the tool - */ - protected void initProperties() { - - add(this.arguments); - } - - /** - * @return the name of the tool (e.g. "java", "mvn", "npm", "node"). - */ - @Override - public final String getName() { - - return this.tool; - } - - /** - * @return the name of the binary executable for this tool. - */ - protected String getBinaryName() { - - return this.tool; - } - - /** - * @return the {@link Path} to the installed {@link #getBinaryName() binary} or {@code null} if not found on the - * {@link com.devonfw.tools.ide.common.SystemPath}. - */ - protected Path getBinaryExecutable() { - - Path binary = this.context.getPath().findBinaryPathByName(getBinaryName()); - if (binary.getParent() == null) { - return null; - } - return binary; - } - - @Override - public final Set getTags() { - - return this.tags; - } - - /** - * @return the execution directory where the tool will be executed. Will be {@code null} by default leading to execution in the users current working - * directory where IDEasy was called. - * @see #setExecutionDirectory(Path) - */ - public Path getExecutionDirectory() { - return this.executionDirectory; - } - - /** - * @param executionDirectory the new value of {@link #getExecutionDirectory()}. - */ - public void setExecutionDirectory(Path executionDirectory) { - this.executionDirectory = executionDirectory; - } - - /** - * @return the {@link EnvironmentVariables#getToolVersion(String) tool version}. - */ - public VersionIdentifier getConfiguredVersion() { - - return this.context.getVariables().getToolVersion(getName()); - } - - /** - * @return the {@link EnvironmentVariables#getToolEdition(String) tool edition}. - */ - public String getConfiguredEdition() { - - return this.context.getVariables().getToolEdition(getName()); - } - - /** - * @return the {@link ToolEdition} with {@link #getName() tool} with its {@link #getConfiguredEdition() edition}. - */ - protected final ToolEdition getToolWithConfiguredEdition() { - - return new ToolEdition(this.tool, getConfiguredEdition()); - } - - @Override - protected void doRun() { - - runTool(this.arguments.asList()); - } - - /** - * @param args the command-line arguments to run the tool. - * @return the {@link ProcessResult result}. - * @see ToolCommandlet#runTool(ProcessMode, GenericVersionRange, List) - */ - public ProcessResult runTool(List args) { - - return runTool(ProcessMode.DEFAULT, null, args); - } - - /** - * Ensures the tool is installed and then runs this tool with the given arguments. - * - * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. - * @param toolVersion the explicit {@link GenericVersionRange version} to run. Typically {@code null} to run the - * {@link #getConfiguredVersion() configured version}. Otherwise, the specified version will be used (from the software repository, if not compatible). - * @param args the command-line arguments to run the tool. - * @return the {@link ProcessResult result}. - */ - public final ProcessResult runTool(ProcessMode processMode, GenericVersionRange toolVersion, List args) { - - return runTool(processMode, toolVersion, ProcessErrorHandling.THROW_CLI, args); - } - - /** - * Ensures the tool is installed and then runs this tool with the given arguments. - * - * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. - * @param toolVersion the explicit {@link GenericVersionRange version} to run. Typically {@code null} to run the - * {@link #getConfiguredVersion() configured version}. Otherwise, the specified version will be used (from the software repository, if not compatible). - * @param errorHandling the {@link ProcessErrorHandling}. - * @param args the command-line arguments to run the tool. - * @return the {@link ProcessResult result}. - */ - public ProcessResult runTool(ProcessMode processMode, GenericVersionRange toolVersion, ProcessErrorHandling errorHandling, List args) { - - ProcessContext pc = newToolProcess(errorHandling); - ToolInstallRequest request = new ToolInstallRequest(true); - if (toolVersion != null) { - request.setRequested(new ToolEditionAndVersion(toolVersion)); - } - request.setProcessContext(pc); - return runTool(request, processMode, args); - } - - /** - * Ensures the tool is installed and then runs this tool with the given arguments. - * - * @param request the {@link ToolInstallRequest}. - * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. - * @param args the command-line arguments to run the tool. - * @return the {@link ProcessResult result}. - */ - public ProcessResult runTool(ToolInstallRequest request, ProcessMode processMode, List args) { - - if (request.isCveCheckDone()) { - // if the CVE check has already been done, we can assume that the install(request) has already been called before - // most likely a postInstall* method was overridden calling this method with the same request what is a programming error - // we render this warning so the error gets detected and can be fixed but we do not block the user by skipping the installation. - LOG.warn("Preventing infinity loop during installation of {}", request.getRequested(), new RuntimeException()); - } else { - ToolInstallation installation = install(request); - if (installation != null && installation.installedAsynchronously()) { - return new ProcessResultImpl(this.tool, this.tool, 0, List.of()); - } - } - return runTool(request.getProcessContext(), processMode, args); - } - - /** - * @param pc the {@link ProcessContext}. - * @param processMode the {@link ProcessMode}. Should typically be {@link ProcessMode#DEFAULT} or {@link ProcessMode#BACKGROUND}. - * @param args the command-line arguments to run the tool. - * @return the {@link ProcessResult result}. - */ - public ProcessResult runTool(ProcessContext pc, ProcessMode processMode, List args) { - - if (this.executionDirectory != null) { - pc.directory(this.executionDirectory); - } - configureToolBinary(pc, processMode); - configureToolArgs(pc, processMode, args); - return pc.run(processMode); - } - - /** - * @param pc the {@link ProcessContext}. - * @param processMode the {@link ProcessMode}. - */ - protected void configureToolBinary(ProcessContext pc, ProcessMode processMode) { - - pc.executable(Path.of(getBinaryName())); - } - - /** - * @param pc the {@link ProcessContext}. - * @param processMode the {@link ProcessMode}. - * @param args the command-line arguments to {@link ProcessContext#addArgs(List) add}. - */ - protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { - - pc.addArgs(args); - } - - /** - * Installs or updates the managed {@link #getName() tool}. - * - * @return the {@link ToolInstallation}. - */ - public ToolInstallation install() { - - return install(true); - } - - /** - * Performs the installation of the {@link #getName() tool} managed by this {@link com.devonfw.tools.ide.commandlet.Commandlet}. - * - * @param silent - {@code true} if called recursively to suppress verbose logging, {@code false} otherwise. - * @return the {@link ToolInstallation}. - */ - public ToolInstallation install(boolean silent) { - return install(new ToolInstallRequest(silent)); - } - - /** - * Performs the installation (install, update, downgrade) of the {@link #getName() tool} managed by this {@link ToolCommandlet}. - * - * @param request the {@link ToolInstallRequest}. - * @return the {@link ToolInstallation}. - */ - public ToolInstallation install(ToolInstallRequest request) { - - completeRequest(request); - if (request.isInstallLoop()) { - return toolAlreadyInstalled(request); - } - ToolInstallation installation = doInstall(request); - if (installation != null && installation.installedAsynchronously()) { - LOG.warn( - "The installation of {} is currently running in the background!\n" - + "You need to complete the installation, potentially reboot and rerun your 'ide' command in a new terminal session" - + " after the installation has completed.", - request.getRequested()); - } - return installation; - } - - /** - * Performs the installation (install, update, downgrade) of the {@link #getName() tool} managed by this {@link ToolCommandlet}. - * - * @param request the {@link ToolInstallRequest}. - * @return the {@link ToolInstallation}. - */ - protected abstract ToolInstallation doInstall(ToolInstallRequest request); - - /** - * @param request the {@link ToolInstallRequest} to complete (fill values that are currently {@code null}). - */ - protected void completeRequest(ToolInstallRequest request) { - - completeRequestInstalled(request); - completeRequestRequested(request); // depends on completeRequestInstalled - completeRequestProcessContext(request); - completeRequestToolPath(request); - } - - private void completeRequestProcessContext(ToolInstallRequest request) { - if (request.getProcessContext() == null) { - request.setProcessContext(newToolProcess(ProcessErrorHandling.THROW_CLI)); - } - } - - /** - * @param errorHandling the {@link ProcessErrorHandling}. - * @return a new {@link ProcessContext} initialized with the environment variables of all installed tools of the project. This tool and its - * {@link ToolDependency dependencies} will override those variables later when their - * {@link #setEnvironment(EnvironmentContext, ToolInstallation, boolean) environment} is set. - */ - private ProcessContext newToolProcess(ProcessErrorHandling errorHandling) { - - ProcessContext pc = this.context.newProcess().errorHandling(errorHandling); - this.context.setEnvironmentOfInstalledTools(pc); - return pc; - } - - private void completeRequestInstalled(ToolInstallRequest request) { - - ToolEditionAndVersion installedToolVersion = request.getInstalled(); - if (installedToolVersion == null) { - installedToolVersion = new ToolEditionAndVersion((GenericVersionRange) null); - request.setInstalled(installedToolVersion); - } - Path toolPath = request.getToolPath(); - if (installedToolVersion.getVersion() == null) { - VersionIdentifier installedVersion; - if ((toolPath != null) && (this instanceof LocalToolCommandlet ltc)) { - installedVersion = ltc.getInstalledVersion(toolPath); - } else { - installedVersion = getInstalledVersion(); - } - if (installedVersion == null) { - return; - } - installedToolVersion.setVersion(installedVersion); - } - if (installedToolVersion.getEdition() == null) { - String installedEdition; - if ((toolPath != null) && (this instanceof LocalToolCommandlet ltc)) { - installedEdition = ltc.getInstalledEdition(toolPath); - } else { - installedEdition = getInstalledEdition(); - } - installedToolVersion.setEdition(new ToolEdition(this.tool, installedEdition)); - } - assert installedToolVersion.getResolvedVersion() != null; - } - - private void completeRequestRequested(ToolInstallRequest request) { - - ToolEdition edition; - ToolEditionAndVersion requested = request.getRequested(); - if (requested == null) { - edition = new ToolEdition(this.tool, getConfiguredEdition()); - requested = new ToolEditionAndVersion(edition); - request.setRequested(requested); - - } else { - edition = requested.getEdition(); - if (edition == null) { - // If no edition was specified, set it to the configured one - edition = new ToolEdition(this.tool, getConfiguredEdition()); - requested.setEdition(edition); - } - } - - // Adjust edition if necessary based on requested version. This is needed for tools like IntelliJ where we may need to automatically switch editions - requested = adjustRequestedEdition(requested); - edition = requested.getEdition(); - - GenericVersionRange version = requested.getVersion(); - if (version == null) { - version = request.isIgnoreProject() ? VersionIdentifier.LATEST : getConfiguredVersion(); - requested.setVersion(version); - } - VersionIdentifier resolvedVersion = requested.getResolvedVersion(); - if (resolvedVersion == null) { - if (this.context.isSkipUpdatesMode()) { - ToolEditionAndVersion installed = request.getInstalled(); - if (installed != null) { - VersionIdentifier installedVersion = installed.getResolvedVersion(); - if (version.contains(installedVersion)) { - resolvedVersion = installedVersion; - } - } - } - if (resolvedVersion == null) { - resolvedVersion = getToolRepository().resolveVersion(this.tool, edition.edition(), version, this); - } - requested.setResolvedVersion(resolvedVersion); - } - } - - /** - * Hook for subclasses to adjust the requested tool edition before the version is finalized. - * - * @param requested the requested {@link ToolEditionAndVersion} - * @return the given or trgansformed {@link ToolEditionAndVersion} - */ - protected ToolEditionAndVersion adjustRequestedEdition(ToolEditionAndVersion requested) { - - // default no-op - return requested; - } - - - private void completeRequestToolPath(ToolInstallRequest request) { - - Path toolPath = request.getToolPath(); - if (toolPath == null) { - toolPath = getToolPath(); - request.setToolPath(toolPath); - } - } - - /** - * @return the {@link Path} where the tool is located (installed). Will be {@code null} for global tools that do not know the {@link Path} since it is - * determined by the installer. - */ - public Path getToolPath() { - return null; - } - - /** - * This method is called after a tool was requested to be installed or updated. - * - * @param request {@code true} the {@link ToolInstallRequest}. - */ - protected void postInstall(ToolInstallRequest request) { - - if (!request.isAlreadyInstalled()) { - postInstallOnNewInstallation(request); - } - } - - /** - * This method is called after a tool was requested to be installed or updated and a new installation was performed. - * - * @param request {@code true} the {@link ToolInstallRequest}. - */ - protected void postInstallOnNewInstallation(ToolInstallRequest request) { - - // nothing to do by default - } - - /** - * @param edition the {@link #getInstalledEdition() edition}. - * @param version the {@link #getInstalledVersion() version}. - * @return the {@link Path} where this tool is installed (physically) or {@code null} if not available. - */ - protected abstract Path getInstallationPath(String edition, VersionIdentifier version); - - /** - * @param request the {@link ToolInstallRequest}. - * @return the existing {@link ToolInstallation}. - */ - protected ToolInstallation createExistingToolInstallation(ToolInstallRequest request) { - - ToolEditionAndVersion installed = request.getInstalled(); - - String edition = this.tool; - VersionIdentifier resolvedVersion = VersionIdentifier.LATEST; - - if (installed != null) { - if (installed.getEdition() != null) { - edition = installed.getEdition().edition(); - } - if (installed.getResolvedVersion() != null) { - resolvedVersion = installed.getResolvedVersion(); - } - } - - return createExistingToolInstallation(edition, resolvedVersion, request.getProcessContext(), - request.isAdditionalInstallation()); - } - - /** - * @param edition the {@link #getConfiguredEdition() edition}. - * @param installedVersion the {@link #getConfiguredVersion() version}. - * @param environmentContext the {@link EnvironmentContext}. - * @param extraInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the - * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. - * @return the {@link ToolInstallation}. - */ - protected ToolInstallation createExistingToolInstallation(String edition, VersionIdentifier installedVersion, EnvironmentContext environmentContext, - boolean extraInstallation) { - - Path installationPath = getInstallationPath(edition, installedVersion); - return createToolInstallation(installationPath, installedVersion, false, environmentContext, extraInstallation); - } - - /** - * @param rootDir the {@link ToolInstallation#rootDir() top-level installation directory}. - * @param version the installed {@link VersionIdentifier}. - * @param newInstallation {@link ToolInstallation#newInstallation() new installation} flag. - * @param environmentContext the {@link EnvironmentContext}. - * @param additionalInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the - * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. - * @return the {@link ToolInstallation}. - */ - protected ToolInstallation createToolInstallation(Path rootDir, VersionIdentifier version, boolean newInstallation, - EnvironmentContext environmentContext, boolean additionalInstallation) { - - Path linkDir = rootDir; - Path binDir = rootDir; - if (rootDir != null) { - // on MacOS applications have a very strange structure - see JavaDoc of findLinkDir and ToolInstallation.linkDir for details. - linkDir = getMacOsHelper().findLinkDir(rootDir, getBinaryName()); - binDir = this.context.getFileAccess().getBinPath(linkDir); - } - return createToolInstallation(rootDir, linkDir, binDir, version, newInstallation, environmentContext, additionalInstallation); - } - - /** - * @param rootDir the {@link ToolInstallation#rootDir() top-level installation directory}. - * @param linkDir the {@link ToolInstallation#linkDir() link directory}. - * @param binDir the {@link ToolInstallation#binDir() bin directory}. - * @param version the installed {@link VersionIdentifier}. - * @param newInstallation {@link ToolInstallation#newInstallation() new installation} flag. - * @param environmentContext the {@link EnvironmentContext}. - * @param additionalInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the - * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. - * @return the {@link ToolInstallation}. - */ - protected ToolInstallation createToolInstallation(Path rootDir, Path linkDir, Path binDir, VersionIdentifier version, boolean newInstallation, - EnvironmentContext environmentContext, boolean additionalInstallation) { - - // Invalidate cached edition/version so that subsequent calls reflect the new installation - invalidateInstalledEditionAndVersion(); - // do not copy the version file into macOS .app bundles: changing the bundle after codesigning breaks the seal. - ToolInstallation toolInstallation = new ToolInstallation(rootDir, linkDir, binDir, version, newInstallation); - setEnvironment(environmentContext, toolInstallation, additionalInstallation); - return toolInstallation; - } - - /** - * Called if the tool {@link ToolInstallRequest#isAlreadyInstalled() is already installed in the correct edition and version} so we can skip the - * installation. - * - * @param request the {@link ToolInstallRequest}. - * @return the {@link ToolInstallation}. - */ - protected ToolInstallation toolAlreadyInstalled(ToolInstallRequest request) { - - logToolAlreadyInstalled(request); - cveCheck(request); - postInstall(request); - return createExistingToolInstallation(request); - } - - /** - * Log that the tool is already installed. - * - * @param request the {@link ToolInstallRequest}. - */ - protected void logToolAlreadyInstalled(ToolInstallRequest request) { - Level level; - if (request.isSilent()) { - level = Level.DEBUG; - } else { - level = Level.INFO; - } - ToolEditionAndVersion installed = request.getInstalled(); - LOG.atLevel(level).log("Version {} of tool {} is already installed", installed.getVersion(), installed.getEdition()); - } - - /** - * Method to get the home path of the given {@link ToolInstallation}. - * - * @param toolInstallation the {@link ToolInstallation}. - * @return the Path to the home of the tool - */ - protected Path getToolHomePath(ToolInstallation toolInstallation) { - return toolInstallation.linkDir(); - } - - /** - * Method to set environment variables for the process context. - * - * @param environmentContext the {@link EnvironmentContext} where to {@link EnvironmentContext#withEnvVar(String, String) set environment variables} for - * this tool. - * @param toolInstallation the {@link ToolInstallation}. - * @param additionalInstallation {@code true} if the {@link ToolInstallation} is an additional installation to the - * {@link #getConfiguredVersion() configured version} due to a conflicting version of a {@link ToolDependency}, {@code false} otherwise. - */ - public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { - - String pathVariable = EnvironmentVariables.getToolVariablePrefix(this.tool) + "_HOME"; - Path toolHomePath = getToolHomePath(toolInstallation); - if (toolHomePath != null) { - environmentContext.withEnvVar(pathVariable, toolHomePath.toString()); - } - if (additionalInstallation) { - environmentContext.withPathEntry(toolInstallation.binDir()); - } - } - - /** - * @return {@code true} to extract (unpack) the downloaded binary file, {@code false} otherwise. - */ - protected boolean isExtract() { - - return true; - } - - /** - * Checks a version to be installed for {@link Cve}s. If at least one {@link Cve} is found, we try to find better/safer versions as alternative. If we find - * something better, we will suggest this to the user and ask him to make his choice. - * - * @param request the {@link ToolInstallRequest}. - * @return the {@link VersionIdentifier} to install. The will may be asked (unless {@code skipSuggestions} is {@code true}) and might choose a different - * version than the originally requested one. - */ - protected VersionIdentifier cveCheck(ToolInstallRequest request) { - - ToolEditionAndVersion requested = request.getRequested(); - VersionIdentifier resolvedVersion = requested.getResolvedVersion(); - if (request.isCveCheckDone()) { - return resolvedVersion; - } - ToolEdition toolEdition = requested.getEdition(); - GenericVersionRange allowedVersions = requested.getVersion(); - boolean requireStableVersion = true; - if (allowedVersions instanceof VersionIdentifier vi) { - requireStableVersion = vi.isStable(); - } - ToolSecurity toolSecurity = this.context.getDefaultToolRepository().findSecurity(this.tool, toolEdition.edition()); - double minSeverity = IdeVariables.CVE_MIN_SEVERITY.get(context); - OperatingSystem os = this.context.getSystemInfo().getOs(); - ToolVulnerabilities currentVulnerabilities = toolSecurity.findCves(resolvedVersion, os, minSeverity); - ToolVersionChoice currentChoice = ToolVersionChoice.ofCurrent(requested, currentVulnerabilities); - request.setCveCheckDone(); - if (currentChoice.logAndCheckIfEmpty()) { - return resolvedVersion; - } - boolean alreadyInstalled = request.isAlreadyInstalled(); - boolean directForceInstall = this.context.isForceMode() && request.isDirect(); - if (alreadyInstalled && !directForceInstall) { - // currently for a transitive dependency it does not make sense to suggest alternative versions, since the choice is not stored anywhere, - // and we then would ask the user again every time the tool having this dependency is started. So we only log the problem and the user needs to react - // (e.g. upgrade the tool with the dependency that is causing this). - IdeLogLevel.INTERACTION.log(LOG, "Please run 'ide -f install {}' to check for update suggestions!", this.tool); - return resolvedVersion; - } - ToolVersionChoice latest = null; - ToolVulnerabilities latestVulnerabilities = currentVulnerabilities; - ToolVersionChoice nearest = null; - ToolVulnerabilities nearestVulnerabilities = currentVulnerabilities; - List toolVersions = getVersions(); - for (VersionIdentifier version : toolVersions) { - - if (Objects.equals(version, resolvedVersion)) { - continue; // Skip the entire iteration for resolvedVersion - } - - if (acceptVersion(version, allowedVersions, requireStableVersion)) { - ToolVulnerabilities newVulnerabilities = toolSecurity.findCves(version, os, minSeverity); - if (newVulnerabilities.isSafer(latestVulnerabilities)) { - // we found a better/safer version - ToolEditionAndVersion toolEditionAndVersion = new ToolEditionAndVersion(toolEdition, version); - if (version.isGreater(resolvedVersion)) { - latestVulnerabilities = newVulnerabilities; - latest = ToolVersionChoice.ofLatest(toolEditionAndVersion, latestVulnerabilities); - nearest = null; - } else { - nearestVulnerabilities = newVulnerabilities; - nearest = ToolVersionChoice.ofNearest(toolEditionAndVersion, nearestVulnerabilities); - } - } else if (newVulnerabilities.isSaferOrEqual(nearestVulnerabilities)) { - if (newVulnerabilities.isSafer(nearestVulnerabilities) || version.isGreater(resolvedVersion)) { - nearest = ToolVersionChoice.ofNearest(new ToolEditionAndVersion(toolEdition, version), newVulnerabilities); - } - nearestVulnerabilities = newVulnerabilities; - } - } - } - if ((nearest != null) && !nearest.vulnerabilities().isSafer(currentVulnerabilities)) { - nearest = null; // never suggest a version that does not reduce the CVEs - } - if ((latest == null) && (nearest == null)) { - LOG.warn("Could not find any other version resolving your CVEs.\n" - + "Please keep attention to this tool and consider updating as soon as security fixes are available."); - if (alreadyInstalled) { - // we came here via "ide -f install ..." but no alternative is available - return resolvedVersion; - } - } - List choices = new ArrayList<>(); - choices.add(currentChoice); - boolean addSuggestions; - if (this.context.isForceMode() && request.isDirect()) { - addSuggestions = true; - } else { - List skipCveFixTools = IdeVariables.SKIP_CVE_FIX.get(this.context); - addSuggestions = !skipCveFixTools.contains(this.tool); - } - if (nearest != null) { - if (addSuggestions) { - choices.add(nearest); - } - nearest.logAndCheckIfEmpty(); - } - if (latest != null) { - if (addSuggestions) { - choices.add(latest); - } - latest.logAndCheckIfEmpty(); - } - ToolVersionChoice[] choicesArray = choices.toArray(ToolVersionChoice[]::new); - LOG.warn("Please note that by selecting an unsafe version to install, you accept the risk to be attacked."); - ToolVersionChoice answer = this.context.question(choicesArray, "Which version do you want to install?"); - VersionIdentifier version = answer.toolEditionAndVersion().getResolvedVersion(); - requested.setResolvedVersion(version); - return version; - } - - private static boolean acceptVersion(VersionIdentifier version, GenericVersionRange allowedVersions, boolean requireStableVersion) { - if (allowedVersions.isPattern() && !allowedVersions.contains(version)) { - return false; - } else if (requireStableVersion && !version.isStable()) { - return false; - } - return true; - } - - /** - * @return the {@link MacOsHelper} instance. - */ - protected MacOsHelper getMacOsHelper() { - - if (this.macOsHelper == null) { - this.macOsHelper = new MacOsHelper(this.context); - } - return this.macOsHelper; - } - - /** - * Gets the installed edition and version together, resolving both in a single operation. This method is final: - * tool-specific logic belongs in the {@link #computeInstalledEditionAndVersion()} hook so that the cache here always - * applies. - * - * @return the {@link EditionAndVersion} or {@code null} if not installed. - */ - public final EditionAndVersion getInstalledEditionAndVersion() { - - return this.installedEditionAndVersion.get(); - } - - /** - * Hook to compute the installed edition and version together. Override this method in subclasses to resolve both - * edition and version in a single operation, avoiding redundant expensive lookups. - * - * @return the {@link EditionAndVersion} or {@code null} if not installed. - */ - protected EditionAndVersion computeInstalledEditionAndVersion() { - - return null; - } - - /** - * @return the currently installed {@link VersionIdentifier version} of this tool or {@code null} if not installed. - */ - public final VersionIdentifier getInstalledVersion() { - - EditionAndVersion ev = getInstalledEditionAndVersion(); - return (ev != null) ? ev.version() : null; - } - - /** - * @return {@code true} if this tool is installed, {@code false} otherwise. - */ - public boolean isInstalled() { - - return getInstalledVersion() != null; - } - - /** - * @return the installed edition of this tool or {@code null} if not installed. - */ - public final String getInstalledEdition() { - - EditionAndVersion ev = getInstalledEditionAndVersion(); - return (ev != null) ? ev.edition() : null; - } - - /** Invalidates the cached installed edition and version so the next call to {@link #getInstalledEditionAndVersion()} recomputes the result. */ - protected void invalidateInstalledEditionAndVersion() { - - this.installedEditionAndVersion.invalidate(); - } - - /** - * Uninstalls the {@link #getName() tool}. - */ - public abstract void uninstall(); - - /** - * @return the {@link ToolRepository}. - */ - public ToolRepository getToolRepository() { - - return this.context.getDefaultToolRepository(); - } - - /** - * List the available editions of this tool. - */ - public void listEditions() { - - List editions = getToolRepository().getSortedEditions(getName()); - for (String edition : editions) { - LOG.info(edition); - } - } - - /** - * List the available versions of this tool. - */ - public void listVersions() { - - List versions = getToolRepository().getSortedVersions(getName(), getConfiguredEdition(), this); - for (VersionIdentifier vi : versions) { - LOG.info(vi.toString()); - } - } - - /** - * @return the {@link com.devonfw.tools.ide.tool.repository.DefaultToolRepository#getSortedVersions(String, String, ToolCommandlet) sorted versions} of this - * tool. - */ - public List getVersions() { - return getToolRepository().getSortedVersions(getName(), getConfiguredEdition(), this); - } - - /** - * Sets the tool version in the environment variable configuration file. - * - * @param version the version (pattern) to set. - */ - public void setVersion(String version) { - - if ((version == null) || version.isBlank()) { - throw new IllegalStateException("Version has to be specified!"); - } - VersionIdentifier configuredVersion = VersionIdentifier.of(version); - if (!configuredVersion.isPattern() && !configuredVersion.isValid()) { - LOG.warn("Version {} seems to be invalid", version); - } - setVersion(configuredVersion, true); - } - - /** - * Sets the tool version in the environment variable configuration file. - * - * @param version the version to set. May also be a {@link VersionIdentifier#isPattern() version pattern}. - * @param hint - {@code true} to print the installation hint, {@code false} otherwise. - */ - public void setVersion(VersionIdentifier version, boolean hint) { - - setVersion(version, hint, null); - } - - /** - * Sets the tool version in the environment variable configuration file. - * - * @param version the version to set. May also be a {@link VersionIdentifier#isPattern() version pattern}. - * @param hint - {@code true} to print the installation hint, {@code false} otherwise. - * @param destination - the destination for the property to be set - */ - public void setVersion(VersionIdentifier version, boolean hint, EnvironmentVariablesFiles destination) { - - String edition = getConfiguredEdition(); - ToolRepository toolRepository = getToolRepository(); - - EnvironmentVariables variables = this.context.getVariables(); - if (destination == null) { - //use default location - destination = EnvironmentVariablesFiles.SETTINGS; - } - EnvironmentVariables settingsVariables = variables.getByType(destination.toType()); - String variableName = EnvironmentVariables.getToolVersionVariable(this.tool); - - VersionIdentifier resolvedVersion = toolRepository.resolveVersion(this.tool, edition, version, this); // verify that the version actually exists - settingsVariables.set(variableName, version.toString(), false); - settingsVariables.save(); - EnvironmentVariables declaringVariables = variables.findVariable(variableName); - if ((declaringVariables != null) && (declaringVariables != settingsVariables)) { - LOG.warn("The variable {} is overridden in {}. Please remove the overridden declaration in order to make the change affect.", variableName, - declaringVariables.getSource()); - } - LOG.info("Version of tool {} has been set to {} ({}={})", this.tool, version, variableName, version); - if (hint && !resolvedVersion.equals(getInstalledVersion())) { - IdeLogLevel.INTERACTION.log(LOG, "To install that version call the following command:\nide install {}", this.tool); - } - } - - /** - * Sets the tool edition in the environment variable configuration file. - * - * @param edition the edition to set. - */ - public void setEdition(String edition) { - - setEdition(edition, true); - } - - /** - * Sets the tool edition in the environment variable configuration file. - * - * @param edition the edition to set - * @param hint - {@code true} to print the installation hint, {@code false} otherwise. - */ - public void setEdition(String edition, boolean hint) { - - setEdition(edition, hint, null); - } - - /** - * Sets the tool edition in the environment variable configuration file. - * - * @param edition the edition to set - * @param hint - {@code true} to print the installation hint, {@code false} otherwise. - * @param destination - the destination for the property to be set - */ - public void setEdition(String edition, boolean hint, EnvironmentVariablesFiles destination) { - - if ((edition == null) || edition.isBlank()) { - throw new IllegalStateException("Edition has to be specified!"); - } - - if (destination == null) { - //use default location - destination = EnvironmentVariablesFiles.SETTINGS; - } - - if (!getToolRepository().getSortedEditions(this.tool).contains(edition)) { - LOG.warn("Edition {} seems to be invalid", edition); - } - EnvironmentVariables variables = this.context.getVariables(); - EnvironmentVariables settingsVariables = variables.getByType(destination.toType()); - String name = EnvironmentVariables.getToolEditionVariable(this.tool); - settingsVariables.set(name, edition, false); - settingsVariables.save(); - - LOG.info("{}={} has been set in {}", name, edition, settingsVariables.getSource()); - EnvironmentVariables declaringVariables = variables.findVariable(name); - if ((declaringVariables != null) && (declaringVariables != settingsVariables)) { - LOG.warn("The variable {} is overridden in {}. Please remove the overridden declaration in order to make the change affect.", name, - declaringVariables.getSource()); - } - if (hint) { - LOG.info("To install that edition call the following command:"); - LOG.info("ide install {}", this.tool); - } - } - - /** - * Runs the tool's help command to provide the user with usage information. - */ - @Override - public void printHelp(NlsBundle bundle) { - - super.printHelp(bundle); - String toolHelpArgs = getToolHelpArguments(); - if (toolHelpArgs != null && getInstalledVersion() != null) { - ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING) - .executable(Path.of(getBinaryName())).addArgs(toolHelpArgs); - pc.run(ProcessMode.DEFAULT); - } - } - - /** - * @return the tool's specific help command. Usually help, --help or -h. Return null if not applicable. - */ - public String getToolHelpArguments() { - - return null; - } - - /** - * Creates a start script for the tool using the tool name. - * - * @param targetDir the {@link Path} of the installation where to create the script. If a "bin" sub-folder is present, the script will be created there - * instead. - * @param binary name of the binary to execute from the start script. - */ - protected void createStartScript(Path targetDir, String binary) { - - createStartScript(targetDir, binary, false); - } - - /** - * Creates a start script for the tool using the tool name. - * - * @param targetDir the {@link Path} of the installation where to create the script. If a "bin" sub-folder is present, the script will be created there - * instead. - * @param binary name of the binary to execute from the start script. - * @param background {@code true} to run the {@code binary} in background, {@code false} otherwise (foreground). - */ - protected void createStartScript(Path targetDir, String binary, boolean background) { - - Path binFolder = targetDir.resolve("bin"); - if (!Files.exists(binFolder)) { - if (this.context.getSystemInfo().isMac()) { - MacOsHelper macOsHelper = getMacOsHelper(); - Path appDir = macOsHelper.findAppDir(targetDir); - binFolder = macOsHelper.findLinkDir(appDir, binary); - } else { - binFolder = targetDir; - } - assert (Files.exists(binFolder)); - } - Path bashFile = binFolder.resolve(getName()); - String bashFileContentStart = "#!/usr/bin/env bash\n\"$(dirname \"$0\")/"; - String bashFileContentEnd = "\" $@"; - if (background) { - bashFileContentEnd += " &"; - } - try { - Files.writeString(bashFile, bashFileContentStart + binary + bashFileContentEnd); - } catch (IOException e) { - throw new RuntimeException(e); - } - assert (Files.exists(bashFile)); - context.getFileAccess().makeExecutable(bashFile); - } - - @Override - public void reset() { - super.reset(); - this.executionDirectory = null; - } - - /** - * @param command the binary that will be searched in the PATH e.g. docker - * @return true if the command is available to use - */ - protected boolean isCommandAvailable(String command) { - return this.context.getPath().hasBinaryOnPath(command); - } - - /** - * @param output the raw output string from executed command e.g. 'docker version' - * @param pattern Regular Expression pattern that filters out the unnecessary texts. - * @return version that has been processed. - */ - protected VersionIdentifier resolveVersionWithPattern(String output, Pattern pattern) { - Matcher matcher = pattern.matcher(output); - - if (matcher.find()) { - return VersionIdentifier.of(matcher.group(1)); - } else { - return null; - } - } - - /** - * @deprecated directly log success message and then report success on step if not null. - */ - @Deprecated - protected void success(Step step, String message, Object... args) { - - if (step == null) { - IdeLogLevel.SUCCESS.log(LOG, message, args); - } else { - step.success(message, args); - } - } +public interface ToolCommandlet extends Commandlet, Tags { } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolEdition.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolEdition.java index fbcbdd06..183f3068 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolEdition.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolEdition.java @@ -3,8 +3,8 @@ /** * Record for the combination of {@link #tool() tool name} and {@link #edition() tool edition}. * - * @param tool the {@link ToolCommandlet#getName() tool name}. - * @param edition the {@link ToolCommandlet#getConfiguredEdition() configured edition}. + * @param tool the {@link AbstractToolCommandlet#getName() tool name}. + * @param edition the {@link AbstractToolCommandlet#getConfiguredEdition() configured edition}. */ public record ToolEdition(String tool, String edition) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudio.java b/cli/src/main/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudio.java index 7cb41009..7dbe5833 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudio.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudio.java @@ -9,13 +9,12 @@ import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.ToolInstallation; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; import com.devonfw.tools.ide.tool.ide.IdeaBasedIdeToolCommandlet; import com.devonfw.tools.ide.tool.ide.IdeaPluginDownloader; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; /** - * {@link IdeToolCommandlet} for AndroidStudio. + * {@link AbstractIdeToolCommandlet} for AndroidStudio. */ public class AndroidStudio extends IdeaBasedIdeToolCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/aws/Aws.java b/cli/src/main/java/com/devonfw/tools/ide/tool/aws/Aws.java index 8dc31d37..07bc91b0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/aws/Aws.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/aws/Aws.java @@ -13,14 +13,14 @@ import com.devonfw.tools.ide.nls.NlsBundle; import com.devonfw.tools.ide.process.EnvironmentContext; import com.devonfw.tools.ide.process.ProcessContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.ToolInstallation; /** - * {@link LocalToolCommandlet} for AWS CLI (Amazon Web Services Command Line Interface). + * {@link AbstractLocalToolCommandlet} for AWS CLI (Amazon Web Services Command Line Interface). */ -public class Aws extends LocalToolCommandlet { +public class Aws extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Aws.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/az/Azure.java b/cli/src/main/java/com/devonfw/tools/ide/tool/az/Azure.java index dc144b88..13a08f3b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/az/Azure.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/az/Azure.java @@ -7,16 +7,15 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.EnvironmentContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.python.Python; /** - * {@link ToolCommandlet} for azure CLI (azure). + * {@link AbstractToolCommandlet} for azure CLI (azure). */ -public class Azure extends LocalToolCommandlet { +public class Azure extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/cdk/Cdk.java b/cli/src/main/java/com/devonfw/tools/ide/tool/cdk/Cdk.java index 895e3914..fbd96885 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/cdk/Cdk.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/cdk/Cdk.java @@ -4,11 +4,10 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.npm.NpmBasedCommandlet; /** - * {@link ToolCommandlet} for AWS CDK. + * {@link AbstractToolCommandlet} for AWS CDK. */ public class Cdk extends NpmBasedCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/claude/Claude.java b/cli/src/main/java/com/devonfw/tools/ide/tool/claude/Claude.java index 254aedc1..b35b7d4c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/claude/Claude.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/claude/Claude.java @@ -10,15 +10,14 @@ import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.process.EnvironmentContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.ToolInstallation; /** - * {@link ToolCommandlet} for Claude Code CLI. + * {@link AbstractToolCommandlet} for Claude Code CLI. */ -public class Claude extends LocalToolCommandlet { +public class Claude extends AbstractLocalToolCommandlet { /** Name of the environment variable that relocates the entire Claude configuration directory. */ static final String CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR"; @@ -62,9 +61,9 @@ public class Claude extends LocalToolCommandlet { /** * Provider/auth environment variables that must not leak from the ambient system environment into the launched Claude process, so an inherited value cannot - * override the per-project configuration. Each variable is only removed when it is inherited from the {@link EnvironmentVariablesType#SYSTEM system - * environment} (or undefined); a value declared in an IDEasy {@code ide.properties} layer (e.g. {@code settings/ide.properties} to share - * {@code ANTHROPIC_MODEL} across a team) is intentional and therefore preserved. + * override the per-project configuration. Each variable is only removed when it is inherited from the + * {@link EnvironmentVariablesType#SYSTEM system environment} (or undefined); a value declared in an IDEasy {@code ide.properties} layer (e.g. + * {@code settings/ide.properties} to share {@code ANTHROPIC_MODEL} across a team) is intentional and therefore preserved. */ static final List SCRUB_VARS = List.of( "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", @@ -125,9 +124,9 @@ public void setEnvironment(EnvironmentContext environmentContext, ToolInstallati /** * @param variables the {@link EnvironmentVariables} of the current {@link IdeContext}. * @param name the name of the environment variable to check. - * @return {@code true} if the variable is undefined or only inherited from the {@link EnvironmentVariablesType#SYSTEM system environment} and should therefore - * be scrubbed; {@code false} if it is defined in an IDEasy {@code ide.properties} layer and must be preserved so it can be shared intentionally (e.g. via - * {@code settings/ide.properties}). + * @return {@code true} if the variable is undefined or only inherited from the {@link EnvironmentVariablesType#SYSTEM system environment} and should + * therefore be scrubbed; {@code false} if it is defined in an IDEasy {@code ide.properties} layer and must be preserved so it can be shared intentionally + * (e.g. via {@code settings/ide.properties}). */ private static boolean isInheritedFromSystem(EnvironmentVariables variables, String name) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/copilot/Copilot.java b/cli/src/main/java/com/devonfw/tools/ide/tool/copilot/Copilot.java index e0bc7ba6..f6e7b148 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/copilot/Copilot.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/copilot/Copilot.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for GitHub Copilot CLI. + * {@link AbstractToolCommandlet} for GitHub Copilot CLI. */ -public class Copilot extends LocalToolCommandlet { +public class Copilot extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/corepack/Corepack.java b/cli/src/main/java/com/devonfw/tools/ide/tool/corepack/Corepack.java index 4e418e5b..62b2509b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/corepack/Corepack.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/corepack/Corepack.java @@ -6,12 +6,11 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.npm.NpmBasedCommandlet; /** - * {@link ToolCommandlet} for corepack. + * {@link AbstractToolCommandlet} for corepack. */ public class Corepack extends NpmBasedCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolCommandlet.java index 23c685cc..d6599a5c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolCommandlet.java @@ -1,14 +1,14 @@ package com.devonfw.tools.ide.tool.custom; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link LocalToolCommandlet} for a {@link CustomToolMetadata}. + * {@link AbstractLocalToolCommandlet} for a {@link CustomToolMetadata}. */ -public class CustomToolCommandlet extends LocalToolCommandlet { +public class CustomToolCommandlet extends AbstractLocalToolCommandlet { private CustomToolMetadata customTool; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryImpl.java b/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryImpl.java index b6f701de..8a05e46c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryImpl.java @@ -11,7 +11,7 @@ import java.util.Map; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.repository.AbstractToolRepository; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; import com.devonfw.tools.ide.url.model.file.json.ToolDependency; @@ -99,7 +99,7 @@ public String getId() { } @Override - protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { CustomToolMetadata customTool = getCustomTool(tool); if (!version.equals(customTool.getVersion())) { @@ -121,7 +121,7 @@ private CustomToolMetadata getCustomTool(String tool) { } @Override - public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet) { + public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet) { CustomToolMetadata customTool = getCustomTool(tool); VersionIdentifier customToolVersion = customTool.getVersion(); @@ -132,7 +132,7 @@ public VersionIdentifier resolveVersion(String tool, String edition, GenericVers } @Override - public List getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet) { + public List getSortedVersions(String tool, String edition, AbstractToolCommandlet toolCommandlet) { CustomToolMetadata customTool = getCustomTool(tool); return List.of(customTool.getVersion()); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java index 5e924d6e..53a0704b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java @@ -1,5 +1,6 @@ package com.devonfw.tools.ide.tool.docker; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Set; @@ -16,6 +17,12 @@ import com.devonfw.tools.ide.tool.GlobalToolCommandlet; import com.devonfw.tools.ide.tool.NativePackage; import com.devonfw.tools.ide.tool.NativePackageManager; +import com.devonfw.tools.ide.tool.PackageManagerCommand; +import com.devonfw.tools.ide.tool.ToolEdition; +import com.devonfw.tools.ide.tool.ToolEditionAndVersion; +import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; /** @@ -33,6 +40,10 @@ public class Docker extends GlobalToolCommandlet { private static final Pattern DOCKER_DESKTOP_VERSION_PATTERN = Pattern.compile("^([0-9]+(?:\\.[0-9]+){1,2})"); + private static final String EDITION_DOCKER = "docker"; + + private Path downloadedDebPackageForDocker; + /** * The constructor. * @@ -68,6 +79,34 @@ private String detectContainerRuntime() { @Override protected List getNativePackages() { + + if (isDockerDesktopEditionConfigured()) { + + List artifactPaths = (this.downloadedDebPackageForDocker == null) ? List.of() : List.of(this.downloadedDebPackageForDocker); + + return List.of( + new NativePackage( + NativePackageManager.APT, + List.of("docker-desktop"), + List.of("--allow-downgrades"), + List.of( + "sudo install -m 0755 -d /etc/apt/keyrings", + "sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", + "sudo chmod a+r /etc/apt/keyrings/docker.asc", + "echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] " + + "https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \\\"$VERSION_CODENAME\\\") stable\" | " + + "sudo tee /etc/apt/sources.list.d/docker.list > /dev/null", + "sudo apt update" + ), + List.of( + "sudo rm -f /etc/apt/sources.list.d/docker.list", + "sudo rm -f /etc/apt/keyrings/docker.asc" + ), + artifactPaths + ) + ); + } + return List.of( new NativePackage( NativePackageManager.ZYPPER, @@ -92,10 +131,53 @@ protected List getNativePackages() { "sudo rm -f /etc/apt/sources.list.d/isv-rancher-stable.list", "sudo rm -f /usr/share/keyrings/isv-rancher-stable-archive-keyring.gpg" ) - ) + ), + new NativePackage(NativePackageManager.YAY, List.of("rancher-desktop")), + new NativePackage(NativePackageManager.BREW_CASK, List.of("docker")) ); } + @Override + public String getMacApplicationName() { + + return "Docker"; + } + + @Override + protected ToolEditionAndVersion adjustRequestedEdition(ToolEditionAndVersion requested) { + + if (this.context.getSystemInfo().isLinux()) { + ToolEdition edition = requested.getEdition(); + if (!"rancher".equals(edition.edition())) { + LOG.warn("Docker Desktop is not yet supported by IDEasy on Linux, installing Rancher Desktop instead."); + requested.replaceEdition(new ToolEdition(this.tool, "rancher")); + } + } + return requested; + } + + @Override + protected ToolInstallation doInstall(ToolInstallRequest request) { + if (isDockerDesktopEditionConfigured()) { + downloadDebPackageStepAndSetPackagePath(request.getRequested().getResolvedVersion()); + } + return super.doInstall(request); + } + + private void downloadDebPackageStepAndSetPackagePath(VersionIdentifier resolvedVersion) { + ToolRepository toolRepository = this.context.getDefaultToolRepository(); + this.downloadedDebPackageForDocker = toolRepository.download(this.tool, EDITION_DOCKER, resolvedVersion, this); + } + + @Override + protected List getInstallPackageManagerCommands(VersionIdentifier resolvedVersion) { + if (!isDockerDesktopEditionConfigured()) { + return super.getInstallPackageManagerCommands(resolvedVersion); + } + + return getNativePackages().stream().map(nativePackage -> nativePackage.install(null)).toList(); + } + @Override public boolean isExtract() { @@ -115,6 +197,9 @@ protected EditionAndVersion computeInstalledEditionAndVersion() { if (isRancherDesktopInstalled()) { VersionIdentifier version = getRancherDesktopClientVersion(); + if (version == null) { + version = getNativePackageVersion(); + } return new EditionAndVersion("rancher", version); } @@ -164,8 +249,18 @@ private VersionIdentifier getDockerDesktopVersionMac() { private VersionIdentifier getRancherDesktopClientVersion() { - String output = this.context.newProcess().runAndGetSingleOutput("rdctl", "version"); - return resolveVersionWithPattern(output, RDCTL_CLIENT_VERSION_PATTERN); + // rdctl may be on the PATH as a dangling symlink (e.g. Rancher Desktop was removed but ~/.rd/bin remained) so executing it can fail to start the process + try { + String output = this.context.newProcess().runAndGetSingleOutput("rdctl", "version"); + return resolveVersionWithPattern(output, RDCTL_CLIENT_VERSION_PATTERN); + } catch (IllegalStateException e) { + LOG.warn("Could not determine the installed Rancher Desktop version - rdctl could not be executed: {}", e.getMessage()); + return null; + } + } + + private boolean isDockerDesktopEditionConfigured() { + return EDITION_DOCKER.equals(getConfiguredEdition()); } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/dotnet/DotNet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/dotnet/DotNet.java index 84e99811..d102de45 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/dotnet/DotNet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/dotnet/DotNet.java @@ -5,14 +5,14 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.EnvironmentContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; /** - * {@link LocalToolCommandlet} for dotnet. The .NET CLI (Command Line Interface) + * {@link AbstractLocalToolCommandlet} for dotnet. The .NET CLI (Command Line Interface) * cross-platform tool for building, running, and managing .NET applications. */ -public class DotNet extends LocalToolCommandlet { +public class DotNet extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java b/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java index 461ee842..8ec7f025 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/eclipse/Eclipse.java @@ -19,15 +19,15 @@ import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.tool.mvn.MvnArtifact; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; /** - * {@link IdeToolCommandlet} for Eclipse. + * {@link AbstractIdeToolCommandlet} for Eclipse. */ -public class Eclipse extends IdeToolCommandlet { +public class Eclipse extends AbstractIdeToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Eclipse.class); @@ -84,7 +84,7 @@ protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, Lis } @Override - protected boolean isPluginUrlNeeded() { + public boolean isPluginUrlNeeded() { return true; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraToolInstallation.java b/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraToolInstallation.java index 6b13a422..e39451d8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraToolInstallation.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraToolInstallation.java @@ -1,6 +1,7 @@ package com.devonfw.tools.ide.tool.extra; import com.devonfw.tools.ide.json.JsonObject; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.version.VersionIdentifier; /** @@ -8,8 +9,7 @@ * * @param name the custom name of the extra installation. Should be in lower-train-case-without-special-chars syntax. * @param version the {@link VersionIdentifier} to install. - * @param edition the explicit edition to use or {@code null} to use the - * {@link com.devonfw.tools.ide.tool.LocalToolCommandlet#getConfiguredEdition() configured edition}. + * @param edition the explicit edition to use or {@code null} to use the {@link AbstractLocalToolCommandlet#getConfiguredEdition() configured edition}. * @see ExtraTools#getExtraInstallations(String) */ public record ExtraToolInstallation(String name, VersionIdentifier version, String edition) implements JsonObject { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraTools.java b/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraTools.java index e419459b..4b88c912 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraTools.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/extra/ExtraTools.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.json.JsonObject; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** * {@link ExtraTools} represents the {@code ide-extra-tools.json} file. @@ -38,7 +39,7 @@ private ExtraTools(Map> tool2installationsMa } /** - * @param tool the {@link com.devonfw.tools.ide.tool.LocalToolCommandlet#getName() name} of the {@link com.devonfw.tools.ide.tool.LocalToolCommandlet}. + * @param tool the {@link AbstractLocalToolCommandlet#getName() name} of the {@link AbstractLocalToolCommandlet}. * @return the {@link List} of {@link ExtraToolInstallation extra installations}. Will be empty if no extra installation is defined. */ public List getExtraInstallations(String tool) { @@ -51,7 +52,7 @@ public List getExtraInstallations(String tool) { } /** - * @param tool the {@link com.devonfw.tools.ide.tool.LocalToolCommandlet#getName() name} of the {@link com.devonfw.tools.ide.tool.LocalToolCommandlet}. + * @param tool the {@link AbstractLocalToolCommandlet#getName() name} of the {@link AbstractLocalToolCommandlet}. * @param extraInstallation the {@link ExtraToolInstallation} to add. */ public void addExtraInstallations(String tool, ExtraToolInstallation extraInstallation) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/gcloganalyzer/GcLogAnalyzer.java b/cli/src/main/java/com/devonfw/tools/ide/tool/gcloganalyzer/GcLogAnalyzer.java index 53b780e7..2c572541 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/gcloganalyzer/GcLogAnalyzer.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/gcloganalyzer/GcLogAnalyzer.java @@ -11,13 +11,12 @@ import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for GC Log Analyzer by Azul Systems. + * {@link AbstractToolCommandlet} for GC Log Analyzer by Azul Systems. */ -public class GcLogAnalyzer extends LocalToolCommandlet { +public class GcLogAnalyzer extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/gcviewer/GcViewer.java b/cli/src/main/java/com/devonfw/tools/ide/tool/gcviewer/GcViewer.java index 6934ec20..1c20d0c5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/gcviewer/GcViewer.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/gcviewer/GcViewer.java @@ -6,14 +6,13 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.java.Java; /** - * {@link ToolCommandlet} for GcViewer. + * {@link AbstractToolCommandlet} for GcViewer. */ -public class GcViewer extends LocalToolCommandlet { +public class GcViewer extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/gh/Gh.java b/cli/src/main/java/com/devonfw/tools/ide/tool/gh/Gh.java index 0a357463..e58a1ecd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/gh/Gh.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/gh/Gh.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for github CLI (gh). + * {@link AbstractToolCommandlet} for github CLI (gh). */ -public class Gh extends LocalToolCommandlet { +public class Gh extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/go/Go.java b/cli/src/main/java/com/devonfw/tools/ide/tool/go/Go.java index ae91bede..95ce230d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/go/Go.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/go/Go.java @@ -1,18 +1,15 @@ package com.devonfw.tools.ide.tool.go; -import java.nio.file.Path; import java.util.Set; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for the Go programming language. + * {@link AbstractToolCommandlet} for the Go programming language. */ -public class Go extends LocalToolCommandlet { +public class Go extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/graalvm/GraalVm.java b/cli/src/main/java/com/devonfw/tools/ide/tool/graalvm/GraalVm.java index 82b1958d..e82b4d49 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/graalvm/GraalVm.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/graalvm/GraalVm.java @@ -5,12 +5,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link LocalToolCommandlet} for GraalVM, an advanced JDK with ahead-of-time Native Image compilation. + * {@link AbstractLocalToolCommandlet} for GraalVM, an advanced JDK with ahead-of-time Native Image compilation. */ -public class GraalVm extends LocalToolCommandlet { +public class GraalVm extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/gradle/Gradle.java b/cli/src/main/java/com/devonfw/tools/ide/tool/gradle/Gradle.java index a20fec8c..2bc655d9 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/gradle/Gradle.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/gradle/Gradle.java @@ -9,13 +9,12 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for gradle. + * {@link AbstractToolCommandlet} for gradle. */ -public class Gradle extends LocalToolCommandlet { +public class Gradle extends AbstractLocalToolCommandlet { /** build.gradle file name */ public static final String BUILD_GRADLE = "build.gradle"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java b/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java index 0ccceabb..0b0aa3d1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/gui/Gui.java @@ -9,7 +9,7 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.cli.CliException; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; @@ -22,9 +22,9 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link Commandlet} to launch the IDEasy GUI. + * {@link AbstractCommandlet} to launch the IDEasy GUI. */ -public class Gui extends Commandlet { +public class Gui extends AbstractCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Gui.class); @@ -84,7 +84,7 @@ protected void doRun() { throw new CliException("Fatal error: The pom.xml file required for launching the IDEasy GUI could not be found in expected location: " + pomPath); } - List args = buildMvnArgs(installationPath, pomPath); + List args = buildMvnArgs(installationPath, pomPath, getGuiExecutable(javaInstallation)); /* * We manually update the PATH entry with our java version, as by default IDEasy includes the SymLink under /projectname/software/java/bin in the PATH @@ -110,15 +110,16 @@ protected void doRun() { * * @param installationPath the {@link IdeContext#getIdeInstallationPath() IDEasy installation} directory containing {@code gui/pom.xml}. * @param pomPath the launcher POM ({@code gui/pom.xml}) to launch the GUI from. Must be verified to exist before this is called. + * @param execExecutable the executable to pass as {@code -Dexec.executable} (see {@link #getGuiExecutable(ToolInstallation)}). * @return the {@code mvn} arguments to launch the GUI. */ - static List buildMvnArgs(Path installationPath, Path pomPath) { + static List buildMvnArgs(Path installationPath, Path pomPath, String execExecutable) { List args = new ArrayList<>(List.of( "-f", //use specified POM file pomPath.toString(), "org.codehaus.mojo:exec-maven-plugin:3.1.0:exec", - "-Dexec.executable=java", + "-Dexec.executable=" + execExecutable, "-Dexec.classpathScope=compile", "-Dexec.args=-classpath %classpath com.devonfw.ide.gui.AppLauncher", "-Dexec.async=true" @@ -134,4 +135,48 @@ static List buildMvnArgs(Path installationPath, Path pomPath) { } return args; } + + private static final String GUI_INFO_PLIST = """ + + + + + CFBundleExecutable + IDEasy + CFBundleIdentifier + com.devonfw.tools.ideasy.gui + CFBundleName + IDEasy + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + + + """; + + /** + * The GUI is launched as a plain {@code java} process without a native app bundle. On macOS this makes the Dock and menu bar show the executable's + * filename "java" instead of "IDEasy" (see issue #2206). Merely renaming/symlinking the bare executable is not reliable: without a real bundle, + * LaunchServices may still fall back to the underlying JDK's own identity. Instead, we wrap the launch in a minimal {@code .app} bundle: a proper + * {@code Info.plist} declaring our own {@code CFBundleName}/{@code CFBundleIdentifier}, with {@code Contents/MacOS/IDEasy} as a symlink pointing + * directly at the real java executable (not a wrapper script - an intermediate {@code exec} would replace the process image and lose the bundle + * identity again). + * + * @param javaInstallation the {@link ToolInstallation} of the java tool used to launch the GUI. + * @return the executable to pass to Maven's {@code exec:exec} goal in order to launch the GUI. + */ + String getGuiExecutable(ToolInstallation javaInstallation) { + + if (!this.context.getSystemInfo().isMac()) { + return "java"; + } + Path javaExecutable = javaInstallation.binDir().resolve("java"); + Path contentsDir = this.context.getTempPath().resolve("IDEasy.app").resolve("Contents"); + Path launcher = contentsDir.resolve("MacOS").resolve("IDEasy"); + this.context.getFileAccess().mkdirs(launcher.getParent()); + this.context.getFileAccess().writeFileContent(GUI_INFO_PLIST, contentsDir.resolve("Info.plist")); + this.context.getFileAccess().symlink(javaExecutable, launcher, false); + return launcher.toString(); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/helm/Helm.java b/cli/src/main/java/com/devonfw/tools/ide/tool/helm/Helm.java index 6ad616af..22469a33 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/helm/Helm.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/helm/Helm.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Helm, the package manager for Kubernetes. + * {@link AbstractToolCommandlet} for Helm, the package manager for Kubernetes. */ -public class Helm extends LocalToolCommandlet { +public class Helm extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/AbstractIdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/AbstractIdeToolCommandlet.java new file mode 100644 index 00000000..4af7cbed --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/AbstractIdeToolCommandlet.java @@ -0,0 +1,124 @@ +package com.devonfw.tools.ide.tool.ide; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; +import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.eclipse.Eclipse; +import com.devonfw.tools.ide.tool.intellij.Intellij; +import com.devonfw.tools.ide.tool.plugin.AbstractPluginBasedCommandlet; +import com.devonfw.tools.ide.tool.vscode.Vscode; + +/** + * {@link AbstractToolCommandlet} for an IDE (integrated development environment) such as {@link Eclipse}, {@link Vscode}, or {@link Intellij}. + */ +public abstract class AbstractIdeToolCommandlet extends AbstractPluginBasedCommandlet implements IdeToolCommandlet { + + private static final String OPTIONS_ENV_SUFFIX = "_OPTIONS"; + + private final IdeWorkspaceConfigurer workspaceConfigurer; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param tool the {@link #getName() tool name}. + * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. + */ + public AbstractIdeToolCommandlet(IdeContext context, String tool, Set tags) { + + super(context, tool, tags); + assert (hasIde(tags)); + this.workspaceConfigurer = new IdeWorkspaceConfigurer(context, tool); + } + + private boolean hasIde(Set tags) { + + for (Tag tag : tags) { + if (tag.isAncestorOf(Tag.IDE) || (tag == Tag.IDE)) { + return true; + } + } + throw new IllegalStateException("Tags of IdeTool has to be connected with tag IDE: " + tags); + } + + @Override + protected final void doRun() { + super.doRun(); + } + + @Override + public ProcessResult runTool(List args) { + + List effectiveArgs = new ArrayList<>(args); + addIdeOptions(effectiveArgs); + return runTool(ProcessMode.BACKGROUND, null, effectiveArgs); + } + + /** + * Appends the tokens of {@code «IDE»_OPTIONS} (e.g. {@code INTELLIJ_OPTIONS}) to the given {@code args}. This is the per-tool analogue of the global + * {@code IDE_OPTIONS} and only applies when actually starting the IDE (not for internal calls like plugin installation or repository import). + * + * @param args the command-line arguments to launch this IDE, extended in place. + */ + private void addIdeOptions(List args) { + + String variableName = EnvironmentVariables.getToolVariablePrefix(this.tool) + OPTIONS_ENV_SUFFIX; + String options = this.context.getVariables().get(variableName); + if ((options != null) && !options.isBlank()) { + for (String option : options.trim().split("\\s+")) { + args.add(option); + } + } + } + + @Override + public ProcessResult runTool(ProcessContext pc, ProcessMode processMode, List args) { + + if ((processMode != null) && processMode.isBackground()) { + configureWorkspace(); + } + return super.runTool(pc, processMode, args); + } + + @Override + protected void postInstall(ToolInstallRequest request) { + configureWorkspace(); + super.postInstall(request); + } + + /** + * @return the {@link IdeWorkspaceConfigurer} for this IDE. + */ + @Override + public IdeWorkspaceConfigurer getWorkspaceConfigurer() { + + return this.workspaceConfigurer; + } + + /** + * Registers support for synchronizing an extra SDK/template for this IDE. + * + *

      + * The registered template path must be relative to the IDE workspace root. During workspace synchronization, the generic extra-SDK handling performed by the + * {@link IdeWorkspaceConfigurer} uses this mapping to locate the corresponding template file in the settings repository and merge it into the current + * workspace. + *

      + * + * @param sdk the name of the extra SDK/tool as configured in {@code ide-extra-tools.json}. + * @param relativeTemplatePath the workspace-relative path of the IDE-specific template file to merge. + */ + protected void registerExtraSdkTemplate(String sdk, Path relativeTemplatePath) { + + this.workspaceConfigurer.registerExtraSdkTemplate(sdk, relativeTemplatePath); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java index f30f44b8..cab23ba1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandlet.java @@ -1,185 +1,61 @@ package com.devonfw.tools.ide.tool.ide; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; - -import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables; -import com.devonfw.tools.ide.environment.EnvironmentVariables; -import com.devonfw.tools.ide.environment.ExtensibleEnvironmentVariables; -import com.devonfw.tools.ide.io.FileAccess; -import com.devonfw.tools.ide.log.IdeLogLevel; -import com.devonfw.tools.ide.merge.xml.XmlMergeDocument; -import com.devonfw.tools.ide.merge.xml.XmlMerger; -import com.devonfw.tools.ide.process.ProcessContext; -import com.devonfw.tools.ide.process.ProcessMode; -import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.eclipse.Eclipse; -import com.devonfw.tools.ide.tool.extra.ExtraToolInstallation; -import com.devonfw.tools.ide.tool.extra.ExtraTools; -import com.devonfw.tools.ide.tool.extra.ExtraToolsMapper; -import com.devonfw.tools.ide.tool.intellij.Intellij; -import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; -import com.devonfw.tools.ide.tool.vscode.Vscode; +import com.devonfw.tools.ide.tool.LocalToolCommandlet; /** - * {@link ToolCommandlet} for an IDE (integrated development environment) such as {@link Eclipse}, {@link Vscode}, or {@link Intellij}. + * {@link Commandlet} interface for IDE-specific features that are independent of the installation mechanism (binary vs. package manager). + *

      + * This allows tools installed via package managers (like pip for Spyder) to still benefit from IDEasy's IDE features such as workspace configuration, metadata + * management, and repository import. */ -public abstract class IdeToolCommandlet extends PluginBasedCommandlet { - - private static final Logger LOG = LoggerFactory.getLogger(IdeToolCommandlet.class); - - private static final String OPTIONS_ENV_SUFFIX = "_OPTIONS"; - private final Map> extraSdkMap; +public interface IdeToolCommandlet extends LocalToolCommandlet { /** - * The constructor. - * - * @param context the {@link IdeContext}. - * @param tool the {@link #getName() tool name}. - * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. + * @return the {@link IdeWorkspaceConfigurer} that configures the workspace of this IDE. Needed so that the default {@link #configureWorkspace()} of this + * interface can delegate the shared workspace configuration logic to a single implementation. */ - public IdeToolCommandlet(IdeContext context, String tool, Set tags) { - - super(context, tool, tags); - assert (hasIde(tags)); - this.extraSdkMap = new HashMap<>(); - } - - private boolean hasIde(Set tags) { - - for (Tag tag : tags) { - if (tag.isAncestorOf(Tag.IDE) || (tag == Tag.IDE)) { - return true; - } - } - throw new IllegalStateException("Tags of IdeTool has to be connected with tag IDE: " + tags); - } - - @Override - protected final void doRun() { - super.doRun(); - } - - @Override - public ProcessResult runTool(List args) { - - List effectiveArgs = new ArrayList<>(args); - addIdeOptions(effectiveArgs); - return runTool(ProcessMode.BACKGROUND, null, effectiveArgs); - } + IdeWorkspaceConfigurer getWorkspaceConfigurer(); /** - * Appends the tokens of {@code «IDE»_OPTIONS} (e.g. {@code INTELLIJ_OPTIONS}) to the given {@code args}. This is the per-tool analogue of the global - * {@code IDE_OPTIONS} and only applies when actually starting the IDE (not for internal calls like plugin installation or repository import). - * - * @param args the command-line arguments to launch this IDE, extended in place. + * Configures (initializes or updates) the workspace for this IDE using the templates from the settings. The default implementation is shared by all IDEs, + * whether installed as binary (see {@link AbstractIdeToolCommandlet}) or via a package manager (see + * {@link com.devonfw.tools.ide.tool.pip.PipBasedIdeToolCommandlet}). */ - private void addIdeOptions(List args) { - - String variableName = EnvironmentVariables.getToolVariablePrefix(this.tool) + OPTIONS_ENV_SUFFIX; - String options = this.context.getVariables().get(variableName); - if ((options != null) && !options.isBlank()) { - for (String option : options.trim().split("\\s+")) { - args.add(option); - } - } - } - - @Override - public ProcessResult runTool(ProcessContext pc, ProcessMode processMode, List args) { - - if ((processMode != null) && processMode.isBackground()) { - configureWorkspace(); - } - return super.runTool(pc, processMode, args); - } + default void configureWorkspace() { - @Override - protected void postInstall(ToolInstallRequest request) { - configureWorkspace(); - super.postInstall(request); + getWorkspaceConfigurer().configureWorkspace(this::getWorkspaceRedirects); } /** - * @return the {@link Path} to the IDE-specific metadata folder for the {@link IdeContext#getWorkspaceName() current workspace}, located at - * {@code $IDE_HOME/.ide/«ide»/«workspace»}. Unlike {@link IdeContext#getWorkspacePath() the workspace path} (which holds the projects to open), this - * folder keeps IDE-specific metadata (e.g. {@code .vmoptions} or {@code *.properties} files) out of the workspace so it stays clean and independent of - * the IDE being used. + * @param workspaceFolder the {@link IdeContext#getWorkspacePath() workspace folder}. + * @return the {@link Map} with the {@link Path}s inside the given {@code workspaceFolder} as keys and the {@link Path}s where the according workspace + * templates shall be merged to instead as values. Allows to keep IDE-specific data out of the workspace (e.g. in {@link #getIdeMetadataPath()}) without + * changing the structure of the workspace templates in the settings. By default, nothing is redirected. */ - protected Path getIdeMetadataPath() { + default Map getWorkspaceRedirects(Path workspaceFolder) { - return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(getName()).resolve(this.context.getWorkspaceName()); + return Map.of(); } /** - * Configure (initialize or update) the workspace for this IDE using the templates from the settings. + * @return the {@link Path} to the IDE-specific metadata folder for the current workspace, located at {@code $IDE_HOME/.ide/«toolName»/«workspace»}. Unlike + * {@link IdeContext#getWorkspacePath() the workspace path} (which holds the projects to open), this folder keeps IDE-specific metadata (e.g. + * {@code .vmoptions} or {@code *.properties} files) out of the workspace so it stays clean and independent of the IDE being used. + * + *

      + * The default implementation is shared by all IDEs, whether installed as binary (see {@link AbstractIdeToolCommandlet}) or via a package manager (see + * {@link com.devonfw.tools.ide.tool.pip.PipBasedIdeToolCommandlet}). */ - public void configureWorkspace() { - - FileAccess fileAccess = this.context.getFileAccess(); - Path workspaceFolder = this.context.getWorkspacePath(); - if (!fileAccess.isExpectedFolder(workspaceFolder)) { - LOG.warn("Current workspace does not exist: {}", workspaceFolder); - return; // should actually never happen... - } - Step step = this.context.newStep("Configuring workspace " + workspaceFolder.getFileName() + " for IDE " + this.tool); - step.run(() -> doMergeWorkspaceStep(step, workspaceFolder)); - } - - private void doMergeWorkspaceStep(Step step, Path workspaceFolder) { - - int errors = 0; - errors = mergeWorkspace(this.context.getUserHomeIde(), workspaceFolder, errors); - errors = mergeWorkspace(this.context.getSettingsPath(), workspaceFolder, errors); - errors = mergeWorkspace(this.context.getConfPath(), workspaceFolder, errors); - - synchronizeExtraToolInstallations(); + default Path getIdeMetadataPath() { - if (errors == 0) { - step.success(); - } else { - step.error("Your workspace configuration failed with {} error(s) - see log above.\n" - + "This is either a configuration error in your settings git repository or a bug in IDEasy.\n" - + "Please analyze the above errors with your team or IDE-admin and try to fix the problem.", errors); - this.context.askToContinue( - "In order to prevent you from being blocked, you can start your IDE anyhow but some configuration may not be in sync."); - } - } - - private int mergeWorkspace(Path configFolder, Path workspaceFolder, int errors) { - - int result = errors; - result = mergeWorkspaceSingle(configFolder.resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); - result = mergeWorkspaceSingle(configFolder.resolve(this.tool).resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, result); - return result; - } - - private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, int errors) { - - Path setupFolder = templatesFolder.resolve(IdeContext.FOLDER_SETUP); - Path updateFolder = templatesFolder.resolve(IdeContext.FOLDER_UPDATE); - if (!Files.isDirectory(setupFolder) && !Files.isDirectory(updateFolder)) { - LOG.trace("Skipping empty or non-existing workspace template folder {}.", templatesFolder); - return errors; - } - LOG.debug("Merging workspace templates from {}...", templatesFolder); - return errors + this.context.getWorkspaceMerger().merge(setupFolder, updateFolder, this.context.getVariables(), workspaceFolder); + IdeContext context = getContext(); + return context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(getName()).resolve(context.getWorkspaceName()); } /** @@ -187,94 +63,8 @@ private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, int * * @param repositoryPath the {@link Path} to the repository directory to import. */ - public void importRepository(Path repositoryPath) { - - throw new UnsupportedOperationException("Repository import is not yet implemented for IDE " + this.tool); - } - - /** - * Registers support for synchronizing an extra SDK/template for this IDE. - * - *

      - * The registered template path must be relative to the IDE workspace root. During workspace synchronization, the generic extra-SDK handling in - * {@link #synchronizeExtraToolInstallations()} uses this mapping to locate the corresponding template file in the settings repository and merge it into the - * current workspace. - *

      - * - * @param sdk the name of the extra SDK/tool as configured in {@code ide-extra-tools.json}. - * @param relativeTemplatePath the workspace-relative path of the IDE-specific template file to merge. - */ - protected void registerExtraSdkTemplate(String sdk, Path relativeTemplatePath) { - - Set templatePaths = this.extraSdkMap.computeIfAbsent(sdk, _ -> new HashSet<>()); - templatePaths.add(relativeTemplatePath); - } - - /** - * Synchronizes extra IDEasy tool installations into the current IDE workspace configuration if supported. - * - *

      - * By default, nothing will happen. Your IDE commandlet has to register one or more according templates in its constructor. - *

      - */ - protected void synchronizeExtraToolInstallations() { - - ExtraTools extraTools = ExtraToolsMapper.get().loadJsonFromFolder(this.context.getSettingsPath()); - if (extraTools == null) { - return; - } - for (String sdk : extraTools.getSortedToolNames()) { - Set templatePaths = this.extraSdkMap.get(sdk); - if ((templatePaths == null) || templatePaths.isEmpty()) { - LOG.debug("Skipping import of extra tool {} into {} because not configured or supported.", sdk, this.tool); - continue; - } - List extraInstallations = extraTools.getExtraInstallations(sdk); - synchronizeExtraToolInstallation(sdk, templatePaths, extraInstallations); - } - } - - private void synchronizeExtraToolInstallation(String sdk, Set templatePaths, List extraInstallations) { - - for (Path templatePath : templatePaths) { - Path workspaceFile = this.context.getWorkspacePath().resolve(templatePath); - Path templateFile = this.context.getSettingsPath().resolve(this.tool).resolve(IdeContext.FOLDER_WORKSPACE) - .resolve(IdeContext.FOLDER_REPOSITORY) - .resolve(templatePath); - if (Files.exists(templateFile)) { - for (ExtraToolInstallation extraInstallation : extraInstallations) { - synchronizeExtraToolInstallation(sdk, templateFile, workspaceFile, extraInstallation); - } - } else { - LOG.warn("You are missing a template file at {}.", templatePath); - IdeLogLevel.INTERACTION.log(LOG, "Please ask the IDEasy admin in your project to merge your settings with upstream."); - } - } - } - - private void synchronizeExtraToolInstallation(String sdk, Path templateFile, Path workspaceFile, ExtraToolInstallation installation) { - - String name = installation.name(); - Path extraToolHome = this.context.getSoftwareExtraPath().resolve(sdk).resolve(name); - if (!Files.isDirectory(extraToolHome)) { - LOG.warn("Skipping extra tool installation import to {} because it is missing at {}", this.tool, extraToolHome); - IdeLogLevel.INTERACTION.log(LOG, "Please run the following command to fix:\nide update"); - return; - } - ExtensibleEnvironmentVariables environmentVariables = new ExtensibleEnvironmentVariables( - (AbstractEnvironmentVariables) this.context.getVariables().getParent(), this.context); - String variablePrefix = "EXTRA_" + sdk.toUpperCase(Locale.ROOT); - environmentVariables.setValue(variablePrefix + "_NAME", name); - environmentVariables.setValue(variablePrefix + "_HOME", extraToolHome.toString().replace('\\', '/')); - environmentVariables.setValue(variablePrefix + "_VERSION", installation.version().toString()); - if (installation.edition() != null) { - environmentVariables.setValue(variablePrefix + "_EDITION", installation.edition()); - } + default void importRepository(Path repositoryPath) { - XmlMerger xmlMerger = new XmlMerger(this.context); - XmlMergeDocument workspaceDocument = xmlMerger.load(workspaceFile); - XmlMergeDocument templateDocument = xmlMerger.loadAndResolve(templateFile, environmentVariables); - Document mergedDocument = xmlMerger.merge(templateDocument, workspaceDocument, false); - xmlMerger.save(mergedDocument, workspaceFile); + throw new CliException("Repository import is not yet supported for IDE " + getName()); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java new file mode 100644 index 00000000..d6556de7 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeWorkspaceConfigurer.java @@ -0,0 +1,192 @@ +package com.devonfw.tools.ide.tool.ide; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables; +import com.devonfw.tools.ide.environment.ExtensibleEnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.merge.xml.XmlMergeDocument; +import com.devonfw.tools.ide.merge.xml.XmlMerger; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; +import com.devonfw.tools.ide.tool.extra.ExtraToolInstallation; +import com.devonfw.tools.ide.tool.extra.ExtraTools; +import com.devonfw.tools.ide.tool.extra.ExtraToolsMapper; + +/** + * Configures IDE workspaces by merging templates from settings repositories. + *

      + * This class encapsulates the workspace configuration logic that is shared between all IDEs, regardless of their installation mechanism (binary, pip, npm, + * etc.). It can be used via composition by any {@link AbstractToolCommandlet} that needs IDE workspace configuration capabilities. + */ +public class IdeWorkspaceConfigurer { + + private static final Logger LOG = LoggerFactory.getLogger(IdeWorkspaceConfigurer.class); + + private final IdeContext context; + private final String toolName; + private final Map> extraSdkMap; + + /** + * Creates a new workspace configurer for the given IDE tool. + * + * @param context the {@link IdeContext}. + * @param toolName the name of the IDE tool (e.g. "intellij", "spyder", "vscode"). + */ + public IdeWorkspaceConfigurer(IdeContext context, String toolName) { + this.context = context; + this.toolName = toolName; + this.extraSdkMap = new HashMap<>(); + } + + /** + * Registers support for synchronizing an extra SDK/template for this IDE. + * + *

      + * The registered template path must be relative to the IDE workspace root. During workspace synchronization, the generic extra-SDK handling in + * {@link #synchronizeExtraToolInstallations()} uses this mapping to locate the corresponding template file in the settings repository and merge it into the + * current workspace. + *

      + * + * @param sdk the name of the extra SDK/tool as configured in {@code ide-extra-tools.json}. + * @param relativeTemplatePath the workspace-relative path of the IDE-specific template file to merge. + */ + public void registerExtraSdkTemplate(String sdk, Path relativeTemplatePath) { + + Set templatePaths = this.extraSdkMap.computeIfAbsent(sdk, _ -> new HashSet<>()); + templatePaths.add(relativeTemplatePath); + } + + /** + * Configure (initialize or update) the workspace for this IDE using the templates from the settings. + * + * @param workspaceRedirects the supplier of the {@link Map} with the workspace-internal {@link Path}s to redirect the workspace templates to instead, + * as provided by the owning tool ({@link IdeToolCommandlet#getWorkspaceRedirects(Path)}). + */ + public void configureWorkspace(Function> workspaceRedirects) { + FileAccess fileAccess = this.context.getFileAccess(); + Path workspaceFolder = this.context.getWorkspacePath(); + if (!fileAccess.isExpectedFolder(workspaceFolder)) { + LOG.warn("Current workspace does not exist: {}", workspaceFolder); + return; // should actually never happen... + } + Step step = this.context.newStep("Configuring workspace " + workspaceFolder.getFileName() + " for IDE " + this.toolName); + step.run(() -> doMergeWorkspaceStep(step, workspaceFolder, workspaceRedirects)); + } + + private void doMergeWorkspaceStep(Step step, Path workspaceFolder, Function> workspaceRedirects) { + + int errors = 0; + Map redirects = workspaceRedirects.apply(workspaceFolder); + errors = mergeWorkspace(this.context.getUserHomeIde(), workspaceFolder, redirects, errors); + errors = mergeWorkspace(this.context.getSettingsPath(), workspaceFolder, redirects, errors); + errors = mergeWorkspace(this.context.getConfPath(), workspaceFolder, redirects, errors); + + synchronizeExtraToolInstallations(); + + if (errors == 0) { + step.success(); + } else { + step.error("Your workspace configuration failed with {} error(s) - see log above.\n" + + "This is either a configuration error in your settings git repository or a bug in IDEasy.\n" + + "Please analyze the above errors with your team or IDE-admin and try to fix the problem.", errors); + this.context.askToContinue( + "In order to prevent you from being blocked, you can start your IDE anyhow but some configuration may not be in sync."); + } + } + + private int mergeWorkspace(Path configFolder, Path workspaceFolder, Map redirects, int errors) { + + int result = errors; + result = mergeWorkspaceSingle(configFolder.resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, redirects, result); + result = mergeWorkspaceSingle(configFolder.resolve(this.toolName).resolve(IdeContext.FOLDER_WORKSPACE), workspaceFolder, redirects, result); + return result; + } + + private int mergeWorkspaceSingle(Path templatesFolder, Path workspaceFolder, Map redirects, int errors) { + + Path setupFolder = templatesFolder.resolve(IdeContext.FOLDER_SETUP); + Path updateFolder = templatesFolder.resolve(IdeContext.FOLDER_UPDATE); + if (!Files.isDirectory(setupFolder) && !Files.isDirectory(updateFolder)) { + LOG.trace("Skipping empty or non-existing workspace template folder {}.", templatesFolder); + return errors; + } + LOG.debug("Merging workspace templates from {}...", templatesFolder); + return errors + this.context.getWorkspaceMerger().merge(setupFolder, updateFolder, this.context.getVariables(), workspaceFolder, redirects); + } + + private void synchronizeExtraToolInstallations() { + + ExtraTools extraTools = ExtraToolsMapper.get().loadJsonFromFolder(this.context.getSettingsPath()); + if (extraTools == null) { + return; + } + for (String sdk : extraTools.getSortedToolNames()) { + Set templatePaths = this.extraSdkMap.get(sdk); + if ((templatePaths == null) || templatePaths.isEmpty()) { + LOG.debug("Skipping import of extra tool {} into {} because not configured or supported.", sdk, this.toolName); + continue; + } + List extraInstallations = extraTools.getExtraInstallations(sdk); + synchronizeExtraToolInstallation(sdk, templatePaths, extraInstallations); + } + } + + private void synchronizeExtraToolInstallation(String sdk, Set templatePaths, List extraInstallations) { + + for (Path templatePath : templatePaths) { + Path workspaceFile = this.context.getWorkspacePath().resolve(templatePath); + Path templateFile = this.context.getSettingsPath().resolve(this.toolName).resolve(IdeContext.FOLDER_WORKSPACE) + .resolve(IdeContext.FOLDER_REPOSITORY) + .resolve(templatePath); + if (Files.exists(templateFile)) { + for (ExtraToolInstallation extraInstallation : extraInstallations) { + synchronizeExtraToolInstallation(sdk, templateFile, workspaceFile, extraInstallation); + } + } else { + LOG.warn("You are missing a template file at {}.", templatePath); + IdeLogLevel.INTERACTION.log(LOG, "Please ask the IDEasy admin in your project to merge your settings with upstream."); + } + } + } + + private void synchronizeExtraToolInstallation(String sdk, Path templateFile, Path workspaceFile, ExtraToolInstallation installation) { + + String name = installation.name(); + Path extraToolHome = this.context.getSoftwareExtraPath().resolve(sdk).resolve(name); + if (!Files.isDirectory(extraToolHome)) { + LOG.warn("Skipping extra tool installation import to {} because it is missing at {}", this.toolName, extraToolHome); + IdeLogLevel.INTERACTION.log(LOG, "Please run the following command to fix:\nide update"); + return; + } + ExtensibleEnvironmentVariables environmentVariables = new ExtensibleEnvironmentVariables( + (AbstractEnvironmentVariables) this.context.getVariables().getParent(), this.context); + String variablePrefix = "EXTRA_" + sdk.toUpperCase(Locale.ROOT); + environmentVariables.setValue(variablePrefix + "_NAME", name); + environmentVariables.setValue(variablePrefix + "_HOME", extraToolHome.toString().replace('\\', '/')); + environmentVariables.setValue(variablePrefix + "_VERSION", installation.version().toString()); + if (installation.edition() != null) { + environmentVariables.setValue(variablePrefix + "_EDITION", installation.edition()); + } + + XmlMerger xmlMerger = new XmlMerger(this.context); + XmlMergeDocument workspaceDocument = xmlMerger.load(workspaceFile); + XmlMergeDocument templateDocument = xmlMerger.loadAndResolve(templateFile, environmentVariables); + Document mergedDocument = xmlMerger.merge(templateDocument, workspaceDocument, false); + xmlMerger.save(mergedDocument, workspaceFile); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaBasedIdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaBasedIdeToolCommandlet.java index e73a6553..7a1e5b74 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaBasedIdeToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaBasedIdeToolCommandlet.java @@ -20,10 +20,10 @@ import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; /** - * {@link IdeToolCommandlet} for IDEA based commandlets like: {@link com.devonfw.tools.ide.tool.intellij.Intellij IntelliJ} and + * {@link AbstractIdeToolCommandlet} for IDEA based commandlets like: {@link com.devonfw.tools.ide.tool.intellij.Intellij IntelliJ} and * {@link com.devonfw.tools.ide.tool.androidstudio.AndroidStudio Android Studio}. */ -public class IdeaBasedIdeToolCommandlet extends IdeToolCommandlet { +public class IdeaBasedIdeToolCommandlet extends AbstractIdeToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(IdeaBasedIdeToolCommandlet.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/inso/Inso.java b/cli/src/main/java/com/devonfw/tools/ide/tool/inso/Inso.java index e5833b53..00e193b8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/inso/Inso.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/inso/Inso.java @@ -1,16 +1,16 @@ package com.devonfw.tools.ide.tool.inso; +import java.util.Set; + import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import java.util.Set; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Inso CLI. + * {@link AbstractToolCommandlet} for Inso CLI. */ -public class Inso extends LocalToolCommandlet{ +public class Inso extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java b/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java index 0e9823c4..9f5cdae5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/intellij/Intellij.java @@ -20,18 +20,17 @@ import com.devonfw.tools.ide.merge.xml.XmlMergeDocument; import com.devonfw.tools.ide.merge.xml.XmlMerger; import com.devonfw.tools.ide.process.EnvironmentContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolEdition; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.gradle.Gradle; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; import com.devonfw.tools.ide.tool.ide.IdeaBasedIdeToolCommandlet; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link IdeToolCommandlet} for IntelliJ. + * {@link AbstractIdeToolCommandlet} for IntelliJ. */ public class Intellij extends IdeaBasedIdeToolCommandlet { @@ -51,7 +50,8 @@ public class Intellij extends IdeaBasedIdeToolCommandlet { private static final String MISC_XML = "misc.xml"; private static final String IDEA_PROPERTIES = "idea.properties"; - private static final Map, String> BUILD_TOOL_TO_IJ_TEMPLATE = Map.of(Mvn.class, MISC_XML, Gradle.class, GRADLE_XML); + private static final Map, String> BUILD_TOOL_TO_IJ_TEMPLATE = Map.of(Mvn.class, MISC_XML, Gradle.class, + GRADLE_XML); /** * The constructor. @@ -162,8 +162,8 @@ private void mergeConfig(Path repositoryPath, String configFilePath) { @Override public void importRepository(Path repositoryPath) { CommandletManager commandletManager = this.context.getCommandletManager(); - for (Entry, String> entry : BUILD_TOOL_TO_IJ_TEMPLATE.entrySet()) { - LocalToolCommandlet buildTool = commandletManager.getCommandlet(entry.getKey()); + for (Entry, String> entry : BUILD_TOOL_TO_IJ_TEMPLATE.entrySet()) { + AbstractLocalToolCommandlet buildTool = commandletManager.getCommandlet(entry.getKey()); Path buildDescriptor = buildTool.findBuildDescriptor(repositoryPath); if (buildDescriptor != null) { String templateFilename = entry.getValue(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/jasypt/Jasypt.java b/cli/src/main/java/com/devonfw/tools/ide/tool/jasypt/Jasypt.java index bf717417..02c5ffb0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/jasypt/Jasypt.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/jasypt/Jasypt.java @@ -11,14 +11,14 @@ import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.property.EnumProperty; import com.devonfw.tools.ide.property.PasswordProperty; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * {@link ToolCommandlet} for Jasypt, The java library which allows to add basic encryption capabilities with minimum - * effort. + * {@link AbstractToolCommandlet} for Jasypt, The java library which allows to add basic encryption capabilities with + * minimum effort. */ -public class Jasypt extends LocalToolCommandlet { +public class Jasypt extends AbstractLocalToolCommandlet { /** {@link EnumProperty} for the command (encrypt or decrypt) */ public final EnumProperty command; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/java/Java.java b/cli/src/main/java/com/devonfw/tools/ide/tool/java/Java.java index 7786960c..5e20053f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/java/Java.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/java/Java.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Java (Java Virtual Machine and Java Development Kit). + * {@link AbstractToolCommandlet} for Java (Java Virtual Machine and Java Development Kit). */ -public class Java extends LocalToolCommandlet { +public class Java extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/jmc/Jmc.java b/cli/src/main/java/com/devonfw/tools/ide/tool/jmc/Jmc.java index f374f562..0c3371ff 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/jmc/Jmc.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/jmc/Jmc.java @@ -14,14 +14,14 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.process.ProcessMode; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** - * {@link ToolCommandlet} for JDK Mission Control, An advanced set of tools for - * managing, monitoring, profiling, and troubleshooting Java applications. + * {@link AbstractToolCommandlet} for JDK Mission Control, An advanced set of + * tools for managing, monitoring, profiling, and troubleshooting Java applications. */ -public class Jmc extends LocalToolCommandlet { +public class Jmc extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Jmc.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/Kotlinc.java b/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/Kotlinc.java index 74e5354a..d4a4b52b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/Kotlinc.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/Kotlinc.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Kotlin command-line compiler (kotlinc). + * {@link AbstractToolCommandlet} for Kotlin command-line compiler (kotlinc). */ -public class Kotlinc extends LocalToolCommandlet { +public class Kotlinc extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/KotlincNative.java b/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/KotlincNative.java index 6059e2d4..5f992c09 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/KotlincNative.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/kotlinc/KotlincNative.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Kotlin Native (kotlincnative). + * {@link AbstractToolCommandlet} for Kotlin Native (kotlincnative). */ -public class KotlincNative extends LocalToolCommandlet { +public class KotlincNative extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/lazydocker/LazyDocker.java b/cli/src/main/java/com/devonfw/tools/ide/tool/lazydocker/LazyDocker.java index fae08814..0360dcf3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/lazydocker/LazyDocker.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/lazydocker/LazyDocker.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for lazydocker. + * {@link AbstractToolCommandlet} for lazydocker. */ -public class LazyDocker extends LocalToolCommandlet { +public class LazyDocker extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java b/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java index 2f5ca8bc..8451ec0d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/msvc/Msvc.java @@ -9,11 +9,11 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessErrorHandling; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.ToolInstallation; -public class Msvc extends LocalToolCommandlet { +public class Msvc extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Msvc.class); @@ -46,6 +46,8 @@ protected void installDownloadedToolPayload(ToolInstallRequest request, Path ins .withExitCodeAcceptor(code -> (code == 0) || (code == 3010)) .addArgs("--installPath", installationPath.toString(), "--add", "Microsoft.VisualStudio.Workload.VCTools", + "--add", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "--add", "Microsoft.VisualStudio.Component.Windows11SDK.26100", "--quiet", "--wait", "--norestart", "--nocache") .run(); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java index d843e41e..79f576a4 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MavenCommandlet.java @@ -3,17 +3,16 @@ import java.util.Set; - import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.completion.AutoCompletionRegistry; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** * Abstract base class for Maven-compatible tool commandlets. */ -public abstract class MavenCommandlet extends LocalToolCommandlet { +public abstract class MavenCommandlet extends AbstractLocalToolCommandlet { /** @@ -48,9 +47,6 @@ protected void initAutoCompletionRegistry(AutoCompletionRegistry registry) { registry.add("help:effective-settings"); registry.add("-DskipTests"); registry.add("-Dmaven.test.skip=true"); - registry.add("exec:java"); - registry.add("-Dexec.mainClass="); - registry.add("-Dexec.args="); registry.add("-P"); registry.add("-pl"); registry.add("-am"); @@ -75,7 +71,8 @@ protected void initAutoCompletionRegistry(AutoCompletionRegistry registry) { registry.add("-Dstyle.color="); registry.add("-Duser.dir="); registry.add("-Duser.home="); + registry.add("exec:java", "exec:exec"); + registry.add("-Dexec.mainClass=").addDependency("exec:java"); + registry.add("-Dexec.args=").addDependency("exec:java", "exec:exec"); } } - - diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/Mvn.java b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/Mvn.java index 4f86d092..c46c197d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/Mvn.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/Mvn.java @@ -24,7 +24,6 @@ import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.BuildTool; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.variable.IdeVariables; import com.devonfw.tools.ide.variable.VariableSyntax; @@ -32,7 +31,7 @@ /** - * {@link ToolCommandlet} for maven. + * {@link AbstractToolCommandlet} for maven. */ public class Mvn extends MavenCommandlet implements BuildTool { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnBasedLocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnBasedLocalToolCommandlet.java index c7bb6ebe..9bcd5979 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnBasedLocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnBasedLocalToolCommandlet.java @@ -5,14 +5,14 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * Extends {@link LocalToolCommandlet} for {@link Mvn Maven} based tools via {@link MvnRepository}. + * Extends {@link AbstractLocalToolCommandlet} for {@link Mvn Maven} based tools via {@link MvnRepository}. */ -public abstract class MvnBasedLocalToolCommandlet extends LocalToolCommandlet { +public abstract class MvnBasedLocalToolCommandlet extends AbstractLocalToolCommandlet { private final MvnArtifact artifact; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnRepository.java index 71bdc296..aa615bb6 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/mvn/MvnRepository.java @@ -28,8 +28,8 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.os.OperatingSystem; import com.devonfw.tools.ide.os.SystemArchitecture; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.IdeasyCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.repository.AbstractToolRepository; import com.devonfw.tools.ide.tool.repository.ArtifactToolRepository; import com.devonfw.tools.ide.url.model.file.UrlChecksums; @@ -99,7 +99,7 @@ public String getId() { } @Override - protected MvnArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected MvnArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { MvnArtifact artifact; if (toolCommandlet instanceof MvnBasedLocalToolCommandlet mvnBasedTool) { artifact = mvnBasedTool.getArtifact(edition); @@ -169,9 +169,8 @@ public MvnArtifactMetadata getMetadata(MvnArtifact artifact, String tool, String /** * Resolves the classifier for a Maven snapshot on ARM64. *

      - * Snapshot metadata is inspected to determine whether the ARM64 artifact exists. - * If it is unavailable but a x64 artifact exists, the x64 artifact is used as fallback. - * Release artifacts are not changed here and are attempted as ARM64 first. + * Snapshot metadata is inspected to determine whether the ARM64 artifact exists. If it is unavailable but a x64 artifact exists, the x64 artifact is used as + * fallback. Release artifacts are not changed here and are attempted as ARM64 first. * * @param artifact the artifact being resolved. * @param classifier the originally resolved classifier. @@ -349,7 +348,7 @@ private boolean isNotUpToDateInLocalRepo(Path file) { } @Override - public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet) { + public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet) { MvnArtifact artifact = resolveArtifact(tool, edition, null, toolCommandlet); return resolveVersion(artifact, version); @@ -495,7 +494,7 @@ protected String getMavenUrl(MvnArtifact artifact) { } @Override - protected MvnArtifactMetadata getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected MvnArtifactMetadata getMetadata(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { return super.getMetadata(tool, edition, version, toolCommandlet); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/mvnd/Mvnd.java b/cli/src/main/java/com/devonfw/tools/ide/tool/mvnd/Mvnd.java index b499f9e8..459b7d6a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/mvnd/Mvnd.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/mvnd/Mvnd.java @@ -3,11 +3,10 @@ import com.devonfw.tools.ide.completion.AutoCompletionRegistry; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.mvn.MavenCommandlet; /** - * {@link ToolCommandlet} for maven daemon. + * {@link AbstractToolCommandlet} for maven daemon. */ public class Mvnd extends MavenCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/nest/Nest.java b/cli/src/main/java/com/devonfw/tools/ide/tool/nest/Nest.java index 30ad1539..9d1d48c3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/nest/Nest.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/nest/Nest.java @@ -4,11 +4,10 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.npm.NpmBasedCommandlet; /** - * {@link ToolCommandlet} for Nest CLI. + * {@link AbstractToolCommandlet} for Nest CLI. */ public class Nest extends NpmBasedCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ng/Ng.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ng/Ng.java index 742af099..fb3c033c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ng/Ng.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ng/Ng.java @@ -4,11 +4,10 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.npm.NpmBasedCommandlet; /** - * {@link ToolCommandlet} for angular CLI. + * {@link AbstractToolCommandlet} for angular CLI. */ public class Ng extends NpmBasedCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java b/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java index c478896c..d9372c78 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java @@ -7,19 +7,13 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.nls.NlsBundle; -import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.PackageManagerRequest; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.npm.Npm; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for node. + * {@link AbstractToolCommandlet} for node. */ -public class Node extends LocalToolCommandlet { +public class Node extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Node.class); @@ -33,32 +27,9 @@ public Node(IdeContext context) { super(context, "node", Set.of(Tag.JAVA_SCRIPT, Tag.RUNTIME)); } - @Override - protected void postInstallOnNewInstallation(ToolInstallRequest request) { - - super.postInstallOnNewInstallation(request); - // this code is slightly dangerous: npm has a dependency to node, while here in node we call npm causing a cyclic dependency - // the problem is that node package already comes with npm so we already have to configure npm properly here - // inside npm we have to guarantee that we will not trigger an installation (again) causing an infinity loop - // we would love to get this clean but node and npm are already flawed forcing us to do such hacks... - Npm npm = this.context.getCommandletManager().getCommandlet(Npm.class); - PackageManagerRequest packageManagerRequest = new PackageManagerRequest("config", this.tool).addArg("config").addArg("set").addArg("prefix") - .addArg(getToolPath().toString()); - ProcessResult result = npm.runPackageManager(packageManagerRequest, true); - if (result.isSuccessful()) { - IdeLogLevel.SUCCESS.log(LOG, "Setting npm config prefix to: {} was successful", getToolPath()); - } - } - @Override public void printHelp(NlsBundle bundle) { LOG.info("For a list of supported options and arguments, use \"node --help\""); } - - @Override - protected boolean isIgnoreSoftwareRepo() { - - return true; - } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java index 39138b46..99e6ecd8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java @@ -6,17 +6,16 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.PackageManagerBasedLocalToolCommandlet; import com.devonfw.tools.ide.tool.PackageManagerRequest; -import com.devonfw.tools.ide.tool.ToolCommandlet; /** - * {@link LocalToolCommandlet} for tools based on npm. + * {@link AbstractLocalToolCommandlet} for tools based on npm. * - * @param

      type of the {@link ToolCommandlet} acting as {@link #getPackageManagerClass() package manager}. + * @param

      type of the {@link AbstractToolCommandlet} acting as {@link #getPackageManagerClass() package manager}. */ -public abstract class NodeBasedCommandlet

      extends PackageManagerBasedLocalToolCommandlet

      { +public abstract class NodeBasedCommandlet

      extends PackageManagerBasedLocalToolCommandlet

      { /** File name of the {@link #findBuildDescriptor(Path) build descriptor} . */ public static final String PACKAGE_JSON = "package.json"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java index ab2aa92d..739ff9cb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java @@ -4,19 +4,50 @@ import java.nio.file.Path; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; -import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link NpmBasedCommandlet} for npm. + * {@link AbstractLocalToolCommandlet} for npm. + *

      + * npm is installed as a pristine, versioned installation in the software repository (same model as the other tools) and is linked into each project's + * {@code software} folder. Global npm packages are installed into a per-project directory (see {@link #NPM_GLOBAL_FOLDER}) so that projects do not interfere + * with each other (see issue #352 and issue #2381). The prefix lives inside the {@code software} folder as a regular sub-folder, so the + * executables of the globally installed packages are also picked up by the generic {@code software} PATH scan (the {@code bin} folder on POSIX, the folder + * itself on Windows); in addition {@link #setEnvironment} adds that folder to the PATH explicitly so that packages installed within the same IDEasy run are + * already resolvable. */ -public class Npm extends NpmBasedCommandlet { +public class Npm extends AbstractLocalToolCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(Npm.class); private static final String NPM_HOME_FOLDER = "npm"; + /** The npm CLI entry point inside a flat npm installation ({@code /bin/npm-cli.js}). */ + static final String NPM_CLI_JS = "npm-cli.js"; + + /** The npx CLI entry point inside a flat npm installation ({@code /bin/npx-cli.js}). */ + static final String NPX_CLI_JS = "npx-cli.js"; + + /** The command name of the {@link com.devonfw.tools.ide.tool.node.Node node.js} runtime to launch. */ + static final String NODE = "node"; + + /** File name of the {@link #findBuildDescriptor(Path) build descriptor} of an npm project. */ + private static final String PACKAGE_JSON = "package.json"; + + /** + * The folder name for the per-project global npm packages, located inside the {@link IdeContext#getSoftwarePath() software} folder. + */ + public static final String NPM_GLOBAL_FOLDER = "node_modules"; + /** * The constructor. * @@ -27,34 +58,35 @@ public Npm(IdeContext context) { super(context, "npm", Set.of(Tag.JAVA_SCRIPT, Tag.BUILD)); } - @Override - protected boolean canBeUninstalled() { - return false; - } - - @Override - protected VersionIdentifier computeInstalledPackageVersion() { - if (hasNodeBinary("npm")) { - return VersionIdentifier.of(this.context.newProcess().runAndGetSingleOutput("npm", "--version")); - } - return null; - } - @Override public String getToolHelpArguments() { return "help"; } + /** + * Detects an npm project by its {@code package.json} build descriptor so that the {@code build} commandlet (and the + * {@link com.devonfw.tools.ide.commandlet.BuildCommandlet BuildCommandlet}) can dispatch it to npm. npm is a standalone tool (not a child of + * {@link com.devonfw.tools.ide.tool.node.Node node}) now, so it must declare its build descriptor itself. + * + * @param directory the {@link Path} to the build directory. + * @return the {@code package.json} {@link Path} if it exists, or {@code null} otherwise. + */ @Override - protected boolean isSkipInstallation() { - return true; + public Path findBuildDescriptor(Path directory) { + + Path buildDescriptor = directory.resolve(PACKAGE_JSON); + if (Files.exists(buildDescriptor)) { + return buildDescriptor; + } + return super.findBuildDescriptor(directory); } /** * @return the {@link Path} to the npm user configuration file, creates the folder and configuration file if it was not existing. */ public Path getOrCreateNpmConfigUserConfig() { + Path confPath = this.context.getConfPath().resolve(NPM_HOME_FOLDER); Path npmConfigFile = confPath.resolve(".npmrc"); if (!Files.isDirectory(confPath)) { @@ -64,9 +96,155 @@ public Path getOrCreateNpmConfigUserConfig() { return npmConfigFile; } + /** + * @param context the {@link IdeContext}. + * @return the {@link Path} to the per-project global npm prefix (see {@link #NPM_GLOBAL_FOLDER}), or {@code null} if not running inside a project. + */ + static Path getGlobalNpmPrefix(IdeContext context) { + + Path softwarePath = context.getSoftwarePath(); + return softwarePath == null ? null : softwarePath.resolve(NPM_GLOBAL_FOLDER); + } + @Override public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { + super.setEnvironment(environmentContext, toolInstallation, additionalInstallation); - environmentContext.withEnvVar("npm_config_prefix", this.context.getSoftwarePath().resolve("node").toString()); + // Global npm packages must not be installed into the shared node installation (issue #352) - they go into a per-project + // directory instead so that projects do not interfere with each other. Outside of a project we do not pin the prefix so + // that the system npm (if the tool is not installed by IDEasy) keeps its own behavior. The folder is not created here on purpose: + // npm creates it when the first global package is installed and IDEasy removes it again via cleanupGlobalPackagesFolder() + // once the last package has been uninstalled. + Path npmGlobalPath = getGlobalNpmPrefix(this.context); + if (npmGlobalPath == null) { + return; + } + environmentContext.withEnvVar("npm_config_prefix", npmGlobalPath.toString()); + + // npm places the global shims in the prefix root on Windows but in /bin on POSIX, so the PATH entry must + // differ per platform - otherwise the globally installed packages (e.g. task, cdk) are not resolvable. + Path npmGlobalBin = this.context.getSystemInfo().isWindows() ? npmGlobalPath : npmGlobalPath.resolve(IdeContext.FOLDER_BIN); + environmentContext.withPathEntry(npmGlobalBin); + } + + /** + * Removes the per-project global npm packages folder (see {@link #NPM_GLOBAL_FOLDER}) if no global package is installed there anymore. + *

      + * {@code npm uninstall -g} removes the package but leaves an empty folder behind (and possibly a {@code .package-lock.json}), so the prefix folder would + * otherwise linger in the {@code software} folder after the last global package of a project is uninstalled. A global package is considered installed if a + * {@code package.json} remains anywhere in the prefix - every installed package has one. This is independent of npm's platform-specific package layout + * (Windows: {@code node_modules}, POSIX: {@code lib/node_modules}), so the whole prefix is searched. No-op if the prefix does not exist or other packages are + * still installed. + */ + public void cleanupGlobalPackagesFolder() { + + Path npmGlobalPath = getGlobalNpmPrefix(this.context); + if (npmGlobalPath == null || !Files.isDirectory(npmGlobalPath)) { + return; + } + boolean noPackageInstalled = this.context.getFileAccess().findFirst(npmGlobalPath, this::isPackageJson, true) == null; + if (noPackageInstalled) { + this.context.getFileAccess().delete(npmGlobalPath); + LOG.info("Removed the now empty global npm packages folder {}.", npmGlobalPath); + } + } + + /** + * @param path the {@link Path} to check. + * @return {@code true} if the given path is a {@code package.json} file, i.e. the marker of an installed npm package. + */ + private boolean isPackageJson(Path path) { + + return Files.isRegularFile(path) && path.getFileName().toString().equals(PACKAGE_JSON); + } + + /** + * Repairs the npm launcher shims ({@code npm}/{@code npx}) so that they resolve to this pristine installation instead of the npm that is bundled with node. + *

      + * The npm registry tarball extracts a flat layout ({@code bin/npm-cli.js}) but ships the launcher shims in the layout npm uses when it is bundled inside a + * node distribution: {@code bin/npm.cmd}, {@code bin/npx.cmd}, {@code bin/npm.ps1}, {@code bin/npx.ps1}, {@code bin/npm} and {@code bin/npx} all point at a + * non-existent {@code node_modules/npm/bin/npm-cli.js} (or a sibling {@code node.exe}). Consequently, on Windows the {@code npm}/{@code npx} shims fail with + * {@code MODULE_NOT_FOUND}, and on Linux they silently run the npm that is bundled with the node distribution rather than this pristine npm. Since the npm + * bin folder is first on the PATH (see {@link com.devonfw.tools.ide.common.SystemPath#getToolPathsInResolutionOrder()}), the broken shims would otherwise + * shadow the correct tool for every invocation of {@code npm}/{@code npx}. + *

      + * This hook rewrites the shims to launch this installation's own CLI entry points ({@code bin/npm-cli.js}/{@code bin/npx-cli.js}) with the {@code node} + * runtime that is already on the PATH. This is a no-op for installations that do not contain a flat {@code bin/npm-cli.js} (e.g. a node-bundled npm or the + * pre-seeded test fixtures) so their shims are left untouched. + * + * @param extractedDir the {@link Path} to the folder with the unpacked npm tool (the package root, containing {@code bin/}). + */ + @Override + protected void postExtract(Path extractedDir) { + + super.postExtract(extractedDir); + Path bin = extractedDir.resolve(IdeContext.FOLDER_BIN); + if (!Files.isRegularFile(bin.resolve(NPM_CLI_JS))) { + return; + } + FileAccess fileAccess = this.context.getFileAccess(); + boolean windows = this.context.getSystemInfo().isWindows(); + if (windows) { + // The .cmd/.ps1 shims are only ever executed on Windows; on other platforms they are not run and thus left untouched. + repairShim(fileAccess, bin.resolve("npm.cmd"), cmdShim(NPM_CLI_JS)); + repairShim(fileAccess, bin.resolve("npx.cmd"), cmdShim(NPX_CLI_JS)); + repairShim(fileAccess, bin.resolve("npm.ps1"), psShim(NPM_CLI_JS)); + repairShim(fileAccess, bin.resolve("npx.ps1"), psShim(NPX_CLI_JS)); + } else { + // The POSIX shims are only ever executed on non-Windows systems; the .cmd/.ps1 shims are not run there and are left untouched. + repairShim(fileAccess, bin.resolve("npm"), posixShim(NPM_CLI_JS)); + repairShim(fileAccess, bin.resolve("npx"), posixShim(NPX_CLI_JS)); + } + LOG.debug("Repaired the npm launcher shims of the pristine npm installation at {} to use the flat layout.", bin); + } + + /** + * Overwrites the given launcher shim with the provided flat-layout content and, on non-Windows systems, marks it executable so that the POSIX shims + * {@code bin/npm}/{@code bin/npx} are runnable. + * + * @param fileAccess the {@link FileAccess} to use. + * @param shim the {@link Path} of the shim to repair. + * @param content the new flat-layout content for the shim. + */ + private void repairShim(FileAccess fileAccess, Path shim, String content) { + + fileAccess.writeFileContent(content, shim, false); + // On non-Windows, make the POSIX shims (bin/npm, bin/npx) executable so they can be launched from the PATH. + // The .cmd/.ps1 shims are not launched as POSIX executables and do not need the execute bit. + if (!this.context.getSystemInfo().isWindows() && !shim.getFileName().toString().contains(".")) { + fileAccess.makeExecutable(shim); + } + } + + /** + * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch. + * @return the {@code .cmd} (Windows) shim content for the flat npm layout. + */ + private static String cmdShim(String cliJs) { + + return "@ECHO OFF\r\n" // + + "SET \"NODE_CMD=" + NODE + "\"\r\n" // + + "\"%NODE_CMD%\" \"%~dp0" + cliJs + "\" %*\r\n"; + } + + /** + * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch. + * @return the {@code .ps1} (PowerShell) shim content for the flat npm layout. + */ + private static String psShim(String cliJs) { + + return "$node = \"" + NODE + "\"\r\n" // + + "& $node (Join-Path $PSScriptRoot \"" + cliJs + "\") @args\r\n"; + } + + /** + * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch. + * @return the POSIX shim content for the flat npm layout. + */ + private static String posixShim(String cliJs) { + + return "#!/usr/bin/env bash\n" // + + "basedir=\"$(dirname \"$0\")\"\n" // + + "exec node \"$basedir/" + cliJs + "\" \"$@\"\n"; } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmBasedCommandlet.java index 6fe79073..8a62a5dd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmBasedCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmBasedCommandlet.java @@ -1,6 +1,7 @@ package com.devonfw.tools.ide.tool.npm; import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -13,14 +14,14 @@ import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.PackageManagerRequest; import com.devonfw.tools.ide.tool.node.NodeBasedCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link LocalToolCommandlet} for tools based on npm. + * {@link AbstractLocalToolCommandlet} for tools based on npm. */ public abstract class NpmBasedCommandlet extends NodeBasedCommandlet { @@ -55,6 +56,33 @@ protected VersionIdentifier computeInstalledPackageVersion() { return runPackageManagerGetInstalledVersion(getPackageName()); } + /** + * @return the {@link Path} where the npm package of this tool is actually installed - the per-project global npm prefix (see + * {@link Npm#NPM_GLOBAL_FOLDER}) - and not the {@code software/node} runtime path that {@link #getToolPath() getToolPath()} reports (see + * issue #352). + */ + @Override + protected Path getInstalledLocation() { + + Path npmGlobalPath = Npm.getGlobalNpmPrefix(this.context); + if (npmGlobalPath == null) { + return super.getInstalledLocation(); + } + return npmGlobalPath; + } + + /** + * Uninstalls the npm package and, since npm-based tools install their package into the shared per-project global npm prefix (see + * {@link Npm#NPM_GLOBAL_FOLDER}), prunes that folder again if the uninstallation no longer leaves a package behind (see + * issue #352). + */ + @Override + public void uninstall() { + + super.uninstall(); + this.context.getCommandletManager().getCommandlet(Npm.class).cleanupGlobalPackagesFolder(); + } + private VersionIdentifier runPackageManagerGetInstalledVersion(String npmPackage) { if (!Files.isDirectory(this.context.getSoftwarePath().resolve("node"))) { LOG.trace("Since node is not installed, also package {} for tool {} cannot be installed.", npmPackage, this.tool); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmRepository.java index 1e481893..58aeadfb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/NpmRepository.java @@ -6,7 +6,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.json.JsonMapping; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.node.NodeBasedCommandlet; import com.devonfw.tools.ide.tool.repository.AbstractToolRepository; import com.devonfw.tools.ide.tool.repository.ArtifactToolRepository; @@ -45,7 +45,7 @@ public String getId() { } @Override - protected NpmArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected NpmArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { if (toolCommandlet instanceof NodeBasedCommandlet nodeBasedCommandlet) { String name = nodeBasedCommandlet.getPackageName(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java b/cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java new file mode 100644 index 00000000..fcbf0cb8 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/obsidian/Obsidian.java @@ -0,0 +1,42 @@ +package com.devonfw.tools.ide.tool.obsidian; + +import java.util.Set; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.tool.GlobalToolCommandlet; + +/** + * {@link GlobalToolCommandlet} for Obsidian. + */ +public class Obsidian extends GlobalToolCommandlet { + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public Obsidian(IdeContext context) { + + super(context, "obsidian", Set.of(Tag.MARK_DOWN)); + } + + @Override + public boolean isExtract() { + + return switch (this.context.getSystemInfo().getOs()) { + // the Windows download is an installer executable that has to be started, not extracted + case WINDOWS -> false; + // the macOS download is a DMG image that has to be mounted so the *.app can be taken out of it + case MAC -> true; + // TODO: depends on which Linux artifact the UrlUpdater ends up publishing (see #2186) + case LINUX -> true; + }; + } + + @Override + public String getWindowsRegistryAppName() { + + return "Obsidian"; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/oc/Oc.java b/cli/src/main/java/com/devonfw/tools/ide/tool/oc/Oc.java index 7f428365..f40aaa77 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/oc/Oc.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/oc/Oc.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Openshift CLI. + * {@link AbstractToolCommandlet} for Openshift CLI. */ -public class Oc extends LocalToolCommandlet { +public class Oc extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java index a07f1bce..0c016271 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java @@ -32,16 +32,26 @@ public PgAdmin(IdeContext context) { @Override protected List getNativePackages() { - return List.of(new NativePackage( - NativePackageManager.APT, - List.of("pgadmin4", "pgadmin4-server", "pgadmin4-desktop", "pgadmin4-web"), - List.of("--allow-downgrades"), - List.of("curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --yes --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg", - "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] " - + "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main\" " - + "> /etc/apt/sources.list.d/pgadmin4.list && apt update'"), - List.of("sudo rm -f /etc/apt/sources.list.d/pgadmin4.list", "sudo rm -f /usr/share/keyrings/packages-pgadmin-org.gpg") - )); + return List.of( + new NativePackage( + NativePackageManager.APT, + List.of("pgadmin4", "pgadmin4-server", "pgadmin4-desktop", "pgadmin4-web"), + List.of("--allow-downgrades"), + List.of( + "curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --yes --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg", + "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] " + + "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main\" " + + "> /etc/apt/sources.list.d/pgadmin4.list && apt update'"), + List.of("sudo rm -f /etc/apt/sources.list.d/pgadmin4.list", "sudo rm -f /usr/share/keyrings/packages-pgadmin-org.gpg") + ), + new NativePackage(NativePackageManager.BREW_CASK, List.of("pgadmin4")) + ); + } + + @Override + public String getMacApplicationName() { + + return "pgAdmin 4"; } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/Pip.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/Pip.java index 90705ed6..1c4aba5a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/Pip.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/Pip.java @@ -7,12 +7,11 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.uv.Uv; /** - * {@link ToolCommandlet} for pip. + * {@link AbstractToolCommandlet} for pip. *

      * Pip is installed via uv using the command {@code uv pip install pip==}. */ diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedCommandlet.java index bf2bb88f..8227eb7a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedCommandlet.java @@ -13,9 +13,9 @@ import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.PackageManagerBasedLocalToolCommandlet; import com.devonfw.tools.ide.tool.PackageManagerRequest; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.python.Python; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.tool.uv.Uv; @@ -25,7 +25,7 @@ * {@link PackageManagerBasedLocalToolCommandlet} for python tools based on pip. *

      */ -public abstract class PipBasedCommandlet extends PackageManagerBasedLocalToolCommandlet { +public abstract class PipBasedCommandlet extends PackageManagerBasedLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(PipBasedCommandlet.class); @@ -51,10 +51,10 @@ public ToolRepository getToolRepository() { @Override @SuppressWarnings({ "unchecked", "rawtypes" }) - protected Class getPackageManagerClass() { + protected Class getPackageManagerClass() { String edition = this.context.getVariables().getToolEdition("pip"); - Class result = + Class result = switch (edition) { case "pip" -> Pip.class; case "uv" -> Uv.class; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java index 30c8be9a..51071526 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandlet.java @@ -1,17 +1,46 @@ package com.devonfw.tools.ide.tool.pip; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.property.FlagProperty; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.PackageManagerRequest; +import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; +import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; +import com.devonfw.tools.ide.tool.ide.IdeWorkspaceConfigurer; +import com.devonfw.tools.ide.tool.plugin.PluginBasedCommandlet; +import com.devonfw.tools.ide.tool.plugin.PluginManager; +import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; +import com.devonfw.tools.ide.version.VersionIdentifier; /** - * Base class for pip-based IDE tools that should launch in the background instead of blocking the terminal. + * Base class for pip-based IDE tools that should launch in the background instead of blocking the terminal. Implements {@link IdeToolCommandlet} to provide IDE + * workspace configuration capabilities and {@link PluginBasedCommandlet} for plugin management. */ -public abstract class PipBasedIdeToolCommandlet extends PipBasedCommandlet { +public abstract class PipBasedIdeToolCommandlet extends PipBasedCommandlet implements IdeToolCommandlet, PluginBasedCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(PipBasedIdeToolCommandlet.class); + + private final IdeWorkspaceConfigurer workspaceConfigurer; + + private final PluginManager pluginManager; + + /** {@link FlagProperty} to force the reset and reinstallation of plugins as configured in the project settings. */ + public FlagProperty forcePluginReinstall; /** * The constructor. @@ -22,10 +51,126 @@ public abstract class PipBasedIdeToolCommandlet extends PipBasedCommandlet { */ public PipBasedIdeToolCommandlet(IdeContext context, String tool, Set tags) { super(context, tool, tags); + this.workspaceConfigurer = new IdeWorkspaceConfigurer(context, tool); + this.pluginManager = new PluginManager(context, this); + } + + @Override + protected void initProperties() { + this.forcePluginReinstall = add(new FlagProperty("--force-plugin-reinstall")); + super.initProperties(); } @Override public ProcessResult runTool(List args) { + configureWorkspace(); return runTool(ProcessMode.BACKGROUND, null, args); } + + /** + * @return the {@link IdeWorkspaceConfigurer} for this IDE. + */ + @Override + public IdeWorkspaceConfigurer getWorkspaceConfigurer() { + + return this.workspaceConfigurer; + } + + /** + * @return the {@link PluginManager} implementing the plugin logic of this {@link PipBasedIdeToolCommandlet}. + */ + @Override + public PluginManager getPluginManager() { + + return this.pluginManager; + } + + /** + * @return the {@link Path} to the python environment the plugins are installed into. Unlike for an {@link AbstractIdeToolCommandlet} this is not a folder + * owned by this tool but the shared python environment (containing {@code site-packages}) that also holds th IDE itself. It must therefore never be + * delted - plugins are removed via {@link #uninstallPlugin(ToolPluginDescriptor)} instead. + */ + @Override + public Path getPluginsInstallationPath() { + + return getParentTool().getToolPath(); + } + + @Override + protected void postInstall(ToolInstallRequest request) { + + super.postInstall(request); + + configureWorkspace(); + if (!request.isAlreadyInstalled() || this.forcePluginReinstall.isTrue()) { + this.pluginManager.resetPlugins(); + } + installPlugins(getPlugins().getPlugins(), request.getProcessContext()); + } + + @Override + public boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessContext pc) { + ProcessResult result = runPluginPackageManager(PackageManagerRequest.TYPE_INSTALL, plugin, pc); + if (result.isSuccessful()) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully installed plugin: {}", plugin.name()); + step.success(); + return true; + } + result.log(IdeLogLevel.DEBUG, IdeLogLevel.ERROR); + step.error("Failed to install plugin {} ({}): exit code war {}", plugin.name(), plugin.id(), result.getExitCode()); + return false; + } + + @Override + public void installPlugin(ToolPluginDescriptor plugin, final Step step) { + + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.THROW_CLI); + ToolInstallRequest request = new ToolInstallRequest(true); + request.setProcessContext(pc); + install(request); + installPlugin(plugin, step, pc); + } + + @Override + public void uninstallPlugin(ToolPluginDescriptor plugin) { + + ProcessResult result = runPluginPackageManager(PackageManagerRequest.TYPE_UNINSTALL, plugin, null); + if (result.isSuccessful()) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully uninstalled plugin {}", plugin.name()); + } else { + result.log(IdeLogLevel.DEBUG, IdeLogLevel.ERROR); + LOG.error("Could not uninstall plugin {} ({}): exit code was {}", plugin.name(), plugin.id(), result.getExitCode()); + } + } + + @Override + public void deleteAllPlugins() { + + for (ToolPluginDescriptor plugin : getPlugins().getPlugins()) { + Path markerFile = retrievePluginMarkerFilePath(plugin); + if ((markerFile != null) && Files.exists(markerFile)) { + uninstallPlugin(plugin); + } + } + } + + /** + * @param type the {@link PackageManagerRequest#getType() type} of the request ({@link PackageManagerRequest#TYPE_INSTALL install} or + * {@link PackageManagerRequest#TYPE_UNINSTALL uninstall}). + * @param plugin the {@link ToolPluginDescriptor} to install or uninstall. Its {@link ToolPluginDescriptor#id() ID} is used as name of the python + * package. + * @param pc the {@link ProcessContext} to use or {@code null} to create a new one. + * @return the {@link ProcessResult} + */ + private ProcessResult runPluginPackageManager(String type, ToolPluginDescriptor plugin, ProcessContext pc) { + + PackageManagerRequest request = new PackageManagerRequest(type, plugin.id()).setProcessMode(ProcessMode.DEFAULT_CAPTURE).setProcessContext(pc); + if (PackageManagerRequest.TYPE_INSTALL.equals(type)) { + String version = plugin.version(); + if ((version != null) && !version.isBlank()) { + request.setVersion(VersionIdentifier.of(version)); + } + } + return runPackageManager(request); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipRepository.java index 6c0143dd..724febe4 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pip/PipRepository.java @@ -4,7 +4,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.json.JsonMapping; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.repository.AbstractToolRepository; import com.devonfw.tools.ide.tool.repository.ArtifactToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -40,7 +40,7 @@ public String getId() { } @Override - protected PipArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected PipArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { if (toolCommandlet instanceof PipBasedCommandlet pip) { String name = pip.getPackageName(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/AbstractPluginBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/AbstractPluginBasedCommandlet.java new file mode 100644 index 00000000..a880df37 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/AbstractPluginBasedCommandlet.java @@ -0,0 +1,113 @@ +package com.devonfw.tools.ide.tool.plugin; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.property.FlagProperty; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; +import com.devonfw.tools.ide.tool.ToolInstallRequest; + +/** + * Base class for {@link AbstractLocalToolCommandlet}s that support plugins. It can automatically install configured plugins for the tool managed by this + * commandlet. + */ +public abstract class AbstractPluginBasedCommandlet extends AbstractLocalToolCommandlet implements PluginBasedCommandlet { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractPluginBasedCommandlet.class); + + private final PluginManager pluginManager; + + /** {@link FlagProperty} to force the reset and reinstallation of plugins as configured in the project settings. */ + public FlagProperty forcePluginReinstall; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param tool the {@link #getName() tool name}. + * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. + */ + public AbstractPluginBasedCommandlet(IdeContext context, String tool, Set tags) { + + super(context, tool, tags); + this.pluginManager = new PluginManager(context, this); + } + + @Override + protected void initProperties() { + + this.forcePluginReinstall = add(new FlagProperty("--force-plugin-reinstall")); + super.initProperties(); + } + + /** + * @return the {@link PluginManager} for this tool. + */ + @Override + public PluginManager getPluginManager() { + + return this.pluginManager; + } + + @Override + protected void postInstall(ToolInstallRequest request) { + + super.postInstall(request); + if (!request.isAlreadyInstalled() || this.forcePluginReinstall.isTrue()) { + this.pluginManager.resetPlugins(); + } + this.context.getFileAccess().mkdirs(getPluginsInstallationPath()); + installPlugins(getPlugins().getPlugins(), request.getProcessContext()); + } + + /** + * @param plugins the configured {@link ToolPluginDescriptor plugins} used to detect undefined entries. + * @return the {@link Set} of {@link ToolPluginDescriptor#name() plugin names} configured in the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable (e.g. + * {@code VSCODE_EXTRA_PLUGINS=copilot,docker}). This allows a user to permanently opt-in to plugins that are not + * {@link ToolPluginDescriptor#active() active} in the project settings, without modifying the shared settings and without losing them when plugins are + * purged and reinstalled on IDE upgrade. Values refer to the {@link ToolPluginDescriptor#name() name} of the plugin (the filename of its + * {@code .properties} file) and not to the {@link ToolPluginDescriptor#id() id}. Names that do not resolve to a configured plugin are logged as a warning + * and skipped so that a single stale entry cannot break the entire installation. + */ + protected Set getExtraPlugins(Collection plugins) { + + return this.pluginManager.getExtraPlugins(plugins); + } + + @Override + public Path getPluginsInstallationPath() { + + return this.context.getPluginsPath().resolve(this.tool); + } + + @Override + public void installPlugin(ToolPluginDescriptor plugin, final Step step) { + + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.THROW_CLI); + ToolInstallRequest request = new ToolInstallRequest(true); + request.setProcessContext(pc); + install(request); + installPlugin(plugin, step, pc); + } + + @Override + public void uninstallPlugin(ToolPluginDescriptor plugin) { + + this.pluginManager.uninstallPlugin(plugin); + } + + @Override + public void deleteAllPlugins() { + + this.context.getFileAccess().delete(getPluginsInstallationPath()); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java index efc7a3c8..2a324137 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandlet.java @@ -1,367 +1,137 @@ package com.devonfw.tools.ide.tool.plugin; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.devonfw.tools.ide.cli.CliException; -import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.environment.EnvironmentVariables; -import com.devonfw.tools.ide.environment.VariableLine; -import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.process.ProcessContext; -import com.devonfw.tools.ide.process.ProcessErrorHandling; -import com.devonfw.tools.ide.property.FlagProperty; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; /** - * Base class for {@link LocalToolCommandlet}s that support plugins. It can automatically install configured plugins for the tool managed by this commandlet. + * {@link Commandlet} for tools that support plugin management. + *

      + * This follows the same pattern as {@link com.devonfw.tools.ide.tool.ide.IdeToolCommandlet}, decoupling plugin capabilities from the installation mechanism. + * Both binary-installed IDEs (VS Code, IntelliJ) and package-manager-installed tools (Spyder via pip) can support plugins by composing a + * {@link PluginManager}. + *

      */ -public abstract class PluginBasedCommandlet extends LocalToolCommandlet { - - private static final Logger LOG = LoggerFactory.getLogger(PluginBasedCommandlet.class); - - private ToolPlugins plugins; - - /** {@link FlagProperty} to force the reset and reinstallation of plugins as configured in the project settings. */ - public FlagProperty forcePluginReinstall; +public interface PluginBasedCommandlet extends LocalToolCommandlet { /** - * The constructor. - * - * @param context the {@link IdeContext}. - * @param tool the {@link #getName() tool name}. - * @param tags the {@link #getTags() tags} classifying the tool. Should be created via {@link Set#of(Object) Set.of} method. + * @return the {@link PluginManager} implementing the plugin logic of the tool. Needed so that the default methods of this interface can delegate the shared + * plugin behaviour to a single implementation. */ - public PluginBasedCommandlet(IdeContext context, String tool, Set tags) { - - super(context, tool, tags); - } - - @Override - protected void initProperties() { - this.forcePluginReinstall = add(new FlagProperty("--force-plugin-reinstall")); - super.initProperties(); - } + PluginManager getPluginManager(); /** - * @return the {@link ToolPlugins} of this {@link PluginBasedCommandlet}. + * @return the configured edition of the tool owning the plugins (see {@code AbstractToolCommandlet#getConfiguredEdition()}). */ - public ToolPlugins getPlugins() { - - if (this.plugins == null) { - ToolPlugins toolPlugins = new ToolPlugins(); - - // Load project-specific plugins - Path pluginsPath = getPluginsConfigPath(); - loadPluginsFromDirectory(toolPlugins, pluginsPath); - - // Load user-specific plugins, this is done after loading the project-specific plugins so the user can potentially - // override plugins (e.g. change active flag). - Path userPluginsPath = getUserHomePluginsConfigPath(); - loadPluginsFromDirectory(toolPlugins, userPluginsPath); - - this.plugins = toolPlugins; - } - - return this.plugins; - } - - private void loadPluginsFromDirectory(ToolPlugins map, Path pluginsPath) { - - List children = this.context.getFileAccess() - .listChildren(pluginsPath, p -> p.getFileName().toString().endsWith(IdeContext.EXT_PROPERTIES)); - for (Path child : children) { - ToolPluginDescriptor descriptor = ToolPluginDescriptor.of(child, this.context, isPluginUrlNeeded()); - map.add(descriptor); - } - } + String getConfiguredEdition(); /** - * @return {@code true} if {@link ToolPluginDescriptor#url() plugin URL} property is needed, {@code false} otherwise. + * @return the installed edition of the tool owning the plugins or {@code null} if not installed (see {@code AbstractToolCommandlet#getInstalledEdition()}). */ - protected boolean isPluginUrlNeeded() { - - return false; - } + String getInstalledEdition(); /** - * @return the {@link Path} to the folder with the plugin configuration files inside the settings. + * @return the {@link Path} to the folder with the plugin configuration files inside the settings. The default implementation is shared by all plugin-capable + * tools (see {@link AbstractPluginBasedCommandlet} and {@link com.devonfw.tools.ide.tool.pip.PipBasedIdeToolCommandlet}). */ - protected Path getPluginsConfigPath() { + default Path getPluginsConfigPath() { - return this.context.getSettingsPath().resolve(this.tool).resolve(IdeContext.FOLDER_PLUGINS); - } - - private Path getUserHomePluginsConfigPath() { - - return this.context.getUserHomeIde().resolve(IdeContext.FOLDER_SETTINGS).resolve(this.tool).resolve(IdeContext.FOLDER_PLUGINS); + return getContext().getSettingsPath().resolve(getName()).resolve(IdeContext.FOLDER_PLUGINS); } /** - * @return the {@link Path} where the plugins of this {@link IdeToolCommandlet} shall be installed. + * @return the {@link Path} where the plugins of this tool shall be installed. */ - public Path getPluginsInstallationPath() { - - return this.context.getPluginsPath().resolve(this.tool); - } - - @Override - protected void postInstall(ToolInstallRequest request) { - - super.postInstall(request); - Path pluginsInstallationPath = getPluginsInstallationPath(); - - if (!request.isAlreadyInstalled() || this.forcePluginReinstall.isTrue()) { - LOG.info("Resetting all installed plugins..."); - deleteAllPlugins(pluginsInstallationPath); - } - this.context.getFileAccess().mkdirs(pluginsInstallationPath); - installPlugins(request.getProcessContext()); - } + Path getPluginsInstallationPath(); /** - * Deletes all installed plugins for this {@link IdeToolCommandlet} by deleting the plugins installation folder and all plugin marker files. - * - * @param pluginsInstallationPath the {@link Path} to the plugins installation folder. + * @return {@code true} if the {@link ToolPluginDescriptor#url() plugin url} is needed, {@code false} otherwise. The default (no URL) is shared by all + * plugin-capable tools; IDEs that need a URL (e.g. Eclipse) override this. */ - private void deleteAllPlugins(Path pluginsInstallationPath) { - - FileAccess fileAccess = this.context.getFileAccess(); - fileAccess.delete(pluginsInstallationPath); - List markerFiles = fileAccess.listChildren(this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE), Files::isRegularFile); - for (Path path : markerFiles) { - if (path.getFileName().toString().startsWith("plugin." + getName())) { - fileAccess.delete(path); - LOG.debug("Plugin marker file {} got deleted.", path); - } - } - } + default boolean isPluginUrlNeeded() { - private void installPlugins(ProcessContext pc) { - installPlugins(getPlugins().getPlugins(), pc); + return false; } /** - * Method to install active plugins or to handle install for inactive plugins - * - * @param plugins as {@link Collection} of plugins to install. - * @param pc the {@link ProcessContext} to use. + * @return the {@link ToolPlugins} configured for this tool. The default implementation is shared by all plugin-capable tools. */ - protected void installPlugins(Collection plugins, ProcessContext pc) { + default ToolPlugins getPlugins() { - Set extraPlugins = getExtraPlugins(plugins); - String edition = getConfiguredEdition(); - List pluginsToInstall = new ArrayList<>(plugins.size()); - for (ToolPluginDescriptor plugin : plugins) { - if (plugin.excludedEditions().contains(edition)) { - LOG.debug("Skipping plugin '{}' (excluded for edition '{}').", plugin.name(), edition); - } else if (plugin.active() || extraPlugins.contains(plugin.name())) { - pluginsToInstall.add(plugin); - } else { - Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); - if ((pluginMarkerFile == null) || !Files.exists(pluginMarkerFile)) { - handleInstallForInactivePlugin(plugin); - } - } - } - int currentPluginIndex = 1; - int totalPlugins = pluginsToInstall.size(); - for (ToolPluginDescriptor plugin : pluginsToInstall) { - Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); - boolean pluginMarkerFileExists = (pluginMarkerFile != null) && Files.exists(pluginMarkerFile); - if (pluginMarkerFileExists) { - LOG.debug("Markerfile for IDE {} and plugin '{}' already exists.", getName(), plugin.name()); - } - if (this.context.isForcePlugins() || !pluginMarkerFileExists) { - String progressMarker = " (" + currentPluginIndex + "/" + totalPlugins + ")"; - Step step = this.context.newStep("Install plugin " + plugin.name() + progressMarker); - step.run(() -> doInstallPluginStep(plugin, step, pc)); - } else { - LOG.debug("Skipping installation of plugin '{}' due to existing marker file: {}", plugin.name(), pluginMarkerFile); - } - currentPluginIndex++; - } + return getPluginManager().getPlugins(); } /** - * @param plugins the configured {@link ToolPluginDescriptor plugins} used to detect undefined entries. - * @return the {@link Set} of {@link ToolPluginDescriptor#name() plugin names} configured in the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable (e.g. - * {@code VSCODE_EXTRA_PLUGINS=copilot,docker}). This allows a user to permanently opt-in to plugins that are not {@link ToolPluginDescriptor#active() - * active} in the project settings, without modifying the shared settings and without losing them when plugins are purged and reinstalled on IDE upgrade. - * Values refer to the {@link ToolPluginDescriptor#name() name} of the plugin (the filename of its {@code .properties} file) and not to the - * {@link ToolPluginDescriptor#id() id}. Names that do not resolve to a configured plugin are logged as a warning and skipped so that a single stale entry - * cannot break the entire installation. + * @param key the filename of the properties file configuring the requested plugin (typically excluding the ".properties" extension). + * @return the {@link ToolPluginDescriptor} for the given {@code key}. The default implementation is shared by all plugin-capable tools. */ - protected Set getExtraPlugins(Collection plugins) { + default ToolPluginDescriptor getPlugin(String key) { - String variable = EnvironmentVariables.getToolExtraPluginsVariable(this.tool); - String value = this.context.getVariables().get(variable); - if ((value == null) || value.isBlank()) { - return Set.of(); - } - Set extraPlugins = new LinkedHashSet<>(); - for (String name : VariableLine.parseArray(value)) { - if (name.endsWith(IdeContext.EXT_PROPERTIES)) { - name = name.substring(0, name.length() - IdeContext.EXT_PROPERTIES.length()); - } - extraPlugins.add(name); - } - Set undefinedPlugins = new LinkedHashSet<>(extraPlugins); - for (ToolPluginDescriptor plugin : plugins) { - undefinedPlugins.remove(plugin.name()); - } - for (String name : undefinedPlugins) { - LOG.info("Ignoring undefined plugin '{}' configured in variable {} - no file {}{} found in {} or {}.", name, variable, name, IdeContext.EXT_PROPERTIES, - getPluginsConfigPath(), getUserHomePluginsConfigPath()); - } - return extraPlugins; - } - - private void doInstallPluginStep(ToolPluginDescriptor plugin, Step step, ProcessContext pc) { - boolean result = installPlugin(plugin, step, pc); - if (result) { - createPluginMarkerFile(plugin); - } + return getPluginManager().getPlugin(key); } /** - * @param plugin the {@link ToolPluginDescriptor plugin} to search for. - * @return Path to the plugin marker file. - */ - public Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin) { - if (this.context.getIdeHome() != null) { - String markerFileName = "plugin" + "." + getName() + "." + getInstalledEdition() + "." + plugin.name(); - String version = plugin.version(); - if ((version != null) && !version.isBlank()) { - markerFileName = markerFileName + ".version-" + normalizeMarkerFileSegment(version); - } - return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(markerFileName); - } - return null; - } - - private String normalizeMarkerFileSegment(String value) { - // replace all characters that are not allowed in filenames with "_" - return value.replaceAll("[^A-Za-z0-9._-]", "_"); - } - - /** - * Creates a marker file for a plugin in $IDE_HOME/.ide/plugin.«ide».«plugin-name» + * Installs the given active plugins and handles the inactive ones. The default implementation is shared by all plugin-capable tools. * - * @param plugin the {@link ToolPluginDescriptor plugin} for which the marker file should be created. + * @param plugins the {@link Collection} of {@link ToolPluginDescriptor plugins} to install. + * @param pc the {@link ProcessContext} to use. */ - public void createPluginMarkerFile(ToolPluginDescriptor plugin) { - Path pluginMarkerFilePath = retrievePluginMarkerFilePath(plugin); - if (pluginMarkerFilePath != null) { - FileAccess fileAccess = this.context.getFileAccess(); - fileAccess.mkdirs(pluginMarkerFilePath.getParent()); - deleteExistingPluginMarkerFiles(fileAccess, plugin, pluginMarkerFilePath); - fileAccess.touch(pluginMarkerFilePath); - } - } - - private void deleteExistingPluginMarkerFiles(FileAccess fileAccess, ToolPluginDescriptor plugin, Path currentMarkerFilePath) { + default void installPlugins(Collection plugins, ProcessContext pc) { - String markerFilePrefix = "plugin" + "." + getName() + "." + getInstalledEdition() + "." + plugin.name(); - List markerFiles = fileAccess.listChildren(currentMarkerFilePath.getParent(), - p -> { - String fileName = p.getFileName().toString(); - return Files.isRegularFile(p) && (fileName.equals(markerFilePrefix) || fileName.startsWith(markerFilePrefix + ".version-")); - }); - for (Path markerFile : markerFiles) { - if (!markerFile.equals(currentMarkerFilePath)) { - fileAccess.delete(markerFile); - LOG.debug("Deleted stale plugin marker file {} before creating {}.", markerFile, currentMarkerFilePath); - } - } + getPluginManager().installPlugins(plugins, pc); } /** + * Performs the tool-specific installation of a single plugin. + * * @param plugin the {@link ToolPluginDescriptor} to install. * @param step the {@link Step} for the plugin installation. * @param pc the {@link ProcessContext} to use. - * @return boolean true if the installation of the plugin succeeded, false if not. + * @return {@code true} if the installation of the plugin succeeded, {@code false} if not. */ - public abstract boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessContext pc); + boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessContext pc); /** + * Ensures that the tool itself is installed and then installs the plugin. + * * @param plugin the {@link ToolPluginDescriptor} to install. * @param step the {@link Step} for the plugin installation. */ - public void installPlugin(ToolPluginDescriptor plugin, final Step step) { - ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.THROW_CLI); - ToolInstallRequest request = new ToolInstallRequest(true); - request.setProcessContext(pc); - install(request); - installPlugin(plugin, step, pc); - } + void installPlugin(ToolPluginDescriptor plugin, final Step step); /** * @param plugin the {@link ToolPluginDescriptor} to uninstall. */ - public void uninstallPlugin(ToolPluginDescriptor plugin) { - - boolean error = false; - Path pluginsPath = getPluginsInstallationPath(); - if (!Files.isDirectory(pluginsPath)) { - LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not exist at {}", - plugin.name(), plugin.id(), pluginsPath); - error = true; - } - FileAccess fileAccess = this.context.getFileAccess(); - Path match = fileAccess.findFirst(pluginsPath, p -> p.getFileName().toString().startsWith(plugin.id()), false); - if (match == null) { - LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not contain a match at {}", - plugin.name(), plugin.id(), pluginsPath); - error = true; - } - if (error) { - LOG.error("Could not uninstall plugin {} because we could not find an installation", plugin); - } else { - fileAccess.delete(match); - LOG.info("Successfully uninstalled plugin {}", plugin); - } - } + void uninstallPlugin(ToolPluginDescriptor plugin); /** - * @param key the filename of the properties file configuring the requested plugin (typically excluding the ".properties" extension). - * @return the {@link ToolPluginDescriptor} for the given {@code key}. + * Uninstalls all currently installed plugins so that they can be installed again as configured in the project settings. */ - public ToolPluginDescriptor getPlugin(String key) { + void deleteAllPlugins(); - if (key == null) { - return null; - } - if (key.endsWith(IdeContext.EXT_PROPERTIES)) { - key = key.substring(0, key.length() - IdeContext.EXT_PROPERTIES.length()); - } + /** + * @param plugin the {@link ToolPluginDescriptor plugin} to search for. + * @return the {@link Path} to the plugin marker file or {@code null} if we are not inside an IDEasy project. The default implementation is shared by all + * plugin-capable tools. + */ + default Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin) { - ToolPlugins toolPlugins = getPlugins(); - ToolPluginDescriptor pluginDescriptor = toolPlugins.getByName(key); - if (pluginDescriptor == null) { - throw new CliException( - "Could not find plugin " + key + " at " + getPluginsConfigPath().resolve(key) + ".properties"); - } - return pluginDescriptor; + return getPluginManager().retrievePluginMarkerFilePath(plugin); } /** - * @param plugin the in{@link ToolPluginDescriptor#active() active} {@link ToolPluginDescriptor} that is skipped for regular plugin installation. + * Creates a marker file for a plugin in {@code $IDE_HOME/.ide/plugin.<>.<>.<>}. The default implementation is shared by all + * plugin-capable tools. + * + * @param plugin the plugin the {@link ToolPluginDescriptor plugin} for which the marker file should be created. */ - protected void handleInstallForInactivePlugin(ToolPluginDescriptor plugin) { + default void createPluginMarkerFile(ToolPluginDescriptor plugin) { - LOG.debug("Omitting installation of inactive plugin {} ({}).", plugin.name(), plugin.id()); + getPluginManager().createPluginMarkerFile(plugin); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java new file mode 100644 index 00000000..79048917 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/plugin/PluginManager.java @@ -0,0 +1,309 @@ +package com.devonfw.tools.ide.tool.plugin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.environment.VariableLine; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.step.Step; + +/** + * Manages plugin configuration and common plugin operations for tools that support plugins. + */ +public class PluginManager { + + private static final Logger LOG = LoggerFactory.getLogger(PluginManager.class); + + /** The prefix of a plugin marker file inside {@link IdeContext#FOLDER_DOT_IDE .ide} Folder. */ + public static final String MARKER_FILE_PREFIX = "plugin."; + + /** The infix of a plugin marker file separating the plugin name from its version. */ + public static final String MARKER_FILE_VERSION_INFIX = ".version-"; + + private final IdeContext context; + + private final PluginBasedCommandlet tool; + + private ToolPlugins plugins; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + * @param tool the {@link PluginBasedCommandlet tool} owning the plugins managed by this {@link PluginManager}. + */ + public PluginManager(IdeContext context, PluginBasedCommandlet tool) { + super(); + this.context = context; + this.tool = tool; + } + + + /** + * @return the {@link ToolPlugins} of this {@link AbstractPluginBasedCommandlet}. + */ + public ToolPlugins getPlugins() { + + if (this.plugins == null) { + ToolPlugins toolPlugins = new ToolPlugins(); + + // Load project-specific plugins + Path pluginsPath = this.tool.getPluginsConfigPath(); + loadPluginsFromDirectory(toolPlugins, pluginsPath); + + // Load user-specific plugins, this is done after loading the project-specific plugins so the user can potentially + // override plugins (e.g. change active flag). + Path userPluginsPath = getUserHomePluginConfigPath(); + loadPluginsFromDirectory(toolPlugins, userPluginsPath); + + this.plugins = toolPlugins; + } + + return this.plugins; + } + + + private void loadPluginsFromDirectory(ToolPlugins map, Path pluginsPath) { + + List children = this.context.getFileAccess() + .listChildren(pluginsPath, p -> p.getFileName().toString().endsWith(IdeContext.EXT_PROPERTIES)); + for (Path child : children) { + ToolPluginDescriptor descriptor = ToolPluginDescriptor.of(child, this.context, this.tool.isPluginUrlNeeded()); + map.add(descriptor); + } + } + + private Path getUserHomePluginConfigPath() { + return this.context.getUserHomeIde().resolve(IdeContext.FOLDER_SETTINGS).resolve(this.tool.getName()).resolve(IdeContext.FOLDER_PLUGINS); + } + + /** + * @param key the filename of the properties file configuring the requested plugin (typically excluding the ".properties" extension). + * @return the {@link ToolPluginDescriptor} for the given {@code key}. + */ + public ToolPluginDescriptor getPlugin(String key) { + + if (key == null) { + return null; + } + if (key.endsWith(IdeContext.EXT_PROPERTIES)) { + key = key.substring(0, key.length() - IdeContext.EXT_PROPERTIES.length()); + } + + ToolPlugins toolPlugins = getPlugins(); + ToolPluginDescriptor pluginDescriptor = toolPlugins.getByName(key); + if (pluginDescriptor == null) { + throw new CliException( + "Could not find plugin " + key + " at " + this.tool.getPluginsConfigPath().resolve(key) + ".properties"); + } + return pluginDescriptor; + } + + /** + * Reset all installed plugins by deleting the {@link PluginBasedCommandlet#getPluginsInstallationPath() plugins installation folder} and all plugin marker + * files. + */ + public void resetPlugins() { + LOG.info("Resetting all installed plugins..."); + this.tool.deleteAllPlugins(); + deleteAllPluginMarkerFiles(); + } + + /** + * Deletes all plugin marker files the {@link PluginBasedCommandlet tool} so that its plugins will be installed again. + */ + public void deleteAllPluginMarkerFiles() { + FileAccess fileAccess = this.context.getFileAccess(); + List markerFiles = fileAccess.listChildren(this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE), Files::isRegularFile); + for (Path path : markerFiles) { + if (path.getFileName().toString().startsWith(MARKER_FILE_PREFIX + this.tool.getName())) { + fileAccess.delete(path); + LOG.debug("Plugin marker file {} got deleted.", path); + } + } + } + + /** + * Installs the given plugins, activating those explicitly requested via the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable in addition to the + * {@link ToolPluginDescriptor#active() active} ones, and handles the inactive plugins that are not explicitly requested. + * + * @param plugins as {@link Collection} of plugins to install. + * @param pc the {@link ProcessContext} to use. + */ + public void installPlugins(Collection plugins, ProcessContext pc) { + + Set extraPlugins = getExtraPlugins(plugins); + String edition = this.tool.getConfiguredEdition(); + List pluginsToInstall = new ArrayList<>(plugins.size()); + for (ToolPluginDescriptor plugin : plugins) { + if (plugin.excludedEditions().contains(edition)) { + LOG.debug("Skipping plugin '{}' (excluded for edition '{}').", plugin.name(), edition); + } else if (plugin.active() || extraPlugins.contains(plugin.name())) { + pluginsToInstall.add(plugin); + } else { + Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); + if ((pluginMarkerFile == null) || !Files.exists(pluginMarkerFile)) { + handleInstallForInactivePlugin(plugin); + } + } + } + int currentPluginIndex = 1; + int totalPlugins = pluginsToInstall.size(); + for (ToolPluginDescriptor plugin : pluginsToInstall) { + Path pluginMarkerFile = retrievePluginMarkerFilePath(plugin); + boolean pluginMarkerFileExists = (pluginMarkerFile != null) && Files.exists(pluginMarkerFile); + if (pluginMarkerFileExists) { + LOG.debug("Markerfile for IDE {} and plugin '{}' already exists.", this.tool.getName(), plugin.name()); + } + if (this.context.isForcePlugins() || !pluginMarkerFileExists) { + String progressMarker = " (" + currentPluginIndex + "/" + totalPlugins + ")"; + Step step = this.context.newStep("Install plugin " + plugin.name() + progressMarker); + step.run(() -> doInstallPluginStep(plugin, step, pc)); + } else { + LOG.debug("Skipping installation of plugin '{}' due to existing marker file: {}", plugin.name(), pluginMarkerFile); + } + currentPluginIndex++; + } + } + + /** + * @param plugins the configured {@link ToolPluginDescriptor plugins} used to detect undefined entries. + * @return the {@link Set} of {@link ToolPluginDescriptor#name() plugin names} configured in the tool-specific {@code «TOOL»_EXTRA_PLUGINS} variable (e.g. + * {@code VSCODE_EXTRA_PLUGINS=copilot,docker}). This allows a user to permanently opt-in to plugins that are not + * {@link ToolPluginDescriptor#active() active} in the project settings, without modifying the shared settings and without losing them when plugins are + * purged and reinstalled on IDE upgrade. Values refer to the {@link ToolPluginDescriptor#name() name} of the plugin (the filename of its + * {@code .properties} file) and not to the {@link ToolPluginDescriptor#id() id}. Names that do not resolve to a configured plugin are logged as a warning + * and skipped so that a single stale entry cannot break the entire installation. + */ + public Set getExtraPlugins(Collection plugins) { + + String variable = EnvironmentVariables.getToolExtraPluginsVariable(this.tool.getName()); + String value = this.context.getVariables().get(variable); + if ((value == null) || value.isBlank()) { + return Set.of(); + } + Set extraPlugins = new LinkedHashSet<>(); + for (String name : VariableLine.parseArray(value)) { + if (name.endsWith(IdeContext.EXT_PROPERTIES)) { + name = name.substring(0, name.length() - IdeContext.EXT_PROPERTIES.length()); + } + extraPlugins.add(name); + } + Set undefinedPlugins = new LinkedHashSet<>(extraPlugins); + for (ToolPluginDescriptor plugin : plugins) { + undefinedPlugins.remove(plugin.name()); + } + for (String name : undefinedPlugins) { + LOG.info("Ignoring undefined plugin '{}' configured in variable {} - no file {}{} found in {} or {}.", name, variable, name, IdeContext.EXT_PROPERTIES, + this.tool.getPluginsConfigPath(), getUserHomePluginConfigPath()); + } + return extraPlugins; + } + + private void doInstallPluginStep(ToolPluginDescriptor plugin, Step step, ProcessContext pc) { + + boolean result = this.tool.installPlugin(plugin, step, pc); + if (result) { + createPluginMarkerFile(plugin); + } + } + + /** + * @param plugin the {@link ToolPluginDescriptor plugin} to search for. + * @return Path to the plugin marker file. + */ + public Path retrievePluginMarkerFilePath(ToolPluginDescriptor plugin) { + if (this.context.getIdeHome() != null) { + String markerFileName = getMarkerFilePrefix(plugin); + String version = plugin.version(); + if ((version != null) && !version.isBlank()) { + markerFileName = markerFileName + ".version-" + normalizeMarkerFileSegment(version); + } + return this.context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve(markerFileName); + } + return null; + } + + private String getMarkerFilePrefix(ToolPluginDescriptor plugin) { + return MARKER_FILE_PREFIX + this.tool.getName() + "." + this.tool.getInstalledEdition() + "." + plugin.name(); + } + + private String normalizeMarkerFileSegment(String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + /** + * Creates a marker file for a plugin in $IDE_HOME/.ide/plugin.«ide».«plugin-name» + * + * @param plugin the {@link ToolPluginDescriptor plugin} for which the marker file should be created. + */ + public void createPluginMarkerFile(ToolPluginDescriptor plugin) { + Path pluginMarkerFilePath = retrievePluginMarkerFilePath(plugin); + if (pluginMarkerFilePath != null) { + FileAccess fileAccess = this.context.getFileAccess(); + fileAccess.mkdirs(pluginMarkerFilePath.getParent()); + deleteExistingPluginMarkerFiles(fileAccess, plugin, pluginMarkerFilePath); + fileAccess.touch(pluginMarkerFilePath); + } + } + + private void deleteExistingPluginMarkerFiles(FileAccess fileAccess, ToolPluginDescriptor plugin, Path currentMarkerFilePath) { + + String markerFilePrefix = getMarkerFilePrefix(plugin); + List markerFiles = fileAccess.listChildren(currentMarkerFilePath.getParent(), + p -> { + String fileName = p.getFileName().toString(); + return Files.isRegularFile(p) && (fileName.equals(markerFilePrefix) || fileName.startsWith(markerFilePrefix + MARKER_FILE_VERSION_INFIX)); + }); + for (Path markerFile : markerFiles) { + if (!markerFile.equals(currentMarkerFilePath)) { + fileAccess.delete(markerFile); + LOG.debug("Deleted stale plugin marker file {} before creating {}.", markerFile, currentMarkerFilePath); + } + } + } + + /** + * @param plugin the {@link ToolPluginDescriptor} to uninstall. + */ + public void uninstallPlugin(ToolPluginDescriptor plugin) { + + boolean error = false; + Path pluginsPath = this.tool.getPluginsInstallationPath(); + if (!Files.isDirectory(pluginsPath)) { + LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not exist at {}", + plugin.name(), plugin.id(), pluginsPath); + error = true; + } + FileAccess fileAccess = this.context.getFileAccess(); + Path match = fileAccess.findFirst(pluginsPath, p -> p.getFileName().toString().startsWith(plugin.id()), false); + if (match == null) { + LOG.debug("Omitting to uninstall plugin {} ({}) as plugins folder does not contain a match at {}", + plugin.name(), plugin.id(), pluginsPath); + error = true; + } + if (error) { + LOG.error("Could not uninstall plugin {} because we could not find an installation", plugin); + } else { + fileAccess.delete(match); + LOG.info("Successfully uninstalled plugin {}", plugin); + } + } + + private void handleInstallForInactivePlugin(ToolPluginDescriptor plugin) { + LOG.debug("Omitting installation of inactive plugin {} ({}).", plugin.name(), plugin.id()); + } + +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pycharm/Pycharm.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pycharm/Pycharm.java index 5f3362b0..01df1cfc 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pycharm/Pycharm.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pycharm/Pycharm.java @@ -7,11 +7,10 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.EnvironmentContext; import com.devonfw.tools.ide.tool.ToolInstallation; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; import com.devonfw.tools.ide.tool.ide.IdeaBasedIdeToolCommandlet; /** - * {@link IdeToolCommandlet} for Pycharm. + * {@link AbstractIdeToolCommandlet} for Pycharm. */ public class Pycharm extends IdeaBasedIdeToolCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java index 3bbcd1c2..79e98735 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java @@ -3,6 +3,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.List; import java.util.Set; import org.slf4j.Logger; @@ -14,8 +15,11 @@ import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.EnvironmentContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.repository.ToolRepository; @@ -23,9 +27,9 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link ToolCommandlet} for python. + * {@link AbstractToolCommandlet} for python. */ -public class Python extends LocalToolCommandlet { +public class Python extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Python.class); @@ -96,20 +100,15 @@ protected boolean isIgnoreMissingSoftwareVersionFile() { } @Override - protected VersionIdentifier getInstalledVersion(Path toolPath) { + protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() { - VersionIdentifier version = super.getInstalledVersion(toolPath); + Path toolPath = getToolPath(); + VersionIdentifier version = readVersionFromPyvenvCfg(toolPath); if (version == null) { - // the virtual environment can be recreated by uv or python and then the version file is lost, so we ask the - // installation itself instead of reporting that python is not installed - see - // https://github.com/devonfw/IDEasy/issues/2190 - version = readVersionFromPyvenvCfg(toolPath); - if (version == null) { - version = readVersionFromInterpreter(toolPath); - } - if (version != null) { - LOG.debug("Determined version {} of python from the installation at {}.", version, toolPath); - } + version = readVersionFromInterpreter(toolPath); + } + if (version != null) { + LOG.debug("Determined version {} of python from the installation at {}.", version, toolPath); } return version; } @@ -146,15 +145,14 @@ private VersionIdentifier readVersionFromPyvenvCfg(Path installationPath) { private VersionIdentifier readVersionFromInterpreter(Path installationPath) { Path binPath = this.context.getFileAccess().getBinPath(installationPath); - Path binaryPath = binPath.resolve(getBinaryName()); - if (!Files.exists(binaryPath)) { - binaryPath = binPath.resolve(getBinaryName() + ".exe"); - } + Path binaryPath = this.context.getPath().findBinary(binPath.resolve(getBinaryName())); if (!Files.exists(binaryPath)) { LOG.debug("Python binary does not exist in {}.", binPath); return null; } - String output = this.context.newProcess().runAndGetSingleOutput(IdeLogLevel.DEBUG, binaryPath.toString(), "--version"); + ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.NONE).withPathEntry(binPath); + ProcessResult result = runTool(pc, ProcessMode.DEFAULT_CAPTURE, List.of("--version")); + String output = result.getSingleOutput(IdeLogLevel.DEBUG); if (output == null) { return null; } @@ -166,7 +164,7 @@ private VersionIdentifier readVersionFromInterpreter(Path installationPath) { if (!version.isEmpty() && Character.isDigit(version.charAt(0))) { return VersionIdentifier.of(version); } - LOG.debug("Could not parse version from output '{}' of {}.", output, binaryPath); + LOG.debug("Could not parse version from output '{}' of {}.", output, binPath); return null; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/python/PythonRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/python/PythonRepository.java index 93a6cb80..e6ec46c7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/python/PythonRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/python/PythonRepository.java @@ -11,7 +11,7 @@ import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.repository.AbstractToolRepository; import com.devonfw.tools.ide.tool.uv.Uv; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; @@ -52,7 +52,7 @@ public List getSortedEditions(String tool) { } @Override - public List getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet) { + public List getSortedVersions(String tool, String edition, AbstractToolCommandlet toolCommandlet) { if (this.cachedVersions == null) { this.cachedVersions = computeSortedVersions(); @@ -92,7 +92,7 @@ protected List fetchUvPythonList() { } @Override - protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { throw new UnsupportedOperationException( "Python is installed via uv and is never downloaded from a URL. This repository only resolves versions."); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/quarkus/Quarkus.java b/cli/src/main/java/com/devonfw/tools/ide/tool/quarkus/Quarkus.java index 22cb564f..eff52f98 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/quarkus/Quarkus.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/quarkus/Quarkus.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for Quarkus. + * {@link AbstractToolCommandlet} for Quarkus. */ -public class Quarkus extends LocalToolCommandlet { +public class Quarkus extends AbstractLocalToolCommandlet { /** * The constructor diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java index 90d34d4f..c12fbea7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java @@ -18,7 +18,7 @@ import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.OperatingSystem; import com.devonfw.tools.ide.os.SystemArchitecture; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.file.UrlChecksums; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; import com.devonfw.tools.ide.url.model.file.UrlGenericChecksum; @@ -58,14 +58,14 @@ public AbstractToolRepository(IdeContext context) { * @param tool the name of the tool to download. * @param edition the edition of the tool to download. * @param version the {@link VersionIdentifier} to download. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the resolved {@link UrlDownloadFileMetadata}. */ - protected abstract UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet); + protected abstract UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet); @Override - public Path download(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + public Path download(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { UrlDownloadFileMetadata metadata = getMetadata(tool, edition, version, toolCommandlet); return download(metadata); @@ -293,7 +293,7 @@ protected void verifyChecksum(Path file, UrlGenericChecksum expectedChecksum) { } @Override - public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet) { + public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet) { List versions = getSortedVersions(tool, edition, toolCommandlet); return VersionIdentifier.resolveVersionPattern(version, versions); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ArtifactToolRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ArtifactToolRepository.java index ad82a938..38ddcef5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ArtifactToolRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ArtifactToolRepository.java @@ -7,7 +7,7 @@ import com.devonfw.tools.ide.cache.CachedValue; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.tool.npm.NpmRepository; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; @@ -38,7 +38,7 @@ public List getSortedEditions(String tool) { } @Override - public List getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet) { + public List getSortedVersions(String tool, String edition, AbstractToolCommandlet toolCommandlet) { A artifact = resolveArtifact(tool, edition, null, toolCommandlet); CachedValue> cachedValue = this.versionCache.computeIfAbsent(artifact, @@ -54,13 +54,13 @@ private List computeSortedVersions(A artifact) { } /** - * @param tool the {@link ToolCommandlet#getName() tool name}. - * @param edition the {@link ToolCommandlet#getConfiguredEdition() tool edition}. + * @param tool the {@link AbstractToolCommandlet#getName() tool name}. + * @param edition the {@link AbstractToolCommandlet#getConfiguredEdition() tool edition}. * @param version the {@link SoftwareArtifact#getVersion() tool version} or {@code null} if undefined. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the resolved {@link SoftwareArtifact}. */ - protected abstract A resolveArtifact(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet); + protected abstract A resolveArtifact(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet); /** * @param artifact the {@link SoftwareArtifact}. @@ -69,7 +69,7 @@ private List computeSortedVersions(A artifact) { protected abstract List fetchVersions(A artifact); @Override - protected M getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected M getMetadata(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { A artifact = resolveArtifact(tool, edition, version, toolCommandlet); return getMetadata(artifact, tool, edition); @@ -77,8 +77,8 @@ protected M getMetadata(String tool, String edition, VersionIdentifier version, /** * @param artifact the {@link SoftwareArtifact} to resolve. - * @param tool the {@link ToolCommandlet#getName() tool name}. - * @param edition the {@link ToolCommandlet#getConfiguredEdition() tool edition}. + * @param tool the {@link AbstractToolCommandlet#getName() tool name}. + * @param edition the {@link AbstractToolCommandlet#getConfiguredEdition() tool edition}. * @return the resolved {@link UrlDownloadFileMetadata}. */ public abstract M getMetadata(A artifact, String tool, String edition); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/DefaultToolRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/DefaultToolRepository.java index 4aa1d67d..0ae07314 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/DefaultToolRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/DefaultToolRepository.java @@ -4,7 +4,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.os.SystemInfo; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.UrlMetadata; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; import com.devonfw.tools.ide.url.model.folder.UrlVersion; @@ -32,7 +32,7 @@ public String getId() { } @Override - protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { UrlMetadata metadata = this.context.getUrls(); UrlVersion urlVersion = metadata.getVersionFolder(tool, edition, version, toolCommandlet); @@ -47,7 +47,7 @@ public List getSortedEditions(String tool) { } @Override - public List getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet) { + public List getSortedVersions(String tool, String edition, AbstractToolCommandlet toolCommandlet) { return this.context.getUrls().getSortedVersions(tool, edition, toolCommandlet); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ToolRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ToolRepository.java index 6b2c8fcb..1d77fe4e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ToolRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/ToolRepository.java @@ -3,7 +3,7 @@ import java.nio.file.Path; import java.util.Collection; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.AbstractUrlMetadata; import com.devonfw.tools.ide.url.model.file.json.Cve; import com.devonfw.tools.ide.url.model.file.json.ToolDependency; @@ -12,8 +12,8 @@ import com.devonfw.tools.ide.version.VersionIdentifier; /** - * Interface for a software repository that allows to {@link #download(String, String, VersionIdentifier, ToolCommandlet) download} software via the network. It - * is responsible for the following aspects: + * Interface for a software repository that allows to {@link #download(String, String, VersionIdentifier, AbstractToolCommandlet) download} software via the + * network. It is responsible for the following aspects: *
        *
      • version resolution (e.g. resolving version patterns and determining the latest (stable) version)
      • *
      • discovery (compute the download URL)
      • @@ -35,12 +35,12 @@ public interface ToolRepository extends AbstractUrlMetadata { * @param tool the name of the tool. * @param edition the edition of the tool. * @param version the {@link VersionIdentifier} to resolve. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the resolved {@link VersionIdentifier}. If the given {@link VersionIdentifier} is NOT a {@link VersionIdentifier#isPattern() pattern} this method * will always just return the given {@link VersionIdentifier}. */ @Override - VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet); + VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet); /** * Will download the requested software specified by the given arguments. If that software is already available in the download-cache it will be returned @@ -49,16 +49,16 @@ public interface ToolRepository extends AbstractUrlMetadata { * * @param tool the name of the tool. * @param edition the edition of the tool. - * @param version the {@link #resolveVersion(String, String, GenericVersionRange, ToolCommandlet) resolved} {@link VersionIdentifier}. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param version the {@link #resolveVersion(String, String, GenericVersionRange, AbstractToolCommandlet) resolved} {@link VersionIdentifier}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the {@link Path} to the downloaded software package. */ - Path download(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet); + Path download(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet); /** * @param tool the name of the tool. * @param edition the edition of the tool. - * @param version the {@link #resolveVersion(String, String, GenericVersionRange, ToolCommandlet) resolved} {@link VersionIdentifier}. + * @param version the {@link #resolveVersion(String, String, GenericVersionRange, AbstractToolCommandlet) resolved} {@link VersionIdentifier}. * @return the {@link Collection} of {@link ToolDependency tool dependencies}. */ Collection findDependencies(String tool, String edition, VersionIdentifier version); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ruby/Ruby.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ruby/Ruby.java index f0f44b92..bc9a3324 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ruby/Ruby.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ruby/Ruby.java @@ -4,13 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for the Ruby programming language. + * {@link AbstractToolCommandlet} for the Ruby programming language. */ -public class Ruby extends LocalToolCommandlet { +public class Ruby extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/rust/Rust.java b/cli/src/main/java/com/devonfw/tools/ide/tool/rust/Rust.java index e82274eb..7147e07a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/rust/Rust.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/rust/Rust.java @@ -12,15 +12,15 @@ import com.devonfw.tools.ide.process.EnvironmentContext; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessErrorHandling; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallRequest; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link LocalToolCommandlet} for Rust. + * {@link AbstractLocalToolCommandlet} for Rust. */ -public class Rust extends LocalToolCommandlet { +public class Rust extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/soapui/SoapUi.java b/cli/src/main/java/com/devonfw/tools/ide/tool/soapui/SoapUi.java index 4afe242b..45aa71dd 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/soapui/SoapUi.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/soapui/SoapUi.java @@ -7,12 +7,12 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link LocalToolCommandlet} for SoapUI, an open source API testing tool for SOAP and REST services. + * {@link AbstractLocalToolCommandlet} for SoapUI, an open source API testing tool for SOAP and REST services. */ -public class SoapUi extends LocalToolCommandlet { +public class SoapUi extends AbstractLocalToolCommandlet { private static final String SOAPUI = "soapui"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/sonar/Sonar.java b/cli/src/main/java/com/devonfw/tools/ide/tool/sonar/Sonar.java index d75ec4b9..4d510d54 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/sonar/Sonar.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/sonar/Sonar.java @@ -13,15 +13,15 @@ import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.property.EnumProperty; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.java.Java; import com.devonfw.tools.ide.tool.mvn.Mvn; /** - * {@link LocalToolCommandlet} for SonarQube. + * {@link AbstractLocalToolCommandlet} for SonarQube. */ -public class Sonar extends LocalToolCommandlet { +public class Sonar extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Sonar.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/spring/Spring.java b/cli/src/main/java/com/devonfw/tools/ide/tool/spring/Spring.java index 16365b96..349f350c 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/spring/Spring.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/spring/Spring.java @@ -4,12 +4,11 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.mvn.MvnArtifact; import com.devonfw.tools.ide.tool.mvn.MvnBasedLocalToolCommandlet; /** - * {@link ToolCommandlet} for Spring-Boot-CLI. + * {@link AbstractToolCommandlet} for Spring-Boot-CLI. */ public class Spring extends MvnBasedLocalToolCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java b/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java index 2a27df1c..8b88a0d6 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/spyder/Spyder.java @@ -1,10 +1,14 @@ package com.devonfw.tools.ide.tool.spyder; +import java.nio.file.Path; +import java.util.List; import java.util.Set; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.pip.PipBasedIdeToolCommandlet; @@ -13,8 +17,11 @@ */ public class Spyder extends PipBasedIdeToolCommandlet { - /** Environment variable that tells Spyder to use an IDEasy-managed config directory instead of the shared user config. */ - private static final String SPYDER_CONFIG_DIR = "SPYDER_CONFIG_DIR"; + /** The name of the Spyder config folder (defaults to .spyder-py3 in user home). */ + private static final String SPYDER_CONFDIR_NAME = ".spyder-py3"; + + /** Environment variable to override Spyder's default config directory. */ + private static final String SPYDER_CONFDIR = "SPYDER_CONFDIR"; /** * The constructor. @@ -29,9 +36,19 @@ public Spyder(IdeContext context) { public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { super.setEnvironment(environmentContext, toolInstallation, additionalInstallation); - // Point Spyder to an IDEasy-managed config directory so its settings stay isolated per IDE_HOME. - if (this.context.getConfPath() != null) { - environmentContext.withEnvVar(SPYDER_CONFIG_DIR, this.context.getConfPath().resolve("spyder").toString()); + Path workspacePath = this.context.getWorkspacePath(); + if (workspacePath != null) { + environmentContext.withEnvVar(SPYDER_CONFDIR, workspacePath.resolve(SPYDER_CONFDIR_NAME).toString()); + } + } + + @Override + protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { + Path workspacePath = this.context.getWorkspacePath(); + if (workspacePath != null) { + pc.addArg("--project"); + pc.addArg(workspacePath.toString()); } + super.configureToolArgs(pc, processMode, args); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/squirrelsql/SquirrelSql.java b/cli/src/main/java/com/devonfw/tools/ide/tool/squirrelsql/SquirrelSql.java index fef8caef..fcfe8118 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/squirrelsql/SquirrelSql.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/squirrelsql/SquirrelSql.java @@ -4,12 +4,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link LocalToolCommandlet} for SQuirreL SQL. + * {@link AbstractLocalToolCommandlet} for SQuirreL SQL. */ -public class SquirrelSql extends LocalToolCommandlet { +public class SquirrelSql extends AbstractLocalToolCommandlet { private static final String SQUIRREL_SQL = "squirrel-sql"; private static final String SQUIRREL_SQL_BAT = SQUIRREL_SQL + ".bat"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/task/Task.java b/cli/src/main/java/com/devonfw/tools/ide/tool/task/Task.java index cefdc5c2..a60f9b2a 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/task/Task.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/task/Task.java @@ -5,11 +5,10 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.npm.NpmBasedCommandlet; /** - * {@link ToolCommandlet} for Task CLI. + * {@link AbstractToolCommandlet} for Task CLI. */ public class Task extends NpmBasedCommandlet { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/terraform/Terraform.java b/cli/src/main/java/com/devonfw/tools/ide/tool/terraform/Terraform.java index a2b89296..e1d48f04 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/terraform/Terraform.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/terraform/Terraform.java @@ -6,13 +6,12 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; /** - * {@link ToolCommandlet} for terraform CLI (terraform). + * {@link AbstractToolCommandlet} for terraform CLI (terraform). */ -public class Terraform extends LocalToolCommandlet { +public class Terraform extends AbstractLocalToolCommandlet { /** * The constructor. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/tomcat/Tomcat.java b/cli/src/main/java/com/devonfw/tools/ide/tool/tomcat/Tomcat.java index abab95ec..8499a41d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/tomcat/Tomcat.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/tomcat/Tomcat.java @@ -5,6 +5,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Set; + import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -22,15 +23,14 @@ import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.version.GenericVersionRange; /** - * {@link ToolCommandlet} for tomcat. + * {@link AbstractToolCommandlet} for tomcat. */ -public class Tomcat extends LocalToolCommandlet { +public class Tomcat extends AbstractLocalToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Tomcat.class); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java b/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java index 990533e4..178b62a0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java @@ -12,8 +12,7 @@ import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.python.PythonUvListEntry; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -21,9 +20,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; /** - * {@link ToolCommandlet} for uv. + * {@link AbstractToolCommandlet} for uv. */ -public class Uv extends LocalToolCommandlet { +public class Uv extends AbstractLocalToolCommandlet { /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/uv/UvRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/uv/UvRepository.java index 8dbd71d0..d27e7733 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/uv/UvRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/uv/UvRepository.java @@ -4,7 +4,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.json.JsonMapping; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.pip.PypiObject; import com.devonfw.tools.ide.tool.repository.ArtifactToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -39,7 +39,7 @@ public String getId() { } @Override - protected UvArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + protected UvArtifact resolveArtifact(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { if (toolCommandlet instanceof UvBasedCommandlet uv) { String name = uv.getPackageName(); if (version == null) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java b/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java index f1131384..ac461b56 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/vscode/Vscode.java @@ -1,8 +1,10 @@ package com.devonfw.tools.ide.tool.vscode; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import org.slf4j.Logger; @@ -10,25 +12,29 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.step.Step; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ide.IdeToolCommandlet; +import com.devonfw.tools.ide.tool.ide.AbstractIdeToolCommandlet; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; +import com.devonfw.tools.ide.variable.IdeVariables; /** - * {@link ToolCommandlet} for vscode. + * {@link AbstractToolCommandlet} for vscode. */ -public class Vscode extends IdeToolCommandlet { +public class Vscode extends AbstractIdeToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(Vscode.class); /** The {@link #getConfiguredEdition() edition} for VSCodium. */ private static final String EDITION_VSCODIUM = "vscodium"; + /** The {@link Path} of the legacy VSCode user-data folder relative to the workspace, still used by the workspace templates in the settings. */ + private static final Path LEGACY_USER_DATA = Path.of(".vscode", ".userdata"); + /** * The constructor. * @@ -80,15 +86,73 @@ public boolean installPlugin(ToolPluginDescriptor plugin, Step step, ProcessCont return false; } + /** + * @return the name of the VSCode profile used to isolate settings, extension state and authentication per IDEasy project and workspace. + */ + private String getProfileName() { + + return "ideasy-" + this.context.getProjectName() + "-" + this.context.getWorkspaceName(); + } + + /** + * @return the {@link Path} to the VSCode user-data folder passed via {@code --user-data-dir}. + */ + private Path getUserDataPath() { + + return getIdeMetadataPath().resolve("config"); + } + + @Override + public void configureWorkspace() { + + cleanupLegacyUserData(); + super.configureWorkspace(); + } + + /** + * Removes the legacy user-data folder from the workspace that VSCode does not read anymore (see #2142 and #2509). If the actual user-data folder does not + * yet exist, the legacy folder is moved there to preserve its content, otherwise it is backed up. + */ + private void cleanupLegacyUserData() { + + Path legacyUserData = this.context.getWorkspacePath().resolve(LEGACY_USER_DATA); + if (!Files.isDirectory(legacyUserData)) { + return; + } + FileAccess fileAccess = this.context.getFileAccess(); + Path userData = getUserDataPath(); + if (Files.exists(userData)) { + LOG.warn("Removing obsolete VSCode user-data folder {} from workspace since VSCode uses {}", legacyUserData, userData); + fileAccess.backup(legacyUserData); + } else { + LOG.info("Moving VSCode user-data folder {} out of workspace to {}", legacyUserData, userData); + fileAccess.mkdirs(userData.getParent()); + fileAccess.move(legacyUserData, userData); + } + } + + @Override + public Map getWorkspaceRedirects(Path workspaceFolder) { + + // the settings still provide the user settings template in the legacy location inside the workspace + return Map.of(workspaceFolder.resolve(LEGACY_USER_DATA), getUserDataPath()); + } + @Override protected void configureToolArgs(ProcessContext pc, ProcessMode processMode, List args) { if (this.context.getSystemInfo().isWsl()) { pc.withEnvVar("DONT_PROMPT_WSL_INSTALL", "1"); } - Path vsCodeConf = getIdeMetadataPath().resolve("config"); pc.addArg("--new-window"); - pc.addArg("--user-data-dir=" + vsCodeConf); + if (Boolean.TRUE.equals(IdeVariables.VSCODE_PROFILE_ENABLED.get(this.context))) { + // Use a named profile (not --user-data-dir) so VS Code keeps its IPC lock at the default location. + // This lets the OS-level vscode:// protocol handler (OAuth callbacks e.g. GitHub/Copilot) find the + // already-running IDEasy window. Each project and workspace gets its own profile for isolated auth and settings. + pc.addArg("--profile=" + getProfileName()); + } else { + pc.addArg("--user-data-dir=" + getUserDataPath()); + } Path vsCodeExtensionFolder = this.context.getIdeHome().resolve("plugins/vscode"); pc.addArg("--extensions-dir=" + vsCodeExtensionFolder); pc.addArg(this.context.getWorkspacePath()); diff --git a/cli/src/main/java/com/devonfw/tools/ide/url/model/AbstractUrlMetadata.java b/cli/src/main/java/com/devonfw/tools/ide/url/model/AbstractUrlMetadata.java index d83a934d..9990e220 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/url/model/AbstractUrlMetadata.java +++ b/cli/src/main/java/com/devonfw/tools/ide/url/model/AbstractUrlMetadata.java @@ -2,7 +2,7 @@ import java.util.List; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.folder.UrlEdition; import com.devonfw.tools.ide.url.model.folder.UrlTool; import com.devonfw.tools.ide.version.GenericVersionRange; @@ -15,26 +15,26 @@ public interface AbstractUrlMetadata { /** * @param tool the name of the tool. - * @return the sorted {@link List} of {@link com.devonfw.tools.ide.tool.ToolCommandlet#getConfiguredEdition() editions}. + * @return the sorted {@link List} of {@link AbstractToolCommandlet#getConfiguredEdition() editions}. */ List getSortedEditions(String tool); /** - * @param tool the {@link ToolCommandlet#getName() name of the tool}. - * @param edition the {@link ToolCommandlet#getConfiguredEdition() tool edition}. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param tool the {@link AbstractToolCommandlet#getName() name of the tool}. + * @param edition the {@link AbstractToolCommandlet#getConfiguredEdition() tool edition}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the {@link List} of {@link VersionIdentifier}s sorted descending so the latest version comes first and the oldest comes last. */ - List getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet); + List getSortedVersions(String tool, String edition, AbstractToolCommandlet toolCommandlet); /** * @param tool the name of the {@link UrlTool}. * @param edition the name of the {@link UrlEdition}. * @param version the {@link GenericVersionRange} to match. May be a {@link VersionIdentifier#isPattern() pattern}, a specific version or {@code null} for * the latest version. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the latest matching {@link VersionIdentifier} for the given {@code tool} and {@code edition}. */ - VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet); + VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/url/model/UrlMetadata.java b/cli/src/main/java/com/devonfw/tools/ide/url/model/UrlMetadata.java index 48167a71..04b55f22 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/url/model/UrlMetadata.java +++ b/cli/src/main/java/com/devonfw/tools/ide/url/model/UrlMetadata.java @@ -15,7 +15,7 @@ import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.os.SystemInfo; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.folder.UrlEdition; import com.devonfw.tools.ide.url.model.folder.UrlRepository; import com.devonfw.tools.ide.url.model.folder.UrlTool; @@ -96,7 +96,7 @@ public List getSortedEditions(String tool) { } @Override - public List getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet) { + public List getSortedVersions(String tool, String edition, AbstractToolCommandlet toolCommandlet) { String key = tool + "/" + edition; return this.toolEdition2VersionMap.computeIfAbsent(key, k -> computeSortedVersions(tool, edition)); @@ -126,11 +126,11 @@ private List computeSortedVersions(String tool, String editio * @param edition the name of the {@link UrlEdition}. * @param version the {@link GenericVersionRange} to match. May be a {@link VersionIdentifier#isPattern() pattern}, a specific version or {@code null} for * the latest version. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the latest matching {@link VersionIdentifier} for the given {@code tool} and {@code edition}. */ @Override - public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet) { + public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet) { List versions = getSortedVersions(tool, edition, toolCommandlet); return VersionIdentifier.resolveVersionPattern(version, versions); @@ -141,10 +141,10 @@ public VersionIdentifier resolveVersion(String tool, String edition, GenericVers * @param edition the name of the {@link UrlEdition}. * @param version the {@link GenericVersionRange} to match. May be a {@link VersionIdentifier#isPattern() pattern}, a specific version or {@code null} for * the latest version. - * @param toolCommandlet the {@link ToolCommandlet}. + * @param toolCommandlet the {@link AbstractToolCommandlet}. * @return the latest matching {@link UrlVersion} for the given {@code tool} and {@code edition}. */ - public UrlVersion getVersionFolder(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet) { + public UrlVersion getVersionFolder(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet) { VersionIdentifier resolvedVersion = resolveVersion(tool, edition, version, toolCommandlet); UrlVersion urlVersion = getEdition(tool, edition).getChild(resolvedVersion.toString()); diff --git a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java index 3fbb48c6..f9b32a26 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java +++ b/cli/src/main/java/com/devonfw/tools/ide/variable/IdeVariables.java @@ -7,6 +7,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.GitUrlSyntax; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; /** * Interface (mis)used to define all the available variables. @@ -22,6 +23,12 @@ public interface IdeVariables { /** {@link VariableDefinition} for {@link com.devonfw.tools.ide.context.IdeContext#getUserHome() HOME}. */ VariableDefinitionPath HOME = new VariableDefinitionPath("HOME", null, IdeContext::getUserHome, true); + /** + * {@link VariableDefinition} for the login name of the current user. Linux and macOS provide this as environment variable while Windows provides USERNAME + * instead, which is therefore used as fallback so that {@code $[USER]} works cross-platform. + */ + VariableDefinitionString USER = new VariableDefinitionString("USER", null, c -> c.getSystem().getEnv("USERNAME")); + /** {@link VariableDefinition} for {@link com.devonfw.tools.ide.context.IdeContext#getWorkspaceName() WORKSPACE}. */ VariableDefinitionString WORKSPACE = new VariableDefinitionString("WORKSPACE", null, IdeContext::getWorkspaceName, true); @@ -62,8 +69,7 @@ public interface IdeVariables { VariableDefinitionPath M2_REPO = new VariableDefinitionPath("M2_REPO", null, IdeVariables::getMavenRepositoryPath, false, true); /** - * {@link VariableDefinition} for {@link com.devonfw.tools.ide.tool.ToolCommandlet#getConfiguredEdition() edition} of - * {@link com.devonfw.tools.ide.tool.docker.Docker docker}. + * {@link VariableDefinition} for {@link AbstractToolCommandlet#getConfiguredEdition() edition} of {@link com.devonfw.tools.ide.tool.docker.Docker docker}. */ VariableDefinitionString DOCKER_EDITION = new VariableDefinitionString("DOCKER_EDITION", null, c -> "rancher"); @@ -126,13 +132,25 @@ public interface IdeVariables { /** {@link VariableDefinition} for support of overriding the default pycharm jvm options. */ VariableDefinitionString PYCHARM_VM_ARGS = new VariableDefinitionString("PYCHARM_VM_ARGS", null); + /** + * {@link VariableDefinition} to enable the isolation of VSCode via {@code --profile} instead of {@code --user-data-dir}. This is a feature toggle: while + * disabled (default) the legacy behaviour with {@code --user-data-dir} is used. See issue #2058 for details. + */ + VariableDefinitionBoolean VSCODE_PROFILE_ENABLED = new VariableDefinitionBoolean("VSCODE_PROFILE_ENABLED", null, + c -> Boolean.FALSE); + + /** {@link VariableDefinition} for the uv redirected cache directory */ + VariableDefinitionPath UV_CACHE_DIR = new VariableDefinitionPath("UV_CACHE_DIR", null, + c -> c.getIdePath() == null ? null : c.getIdePath().resolve(IdeContext.FOLDER_SOFTWARE).resolve("uv"), true, true); + /** A {@link Collection} with all pre-defined {@link VariableDefinition}s. */ - Collection> VARIABLES = List.of(PATH, HOME, WORKSPACE_PATH, IDE_HOME, IDE_ROOT, WORKSPACE, IDE_TOOLS, HTTP_VERSIONS, + Collection> VARIABLES = List.of(PATH, HOME, USER, WORKSPACE_PATH, IDE_HOME, IDE_ROOT, WORKSPACE, IDE_TOOLS, HTTP_VERSIONS, CREATE_START_SCRIPTS, IDE_MIN_VERSION, MVN_VERSION, M2_REPO, DOCKER_EDITION, MVN_BUILD_OPTS, NPM_BUILD_OPTS, NPM_CONFIG_USERCONFIG, GRADLE_BUILD_OPTS, GRADLE_USER_HOME, YARN_BUILD_OPTS, JASYPT_OPTS, MAVEN_ARGS, INTELLIJ_VM_ARGS, ANDROID_STUDIO_VM_ARGS, PYCHARM_VM_ARGS, + UV_CACHE_DIR, PROJECT_NAME, IDE_VARIABLE_SYNTAX_LEGACY_SUPPORT_ENABLED, PREFERRED_GIT_PROTOCOL); /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java b/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java index 589dcbd6..54ab565e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java +++ b/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java @@ -9,7 +9,7 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.cli.CliException; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -78,8 +78,8 @@ private VersionIdentifier(VersionSegment start) { * * @param version the version pattern to resolve * @param versions the - * {@link com.devonfw.tools.ide.tool.repository.ToolRepository#getSortedVersions(String, String, ToolCommandlet) available versions, sorted in descending - * order}. + * {@link com.devonfw.tools.ide.tool.repository.ToolRepository#getSortedVersions(String, String, AbstractToolCommandlet) available versions, sorted in + * descending order}. * @return the resolved version */ public static VersionIdentifier resolveVersionPattern(GenericVersionRange version, List versions) { diff --git a/cli/src/main/package/bin/ide.bat b/cli/src/main/package/bin/ide.bat index 00a95cc1..b83228cb 100644 --- a/cli/src/main/package/bin/ide.bat +++ b/cli/src/main/package/bin/ide.bat @@ -45,7 +45,12 @@ ideasy %IDE_OPTIONS% env >nul if %ERRORLEVEL% EQU 0 ( if "%~1" == "" ( - echo IDE environment variables have been set for %IDE_HOME% in workspace %WORKSPACE% + setlocal EnableDelayedExpansion + set "_workspaceMessage= with workspace set to !WORKSPACE!" + if not defined WORKSPACE_PATH set "_workspaceMessage=!_workspaceMessage! (fallback to default)" + if defined WORKSPACE_PATH if "!CD:%WORKSPACE_PATH%=!" == "!CD!" set "_workspaceMessage=!_workspaceMessage! (fallback to default)" + echo IDE environment variables have been set for !IDE_HOME!!_workspaceMessage! + endlocal ) ) diff --git a/cli/src/main/package/functions b/cli/src/main/package/functions index 266000ca..258cd71e 100644 --- a/cli/src/main/package/functions +++ b/cli/src/main/package/functions @@ -43,7 +43,11 @@ function ide() { if [ $? = 0 ]; then eval "${ide_env}" if [ $# = 0 ] && [ -n "${IDE_HOME}" ]; then - echo "IDE environment variables have been set for ${IDE_HOME} in workspace ${WORKSPACE}" + local workspace_message=" with workspace set to ${WORKSPACE}" + if [ -z "${WORKSPACE_PATH}" ] || [ "${PWD#"${WORKSPACE_PATH}"}" = "${PWD}" ]; then + workspace_message="${workspace_message} (fallback to default)" + fi + echo "IDE environment variables have been set for ${IDE_HOME}${workspace_message}" fi fi if _ide_is_cygwin && [ "$#" != 0 ]; then @@ -235,6 +239,22 @@ function claude() { fi } +function node() { + if [ -n "${IDE_HOME}" ]; then + ide node "$@" + else + command node "$@" + fi +} + +function npm() { + if [ -n "${IDE_HOME}" ]; then + ide npm "$@" + else + command npm "$@" + fi +} + _ide_create_project() { local found_create=false diff --git a/cli/src/main/package/functions.ps1 b/cli/src/main/package/functions.ps1 index 251f3a72..3bd5666f 100644 --- a/cli/src/main/package/functions.ps1 +++ b/cli/src/main/package/functions.ps1 @@ -98,7 +98,13 @@ function ide { if ($ideArguments.Count -eq 0 -and -not [string]::IsNullOrEmpty($env:IDE_HOME)) { - Write-Host "IDE environment variables have been set for $env:IDE_HOME in workspace $env:WORKSPACE" + $workspaceMessage = " with workspace set to $env:WORKSPACE" + $currentPath = (Get-Location).Path.TrimEnd('\', '/') + $workspacePath = $env:WORKSPACE_PATH.TrimEnd('\', '/') + if (-not $currentPath.StartsWith($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) { + $workspaceMessage += " (fallback to default)" + } + Write-Host "IDE environment variables have been set for $env:IDE_HOME$workspaceMessage" } } } @@ -306,6 +312,48 @@ function claude { & $claudeCommand.Source @args } +function node { + if (-not [string]::IsNullOrEmpty($env:IDE_HOME)) { + ide node @args + return + } + + # "node" is now a PowerShell function, so explicitly search for + # the external command to avoid recursively calling this function. + $nodeCommand = Get-Command node ` + -CommandType Application, ExternalScript ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($null -eq $nodeCommand) { + Write-Error "The command 'node' could not be found." + return + } + + & $nodeCommand.Source @args +} + +function npm { + if (-not [string]::IsNullOrEmpty($env:IDE_HOME)) { + ide npm @args + return + } + + # "npm" is now a PowerShell function, so explicitly search for + # the external command to avoid recursively calling this function. + $npmCommand = Get-Command npm ` + -CommandType Application, ExternalScript ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($null -eq $npmCommand) { + Write-Error "The command 'npm' could not be found." + return + } + + & $npmCommand.Source @args +} + function _ide_completion { $registerCommand = Get-Command Register-ArgumentCompleter -ErrorAction SilentlyContinue diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index e678805c..426772fd 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -9,6 +9,9 @@ cmd.build.detail=The `build` commandlet is an abstraction of build systems like cmd.build.val.args=Build arguments to be used when running a build job. cmd.cdk=Tool commandlet for AWS CDK. cmd.cdk.detail=The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework for defining cloud infrastructure in code and provisioning it through AWS CloudFormation. Detailed documentation can be found at https://docs.aws.amazon.com/cdk/v2/guide/home.html +cmd.check=Checks the repository for best-practices. +cmd.check.detail=Verifies that your current repository is sane and follows common best-practices avoiding anti-patterns. Issues can be fixed using the '--fix' option. +cmd.check.opt.--fix=Fixes issues where a fix is available. cmd.claude=Tool commandlet for Claude Code CLI. cmd.claude.detail=Claude Code CLI is a command-line interface for interacting with the Claude AI assistant. Detailed documentation can be found at https://code.claude.com/docs/en/overview cmd.cleanup=Commandlet to clean up the IDEasy installation by uninstalling all unused tools. @@ -17,7 +20,7 @@ cmd.complete=Internal commandlet for bash auto-completion. cmd.complete.detail=Run 'ide complete ' to activate the non-interactive autocompletion, replace with the arguments you want to autocomplete.\nE.g. type: 'ide complete in' to get 'install' and 'intellij' suggestions. cmd.copilot=Tool commandlet for GitHub Copilot CLI. cmd.copilot.detail=GitHub Copilot CLI is an AI-powered coding assistant that runs from the command line. Detailed documentation can be found at https://docs.github.com/copilot/how-tos/copilot-cli -cmd.corepack=Corepack for nodejs. +cmd.corepack=Tool commandlet for corepack (node package-manager installer). cmd.corepack.detail=Corepack will let you use Yarn and npm without having to install them. cmd.create=Create a new IDEasy project. cmd.create.detail=Creates a new IDEasy project in the projects directory (IDE_ROOT) using your custom project settings. If you want to have the default settings, you need to provide '-' as . If you omit it, you will get an interactive question for the git settings URL. @@ -107,6 +110,8 @@ cmd.node=Tool commandlet for Node.js (JavaScript runtime). cmd.node.detail=Node.js is a JavaScript runtime environment for server-side applications. Detailed documentation can be found at https://nodejs.org/en/docs/ cmd.npm=Tool commandlet for Npm (JavaScript Node Package Manager). cmd.npm.detail=npm (Node Package Manager) is used for managing JavaScript packages. Detailed documentation can be found at https://docs.npmjs.com/ +cmd.obsidian=Tool commandlet for Obsidian. +cmd.obsidian.detail=Obsidian is a knowledge base and note-taking application that works on a local folder of plain Markdown files. Detailed documentation can be found at https://help.obsidian.md/ cmd.oc=Tool commandlet for Openshift CLI (Kubernetes management tool). cmd.oc.detail=OpenShift CLI (oc) is a command-line tool for managing OpenShift clusters. Detailed documentation can be found at https://docs.openshift.com/ cmd.pgadmin=Tool commandlet for pgAdmin. @@ -187,7 +192,6 @@ cmd.yarn.detail=Yarn is a package manager and build tool for JavaScript. Detaile commandlets=Available commandlets: icd-hint=Hint: Use 'icd' command to easily navigate between your IDE home, projects, and workspaces. Type 'icd --help' for more details. opt.--batch=enable batch mode (non-interactive). -opt.--code=clone given code repository containing a settings folder into workspaces so that settings can be committed alongside code changes. opt.--debug=enable debug logging. opt.--force=enable force mode. opt.--force-plugin-reinstall=resets installed plugins to the project configuration diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index 8276ff9b..0775bc44 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -9,6 +9,9 @@ cmd.build.detail=Der `build`-Befehl ist eine Abstraktion von Build-Systemen wie cmd.build.val.args=Argumente die bei dem build Prozess genutzt werden sollen. cmd.cdk=Werkzeug Kommando für AWS CDK. cmd.cdk.detail=Das AWS Cloud Development Kit (AWS CDK) ist ein Open-Source-Softwareentwicklungsframework zur Modellierung und Bereitstellung von Cloud-Anwendungen mithilfe von bekannten Programmiersprachen. Detaillierte Dokumentation ist zu finden unter https://docs.aws.amazon.com/cdk/v2/guide/home.html +cmd.check=Prüft das Repository auf Best-Practices. +cmd.check.detail=Prüft, ob im das aktuelle Git-Repository plausibel ist und allgemeine Best-Practices einhält sowie Anti-Patterns vermeidet. Gefundene Probleme können mit '--fix' automatisch behoben werden. +cmd.check.opt.--fix=Behebt erkannte Probleme automatisch, sofern möglich. cmd.claude=Werkzeug Kommando für Claude Code CLI. cmd.claude.detail=Claude Code CLI ist ein KI-gestützter Programmierassistent, der über die Befehlszeile ausgeführt wird. Detaillierte Dokumentation ist zu finden unter https://code.claude.com/docs/de/overview cmd.cleanup=Werkzeug zum Aufräumen der IDEasy-Installation durch Deinstallieren aller ungenutzten Werkzeuge. @@ -17,7 +20,7 @@ cmd.complete=Internes Werkzeug für bash Autovervollständigung. cmd.complete.detail=Geben Sie 'ide complete ' in die Konsole ein um die einfache Autovervollständigung zu aktivieren, ersetzen Sie mit dem Ausdruck, der automatisch vervollständigt werden soll.\nZ.B. geben Sie einfach 'ide complete in' in die Konsole ein um 'install' und 'intellij' als Vorschläge zu erhalten. cmd.copilot=Werkzeug Kommando für GitHub Copilot CLI. cmd.copilot.detail=GitHub Copilot CLI ist ein KI-gestützter Programmierassistent, der über die Befehlszeile ausgeführt wird. Detaillierte Dokumentation ist zu finden unter https://docs.github.com/copilot/how-tos/copilot-cli -cmd.corepack=Corepack für nodejs. +cmd.corepack=Werkzeug Kommando für corepack (node package-manager installer). cmd.corepack.detail=Corepack ermöglicht die Verwendung von Yarn und npm ohne beide installieren zu müssen. cmd.create=Erstellt ein neues IDEasy Projekt. cmd.create.detail=Erstellt ein neues IDEasy-Projekt im Projektverzeichnis (IDE_ROOT) unter Verwendung Ihrer benutzerdefinierten Projekteinstellungen. Wenn Sie die Standardeinstellungen verwenden möchten, müssen Sie '-' als angeben. Wenn Sie '-' weglassen, erhalten Sie eine interaktive Abfrage für die Git-Settings-URL. @@ -107,6 +110,8 @@ cmd.node=Werkzeug Kommando für Node.js (JavaScript Laufzeitumgebung). cmd.node.detail=Node.js ist eine JavaScript-Laufzeitumgebung für serverseitige Anwendungen. Detaillierte Dokumentation ist zu finden unter https://nodejs.org/en/docs/ cmd.npm=Werkzeug Kommando für Npm (JavaScript Node Package Manager). cmd.npm.detail=npm wird zur Verwaltung von JavaScript-Paketen verwendet. Detaillierte Dokumentation ist zu finden unter https://docs.npmjs.com/ +cmd.obsidian=Werkzeug Kommando für Obsidian. +cmd.obsidian.detail=Obsidian ist eine Wissensdatenbank und Notizanwendung, die auf einem lokalen Ordner mit einfachen Markdown-Dateien arbeitet. Detaillierte Dokumentation ist zu finden unter https://help.obsidian.md/ cmd.oc=Werkzeug Kommando für Openshift CLI (Kubernetes Management Tool). cmd.oc.detail=OpenShift CLI (oc) ist ein Befehlszeilentool zur Verwaltung von OpenShift-Clustern. Detaillierte Dokumentation ist zu finden unter https://docs.openshift.com/ cmd.pgadmin=Werkzeug Kommando für pgAdmin. @@ -187,7 +192,6 @@ cmd.yarn.detail=Yarn ist ein Package Manager und Build-Werkzeug für JavaScript. commandlets=Verfügbare Kommandos: icd-hint=Hinweis: Verwenden Sie den Befehl 'icd' um einfach zwischen Ihrem IDE-Hauptverzeichnis, Projekten und Workspaces zu navigieren. Geben Sie 'icd --help' für weitere Details ein. opt.--batch=Aktiviert den Batch-Modus (nicht-interaktive Stapelverarbeitung). -opt.--code=Git-Repository sowohl als Code- als auch als Settings-Repository verwenden. opt.--debug=Aktiviert Debug-Ausgaben (Fehleranalyse). opt.--force=Aktiviert den Force-Modus (Erzwingen). opt.--force-plugin-reinstall=Setzt installierte Plugins zurück auf die Projektkonfiguration. diff --git a/cli/src/test/functions-test.sh b/cli/src/test/functions-test.sh index 2a671016..911fc8ba 100644 --- a/cli/src/test/functions-test.sh +++ b/cli/src/test/functions-test.sh @@ -21,6 +21,23 @@ trap 'rm -rf "${STUB_DIR}"' EXIT # minimal fake "ideasy" binary so sourcing the functions file has no side effects cat > "${STUB_DIR}/ideasy" <<'EOS' #!/usr/bin/env bash +if [[ " $* " == *" env "* ]]; then + if [ -n "${STUB_IDE_HOME:-}" ]; then + workspace="main" + workspace_path="${STUB_IDE_HOME}/workspaces/main" + case "${PWD}" in + "${STUB_IDE_HOME}"/workspaces/*) + workspace_prefix="${STUB_IDE_HOME}/workspaces/" + workspace="${PWD#${workspace_prefix}}" + workspace="${workspace%%/*}" + workspace_path="${STUB_IDE_HOME}/workspaces/${workspace}" + ;; + esac + echo "IDE_HOME=${STUB_IDE_HOME}" + echo "WORKSPACE=${workspace}" + echo "WORKSPACE_PATH=${workspace_path}" + fi +fi exit 0 EOS chmod +x "${STUB_DIR}/ideasy" @@ -74,6 +91,44 @@ check "legacy Git Bash (OSTYPE=msys, uname=MINGW) shows no warning" hidden msys # MSYS environment -> no warning check "MSYS environment shows no warning" hidden cygwin "MSYS_NT-10.0-26200" +echo +echo "Testing workspace fallback messages in ${FUNCTIONS_FILE}" + +MESSAGE_HOME="${STUB_DIR}/message-project" +mkdir -p "${MESSAGE_HOME}/workspaces/main" "${MESSAGE_HOME}/workspaces/foo" "${MESSAGE_HOME}/settings" + +runIdeMessage() { + local directory="$1" + STUB_IDE_HOME="${MESSAGE_HOME}" MESSAGE_DIRECTORY="${directory}" \ + FUNCTIONS_FILE="${FUNCTIONS_FILE}" STUB_DIR="${STUB_DIR}" \ + bash --noprofile --norc -c ' + export PATH="${STUB_DIR}:${PATH}" + source "${FUNCTIONS_FILE}" >/dev/null 2>&1 + cd "${MESSAGE_DIRECTORY}" || exit 99 + ide + ' +} + +checkMessage() { + local description="$1" directory="$2" expected="$3" + total=$((total + 1)) + local output + output="$(runIdeMessage "${directory}")" + if [[ "${output}" == *"${expected}"* ]]; then + doSuccess "PASSED: ${description}" + else + doError "FAILED: ${description} - expected '${expected}' but was '${output}'" + failed=$((failed + 1)) + fi +} + +checkMessage "project root marks the default workspace as fallback" "${MESSAGE_HOME}" \ + "with workspace set to main (fallback to default)" +checkMessage "main workspace does not mark a fallback" "${MESSAGE_HOME}/workspaces/main" \ + "with workspace set to main" +checkMessage "named workspace does not mark a fallback" "${MESSAGE_HOME}/workspaces/foo" \ + "with workspace set to foo" + echo echo "Testing icd -r repository navigation in ${FUNCTIONS_FILE}" diff --git a/cli/src/test/integration-tests/install-cdk.sh b/cli/src/test/integration-tests/install-cdk.sh index 5b93697f..3ce15457 100644 --- a/cli/src/test/integration-tests/install-cdk.sh +++ b/cli/src/test/integration-tests/install-cdk.sh @@ -10,4 +10,6 @@ else cdk_location="bin/" fi -assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node/${cdk_location}cdk" exists +# Since #2381, global npm packages are installed into the per-project software/node_modules prefix (not software/node). +# npm places the launcher in the prefix root on Windows but in /bin on POSIX (see Npm#setEnvironment). +assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node_modules/${cdk_location}cdk" exists diff --git a/cli/src/test/integration-tests/install-corepack.sh b/cli/src/test/integration-tests/install-corepack.sh index 66e2aeb2..2a7443d2 100755 --- a/cli/src/test/integration-tests/install-corepack.sh +++ b/cli/src/test/integration-tests/install-corepack.sh @@ -10,4 +10,6 @@ else corepack_location="bin/" fi -assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node/${corepack_location}corepack" exists +# Since #2381, global npm packages are installed into the per-project software/node_modules prefix (not software/node). +# npm places the launcher in the prefix root on Windows but in /bin on POSIX (see Npm#setEnvironment). +assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node_modules/${corepack_location}corepack" exists diff --git a/cli/src/test/integration-tests/install-nest.sh b/cli/src/test/integration-tests/install-nest.sh index ad5f9654..1316ec77 100644 --- a/cli/src/test/integration-tests/install-nest.sh +++ b/cli/src/test/integration-tests/install-nest.sh @@ -10,4 +10,6 @@ else nest_location="bin/" fi -assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node/${nest_location}nest" exists +# Since #2381, global npm packages are installed into the per-project software/node_modules prefix (not software/node). +# npm places the launcher in the prefix root on Windows but in /bin on POSIX (see Npm#setEnvironment). +assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node_modules/${nest_location}nest" exists diff --git a/cli/src/test/integration-tests/install-ng.sh b/cli/src/test/integration-tests/install-ng.sh index 7b82e924..dfc7c8f2 100644 --- a/cli/src/test/integration-tests/install-ng.sh +++ b/cli/src/test/integration-tests/install-ng.sh @@ -10,4 +10,6 @@ else ng_location="bin/" fi -assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node/${ng_location}ng" exists +# Since #2381, global npm packages are installed into the per-project software/node_modules prefix (not software/node). +# npm places the launcher in the prefix root on Windows but in /bin on POSIX (see Npm#setEnvironment). +assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node_modules/${ng_location}ng" exists diff --git a/cli/src/test/integration-tests/install-task.sh b/cli/src/test/integration-tests/install-task.sh index 3cef7b22..5df64992 100644 --- a/cli/src/test/integration-tests/install-task.sh +++ b/cli/src/test/integration-tests/install-task.sh @@ -9,4 +9,6 @@ else task_location="bin/" fi -assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node/${task_location}task" exists +# Since #2381, global npm packages are installed into the per-project software/node_modules prefix (not software/node). +# npm places the launcher in the prefix root on Windows but in /bin on POSIX (see Npm#setEnvironment). +assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node_modules/${task_location}task" exists diff --git a/cli/src/test/integration-tests/install-yarn.sh b/cli/src/test/integration-tests/install-yarn.sh index fd152b14..ec0986e0 100755 --- a/cli/src/test/integration-tests/install-yarn.sh +++ b/cli/src/test/integration-tests/install-yarn.sh @@ -10,4 +10,6 @@ else yarn_location="bin/" fi -assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node/${yarn_location}yarn" exists +# Since #2381, global npm packages are installed into the per-project software/node_modules prefix (not software/node). +# npm places the launcher in the prefix root on Windows but in /bin on POSIX (see Npm#setEnvironment). +assertThat "${IDE_ROOT}/${TEST_PROJECT_NAME}/software/node_modules/${yarn_location}yarn" exists diff --git a/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java b/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java index 896112f9..54b36afb 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; @@ -37,8 +37,8 @@ void testEnvOutsideProjectDoesNotLogCliExitException() { } /** - * Test that a {@link Commandlet#isProcessableOutput() processable-output} commandlet that throws inside {@link Commandlet#run() run} does not leak - * an ERROR-level error block ("An unexpected error occurred! … please file a bug") nor a "Logfile can be found at …" line into the captured log, while + * Test that a {@link AbstractCommandlet#isProcessableOutput() processable-output} commandlet that throws inside {@link AbstractCommandlet#run() run} does not + * leak an ERROR-level error block ("An unexpected error occurred! … please file a bug") nor a "Logfile can be found at …" line into the captured log, while * still marking the step as failed. *

        * Regression test: rethrowing the exception made {@link Ideasy#run(String...)} log the error at ERROR level into the machine-consumed (auto-completion) @@ -66,10 +66,10 @@ void testProcessableOutputCommandletFailureDoesNotLogError() { } /** - * A minimal {@link Commandlet} that produces processable output (like {@code complete}) but always fails, used to verify how a failure in such a + * A minimal {@link AbstractCommandlet} that produces processable output (like {@code complete}) but always fails, used to verify how a failure in such a * commandlet is reported. */ - private static final class ThrowingProcessableCommandlet extends Commandlet { + private static final class ThrowingProcessableCommandlet extends AbstractCommandlet { /** * @param context the {@link IdeContext}. @@ -144,8 +144,7 @@ void testVersionShortOption() { } /** - * Test that running 'ide' without arguments does not trigger any tool installation. - * Verifies fix for issue #1667. + * Test that running 'ide' without arguments does not trigger any tool installation. Verifies fix for issue #1667. */ @Test public void testRunWithoutArgumentsDoesNotTriggerInstallation() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java new file mode 100644 index 00000000..798d6622 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CheckCommandletTest.java @@ -0,0 +1,125 @@ +package com.devonfw.tools.ide.commandlet; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.commandlet.check.CheckCommandlet; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; + +/** + * Test of {@link CheckCommandlet} + */ +public class CheckCommandletTest extends AbstractIdeContextTest { + + /** + * Test that {@code ide check} does not fail if the CWD is not inside a git repository. + */ + @Test + void testCheckWithoutGitRepoOnlyWarns(@TempDir Path tempDir) { + + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = new CheckCommandlet(context); + + check.run(); + + assertThat(context).log().hasEntries( + IdeLogEntry.ofWarning("This does not seem to be a git repository (no .git folder found in " + tempDir + " or any parent directory).") + ); + } + + /** + * Test that {@code ide check} reports a missing .gitignore and fails (exit code 1) without --fix. + */ + @Test + void testCheckMissingGitignoreWithoutFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = new CheckCommandlet(context); + + assertThrows(CliException.class, check::run); + Path gitignore = tempDir.resolve(".gitignore"); + assertThat(gitignore).doesNotExist(); + assertThat(context).log().hasEntries(IdeLogEntry.ofWarning(gitignore + ": No .gitignore found in repository root.")); + } + + /** + * Test that {@code ide check --fix} creates a missing .gitignore with rules. + */ + @Test + void testCheckMissingGitignoreWithFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = new CheckCommandlet(context); + check.fix.setValue(true); + + check.run(); + + Path gitignore = tempDir.resolve(".gitignore"); + assertThat(gitignore).exists(); + List lines = Files.readAllLines(gitignore); + assertThat(lines).contains(".*", "!.gitignore"); + } + + /** + * Test that {@code ide check} detects missing rules in an existing .gitignore and fails without --fix. + */ + @Test + void testCheckExistingGitignoreMissingRulesWithoutFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + Files.writeString(tempDir.resolve(".gitignore"), "target/\n"); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = context.getCommandletManager().getCommandlet(CheckCommandlet.class); + + assertThrows(CliException.class, check::run); + List lines = Files.readAllLines(tempDir.resolve(".gitignore")); + assertThat(lines).containsExactly("target/"); + } + + /** + * Test that {@code ide check --fix} appends missing rules to an existing .gitignore. + */ + @Test + void testCheckExistingGitignoreMissingRulesWithFix(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + Files.writeString(tempDir.resolve(".gitignore"), "target/\n"); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = context.getCommandletManager().getCommandlet(CheckCommandlet.class); + check.fix.setValue(true); + + check.run(); + + List lines = Files.readAllLines(tempDir.resolve(".gitignore")); + assertThat(lines).contains("target/", ".*", "!.gitignore"); + } + + /** + * Test that {@code ide check} passes without issues if the .gitignore already has both baseline rule + */ + @Test + void testCheckPassesWhenGitignoreAlreadyValid(@TempDir Path tempDir) throws IOException { + + Files.createDirectory(tempDir.resolve(".git")); + Files.writeString(tempDir.resolve(".gitignore"), ".*\n!.gitignore\n"); + IdeTestContext context = newContext(tempDir); + CheckCommandlet check = context.getCommandletManager().getCommandlet(CheckCommandlet.class); + check.fix.setValue(true); + + check.run(); + + assertThat(context).log().hasEntries(IdeLogEntry.ofSuccess("No issues found.")); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java index 98534e72..683b986c 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java @@ -7,18 +7,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import com.devonfw.tools.ide.cli.CliArguments; import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; -import com.devonfw.tools.ide.context.ProcessContextGitMock; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.git.GitContextImplMock; +import com.devonfw.tools.ide.io.WindowsSymlinkTestHelper; import com.devonfw.tools.ide.version.IdeVersion; /** @@ -67,54 +65,9 @@ void testCreateCommandletRun() { assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists(); assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists(); assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists(); - } - - @ParameterizedTest - @ValueSource(strings = { "https://some-code-repository", "ssh://some-settings-repository" }) - void testWarningWhenRepoDoesNotMeetNamingConvention(String invalidRepo, @TempDir Path tempDir) { - // arrange - ProcessContextGitMock gitMock = new ProcessContextGitMock(context, tempDir); - context.setProcessContext(gitMock); - CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); - cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); - cc.codeRepositoryFlag.setValue(!invalidRepo.contains("code")); // raise conflict - cc.settingsRepo.setValue(invalidRepo); - cc.skipTools.setValue(true); - context.setAnswers("yes"); - // act - cc.run(); - // assert - assertThat(context).logAtInteraction().hasMessageContaining("Do you really want to create the project?"); - Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); - assertThat(newProjectPath).exists(); - assertThat(context.getIdeHome()).isEqualTo(newProjectPath); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists(); - } - - @Test - void testWarningWhenCodeRepoUsingDefaultMark(@TempDir Path tempDir) { - String invalidCodeRepo = "-"; - // arrange - ProcessContextGitMock gitMock = new ProcessContextGitMock(context, tempDir); - context.setProcessContext(gitMock); - CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); - cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); - cc.settingsRepo.setValue(invalidCodeRepo); - cc.codeRepositoryFlag.setValue(true); - cc.skipTools.setValue(true); - context.setAnswers("https://some-code-repository"); - // act - cc.run(); - // assert - assertThat(context).logAtWarning().hasMessageContaining("'-' is found after '--code'. This is invalid."); - Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); - assertThat(newProjectPath).exists(); - assertThat(context.getIdeHome()).isEqualTo(newProjectPath); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists(); - assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists(); + // the settings have to be cloned into the new project and not into the project the create command was started from + assertThat(newProjectPath.resolve(IdeContext.FOLDER_SETTINGS).resolve("ide.properties")).exists(); + assertThat(context.getIdeRoot().resolve("_ide/tmp/projects").resolve(NEW_PROJECT_NAME)).doesNotExist(); } @Test @@ -220,6 +173,54 @@ void testWelcomeMessageDisplayed() { assertThat(context).logAtInfo().hasMessageContaining("Welcome to your new IDEasy project!"); } + @Test + void testProjectWithInvalidRepositoryNotCreated() { + + // arrange - create a new project that is invalid (does not contain ide.properties file) + GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("pypi")); + + context.setGitContext(gitContextImplMock); + CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); + cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); + cc.settingsRepo.setValue(IdeContext.DEFAULT_SETTINGS_REPO_URL); + cc.skipTools.setValue(true); + + // act - run the create command + assertThatThrownBy(cc::run) + .isInstanceOf(CliException.class) + .hasMessageContaining( + "Fatal error while cloning settings: The settings health check failed: " + + "The given git repository URL does not point to a valid settings or code-settings repository. Please verify and try again."); + + // assert - if "ide create" fails then no project shall be created at all + assertThat(context.getIdeRoot().resolve(NEW_PROJECT_NAME)).doesNotExist(); + assertThat(context.getTempPath().resolve(IdeContext.FOLDER_PROJECTS).resolve(NEW_PROJECT_NAME)).doesNotExist(); + } + + @Test + void testCreateWithCodeSettingsRepository() { + + // arrange - a combined code and settings repository has the settings in a top-level "settings" folder + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("code-settings")); + context.setGitContext(gitContextImplMock); + CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class); + cc.newProject.setValueAsString(NEW_PROJECT_NAME, context); + cc.settingsRepo.setValue("https://github.com/devonfw/code-settings-repo.git"); + cc.skipTools.setValue(true); + + // act + cc.run(); + + // assert - the repository is placed into the workspace and IDE_HOME/settings is a symlink to its settings folder + Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); + Path codePath = newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN).resolve("code-settings-repo"); + assertThat(codePath.resolve(IdeContext.FOLDER_SETTINGS).resolve("ide.properties")).exists(); + Path settingsLink = newProjectPath.resolve(IdeContext.FOLDER_SETTINGS); + assertThat(settingsLink).isSymbolicLink(); + assertThat(settingsLink.resolve("ide.properties")).exists(); + } + @Test void testCreateWithDashPlaceholderAsCliArgument() { // arrange - see https://github.com/devonfw/IDEasy/issues/2106 @@ -234,7 +235,7 @@ void testCreateWithDashPlaceholderAsCliArgument() { assertThat(result).isEqualTo(0); assertThat(context).logAtError().hasNoMessageContaining("not found for commandlet"); assertThat(context).logAtInfo() - .hasMessageContaining("'-' was found for settings repository, the default settings repository"); + .hasMessageContaining("'-' was found for the repository, the default settings repository"); Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME); assertThat(newProjectPath).exists(); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java index 75b27cdb..d2b185ab 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java @@ -10,6 +10,7 @@ import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.tool.npm.Npm; /** * Test of {@link EnvironmentCommandlet}. @@ -80,6 +81,7 @@ void testRunDebugLogging() { new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export MAVEN_ARGS=\"-s ", true), // IdeLogEntry.ofProcessable("export NPM_CONFIG_USERCONFIG=\"" + normalize(context.getNpmConfigUserConfig()) + "\""), // new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export PATH=", true), // + IdeLogEntry.ofProcessable("export UV_CACHE_DIR=\"" + normalize(context.getIdeRoot().resolve("_ide/software/uv")) + "\""), // IdeLogEntry.ofProcessable("WORKSPACE=\"foo-test\""), // IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\""), // @@ -125,6 +127,7 @@ void testRunInfoLogging() { IdeLogEntry.ofProcessable("export MVN_HOME=\"" + softwarePath.resolve("mvn") + "\""), IdeLogEntry.ofProcessable("MVN_VERSION=\"3.9.1\""), // IdeLogEntry.ofProcessable("export NPM_CONFIG_USERCONFIG=\"" + normalize(context.getNpmConfigUserConfig()) + "\""), // + IdeLogEntry.ofProcessable("export NPM_HOME=\"" + context.getSoftwarePath() + FileSystems.getDefault().getSeparator() + "npm\""), // new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export PATH=", true), // IdeLogEntry.ofProcessable("SOME=\"some-${UNDEFINED}\""), // IdeLogEntry.ofProcessable("TEST_ARGS1=\" user1 settings1 workspace1 conf1\""), // @@ -141,8 +144,11 @@ void testRunInfoLogging() { IdeLogEntry.ofProcessable("TEST_ARGSb=\"user10 workspace10 settingsb user1 settings1 workspace1 conf1 user3 workspace3 confa userb\""), // IdeLogEntry.ofProcessable("TEST_ARGSc=\" user1 settings1 workspace1 conf1 userc settingsc confc\""), // IdeLogEntry.ofProcessable("TEST_ARGSd=\" user1 settings1 workspace1 conf1 userd workspaced\""), // + IdeLogEntry.ofProcessable("export UV_CACHE_DIR=\"" + normalize(context.getIdeRoot().resolve("_ide/software/uv")) + "\""), // IdeLogEntry.ofProcessable("WORKSPACE=\"foo-test\""), // - IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\"") // + IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\""), // + IdeLogEntry.ofProcessable( + "export npm_config_prefix=\"" + context.getSoftwarePath() + FileSystems.getDefault().getSeparator() + Npm.NPM_GLOBAL_FOLDER + "\"") // ); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/HelpCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/HelpCommandletTest.java index b2936a79..9cc957c8 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/HelpCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/HelpCommandletTest.java @@ -55,7 +55,7 @@ void testRun() { assertLogoMessage(context); assertThat(context).logAtSuccess().hasMessageContaining("Current version of IDE is"); assertThat(context).logAtInfo().hasMessage("Usage: ide [global-option]* [[commandlet] [local-option]* [arg]*]"); - for (Commandlet cmd : context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : context.getCommandletManager().getCommandlets()) { assertThat(context).log().hasMessageContaining(cmd.getName()); } assertThat(context).logAtInfo().hasMessageContaining("Hint: Use 'icd' command to easily navigate"); @@ -100,7 +100,7 @@ void testRunWithCommandlet() { } /** - * Ensure that for every {@link Commandlet} and each of their {@link Property} a help text is defined. + * Ensure that for every {@link AbstractCommandlet} and each of their {@link Property} a help text is defined. * * @param locale the {@link String} representation of the {@link Locale} to test. The empty {@link String} will be used for {@link Locale#ROOT}. */ @@ -114,7 +114,7 @@ void testEnsureAllNlsPropertiesPresent(String locale) throws IOException { NlsBundle bundle = new NlsBundle(context, Locale.forLanguageTag(locale)); SoftAssertions soft = new SoftAssertions(); // act - for (Commandlet commandlet : context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet commandlet : context.getCommandletManager().getCommandlets()) { String message = bundle.get(commandlet); soft.assertThat(message).doesNotStartWith("?"); String detail = bundle.getDetail(commandlet); diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java index 310c7304..2ec13f08 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/RepositoryCommandletTest.java @@ -6,9 +6,11 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliAbortException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.git.GitContextImplMock; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; import com.devonfw.tools.ide.io.FileAccess; @@ -86,6 +88,103 @@ void testSetupAllRepositoriesInactive() { assertThat(context).logAtInfo().hasMessage("Skipping repository test because it is not active, use --force-repositories to setup all repositories ..."); } + @Test + void testInactiveRepositoryIsNotResolved() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + Properties properties = createDefaultProperties(); + properties.setProperty("git_url", "https://github.com/$[UNDEFINED_VARIABLE]/" + TEST_GIT_REPO + ".git"); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + saveProperties(context, properties); + // act + rc.run(); + // assert + assertThat(context).logAtInfo().hasMessage("Skipping repository test because it is not active, use --force-repositories to setup all repositories ..."); + // an inactive repository must not be resolved at all so that an expression like @ask-variable never asks the user for a repository that is skipped + assertThat(context).logAtWarning().hasNoMessageContaining("Undefined variable $[UNDEFINED_VARIABLE]"); + } + + @Test + void testActiveExpressionIsEvaluatedOnlyOnce() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + Properties properties = createDefaultProperties(); + // an expression with an empty variable name always asks and is never persisted, so a second evaluation would ask again + properties.setProperty("active", "@ask-variable('', 'Setup this repository?')"); + // exactly one answer is available: if the property is evaluated twice, readLine fails with "End of answers reached!" + context.setAnswers("true"); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + saveProperties(context, properties); + // act + rc.run(); + // assert + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).isDirectory(); + } + + @Test + void testInvalidExpressionDoesNotPreventOtherRepositories() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + Properties broken = createDefaultProperties(); + broken.setProperty("active", "true"); + broken.setProperty("path", "broken-repo"); + // @ask-variable requires at least one argument, so resolving this git_url throws a CliException + broken.setProperty("git_url", "https://github.com/@ask-variable()/broken.git"); + saveProperties(context, broken, "broken.properties"); + Properties good = createDefaultProperties(); + good.setProperty("active", "true"); + saveProperties(context, good); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + // act + rc.run(); + // assert + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).isDirectory(); + assertThat(context).logAtError().hasMessageContaining("Invalid template expression"); + } + + @Test + void testAbortIsNotIgnoredWhenSettingUpAllRepositories() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + // in batch mode without force mode a question cannot be asked, so evaluating the active flag aborts + context.getStartContext().setBatchMode(true); + Properties properties = createDefaultProperties(); + properties.setProperty("active", "@ask-variable('', 'Setup this repository?')"); + saveProperties(context, properties); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + // act & assert + assertThatThrownBy(rc::run).isInstanceOf(CliAbortException.class); + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).doesNotExist(); + } + + @Test + void testCircularVariableDoesNotPreventOtherRepositories() { + + // arrange + IdeTestContext context = newContext(IdeContext.FOLDER_REPOSITORY); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set("REPO_A", "$[REPO_B]"); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set("REPO_B", "$[REPO_A]"); + Properties broken = createDefaultProperties(); + broken.setProperty("active", "true"); + broken.setProperty("path", "broken-repo"); + // the two variables reference each other, so resolving this git_url exceeds the maximum recursion + broken.setProperty("git_url", "https://github.com/$[REPO_A]/broken.git"); + saveProperties(context, broken, "broken.properties"); + Properties good = createDefaultProperties(); + good.setProperty("active", "true"); + saveProperties(context, good); + RepositoryCommandlet rc = context.getCommandletManager().getCommandlet(RepositoryCommandlet.class); + // act + rc.run(); + // assert + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES).resolve(TEST_WORKSPACE).resolve(TEST_REPO)).isDirectory(); + assertThat(context).logAtError().hasMessageContaining("Reached maximum recursion"); + } + @Test void testSetupSpecificRepositoryWithoutPath() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java index 958ea390..4ee4ef86 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java @@ -14,8 +14,10 @@ import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.migration.IdeMigrator; import com.devonfw.tools.ide.os.SystemInfo; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.version.VersionIdentifier; /** * Test of {@link StatusCommandlet}. @@ -123,6 +125,46 @@ void testStatusWhenPkixIssueInMessage() { assertThat(context).logAtInteraction().hasMessageContaining("proxy-support.adoc#tls-certificate-issues"); } + /** + * Tests that {@link StatusCommandlet} reports an outdated project with a message that is understandable for end-users and does not expose the internal + * migration target version. + */ + @Test + void testStatusWhenProjectNeedsMigration() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setProjectVersion(VersionIdentifier.of("2025.01.001-beta")); + VersionIdentifier targetVersion = new IdeMigrator().getTargetVersion(); + StatusCommandlet status = context.getCommandletManager().getCommandlet(StatusCommandlet.class); + + // act + status.run(); + + // assert + assertThat(context).logAtInteraction().hasMessage("Your project '" + context.getProjectName() + + "' was created or last updated with IDEasy version 2025.01.001-beta and needs an update.\nPlease run 'ide update' to migrate your project."); + assertThat(context).log().hasNoMessageContaining(targetVersion.toString()); + } + + /** + * Tests that {@link StatusCommandlet} does not report a required project update if the project is already up-to-date. + */ + @Test + void testStatusWhenProjectIsUpToDate() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setProjectVersion(new IdeMigrator().getTargetVersion()); + StatusCommandlet status = context.getCommandletManager().getCommandlet(StatusCommandlet.class); + + // act + status.run(); + + // assert + assertThat(context).log().hasNoMessageContaining("needs an update"); + } + /** * Tests that the output of {@link StatusCommandlet} does not contain the username when run with active privacy mode on all OS (windows, linux, WSL, mac). * diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/TestCommandletManager.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/TestCommandletManager.java index 33c457c7..f0af653a 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/TestCommandletManager.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/TestCommandletManager.java @@ -3,7 +3,7 @@ import com.devonfw.tools.ide.context.IdeContext; /** - * Extends {@link CommandletManagerImpl} to make {@link #add(Commandlet)} method visible for testing and mocking. + * Extends {@link CommandletManagerImpl} to make {@link #add(AbstractCommandlet)} method visible for testing and mocking. */ public class TestCommandletManager extends CommandletManagerImpl { @@ -16,7 +16,7 @@ public TestCommandletManager(IdeContext context) { } @Override - public void add(Commandlet commandlet) { + public void add(AbstractCommandlet commandlet) { super.add(commandlet); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java index 626d0b0a..a1efb803 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java @@ -7,11 +7,14 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; +import com.devonfw.tools.ide.git.GitContext; +import com.devonfw.tools.ide.git.GitContextMock; import com.devonfw.tools.ide.tool.java.Java; import com.devonfw.tools.ide.tool.mvn.Mvn; import com.devonfw.tools.ide.variable.IdeVariables; @@ -25,7 +28,7 @@ class UpdateCommandletTest extends AbstractIdeContextTest { private static final String PROJECT_UPDATE = "update"; - private static final String SUCCESS_UPDATE_SETTINGS = "Successfully ended step 'update (pull) settings repository'."; + private static final String SUCCESS_UPDATE_SETTINGS = "Successfully ended step 'Update settings repository'."; private static final String SUCCESS_INSTALL_OR_UPDATE_SOFTWARE = "Install or update software"; @Test @@ -155,4 +158,57 @@ void testRunUpdateSoftwareDoesNotFailWhenSettingsPathIsDeleted(WireMockRuntimeIn assertThat(context).logAtSuccess().hasMessage(SUCCESS_UPDATE_SETTINGS); assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE); } + + /** + * Tests that a settings folder that exists but is not a git repository is backed up and cloned from scratch after the user confirmed. + */ + @Test + void testRunUpdateWithBrokenSettingsFolder() { + + // arrange + IdeTestContext context = newContext(PROJECT_UPDATE); + Path settingsPath = context.getSettingsPath(); + // remove the '.git' folder so the settings are present but broken + context.getFileAccess().delete(settingsPath.resolve(GitContext.GIT_FOLDER)); + UpdateCommandlet update = context.getCommandletManager().getCommandlet(UpdateCommandlet.class); + // first answer confirms the backup of the broken settings, second answer picks the default settings repository + context.setAnswers("yes", "-"); + + // act + update.run(); + + // assert + assertThat(context).logAtSuccess().hasMessage(SUCCESS_UPDATE_SETTINGS); + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS)).exists(); + assertThat(settingsPath.resolve(GitContext.GIT_FOLDER)).exists(); + assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE); + } + + /** + * Tests that a failing "git pull" (e.g. due to an error of a custom git server) only fails the settings step while the software is still installed. + *

        + * See: #2335 for reference. + */ + @Test + void testRunUpdateContinuesWhenPullFails() { + + // arrange + IdeTestContext context = newContext(PROJECT_UPDATE); + context.setGitContext(new GitContextMock(context) { + @Override + public void pull(Path repository) { + + throw new IllegalStateException("git pull failed due to an error of the custom git server"); + } + }); + UpdateCommandlet update = context.getCommandletManager().getCommandlet(UpdateCommandlet.class); + + // act + update.run(); + + // assert + assertThat(context).logAtError().hasMessage("Step 'Applying settings' ended with failure."); + assertThat(context).log().hasNoMessage(SUCCESS_UPDATE_SETTINGS); + assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java b/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java index 2fff021f..2e9a6822 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java @@ -13,10 +13,12 @@ import org.junit.jupiter.params.provider.ValueSource; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemInfo; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.tool.npm.Npm; /** * Tests of {@link SystemPath}. @@ -145,6 +147,49 @@ void testFindBinaryFindsNothingWithoutFilter() { assertThat(result).isEqualTo(test); } + @Test + void testPrecedenceToolBinPrecedesBundlingRuntimeBinForBothLookupAndPathString() throws IOException { + // arrange - node is a flat install (no bin/), so node's own bundled npm shadows the pristine npm unless npm is ordered first + // copyForMutation=true: this test creates files, so it must work on a scratch copy (in target/), never the committed fixtures + IdeTestContext context = newContext("find-binary", "project/workspaces", true); + Path npmTool = context.getSoftwarePath().resolve("npm"); + Files.createDirectories(npmTool.resolve("bin")); + Files.writeString(npmTool.resolve("bin/npm"), "pristine-npm"); + Path nodeTool = context.getSoftwarePath().resolve("node"); + Files.createDirectories(nodeTool); + Files.writeString(nodeTool.resolve("npm"), "node-bundled-npm"); + SystemPath systemPath = new SystemPath(context, "", context.getIdeRoot(), context.getSoftwarePath(), ';', new ArrayList<>()); + Path pristine = npmTool.resolve("bin").resolve("npm"); + Path bundled = nodeTool.resolve("npm"); + + // act & assert - findBinary resolves the pristine npm, not the node-bundled one + assertThat(systemPath.findBinary(Path.of("npm"))).isEqualTo(pristine).isNotEqualTo(bundled); + // act & assert - the pristine npm bin is also placed before the node dir in the PATH string + String pathString = systemPath.toString(); + assertThat(pathString.indexOf(npmTool.resolve("bin").toString())) + .isNotNegative().isLessThan(pathString.indexOf(nodeTool.toString())); + } + + @Test + void testGlobalNpmPackagesInSoftwareNpmGlobalAreOnPath() throws IOException { + // arrange - npm's per-project global prefix is software/node_modules. npm places its global shims in /bin on POSIX but in the prefix root + // on Windows, so the generic software scan (bin/ subdir if present, else the tool dir) picks the right entry on both platforms without special-casing. + IdeTestContext context = newContext(PROJECT_BASIC, "project/workspaces", true); + Path globalBin = context.getSystemInfo().isWindows() ? context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER) + : context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER).resolve(IdeContext.FOLDER_BIN); + Path globalPackage = globalBin.resolve("fakeglobal"); + Files.createDirectories(globalBin); + Files.writeString(globalPackage, "fake"); + SystemPath systemPath = new SystemPath(context, "", context.getIdeRoot(), context.getSoftwarePath(), ';', new ArrayList<>()); + + // act + Path resolved = systemPath.findBinary(Path.of("fakeglobal")); + + // assert - the globally installed npm package is resolvable through the generic software PATH scan + assertThat(resolved).isEqualTo(globalPackage); + assertThat(systemPath.toString()).contains(globalBin.toString()); + } + @Test void testFindBinaryFindsBinaryInExtraPathEntries() throws IOException { // arrange diff --git a/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java b/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java index eaa00527..ad5b0fe5 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/completion/CompleteTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import com.devonfw.tools.ide.cli.CliArguments; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.ContextCommandlet; import com.devonfw.tools.ide.context.AbstractIdeContext; import com.devonfw.tools.ide.context.AbstractIdeContextTest; @@ -189,7 +189,7 @@ private static List getExpectedCandidates(AbstractIdeContext context, bo } } if (commandlets) { - for (Commandlet cmd : context.getCommandletManager().getCommandlets()) { + for (AbstractCommandlet cmd : context.getCommandletManager().getCommandlets()) { expectedCandidates.add(cmd.getName()); if (addAlias) { Property firstProperty = cmd.getValues().get(0); @@ -407,8 +407,8 @@ void testSynonymFilteringWithProvidedSynonym() { } /** - * Test that completion works for a second tool argument (e.g. "ide mvn clean [tab]"), which is the real-world scenario - * that previously failed because the multivalued arguments property consumed the completion marker greedily. + * Test that completion works for a second tool argument (e.g. "ide mvn clean [tab]"), which is the real-world scenario that previously failed because the + * multivalued arguments property consumed the completion marker greedily. */ @Test void testCompleteMavenSecondToolArgument() { @@ -425,4 +425,101 @@ void testCompleteMavenSecondToolArgument() { assertThat(candidates.stream().map(CompletionCandidate::text)) .contains("dependency:list", "dependency:tree", "deploy"); } + + /** + * Verifies that {@code exec:exec} is not suggested when {@code exec:java} has already been provided as an alternative command. + */ + @Test + void testAlternativeFilteringWhenOtherAlternativeProvidedJava() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:java", "" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + List texts = candidates.stream().map(CompletionCandidate::text).toList(); + assertThat(texts).doesNotContain("exec:exec"); + } + + /** + * Verifies that {@code exec:java} is not suggested when {@code exec:exec} has already been provided as an alternative command. + */ + @Test + void testAlternativeFilteringWhenOtherAlternativeProvidedExec() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:exec", "" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + List texts = candidates.stream().map(CompletionCandidate::text).toList(); + assertThat(texts).doesNotContain("exec:java"); + } + + /** + * Test that an entry with an unsatisfied dependency is not suggested. + */ + @Test + void testDependencyNotSatisfiedIsNotSuggested() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "-Dexec.main" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + assertThat(candidates.stream().map(CompletionCandidate::text)).doesNotContain("-Dexec.mainClass="); + } + + /** + * Test that an entry with a satisfied dependency is suggested. + */ + @Test + void testDependencySatisfiedIsSuggested() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:java", "-Dexec.mainCla" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + assertThat(candidates.stream().map(CompletionCandidate::text)).contains("-Dexec.mainClass="); + } + + /** + * Test that an entry with an OR-dependency is suggested if any alternative of the group is provided. + */ + @Test + void testDependencyOrGroupSatisfiedByEitherAlternative() { + + // arrange + AbstractIdeContext context = newContext(PROJECT_BASIC, null, false); + String[] argsArray = { "mvn", "exec:exec", "-Dexec.arg" }; + CliArguments args = CliArguments.ofCompletion(argsArray); + CompletionCandidateCollector collector = createCollector(context, argsArray); + + // act + List candidates = context.complete(args, collector, true); + + // assert + assertThat(candidates.stream().map(CompletionCandidate::text)).contains("-Dexec.args="); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefaultTest.java b/cli/src/test/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefaultTest.java index b6e034de..92597b20 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefaultTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/completion/CompletionCandidateCollectorDefaultTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.Test; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.VersionCommandlet; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; @@ -16,7 +16,7 @@ class CompletionCandidateCollectorDefaultTest extends AbstractIdeContextTest { /** - * Test of {@link CompletionCandidateCollectorDefault#addAllMatches(String, String[], Property, Commandlet)} + * Test of {@link CompletionCandidateCollectorDefault#addAllMatches(String, String[], Property, AbstractCommandlet)} */ @Test void testAddAllMatches() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java b/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java index b02d2edf..9625191b 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/AbstractIdeTestContext.java @@ -9,7 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; import com.devonfw.tools.ide.commandlet.TestCommandletManager; import com.devonfw.tools.ide.common.SystemPath; @@ -374,9 +374,9 @@ public void setCommandletManager(CommandletManager commandletManager) { } /** - * @param commandlet the {@link Commandlet} to add to {@link #getCommandletManager()} for testing. + * @param commandlet the {@link AbstractCommandlet} to add to {@link #getCommandletManager()} for testing. */ - public void addCommandlet(Commandlet commandlet) { + public void addCommandlet(AbstractCommandlet commandlet) { if (this.testCommandletManager == null) { setCommandletManager(new TestCommandletManager(this)); @@ -416,7 +416,7 @@ protected Path findBashInWindowsRegistry() { } @Override - protected boolean isWriteLogfile(Commandlet cmd) { + protected boolean isWriteLogfile(AbstractCommandlet cmd) { return false; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextMock.java new file mode 100644 index 00000000..e221c007 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/context/CapturingProcessContextMock.java @@ -0,0 +1,60 @@ +package com.devonfw.tools.ide.context; + +import java.util.ArrayList; +import java.util.List; + +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessContextImpl; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.process.ProcessResultImpl; + +/** + * Mock {@link ProcessContext} that captures executed commands for testing without actually running them. + */ +public class CapturingProcessContextMock extends ProcessContextImpl { + + private final List executedCommands = new ArrayList<>(); + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public CapturingProcessContextMock(IdeContext context) { + super(context); + } + + @Override + public ProcessContext createChild() { + + return this; + } + + @Override + public ProcessResult run(ProcessMode processMode) { + String executable = this.executable.toString(); + List args = this.arguments; + String command = executable + " " + String.join(" ", args); + this.executedCommands.add(command); + + this.arguments.clear(); + this.executable = null; + + return new ProcessResultImpl("bash", command, ProcessResult.SUCCESS, true, List.of()); + } + + /** + * @return the list of captured commands that would have been executed. + */ + public List getExecutedCommands() { + return this.executedCommands; + } + + /** + * @return the arguments added since the last {@link #run(ProcessMode) run}. + */ + public List getArgs() { + return this.arguments; + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java b/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java index ee55d7c6..03a6fa60 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/environment/EnvironmentVariablesTest.java @@ -301,4 +301,32 @@ void testPlainVariableIsNotMasked() { assertThat(context.getSecretLineCount()).isZero(); } + /** + * Test of {@link EnvironmentVariables#inverseResolve(String, Object)} that variables which resolve to an empty value (e.g. + * {@link IdeVariables#HTTP_VERSIONS} when not configured) are skipped instead of being substituted. Replacing an empty + * value with a variable expression used to corrupt the value by inserting the expression between every character. + */ + @Test + void testInverseResolveSkipsVariablesResolvedToEmptyValue() { + + // arrange + IdeTestContext context = newContext(ENVIRONMENT_PROJECT, null, false); + EnvironmentVariables variables = context.getVariables(); + + // IDE_TOOLS must resolve to a non-empty value, while HTTP_VERSIONS must resolve to the empty string (its + // default when not configured) - the input that used to corrupt the result when inverseResolve ran + String ideTools = variables.get(IdeVariables.IDE_TOOLS.getName()); + assertThat(ideTools).isNotEmpty(); + assertThat(variables.get(IdeVariables.HTTP_VERSIONS.getName())).isEmpty(); + + // the editor value is similar to the one of a main.prefs produced by PropertiesMerger.inverseMerge + String editor = "notepad --wait " + ideTools; + + // act + String inverseResolved = variables.inverseResolve(editor, "test"); + + // assert + assertThat(inverseResolved).isEqualTo("notepad --wait $[IDE_TOOLS]"); + } + } diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java index c5f6aa18..a677a3fa 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextImplMock.java @@ -65,8 +65,7 @@ public void clone(GitUrl gitUrl, Path repository) { } @Override - public String determineRemote(Path repository) { - + public String determineTrackedRemote(Path repository) { return DEFAULT_REMOTE; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java index d794f0ad..86e660e4 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java @@ -20,7 +20,11 @@ */ public class GitContextMock extends GitContextImpl { - private static final String MOCKED_URL_VALUE = "mocked url value"; + /** + * Fallback URL for repositories without a mocked {@code .git/config} - has to be a valid git URL that is not the default settings URL, so both the settings + * health check and tool settings substitution (e.g. Maven) run succesfully (they check URL validity). + */ + private static final String MOCKED_URL_VALUE = "https://github.com/devonfw/mocked-settings.git"; /** Filename used to persist mocked remotes inside the {@code .git} folder. */ private static final String REMOTES_FILE = "remotes.properties"; @@ -57,6 +61,9 @@ public void clone(GitUrl gitUrl, Path repository) { FileAccess fileAccess = this.context.getFileAccess(); fileAccess.mkdirs(repository); + // Create ide.properties to simulate a valid repository + fileAccess.touch(repository.resolve("ide.properties")); + Path gitFolder = repository.resolve(GIT_FOLDER); fileAccess.mkdirs(gitFolder); String branch = gitUrl.branch(); @@ -256,11 +263,11 @@ public String determineCurrentBranch(Path repository) { } @Override - public String determineRemote(Path repository) { + public String determineTrackedRemote(Path repository) { return DEFAULT_REMOTE; } -/** + /** * Adds pending commits to simulate remote changes. Commits are stored per repository and applied on pull. * * @param repository the repository the commits belong to diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java index 745e1802..a2fad525 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextTest.java @@ -1,7 +1,5 @@ package com.devonfw.tools.ide.git; -import static org.junit.jupiter.api.Assertions.assertThrows; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -36,6 +34,9 @@ class GitContextTest extends AbstractIdeContextTest { */ private class GitContextMock extends GitContextImpl { + /** Simulates the remote the current branch tracks via {@code branch..remote}; {@code null} simulates a branch without upstream. */ + private String trackedRemote = DEFAULT_REMOTE; + /** * @param context the {@link IdeContext context}. */ @@ -47,15 +48,30 @@ public GitContextMock(IdeContext context) { public Path findGitRequired() { return Path.of("git"); } + + @Override + public String determineTrackedRemote(Path repository) { + return this.trackedRemote; + } + + /** + * @param trackedRemote the remote the current branch tracks, or {@code null} to simulate a branch without upstream. + */ + public void setTrackedRemote(String trackedRemote) { + this.trackedRemote = trackedRemote; + } } + private GitContextMock gitContextMock; + private IdeTestContext newGitContext(Path dir) { IdeTestContext context = newContext(dir); context.getNetworkStatus().simulateOnline(); this.processContext = new ProcessContextGitMock(context, dir); context.setProcessContext(processContext); - context.setGitContext(new GitContextMock(context)); + this.gitContextMock = new GitContextMock(context); + context.setGitContext(this.gitContextMock); return context; } @@ -74,7 +90,7 @@ void testRunGitCloneInOfflineModeThrowsException(@TempDir Path tempDir) { context.getStartContext().setOfflineMode(true); // act - CliException e1 = assertThrows(CliException.class, () -> { + CliException e1 = catchThrowableOfType(CliException.class, () -> { context.getGitContext().pullOrClone(gitUrl, tempDir); }); // assert @@ -112,8 +128,6 @@ void testRunGitPullWithoutForce(@TempDir Path tempDir) { // arrange String gitRepoUrl = "https://github.com/test"; IdeTestContext context = newGitContext(tempDir); - OutputMessage outputMessage = new OutputMessage(false, "test-remote"); - this.processContext.addOutputMessage(outputMessage); FileAccess fileAccess = new FileAccessImpl(context); Path gitFolderPath = tempDir.resolve(GitContext.GIT_FOLDER); fileAccess.mkdirs(gitFolderPath); @@ -123,6 +137,45 @@ void testRunGitPullWithoutForce(@TempDir Path tempDir) { assertThat(tempDir.resolve(GitContext.GIT_FOLDER).resolve("update")).hasContent(this.processContext.getNow().toString()); } + /** + * Runs a simulated git pull on a repository whose current branch tracks a non-default remote (as it would in a repository with multiple remotes) and checks + * that the pull is performed instead of asking the user to continue. See issue #840. + * + * @param tempDir a {@link TempDir} {@link Path}. + */ + @Test + void testRunGitPullWithTrackedRemote(@TempDir Path tempDir) { + + // arrange - a non-default tracked remote, as in a repository with multiple remotes + String gitRepoUrl = "https://github.com/test"; + IdeTestContext context = newGitContext(tempDir); + this.gitContextMock.setTrackedRemote("upstream"); + FileAccess fileAccess = new FileAccessImpl(context); + fileAccess.mkdirs(tempDir.resolve(GitContext.GIT_FOLDER)); + // act + context.getGitContext().pullOrClone(GitUrl.of(gitRepoUrl), tempDir); + // assert + assertThat(tempDir.resolve(GitContext.GIT_FOLDER).resolve("update")).hasContent(this.processContext.getNow().toString()); + } + + /** + * Runs a simulated git pull on a repository without any remote and checks that the user is asked whether to continue. + * + * @param tempDir a {@link TempDir} {@link Path}. + */ + @Test + void testRunGitPullWithoutRemoteAsksToContinue(@TempDir Path tempDir) { + + // arrange + String gitRepoUrl = "https://github.com/test"; + IdeTestContext context = newGitContext(tempDir); + this.gitContextMock.setTrackedRemote(null); + FileAccess fileAccess = new FileAccessImpl(context); + fileAccess.mkdirs(tempDir.resolve(GitContext.GIT_FOLDER)); + // act + assert (no answers are configured, so asking a question fails) + assertThatThrownBy(() -> context.getGitContext().pullOrClone(GitUrl.of(gitRepoUrl), tempDir)).isInstanceOf(IllegalStateException.class); + } + /** * Runs a git pull with force mode, creates temporary files to simulate a proper cleanup. * @@ -186,7 +239,7 @@ void testRunGitPullWithForceStartsCleanup(@TempDir Path tempDir) { @Test void testGitRepoIsRecognizedCorrectly(@TempDir Path tempDir) { String gitRepoUrl = "https://github.com/test"; - + IdeTestContext context = newGitContext(tempDir); GitContext gitContext = context.getGitContext(); @@ -199,7 +252,7 @@ void testGitRepoIsRecognizedCorrectly(@TempDir Path tempDir) { void testNormalDirIsNoRepo(@TempDir Path tempDir) { IdeTestContext context = newGitContext(tempDir); GitContext gitContext = context.getGitContext(); - + FileAccess fileAccess = context.getFileAccess(); fileAccess.mkdirs(tempDir.resolve("new-folder")); diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java b/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java index 06e512f1..bba18aae 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/repository/RepositoryPropertiesTest.java @@ -8,6 +8,9 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; + /** * Test of {@link RepositoryProperties}. */ @@ -27,6 +30,10 @@ class RepositoryPropertiesTest { private static final String URL_EXAMPLE_REPO = "https://example.com/repo.git"; private static final String URL_SSH_DEVONFW = "git@github.com:devonfw/ide-settings.git"; + // Test variable used to verify resolution of variables and expressions + private static final String VARIABLE_GIT_USER = "GIT_USER"; + private static final String GIT_USER = "quando632"; + @Test void testGetId() { @@ -57,6 +64,18 @@ private static RepositoryProperties properties(String filename, Properties prope return new RepositoryProperties(Path.of(filename), properties); } + private static RepositoryProperties properties(String filename, Properties properties, IdeTestContext context) { + + return new RepositoryProperties(Path.of(filename), properties, context); + } + + private static IdeTestContext contextWithVariable(String name, String value) { + + IdeTestContext context = new IdeTestContext(); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set(name, value); + return context; + } + @Test void testGetRemotesEmpty() { @@ -144,4 +163,72 @@ void testGetRemotesSshUrl() { assertThat(remotes.get(0).name()).isEqualTo(REMOTE_NAME_UPSTREAM); assertThat(remotes.get(0).url()).isEqualTo(URL_SSH_DEVONFW); } + + @Test + void testGitUrlResolvesVariable() { + + IdeTestContext context = contextWithVariable(VARIABLE_GIT_USER, GIT_USER); + Properties props = new Properties(); + props.setProperty("git_url", "https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getGitUrl()).isEqualTo("https://github.com/" + GIT_USER + "/IDEasy.git"); + } + + @Test + void testGitRemoteResolvesVariable() { + + IdeTestContext context = contextWithVariable(VARIABLE_GIT_USER, GIT_USER); + Properties props = new Properties(); + props.setProperty("git_remote", REMOTE_NAME_FORK + ":https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + List remotes = repositoryProperties.getRemotes(); + assertThat(remotes).hasSize(1); + assertThat(remotes.get(0).name()).isEqualTo(REMOTE_NAME_FORK); + assertThat(remotes.get(0).url()).isEqualTo("https://github.com/" + GIT_USER + "/IDEasy.git"); + } + + @Test + void testPathResolvesVariableBeforeSanitizing() { + + IdeTestContext context = contextWithVariable(VARIABLE_GIT_USER, GIT_USER); + Properties props = new Properties(); + props.setProperty("path", "repos/$[" + VARIABLE_GIT_USER + "]"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getPath()).isEqualTo("repos/" + GIT_USER); + } + + @Test + void testUndefinedVariableIsKept() { + + IdeTestContext context = new IdeTestContext(); + Properties props = new Properties(); + props.setProperty("git_url", "https://github.com/$[UNDEFINED_VARIABLE]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getGitUrl()).isEqualTo("https://github.com/$[UNDEFINED_VARIABLE]/IDEasy.git"); + } + + @Test + void testLegacyCurlySyntaxIsNotResolved() { + + IdeTestContext context = contextWithVariable("project.version", "1.2.3"); + Properties props = new Properties(); + props.setProperty("build_cmd", "mvn -Dversion=${project.version} clean install"); + RepositoryProperties repositoryProperties = properties("test.properties", props, context); + + assertThat(repositoryProperties.getBuildCmd()).isEqualTo("mvn -Dversion=${project.version} clean install"); + } + + @Test + void testWithoutContextNothingIsResolved() { + + Properties props = new Properties(); + props.setProperty("git_url", "https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + RepositoryProperties repositoryProperties = properties("test.properties", props); + + assertThat(repositoryProperties.getGitUrl()).isEqualTo("https://github.com/$[" + VARIABLE_GIT_USER + "]/IDEasy.git"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java b/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java index a620211a..2a3996a9 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java @@ -24,10 +24,14 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoImpl; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.process.ProcessContext; /** * Test of {@link FileAccessImpl}. @@ -799,6 +803,49 @@ void testUnzipFilePermissionsSkippedOnSimulatedWindows(@TempDir Path tempDir) { } } + /** + * Test of {@link FileAccessImpl#extractDmg(Path, Path)} with a progress bar for copying the mounted app. + */ + @Test + void testExtractDmgWithProgressBar(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = newContext(tempDir); + context.setIdeHome(tempDir); + context.setSystemInfo(SystemInfoMock.MAC_X64); + ProcessContext processContext = Mockito.mock(ProcessContext.class); + context.setProcessContext(processContext); + Path appPath = context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME).resolve("MyApp.app"); + Path resourceFile = appPath.resolve("Contents/Resources/resource.txt"); + Path executableFile = appPath.resolve("Contents/MacOS/myapp"); + Files.createDirectories(resourceFile.getParent()); + Files.createDirectories(executableFile.getParent()); + Files.writeString(resourceFile, "x".repeat(150_000)); + Files.writeString(executableFile, "#!/bin/sh\necho myapp\n"); + boolean posix = !SystemInfoImpl.INSTANCE.isWindows(); + if (posix) { + Files.setPosixFilePermissions(executableFile, PosixFilePermissions.fromString("rwxr-xr-x")); + } + long appSize = Files.size(resourceFile) + Files.size(executableFile); + Path target = tempDir.resolve("target"); + Path targetApp = target.resolve(appPath.getFileName()); + + // act + context.getFileAccess().extractDmg(tempDir.resolve("MyApp.dmg"), target); + + // assert + assertThat(targetApp.resolve(appPath.relativize(resourceFile))).hasSameBinaryContentAs(resourceFile); + assertThat(targetApp.resolve(appPath.relativize(executableFile))).hasSameBinaryContentAs(executableFile); + if (posix) { + assertPosixFilePermissions(targetApp.resolve(appPath.relativize(executableFile)), "rwxr-xr-x"); + } + IdeProgressBarTestImpl progressBar = context.getProgressBarMap().get(IdeProgressBar.TITLE_COPYING); + assertThat(progressBar).isNotNull(); + assertThat(progressBar.getMaxSize()).isEqualTo(appSize); + assertThat(progressBar.getCurrentProgress()).isEqualTo(appSize); + assertThat(progressBar.getEventList()).hasSizeGreaterThan(2); + } + /** * Test of {@link FileAccessImpl#generatePermissionString(int)}. */ diff --git a/cli/src/test/java/com/devonfw/tools/ide/merge/IniMergerTest.java b/cli/src/test/java/com/devonfw/tools/ide/merge/IniMergerTest.java new file mode 100644 index 00000000..f0839551 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/IniMergerTest.java @@ -0,0 +1,74 @@ +package com.devonfw.tools.ide.merge; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.environment.EnvironmentVariablesPropertiesMock; +import com.devonfw.tools.ide.environment.EnvironmentVariablesType; + +/** + * Test of {@link IniMerger}. + */ +class IniMergerTest extends AbstractIdeContextTest { + + /** + * Creates a clean {@link EnvironmentVariables} instance (same approach as {@code XmlMergerTest}) so that {@code resolve} does not wrap plain values, + * allowing the test to assert on the exact merged values. + * + * @param context the {@link IdeTestContext} to base the mock variables on. + * @return the clean {@link EnvironmentVariables} to use for merging. + */ + private EnvironmentVariables cleanVariables(IdeTestContext context) { + + EnvironmentVariablesPropertiesMock mockVariables = new EnvironmentVariablesPropertiesMock(null, EnvironmentVariablesType.SETTINGS, context); + return mockVariables.resolved(); + } + + /** + * Tests that {@link IniMerger#merge} merges the update template into the existing workspace: values that exist in both are overridden by the update template, + * workspace-only values are preserved (user modifications), and update-only values are added. + * + * @param tempDir the temporary folder to use as workspace for this test. + * @throws Exception on error. + */ + @Test + void testMergeOverridesSharedKeepsWorkspaceAndAddsUpdateKeys(@TempDir Path tempDir) throws Exception { + + // arrange + IdeTestContext context = new IdeTestContext(); + EnvironmentVariables variables = cleanVariables(context); + IniMerger iniMerger = new IniMerger(context); + + Path workspaceIni = tempDir.resolve("settings.ini"); + Files.writeString(workspaceIni, """ + [editor] + editor.fontSize = 12 + editor.tabSize = 4 + """); + + Path updateIni = tempDir.resolve("update.ini"); + Files.writeString(updateIni, """ + [editor] + editor.tabSize = 2 + editor.wordWrap = on + """); + + // act + iniMerger.merge(null, updateIni, variables, workspaceIni); + + // assert + String result = Files.readString(workspaceIni); + // update template wins over the workspace for the shared key + assertThat(result).contains("editor.tabSize = 2"); + // workspace-only value is preserved (not overwritten, not dropped) + assertThat(result).contains("editor.fontSize = 12"); + // update-only value is added to the workspace + assertThat(result).contains("editor.wordWrap = on"); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/merge/PropertiesMergerTest.java b/cli/src/test/java/com/devonfw/tools/ide/merge/PropertiesMergerTest.java new file mode 100644 index 00000000..254a5dd2 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/PropertiesMergerTest.java @@ -0,0 +1,195 @@ +package com.devonfw.tools.ide.merge; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.io.FileAccess; + +/** + * Test of {@link PropertiesMerger}. + */ +class PropertiesMergerTest extends AbstractIdeContextTest { + + /** + * Test of {@link PropertiesMerger#doMerge(Path, Path, EnvironmentVariables, Path)} with existing workspace and update file. + * + * @param workspaceDir the temporary folder to use as workspace for this test. + * @throws Exception on error. + */ + @Test + void testMergeWithExistingWorkspaceAndExistingUpdate(@TempDir Path workspaceDir) throws Exception { + //arrange + IdeContext context= newContext(PROJECT_BASIC,null,false); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + Path workspace= workspaceDir.resolve("workspace.prefs"); + Files.writeString(workspace, "theme=dark\n"); + Path update = workspaceDir.resolve("update.prefs"); + Files.writeString(update, "ui=linux\n"); + Path setup = workspaceDir.resolve("setup.prefs"); + Files.writeString(setup, "editor=vi\n"); + //act + propertiesMerger.doMerge(setup, update, context.getVariables(), workspace); + //assert + Properties result= context.getFileAccess().readProperties(workspace); + assertThat(result).containsEntry("theme", "dark"); + assertThat(result).containsEntry("ui", "linux"); + } + @Test + void testMergeWithExistingWorkspaceAndMissingUpdate(@TempDir Path workspaceDir) + throws Exception { + + // arrange + IdeContext context = newContext(PROJECT_BASIC, null, false); + PropertiesMerger propertiesMerger = new PropertiesMerger(context); + + Path workspace = workspaceDir.resolve("workspace.prefs"); + Files.writeString(workspace, "theme=light\n"); + + Path setup = workspaceDir.resolve("setup.prefs"); + Files.writeString(setup, "theme=dark\n"); + + Path update = workspaceDir.resolve("missing-update.prefs"); + + String before = Files.readString(workspace); + + // act + propertiesMerger.doMerge( + setup, + update, + context.getVariables(), + workspace); + + // assert + String after = Files.readString(workspace); + + assertThat(after).isEqualTo(before); + } + + @Test + void testMergeTwoPropertyFiles(@TempDir Path workspaceDir){ + //arrange + IdeContext context = newContext(PROJECT_BASIC, null, false); + DirectoryMerger merger = context.getWorkspaceMerger(); + + Path templates = Path.of("src/test/resources/templates"); + Path setup = templates.resolve(IdeContext.FOLDER_SETUP); + Path update = templates.resolve(IdeContext.FOLDER_UPDATE); + merger.merge(setup, update, context.getVariables(), workspaceDir); + + FileAccess fileAccess = context.getFileAccess(); + Path workspaceMain = workspaceDir.resolve("main.prefs"); + + //assert + Properties result = fileAccess.readProperties(workspaceMain); + assertThat(result).containsEntry("theme", "dark").containsEntry("ui", "classic").containsEntry("java.version", "1.11"); + } + + @Test + void testInverseMergeRestoresOriginal(@TempDir Path workspaceDir)throws Exception{ + //arrange + IdeContext context = newContext(PROJECT_BASIC, null, false); + DirectoryMerger merger = context.getWorkspaceMerger(); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + FileAccess fileAccess = context.getFileAccess(); + Path templates = Path.of("src/test/resources/templates"); + Path setup = templates.resolve(IdeContext.FOLDER_SETUP); + Path update = templates.resolve(IdeContext.FOLDER_UPDATE); + merger.merge(setup, update, context.getVariables(), workspaceDir); + EnvironmentVariables environmentVariables = context.getVariables(); + + + Path workspaceMain = workspaceDir.resolve("main.prefs"); + Path updateMain= workspaceDir.resolve("update-main.prefs"); + Files.copy(update.resolve("main.prefs"), updateMain); + Properties workspaceProperties= fileAccess.readProperties(workspaceMain); + workspaceProperties.setProperty("theme", "light"); + workspaceProperties.setProperty("ui", "linux"); + workspaceProperties.setProperty("editor", "vi"); + //act + fileAccess.writeProperties(workspaceProperties, workspaceMain); + propertiesMerger.inverseMerge(workspaceMain, environmentVariables, true, updateMain); + //assert + Properties result= fileAccess.readProperties(updateMain); + assertThat(result).containsEntry("theme", "light").containsEntry("ui", "linux").containsEntry("editor", "vi") + .containsEntry("java.home", "${IDE_HOME}/software/java"); + } + @Test + void testConflictResolution(@TempDir Path workspaceDir)throws Exception{ + //arrange + IdeContext context = newContext(PROJECT_BASIC, null, false); + DirectoryMerger merger = context.getWorkspaceMerger(); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + FileAccess fileAccess = context.getFileAccess(); + Path templates = Path.of("src/test/resources/templates"); + Path setup = templates.resolve(IdeContext.FOLDER_SETUP); + Path update = templates.resolve(IdeContext.FOLDER_UPDATE); + merger.merge(setup, update, context.getVariables(), workspaceDir); + EnvironmentVariables environmentVariables = context.getVariables(); + + + Path workspaceMain = workspaceDir.resolve("main.prefs"); + Path updateMain= workspaceDir.resolve("update-main.prefs"); + Files.copy(update.resolve("main.prefs"), updateMain); + Properties workspaceProperties= fileAccess.readProperties(workspaceMain); + workspaceProperties.setProperty("java.version", "1.99"); + workspaceProperties.setProperty("theme", "light"); + fileAccess.writeProperties(workspaceProperties, workspaceMain); + //act + propertiesMerger.inverseMerge(workspaceMain, environmentVariables, false, updateMain); + //assert + Properties result = fileAccess.readProperties(updateMain); + assertThat(result).containsEntry("java.version", "1.99").containsEntry("java.home", "${IDE_HOME}/software/java").doesNotContainKey("theme"); + } + @Test + void testInverseMergeWithNonexistentWorkspace(@TempDir Path workspaceDir)throws Exception{ + IdeContext context = newContext(PROJECT_BASIC, null, false); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + Path workspaceMain = workspaceDir.resolve("missing.prefs"); + Path updateMain= workspaceDir.resolve("update.prefs"); + Files.createFile(updateMain); + assertThatCode(() -> propertiesMerger.inverseMerge(workspaceMain, context.getVariables(), false, updateMain)).doesNotThrowAnyException(); + assertThat(updateMain).isEmptyFile(); + } + @Test + void missingFilesTest(@TempDir Path workspaceDir)throws Exception{ + IdeContext context = newContext(PROJECT_BASIC, null, false); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + Path workspaceMain = workspaceDir.resolve("workspace.prefs"); + Files.createFile(workspaceMain); + Path updateMain= workspaceDir.resolve("missing-update.prefs"); + assertThatCode(()->propertiesMerger.inverseMerge(workspaceMain, context.getVariables(), true, updateMain)).doesNotThrowAnyException(); + } + @Test + void fileUnchangedTest(@TempDir Path workspaceDir)throws Exception{ + //arrange + IdeContext context = newContext(PROJECT_BASIC, null, false); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + Path workspaceMain = workspaceDir.resolve("workspace.prefs"); + Path updateMain= workspaceDir.resolve("update.prefs"); + Files.writeString(workspaceMain, ""); + Files.writeString(updateMain, "theme=dark\n"); + long sizeBefore=Files.size(updateMain); + //act + propertiesMerger.inverseMerge(workspaceMain, context.getVariables(), true, updateMain); + //assert + assertThat(Files.size(updateMain)).isEqualTo(sizeBefore); + } + @Test + void testUpgrade(@TempDir Path tempDir) throws Exception { + IdeContext context = newContext(PROJECT_BASIC, null, false); + PropertiesMerger propertiesMerger= new PropertiesMerger(context); + Path file = tempDir.resolve("test.properties"); + Files.writeString(file, "old"); + boolean modified= propertiesMerger.doUpgrade(file); + assertThat(modified).isFalse(); + assertThat(Files.readString(file)).isEqualTo("old"); + + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java b/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java index 57174c6b..0285ef21 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/process/ProcessContextImplTest.java @@ -12,6 +12,7 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -293,8 +294,8 @@ void windowsQuoteShouldThrowOnArgumentContainingDoubleQuote() throws Exception { } /** - * Verify that {@code windowsQuote} escapes the special {@code cmd.exe} metacharacters ({@code ^ & | > < %}) and surrounds the value with double quotes, - * so the argument is passed through as a single literal token. + * Verify that {@code windowsQuote} escapes the special {@code cmd.exe} metacharacters ({@code ^ & | > < %}) and surrounds the value with double quotes, so + * the argument is passed through as a single literal token. */ @Test void windowsQuoteShouldEscapeCommandInjectionCharactersAndWrapInDoubleQuotes() throws Exception { @@ -329,4 +330,19 @@ private String invokeWindowsQuote(String value) throws Exception { } } + /** + * Verify that the log message of a failing process contains the explicitly set working directory. + */ + @Test + void failingProcessShouldIncludeWorkingDirectoryInLogMessage() throws InterruptedException { + Path workingDirectory = Path.of("/opt/my-repo"); + when(this.processMock.waitFor()).thenReturn(ProcessResult.TOOL_NOT_INSTALLED); + this.processContextUnderTest.errorHandling(ProcessErrorHandling.LOG_WARNING); + this.processContextUnderTest.directory(workingDirectory); + + this.processContextUnderTest.run(ProcessMode.DEFAULT); + + assertThat(this.context).log(IdeLogLevel.WARNING).hasMessageContaining("Running command"); + assertThat(this.context).log(IdeLogLevel.WARNING).hasMessageContaining("in '" + workingDirectory + "'"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/property/LocalePropertyTest.java b/cli/src/test/java/com/devonfw/tools/ide/property/LocalePropertyTest.java index 8ccdac5f..2ccb473b 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/property/LocalePropertyTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/property/LocalePropertyTest.java @@ -5,6 +5,7 @@ import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.ContextCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidate; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; @@ -32,8 +33,7 @@ void testGermany() { } /** - * Test of - * {@link LocaleProperty#completeValue(String, IdeContext, com.devonfw.tools.ide.commandlet.Commandlet, CompletionCandidateCollector) auto-completion}. + * Test of {@link LocaleProperty#completeValue(String, IdeContext, AbstractCommandlet, CompletionCandidateCollector) auto-completion}. */ @Test void testCompletion() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/property/VersionPropertyTest.java b/cli/src/test/java/com/devonfw/tools/ide/property/VersionPropertyTest.java index 1105b1f4..4ac31151 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/property/VersionPropertyTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/property/VersionPropertyTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.Test; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.commandlet.HelpCommandlet; import com.devonfw.tools.ide.commandlet.InstallCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidate; @@ -29,7 +29,7 @@ void testParse() { } /** - * Test of {@link VersionProperty#completeValue(String, IdeContext, Commandlet, CompletionCandidateCollector)}. When a commandlet that does not handle + * Test of {@link VersionProperty#completeValue(String, IdeContext, AbstractCommandlet, CompletionCandidateCollector)}. When a commandlet that does not handle * versions is provided as argument, we except on versions as candidates to be returned. */ @Test @@ -44,8 +44,8 @@ void testCompleteValueUnfitCommandlet() { } /** - * Test of {@link VersionProperty#completeValue(String, IdeContext, Commandlet, CompletionCandidateCollector)}. When a pattern is provided as argument to be - * completed, we expect this argument to be kept as is, and given as the sole candidate. + * Test of {@link VersionProperty#completeValue(String, IdeContext, AbstractCommandlet, CompletionCandidateCollector)}. When a pattern is provided as argument + * to be completed, we expect this argument to be kept as is, and given as the sole candidate. */ @Test void testCompleteValuePatternGiven() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java index 0a5b87d1..9d1a0243 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java @@ -1,7 +1,11 @@ package com.devonfw.tools.ide.step; +import java.util.concurrent.atomic.AtomicBoolean; + import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliAbortException; +import com.devonfw.tools.ide.cli.CliFatalException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.log.IdeLogEntry; @@ -130,4 +134,92 @@ void testInvalidUsageErrorSuccess() { IdeLogEntry.ofDebug("Step 'Test-Step' ended successfully.")); } + @Test + void testRunSwallowsRegularError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act + boolean success = step.run(() -> { + throw new IllegalStateException("regular error"); + }); + // assert + assertThat(success).isFalse(); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testRunRethrowsForcedError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act & assert + assertThatThrownBy(() -> step.run(() -> { + throw new CliFatalException("fatal error"); + })).isInstanceOf(CliFatalException.class).hasMessage("fatal error"); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testCallReturnsFallbackOnRegularError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act + String result = step.call(() -> { + throw new IllegalStateException("regular error"); + }, () -> "fallback"); + // assert + assertThat(result).isEqualTo("fallback"); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testCallRethrowsForcedError() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + AtomicBoolean fallbackUsed = new AtomicBoolean(false); + // act & assert + assertThatThrownBy(() -> step.call(() -> { + throw new CliFatalException("fatal error"); + }, () -> { + fallbackUsed.set(true); + return "fallback"; + })).isInstanceOf(CliFatalException.class).hasMessage("fatal error"); + assertThat(step.isFailure()).isTrue(); + // the error is rethrown, so the fallback must not be returned + assertThat(fallbackUsed).isFalse(); + } + + @Test + void testRunRethrowsAbort() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act & assert + assertThatThrownBy(() -> step.run(() -> { + throw new CliAbortException(); + })).isInstanceOf(CliAbortException.class); + assertThat(step.isFailure()).isTrue(); + } + + @Test + void testCallRethrowsAbort() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, "project", false); + Step step = context.newStep("Test-Step"); + // act & assert + assertThatThrownBy(() -> step.call(() -> { + throw new CliAbortException(); + }, () -> "fallback")).isInstanceOf(CliAbortException.class); + assertThat(step.isFailure()).isTrue(); + } + } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/CveCheckInstallTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/CveCheckInstallTest.java index 786be3e8..ff925708 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/CveCheckInstallTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/CveCheckInstallTest.java @@ -12,7 +12,7 @@ import com.devonfw.tools.ide.tool.repository.ToolRepository; /** - * Test of {@link com.devonfw.tools.ide.url.model.file.json.Cve} checks in {@link LocalToolCommandlet}. + * Test of {@link com.devonfw.tools.ide.url.model.file.json.Cve} checks in {@link AbstractLocalToolCommandlet}. */ class CveCheckInstallTest extends AbstractIdeContextTest { diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java index ede37217..4eeaae5c 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java @@ -1,17 +1,27 @@ package com.devonfw.tools.ide.tool; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.CapturingProcessContextMock; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.os.WindowsAppInstallation; import com.devonfw.tools.ide.os.WindowsHelperMock; +import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -173,30 +183,227 @@ protected List getNativePackages() { protected String getBinaryName() { return TOOL_NAME; } + + @Override + protected boolean isPackageManagerAvailable(NativePackageManager packageManager) { + // apt is not on the PATH on Windows/macOS build machines, so pretend it is available to make the test OS-independent + return true; + } + } + + /** + * Verifies that calling {@link GlobalToolCommandlet#uninstall()} on Linux executes the uninstall commands via package manager. Uses a mock + * {@link ProcessContext} to capture executed commands without actually running them. + */ + @Test + void testUninstallExecutesPackageManagerCommandsAndLogsThem() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + PackageManagedToolCommandlet commandlet = new PackageManagedToolCommandlet(context); + + // Create a mock ProcessContext that captures commands but doesn't execute them + CapturingProcessContextMock mockProcessContext = new CapturingProcessContextMock(context); + context.setProcessContext(mockProcessContext); + + // act + commandlet.uninstall(); + + // assert: verify the commands were captured by our mock (no actual execution) + List executedCommands = mockProcessContext.getExecutedCommands(); + assertThat(executedCommands).hasSize(2); + assertThat(executedCommands.get(0)).contains("sudo apt -y autoremove --purge mytool"); + assertThat(executedCommands.get(1)).contains("sudo rm -f /etc/apt/sources.list.d/mytool.list"); + assertThat(context).log().hasEntries( + IdeLogEntry.ofInteraction("sudo apt -y autoremove --purge mytool"), + IdeLogEntry.ofInteraction("sudo rm -f /etc/apt/sources.list.d/mytool.list"), + IdeLogEntry.ofSuccess("Successfully uninstalled mytool")); } /** - * Verifies that {@link GlobalToolCommandlet#getUninstallPackageManagerCommands()} correctly derives uninstall commands from - * {@link GlobalToolCommandlet#getNativePackages()}. + * Verifies that an explicitly requested exact version is preserved for a Linux tool installed via a native package manager. */ @Test - void testGetUninstallPackageManagerCommandsDerivesFromNativePackages() { + void testResolveVersionForInstallKeepsExactVersionForLinuxNativePackage() { // arrange IdeTestContext context = newContext(PROJECT_BASIC); context.setSystemInfo(SystemInfoMock.LINUX_X64); PackageManagedToolCommandlet commandlet = new PackageManagedToolCommandlet(context); + VersionIdentifier version = VersionIdentifier.of("1.2.3"); + + // act + VersionIdentifier resolvedVersion = commandlet.resolveVersionForInstall("default", version); + + // assert + assertThat(resolvedVersion).isEqualTo(version); + } + + /** + * Verifies that a version pattern is not resolved by IDEasy for a Linux tool installed via a native package manager, so the package manager can determine the + * concrete version. + */ + @Test + void testResolveVersionForInstallReturnsNullForPatternOnLinuxNativePackage() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + PackageManagedToolCommandlet commandlet = new PackageManagedToolCommandlet(context); + VersionIdentifier version = VersionIdentifier.of("1.2.*"); + + // act + VersionIdentifier resolvedVersion = commandlet.resolveVersionForInstall("default", version); + + // assert + assertThat(resolvedVersion).isNull(); + } + + /** + * Dummy {@link GlobalToolCommandlet} that declares a Homebrew cask for testing macOS uninstall via {@link NativePackageManager#BREW_CASK}. + */ + static class BrewCaskToolCommandlet extends GlobalToolCommandlet { + + private static final String TOOL_NAME = "mytool"; + + BrewCaskToolCommandlet(IdeContext context) { + + super(context, TOOL_NAME, Set.of(Tag.MISC)); + } + + @Override + protected List getNativePackages() { + + return List.of(new NativePackage(NativePackageManager.BREW_CASK, List.of(TOOL_NAME))); + } + + @Override + protected String getBinaryName() { + return TOOL_NAME; + } + } + + /** + * Verifies that {@link GlobalToolCommandlet#getUninstallPackageManagerCommands()} derives a Homebrew cask uninstall command without a {@code sudo} prefix + * (Homebrew must never be run as root). + */ + @Test + void testGetUninstallPackageManagerCommandsDerivesBrewCaskWithoutSudo() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + BrewCaskToolCommandlet commandlet = new BrewCaskToolCommandlet(context); // act List uninstallCommands = commandlet.getUninstallPackageManagerCommands(); - // assert: exactly one command for APT + // assert assertThat(uninstallCommands).hasSize(1); PackageManagerCommand cmd = uninstallCommands.getFirst(); - assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.APT); - // The uninstall command includes the package removal and the cleanup command - assertThat(cmd.commands()).containsExactly( - "sudo apt -y autoremove --purge mytool", - "sudo rm -f /etc/apt/sources.list.d/mytool.list"); + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.BREW_CASK); + assertThat(cmd.commands()).containsExactly("brew uninstall --cask mytool"); + } + + /** + * Dummy {@link GlobalToolCommandlet} that declares a known macOS application bundle name but no package manager, for testing the *.app removal fallback of + * {@link GlobalToolCommandlet#uninstall()}. + */ + static class MacAppBundleToolCommandlet extends GlobalToolCommandlet { + + private static final String TOOL_NAME = "mytool"; + + MacAppBundleToolCommandlet(IdeContext context) { + + super(context, TOOL_NAME, Set.of(Tag.MISC)); + } + + @Override + protected String getBinaryName() { + return TOOL_NAME; + } + + @Override + public String getMacApplicationName() { + return "MyTool"; + } + } + + /** + * Verifies that on macOS, when no package manager can uninstall the tool, {@link GlobalToolCommandlet#uninstall()} removes the known *.app bundle from the + * user's Applications folder. + */ + @Test + void testUninstallOnMacRemovesKnownApplicationBundle() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + MacAppBundleToolCommandlet commandlet = new MacAppBundleToolCommandlet(context); + Path appBundle = context.getUserHome().resolve("Applications").resolve("MyTool.app"); + context.getFileAccess().mkdirs(appBundle); + + // act + commandlet.uninstall(); + + // assert + assertThat(appBundle).doesNotExist(); + assertThat(context).log().hasEntries(IdeLogEntry.ofSuccess("Successfully uninstalled mytool by removing " + appBundle)); + } + + /** + * Verifies that on macOS, when the known *.app bundle exists but cannot be deleted (e.g. because - since macOS Monterey - the Applications folder is + * protected and denies the operation), {@link GlobalToolCommandlet#uninstall()} does not let the resulting exception propagate but falls back to logging + * manual-uninstall guidance instead. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testUninstallOnMacFallsBackToGuidanceWhenApplicationBundleCannotBeDeleted() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + MacAppBundleToolCommandlet commandlet = new MacAppBundleToolCommandlet(context); + Path applicationsDir = context.getUserHome().resolve("Applications"); + Path appBundle = applicationsDir.resolve("MyTool.app"); + context.getFileAccess().mkdirs(appBundle); + // deleting an entry requires write permission on its *parent* directory - remove it to simulate macOS denying the deletion + Files.setPosixFilePermissions(applicationsDir, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_EXECUTE)); + + try { + // act + commandlet.uninstall(); + + // assert: deletion failed so the bundle is still there and we fell back to manual guidance instead of crashing + assertThat(appBundle).exists(); + assertThat(context).logAtWarning().hasMessageContaining("Could not automatically remove " + appBundle); + assertThat(context).logAtError().hasMessageContaining("Couldn't automatically uninstall mytool on macOS"); + } finally { + // restore write permission so the temp directory can be cleaned up afterwards + Files.setPosixFilePermissions(applicationsDir, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + } + } + + /** + * Verifies that on macOS, when neither a package manager nor a known *.app bundle can be found, {@link GlobalToolCommandlet#uninstall()} logs actionable + * manual-uninstall guidance instead of the previous generic "uninstall manually" error. + */ + @Test + void testUninstallOnMacLogsManualGuidanceWhenNothingFound() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + AsyncInstallerToolCommandlet commandlet = new AsyncInstallerToolCommandlet(context); + + // act + commandlet.uninstall(); + + // assert + assertThat(context).logAtError().hasMessageContaining( + "Couldn't automatically uninstall " + TOOL_NAME + " on macOS. Please uninstall it manually, e.g. by moving it from the Applications folder to the " + + "Trash"); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java index 04a82b7d..656fb69a 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/LocalToolCommandletTest.java @@ -21,7 +21,7 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest; /** - * Test of {@link LocalToolCommandlet}. + * Test of {@link AbstractLocalToolCommandlet}. */ @WireMockTest class LocalToolCommandletTest extends AbstractIdeContextTest { @@ -29,9 +29,9 @@ class LocalToolCommandletTest extends AbstractIdeContextTest { static final String JAVA_VERSION_FOR_INTELLIJ = "17.0.10_7"; /** - * Dummy commandlet extending {@link LocalToolCommandlet} for testing. + * Dummy commandlet extending {@link AbstractLocalToolCommandlet} for testing. */ - public static class LocalToolDummyCommandlet extends LocalToolCommandlet { + public static class LocalToolDummyCommandlet extends AbstractLocalToolCommandlet { LocalToolDummyCommandlet(IdeContext context) { @@ -40,14 +40,79 @@ public static class LocalToolDummyCommandlet extends LocalToolCommandlet { } /** - * Test {@link LocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with a long installation path, as commonly encountered on macOS systems. + * Dummy commandlet extending {@link AbstractLocalToolCommandlet} that tolerates a missing software version file (see + * {@link AbstractLocalToolCommandlet#isIgnoreMissingSoftwareVersionFile()}) and can determine its installed version from the installation itself (like + * python does). + * The repository lookups and the actual installation performed by {@link AbstractLocalToolCommandlet#installTool(ToolInstallRequest)} are short-circuited + * so the relevant branches can be tested in isolation without requiring the tool to be registered in the tool repository. + */ + public static class LocalToolRestoreVersionDummyCommandlet extends LocalToolDummyCommandlet { + + /** The version the dummy determines from the installation itself or {@code null} if it cannot determine it. */ + private final VersionIdentifier computedVersion; + + LocalToolRestoreVersionDummyCommandlet(IdeContext context) { + this(context, VersionIdentifier.of("9.9.9")); + } + + LocalToolRestoreVersionDummyCommandlet(IdeContext context, VersionIdentifier computedVersion) { + super(context); + this.computedVersion = computedVersion; + } + + @Override + protected boolean isIgnoreSoftwareRepo() { + + // like package-manager based tools (e.g. python) the installation lives directly in the software folder + return true; + } + + @Override + protected boolean isIgnoreMissingSoftwareVersionFile() { + + return true; + } + + @Override + protected VersionIdentifier cveCheck(ToolInstallRequest request) { + + return request.getRequested().getResolvedVersion(); + } + + @Override + protected void installToolDependencies(ToolInstallRequest request) { + + // the dummy tool has no dependencies + } + + /** + * @return the installed version of the dummy tool determined from the installation itself, or {@code null} if it cannot be determined. + */ + @Override + protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() { + + // as if the version was determined from the installation itself (e.g. by running the installed tool) + return this.computedVersion; + } + + @Override + protected void performToolInstallation(ToolInstallRequest request, Path installationPath) { + + // simulate a fresh installation without triggering a real download + context.writeVersionFile(request.getRequested().getResolvedVersion(), installationPath); + } + } + + /** + * Test {@link AbstractLocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with a long installation path, as commonly encountered on macOS + * systems. */ @Test void testGetValidInstalledSoftwareRepoPathWithLongPath() { // arrange Path installPath = Path.of("/projects/_ide/software/default/java/java/21.0.7_6/Contents/Resources/app/"); Path softwareRepoPath = Path.of("/projects/_ide/software/"); - Path expectedResultPath = Path.of("/projects/_ide/software/default/java/java/21.0.7_6/"); + Path expectedResultPath = Path.of("/projects/_ide/software/default/java/java/21.0.7_6/").toAbsolutePath().normalize(); IdeTestContext context = newContext(PROJECT_BASIC); LocalToolDummyCommandlet localToolCommandlet = new LocalToolDummyCommandlet(context); @@ -59,7 +124,7 @@ void testGetValidInstalledSoftwareRepoPathWithLongPath() { } /** - * Test {@link LocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with a valid installation path. + * Test {@link AbstractLocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with a valid installation path. */ @Test void testGetValidInstalledSoftwareRepoPathWithValidPath() { @@ -73,11 +138,11 @@ void testGetValidInstalledSoftwareRepoPathWithValidPath() { Path resultPath = localToolCommandlet.getValidInstalledSoftwareRepoPath(installPath, softwareRepoPath); // assert - assertThat(resultPath).isEqualTo(installPath); + assertThat(resultPath).isEqualTo(installPath.toAbsolutePath().normalize()); } /** - * Test {@link LocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with an installation path that is too short. + * Test {@link AbstractLocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with an installation path that is too short. */ @Test void testGetValidInstalledSoftwareRepoPathWithShortPath() { @@ -92,11 +157,11 @@ void testGetValidInstalledSoftwareRepoPathWithShortPath() { // assert assertThat(resultPath).isNull(); - assertThat(context).log().hasEntries(IdeLogEntry.ofWarning("The installation path is faulty " + installPath + ".")); + assertThat(context).log().hasEntries(IdeLogEntry.ofWarning("The installation path is not located within the software repository " + installPath + ".")); } /** - * Test {@link LocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with an installation path that completely differs from the software + * Test {@link AbstractLocalToolCommandlet#getValidInstalledSoftwareRepoPath(Path, Path)} with an installation path that completely differs from the software * repository path. */ @Test @@ -116,8 +181,8 @@ void testGetValidInstalledSoftwareRepoPathWithWrongPath() { } /** - * Test that {@link LocalToolCommandlet#run()} will ensure that a dependent tool is used in the correct version even if that version is not compatible with - * the project and has been removed after initial installation. + * Test that {@link AbstractLocalToolCommandlet#run()} will ensure that a dependent tool is used in the correct version even if that version is not compatible + * with the project and has been removed after initial installation. */ @Test void testRunToolWithDependencies() { @@ -137,7 +202,7 @@ void testRunToolWithDependencies() { } /** - * Test that {@link LocalToolCommandlet#installAsDependency(VersionRange, ToolInstallRequest)} ignores the configured project version when + * Test that {@link AbstractLocalToolCommandlet#installAsDependency(VersionRange, ToolInstallRequest)} ignores the configured project version when * {@link ToolInstallRequest#isIgnoreProject()} is {@code true}. */ @Test @@ -165,6 +230,105 @@ void testInstallAsDependencyIgnoresProject(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(context).logAtDebug().hasMessageContaining("Ignoring project for dependency"); } + /** + * Verifies that {@link AbstractLocalToolCommandlet#installTool(ToolInstallRequest)} re-installs an existing installation when the software version + * file is missing and the installed version cannot be determined. + *

        + * The dummy tool cannot recover its installed version from the installation itself ({@code computeInstalledVersionFromLocalSoftwareFolder()} returns + * {@code null}). Since the version remains unknown, the installation isconsidered broken and must be re-installed instead of being preserved. + */ + @Test + void testInstallToolReinstallsWhenInstalledVersionNotDetermined() { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + LocalToolRestoreVersionDummyCommandlet commandlet = new LocalToolRestoreVersionDummyCommandlet(context, null); + // an existing installation without a software version file for a tool that cannot determine its installed version + Path installationPath = context.getSoftwarePath().resolve("dummy"); + context.getFileAccess().mkdirs(installationPath); + context.getFileAccess().writeFileContent("tool content", installationPath.resolve("somefile.txt")); + Path versionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + assertThat(versionFile).doesNotExist(); + VersionIdentifier resolvedVersion = VersionIdentifier.of("1.2.3"); + ToolInstallRequest request = new ToolInstallRequest(false); + request.setRequested(new ToolEditionAndVersion(new ToolEdition("dummy", "dummy"), resolvedVersion)); + + // act + ToolInstallation installation = commandlet.installTool(request); + + // assert - the broken installation was re-installed (the dummy performToolInstallation writes the version file) + assertThat(installation.rootDir()).isEqualTo(installationPath); + assertThat(installation.newInstallation()).isTrue(); + assertThat(installation.resolvedVersion()).isEqualTo(resolvedVersion); + assertThat(versionFile).exists().hasContent("1.2.3"); + // assert - the tool was treated as broken (reinstalled), not as already installed and not as corrupted and deleted + assertThat(context).logAtWarning().hasMessageContaining("considered broken and will be reinstalled"); + assertThat(context).logAtWarning().hasNoMessageContaining("Deleting corrupted installation"); + } + + /** + * Verifies that a missing software version file is restored when the installed version can be determined from the installation itself. + *

        + * The tool reports its actual installed version via + * {@link AbstractLocalToolCommandlet#computeInstalledVersionFromLocalSoftwareFolder()}. That version is written back to the missing version file and the + * existing installation is kept if it matches the requested version. + */ + @Test + void testGetInstalledEditionAndVersionRestoresMissingVersionFile() { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + LocalToolRestoreVersionDummyCommandlet commandlet = new LocalToolRestoreVersionDummyCommandlet(context); + Path installationPath = context.getSoftwarePath().resolve("dummy"); + context.getFileAccess().mkdirs(installationPath); + Path versionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + assertThat(versionFile).doesNotExist(); + + // act - determine the installed edition and version with the version file missing + EditionAndVersion installed = commandlet.getInstalledEditionAndVersion(); + + // assert - the version was determined from the installation itself + assertThat(installed).isNotNull(); + assertThat(installed.version()).isEqualTo(VersionIdentifier.of("9.9.9")); + // assert - the missing version file was restored with the determined (actual) version + assertThat(versionFile).exists().hasContent("9.9.9"); + assertThat(context).logAtWarning().hasMessageContaining("restoring it with version 9.9.9"); + } + + /** + * Verifies that a missing software version file does not trigger a reinstallation when the installed version can be determined and matches the requested + * version. + *

        + * The version file is restored with the detected installed version and the existing installation is preserved. + */ + @Test + void testInstallToolKeepsInstallationWhenInstalledVersionIsRestored() { + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + LocalToolRestoreVersionDummyCommandlet commandlet = new LocalToolRestoreVersionDummyCommandlet(context); + // an existing installation without a software version file; the tool determines its installed version from the installation itself (9.9.9) + Path installationPath = context.getSoftwarePath().resolve("dummy"); + context.getFileAccess().mkdirs(installationPath); + context.getFileAccess().writeFileContent("tool content", installationPath.resolve("somefile.txt")); + Path versionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + assertThat(versionFile).doesNotExist(); + ToolInstallRequest request = new ToolInstallRequest(false); + request.setRequested(new ToolEditionAndVersion(new ToolEdition("dummy", "dummy"), VersionIdentifier.of("9.9.9"))); + + // act + ToolInstallation installation = commandlet.installTool(request); + + // assert - the version file was restored with the installed version determined from the installation itself + assertThat(versionFile).exists().hasContent("9.9.9"); + // assert - the installation was kept (not reinstalled, because installed version matches the requested version) + assertThat(installation.rootDir()).isEqualTo(installationPath); + assertThat(installation.newInstallation()).isFalse(); + assertThat(installation.resolvedVersion()).isEqualTo(VersionIdentifier.of("9.9.9")); + assertThat(installationPath.resolve("somefile.txt")).exists(); + // assert - the tool was not treated as broken and reinstalled + assertThat(context).logAtWarning().hasMessageContaining("restoring it with version 9.9.9"); + assertThat(context).logAtWarning().hasNoMessageContaining("considered broken and will be reinstalled"); + assertThat(context).logAtWarning().hasNoMessageContaining("Deleting corrupted installation"); + } + private static void runIntellijAndCheckInstallationWithJavaDependency(IdeTestContext context) { // arrange context.getTestStartContext().getEntries().clear(); // clear logs from previous run(s) diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java index b57c8d8e..76916f78 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; @@ -167,6 +168,26 @@ void testDnfUninstallCommand() { assertThat(cmd.commands()).containsExactly("sudo dnf remove -y pkg1", "sudo rm -f /etc/yum.repos.d/example.repo"); } + @Test + void testYayInstallCommand() { + NativePackage np = new NativePackage(NativePackageManager.YAY, List.of("rancher-desktop")); + + var cmd = NativePackageManager.YAY.install(np, "1.24.0"); + + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.YAY); + assertThat(cmd.commands()).containsExactly("yay -S --needed --noconfirm rancher-desktop"); + } + + @Test + void testYayUninstallCommand() { + NativePackage np = new NativePackage(NativePackageManager.YAY, List.of("rancher-desktop")); + + var cmd = NativePackageManager.YAY.uninstall(np); + + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.YAY); + assertThat(cmd.commands()).containsExactly("yay -Rs --noconfirm rancher-desktop"); + } + @Test void testVersionQueryCommandForDebianBasedPackageManager() { assertThat(NativePackageManager.APT.getVersionQueryCommand("pkg1")).containsExactly("dpkg-query", "-W", "-f=${db:Status-Status}|${Version}", @@ -192,4 +213,96 @@ void testParseVersionQueryOutputForRpmBasedPackageManager() { assertThat(NativePackageManager.YUM.parseVersionQueryOutput("1.0.0")).isEqualTo("1.0.0"); assertThat(NativePackageManager.DNF.parseVersionQueryOutput("1.0.0")).isEqualTo("1.0.0"); } + + @Test + void installUsesPackageNameAndVersionWhenNoArtifactPathConfigured() { + NativePackage nativePackage = new NativePackage(NativePackageManager.APT, List.of("docker-desktop")); + PackageManagerCommand result = NativePackageManager.APT.install(nativePackage, "1.2.3"); + + List commands = result.commands(); + String installCommand = commands.getLast(); + assertThat(installCommand).contains("apt", "install -y", "docker-desktop=1.2.3*"); + assertThat(installCommand).doesNotContain(".deb"); + } + + @Test + void installUsesArtifactPathWhenConfiguredInsteadOfPackageName() { + Path debPath = Path.of("/tmp/downloads/docker-desktop-4.34.0-amd64"); + + NativePackage nativePackage = new NativePackage(NativePackageManager.APT, List.of("docker-desktop"), null, null, null, List.of(debPath)); + + PackageManagerCommand result = NativePackageManager.APT.install(nativePackage, null); + + List commands = result.commands(); + String installCommand = commands.getLast(); + assertThat(installCommand).contains(debPath.toString()); + assertThat(installCommand).doesNotContain("docker-desktop="); + } + + @Test + void installFallsBackToPackagesWhenArtifactPathListIsEmpty() { + NativePackage nativePackage = new NativePackage(NativePackageManager.APT, List.of("docker-desktop"), null, null, null); + + PackageManagerCommand result = NativePackageManager.APT.install(nativePackage, null); + + List commands = result.commands(); + String installCommand = commands.getLast(); + assertThat(installCommand).contains("docker-desktop"); + } + + @Test + void installRejectsMismatchingPackageManager() { + NativePackage nativePackage = new NativePackage(NativePackageManager.APT, List.of("docker-desktop"), null, null, null); + + PackageManagerCommand result = NativePackageManager.APT.install(nativePackage, null); + + List commands = result.commands(); + String installCommand = commands.getLast(); + assertThat(installCommand).contains("docker-desktop"); + } + + @Test + void testBrewInstallAndUninstallCommandsDoNotUseSudo() { + NativePackage np = NativePackage.of(NativePackageManager.BREW, "pkg1"); + + var installCmd = NativePackageManager.BREW.install(np, "1.0.0"); + var uninstallCmd = NativePackageManager.BREW.uninstall(np); + + assertThat(installCmd.commands()).containsExactly("brew install pkg1@1.0.0"); + assertThat(uninstallCmd.commands()).containsExactly("brew uninstall pkg1"); + } + + @Test + void testBrewCaskInstallAndUninstallCommandsDoNotUseSudo() { + NativePackage np = NativePackage.of(NativePackageManager.BREW_CASK, "docker"); + + var installCmd = NativePackageManager.BREW_CASK.install(np, null); + var uninstallCmd = NativePackageManager.BREW_CASK.uninstall(np); + + assertThat(installCmd.commands()).containsExactly("brew install --cask docker"); + assertThat(uninstallCmd.commands()).containsExactly("brew uninstall --cask docker"); + } + + @Test + void testVersionQueryCommandForBrew() { + assertThat(NativePackageManager.BREW.getVersionQueryCommand("pkg1")).containsExactly("brew", "list", "--versions", "pkg1"); + assertThat(NativePackageManager.BREW_CASK.getVersionQueryCommand("pkg1")).containsExactly("brew", "list", "--cask", "--versions", "pkg1"); + } + + @Test + void testParseVersionQueryOutputForBrew() { + assertThat(NativePackageManager.BREW.parseVersionQueryOutput("pkg1 1.2.3")).isEqualTo("1.2.3"); + assertThat(NativePackageManager.BREW_CASK.parseVersionQueryOutput("docker 24.0.0")).isEqualTo("24.0.0"); + assertThat(NativePackageManager.BREW.parseVersionQueryOutput("")).isNull(); + } + + @Test + void testIsNeedSudo() { + assertThat(NativePackageManager.APT.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.ZYPPER.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.YUM.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.DNF.isNeedSudo()).isTrue(); + assertThat(NativePackageManager.BREW.isNeedSudo()).isFalse(); + assertThat(NativePackageManager.BREW_CASK.isNeedSudo()).isFalse(); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java index 620f243e..ea5e46cd 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/androidstudio/AndroidStudioTest.java @@ -10,6 +10,7 @@ import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -20,6 +21,9 @@ import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemInfo; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; +import com.devonfw.tools.ide.version.VersionIdentifier; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -113,6 +117,25 @@ void testAndroidStudioRunWithCustomJvmOptions(String os) { """); } + /** + * Tests if the environment variable {@code STUDIO_PROPERTIES} is set to the path of the {@code studio.properties} file in the workspace. + */ + @Test + void testSetEnvironmentSetsStudioProperties() { + + // arrange + AndroidStudio commandlet = new AndroidStudio(this.context); + Path dummy = this.context.getSoftwarePath().resolve("android-studio"); + ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of("2024.1.1.1"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert + assertThat(environmentContext.set).containsEntry("STUDIO_PROPERTIES", this.context.getWorkspacePath().resolve("studio.properties").toString()); + } + private void checkInstallation(IdeTestContext context) { // commandlet - android-studio AndroidStudio commandlet = context.getCommandletManager().getCommandlet(AndroidStudio.class); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/claude/RecordingEnvironmentContext.java b/cli/src/test/java/com/devonfw/tools/ide/tool/claude/RecordingEnvironmentContext.java index 797f093c..0fe6209d 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/claude/RecordingEnvironmentContext.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/claude/RecordingEnvironmentContext.java @@ -1,8 +1,10 @@ package com.devonfw.tools.ide.tool.claude; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -19,6 +21,9 @@ public class RecordingEnvironmentContext implements EnvironmentContext { /** Variables removed via {@link #removeEnvVar(String)}. */ public final Set removed = new HashSet<>(); + /** Path entries added via {@link #withPathEntry(Path)}. */ + public final List pathEntries = new ArrayList<>(); + @Override public EnvironmentContext withEnvVar(String key, String value) { this.set.put(key, value); @@ -27,6 +32,7 @@ public EnvironmentContext withEnvVar(String key, String value) { @Override public EnvironmentContext withPathEntry(Path path) { + this.pathEntries.add(path); return this; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java index fd7546f1..7b43aab8 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java @@ -88,7 +88,6 @@ void testCorepackRun(WireMockRuntimeInfo wireMockRuntimeInfo) { private void checkInstallation(IdeTestContext context) { assertThat(context).logAtInfo().hasMessageContaining("npm install -gf corepack@0.34.0"); - assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful"); assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed corepack in version 0.34.0"); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryTest.java index 9c2f6f83..95f1b708 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/custom/CustomToolRepositoryTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.io.TempDir; import com.devonfw.tools.ide.context.IdeTestContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.url.model.file.json.ToolSecurity; import com.devonfw.tools.ide.version.GenericVersionRange; @@ -196,8 +196,8 @@ void testDownloadWithUndefinedVersionThrowsException(@TempDir Path tempDir, Wire // --- Repository metadata ---------------------------------------------------- /** - * Tests that {@link com.devonfw.tools.ide.url.model.AbstractUrlMetadata#getSortedVersions(String, String, ToolCommandlet)} returns the single configured - * custom tool version. + * Tests that {@link com.devonfw.tools.ide.url.model.AbstractUrlMetadata#getSortedVersions(String, String, AbstractToolCommandlet)} returns the single + * configured custom tool version. * * @param tempDir temporary test directory used as working directory and settings base. * @param wmRuntimeInfo wireMock server on a random port. diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java index 95335cbe..16b12267 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/gui/GuiTest.java @@ -5,17 +5,22 @@ import java.nio.file.Path; import java.util.List; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.io.WindowsSymlinkTestHelper; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.version.IdeVersion; +import com.devonfw.tools.ide.version.VersionIdentifier; /** * Test of {@link Gui}. */ -class GuiTest extends Assertions { +class GuiTest extends AbstractIdeContextTest { /** Version in effect before the tests so it can be restored by {@link #restoreVersion()}. */ private final String originalVersion = IdeVersion.getVersionString(); @@ -30,6 +35,76 @@ void restoreVersion() { IdeVersion.setMockVersionForTesting(this.originalVersion); } + private ToolInstallation newJavaInstallation(IdeTestContext context) { + + Path binDir = context.getSoftwarePath().resolve("java/bin"); + context.getFileAccess().mkdirs(binDir); + Path javaExecutable = binDir.resolve("java"); + try { + Files.createFile(javaExecutable); + } catch (Exception e) { + throw new IllegalStateException(e); + } + return new ToolInstallation(binDir.getParent(), binDir.getParent(), binDir, VersionIdentifier.of("25.0.0"), false); + } + + @Test + void testGetGuiExecutableOnMacCreatesAppBundleWithSymlinkNamedIDEasy() throws Exception { + + // arrange + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + context.setSystemInfo(SystemInfoMock.MAC_ARM64); + ToolInstallation javaInstallation = newJavaInstallation(context); + Gui gui = new Gui(context); + + // act + String executable = gui.getGuiExecutable(javaInstallation); + + // assert + Path contentsDir = context.getTempPath().resolve("IDEasy.app").resolve("Contents"); + Path launcher = contentsDir.resolve("MacOS").resolve("IDEasy"); + assertThat(executable).isEqualTo(launcher.toString()); + assertThat(Files.isSymbolicLink(launcher)).isTrue(); + assertThat(launcher.toRealPath()).isEqualTo(javaInstallation.binDir().resolve("java").toRealPath()); + Path infoPlist = contentsDir.resolve("Info.plist"); + assertThat(infoPlist).exists(); + assertThat(Files.readString(infoPlist)).contains("CFBundleName", "IDEasy", + "CFBundleIdentifier"); + } + + @Test + void testGetGuiExecutableOnWindowsReturnsPlainJava() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + ToolInstallation javaInstallation = newJavaInstallation(context); + Gui gui = new Gui(context); + + // act + String executable = gui.getGuiExecutable(javaInstallation); + + // assert + assertThat(executable).isEqualTo("java"); + } + + @Test + void testGetGuiExecutableOnLinuxReturnsPlainJava() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + ToolInstallation javaInstallation = newJavaInstallation(context); + Gui gui = new Gui(context); + + // act + String executable = gui.getGuiExecutable(javaInstallation); + + // assert + assertThat(executable).isEqualTo("java"); + } + /** * Verifies the GUI is launched from the self-contained maven repository inside the installation when the running version is a local-dev build (stamped with * {@link IdeVersion#LOCAL_DEV_SUFFIX}), and that the snapshot (-U) behavior is not applied in that case. @@ -42,7 +117,7 @@ void testBuildMvnArgsWithLocalDevBuild() { Path installationPath = this.tempDir; // act - List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml")); + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "java"); // assert String m2RepoFlag = "-Dmaven.repo.local=" + installationPath.resolve(".m2"); @@ -62,7 +137,7 @@ void testBuildMvnArgsWithSnapshotVersion() throws IOException { String m2RepoFlag = "-Dmaven.repo.local=" + installationPath.resolve(".m2"); // act - List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml")); + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "java"); // assert assertThat(args).contains("-U"); @@ -82,11 +157,26 @@ void testBuildMvnArgsWithStableVersion() { String m2RepoFlag = "-Dmaven.repo.local=" + installationPath.resolve(".m2"); // act - List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml")); + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "java"); // assert assertThat(args).doesNotContain("-U"); assertThat(args).doesNotContain(m2RepoFlag); assertThat(args).doesNotContain("-o"); } + + @Test + void testBuildMvnArgsUsesGivenExecutable() { + + // arrange + Path installationPath = this.tempDir; + IdeVersion.setMockVersionForTesting("2026.08.001"); + + // act + List args = Gui.buildMvnArgs(installationPath, installationPath.resolve("gui/pom.xml"), "/tmp/IDEasy.app/Contents/MacOS/IDEasy"); + + // assert + assertThat(args).contains("-Dexec.executable=/tmp/IDEasy.app/Contents/MacOS/IDEasy"); + } + } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandletTest.java index ff45f9a6..21e624ae 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolCommandletTest.java @@ -9,7 +9,7 @@ import com.devonfw.tools.ide.tool.intellij.Intellij; /** - * Test of {@link IdeToolCommandlet}. + * Test of {@link AbstractIdeToolCommandlet}. */ class IdeToolCommandletTest extends AbstractIdeContextTest { @@ -30,13 +30,13 @@ void testConfigureWorkspace() { } /** - * Tests that {@link IdeToolCommandlet#getIdeMetadataPath()} resolves to {@code $IDE_HOME/.ide/«ide»/«workspace»} instead of the workspace itself. + * Tests that {@link AbstractIdeToolCommandlet#getIdeMetadataPath()} resolves to {@code $IDE_HOME/.ide/«ide»/«workspace»} instead of the workspace itself. */ @Test void testGetIdeMetadataPath() { // arrange IdeContext context = newContext("intellij"); - IdeToolCommandlet ide = context.getCommandletManager().getCommandlet(Intellij.class); + AbstractIdeToolCommandlet ide = context.getCommandletManager().getCommandlet(Intellij.class); // act Path metadataPath = ide.getIdeMetadataPath(); // assert diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolDummyCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolDummyCommandletTest.java index 9fceae99..c2ff93fe 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolDummyCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/ide/IdeToolDummyCommandletTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.commandlet.AbstractCommandlet; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.AbstractIdeTestContext; @@ -25,7 +25,7 @@ import com.devonfw.tools.ide.version.GenericVersionRange; /** - * Test of {@link IdeToolCommandlet} using {@link IdeToolDummyCommandlet}. + * Test of {@link AbstractIdeToolCommandlet} using {@link IdeToolDummyCommandlet}. */ class IdeToolDummyCommandletTest extends AbstractIdeContextTest { @@ -45,7 +45,7 @@ void testDummyCommandlet(@TempDir Path tempDir) { context.addCommandlet(dummyCommandlet); - Commandlet dummy = context.getCommandletManager().getCommandlet("dummy"); + AbstractCommandlet dummy = context.getCommandletManager().getCommandlet("dummy"); assertThat(dummy).isSameAs(dummyCommandlet); dummy.run(); assertThat(dummyCommandlet.installedPlugins).hasSize(1); @@ -56,9 +56,9 @@ void testDummyCommandlet(@TempDir Path tempDir) { } /** - * Dummy commandlet extending {@link IdeToolCommandlet} for testing. + * Dummy commandlet extending {@link AbstractIdeToolCommandlet} for testing. */ - public static class IdeToolDummyCommandlet extends IdeToolCommandlet { + public static class IdeToolDummyCommandlet extends AbstractIdeToolCommandlet { final List installedPlugins; diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java index 2791568b..5d64a3e2 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/intellij/IntellijTest.java @@ -17,6 +17,8 @@ import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.tool.ToolEdition; import com.devonfw.tools.ide.tool.ToolEditionAndVersion; +import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; import com.devonfw.tools.ide.version.VersionIdentifier; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -413,6 +415,25 @@ void testMissingExtraJavaTemplateFails() throws Exception { assertThat(jdkTableContent).contains("software/extra/java/client"); } + /** + * Tests if the environment variable {@code IDEA_PROPERTIES} is set to the path of the {@code idea.properties} file in the workspace. + */ + @Test + void testSetEnvironmentSetsIdeaProperties() { + + // arrange + Intellij commandlet = new Intellij(this.context); + Path dummy = context.getSoftwarePath().resolve("intellij"); + ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of("2023.3.3"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert + assertThat(environmentContext.set).containsEntry("IDEA_PROPERTIES", this.context.getWorkspacePath().resolve("idea.properties").toString()); + } + private void checkInstallation(IdeTestContext context) { Intellij commandlet = context.getCommandletManager().getCommandlet(Intellij.class); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnRepositoryTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnRepositoryTest.java index ba766f63..586d5595 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnRepositoryTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnRepositoryTest.java @@ -19,7 +19,7 @@ import com.devonfw.tools.ide.os.OperatingSystem; import com.devonfw.tools.ide.os.SystemArchitecture; import com.devonfw.tools.ide.os.SystemInfoMock; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.file.UrlChecksums; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; import com.devonfw.tools.ide.url.model.file.UrlGenericChecksum; @@ -151,7 +151,7 @@ void testGetMetadataWithRelease() { VersionIdentifier version = VersionIdentifier.of("2025.01.001-beta"); OperatingSystem os = context.getSystemInfo().getOs(); SystemArchitecture arch = context.getSystemInfo().getArchitecture(); - ToolCommandlet toolCommandlet = null; + AbstractToolCommandlet toolCommandlet = null; // this triggers maven download of checksums. Should we fake the checksum files into mocked maven repo in arrange phase? // act @@ -192,7 +192,7 @@ void testGetMetadataWithSnapshot() { VersionIdentifier version = VersionIdentifier.of("2025.01.001-beta-20250121.023134-9"); OperatingSystem os = context.getSystemInfo().getOs(); SystemArchitecture arch = context.getSystemInfo().getArchitecture(); - ToolCommandlet toolCommandlet = null; + AbstractToolCommandlet toolCommandlet = null; // act UrlDownloadFileMetadata metadata = mavenRepo.getMetadata(tool, edition, version, toolCommandlet); @@ -267,13 +267,13 @@ void testResolveSnapshotClassifierKeepsWindowsArm64WhenAvailable() { MvnRepository mvnRepository = context.getMvnRepository(); String arm64Artifact = """ - - windows-arm64 - tar.gz - 2025.02.001-beta-20250204.023111-1 - 20250204023111 - - """; + + windows-arm64 + tar.gz + 2025.02.001-beta-20250204.023111-1 + 20250204023111 + + """; String xml = XML_SNAPSNOT_METADATA.replace( "", diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java index b5bdd75f..8f91fba4 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java @@ -1,9 +1,19 @@ package com.devonfw.tools.ide.tool.npm; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + import org.junit.jupiter.api.Test; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.tool.ToolInstallation; +import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; +import com.devonfw.tools.ide.version.VersionIdentifier; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -77,16 +87,213 @@ void testNpmUninstall(WireMockRuntimeInfo wireMockRuntimeInfo) { // assert II assertThat(context).logAtInfo().hasNoMessageContaining("npm uninstall -g npm"); - assertThat(context).logAtInfo().hasMessageContaining("IDEasy does not support uninstalling the tool npm since this will break your installation.\n" - + "If you really want to uninstall it, please uninstall its parent tool via:\n" - + "ide uninstall node"); assertThat(context).logAtSuccess().hasMessage("Successfully uninstalled npm"); } + /** + * Tests that {@link Npm#setEnvironment(EnvironmentContext, ToolInstallation, boolean)} points the npm global prefix at the per-project + * {@code software/node_modules} folder so that globally installed npm packages are isolated per project (see + * issue #352 and issue #2381). + */ + @Test + void testSetEnvironmentPointsGlobalPrefixToPerProjectNpmGlobal() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, null, true); + Npm commandlet = new Npm(context); + context.getFileAccess().mkdirs(context.getSoftwarePath().resolve("npm")); + ToolInstallation installation = new ToolInstallation(context.getSoftwarePath(), context.getSoftwarePath(), context.getSoftwarePath(), + VersionIdentifier.of("9.9.2"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert + Path npmGlobalPath = context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER); + assertThat(environmentContext.set).containsEntry("npm_config_prefix", npmGlobalPath.toString()); + } + + /** + * Tests that {@link Npm#setEnvironment(EnvironmentContext, ToolInstallation, boolean)} puts the folder holding the executables of globally installed npm + * packages on the PATH (the prefix's {@code bin} folder on POSIX but the prefix root on Windows, matching where npm actually places the shims) so that the + * packages (e.g. {@code task}, {@code cdk}) are resolvable (see + * issue #2381). + */ + @Test + void testSetEnvironmentAddsGlobalPackagesFolderToPath() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, null, true); + Npm commandlet = new Npm(context); + context.getFileAccess().mkdirs(context.getSoftwarePath().resolve("npm")); + Path softwareNpm = context.getSoftwarePath().resolve("npm"); + ToolInstallation installation = new ToolInstallation(softwareNpm, softwareNpm, softwareNpm, VersionIdentifier.of("9.9.2"), false); + RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext(); + + // act + commandlet.setEnvironment(environmentContext, installation, false); + + // assert - the PATH must point at the folder where npm actually places the global shims (the prefix root on Windows, /bin otherwise) + Path npmGlobalPath = context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER); + Path expectedBin = context.getSystemInfo().isWindows() ? npmGlobalPath : npmGlobalPath.resolve(IdeContext.FOLDER_BIN); + assertThat(environmentContext.pathEntries).contains(expectedBin); + } + + /** + * Tests that {@link Npm#setEnvironment(EnvironmentContext, ToolInstallation, boolean)} does not create the per-project {@code software/node_modules} prefix + * folder on purpose: it is created by npm when the first global package is installed and removed again by {@link Npm#cleanupGlobalPackagesFolder()} once the + * last one is uninstalled, so the software folder stays clean (see + * issue #2381). + */ + @Test + void testSetEnvironmentDoesNotCreatePerProjectNpmGlobalFolder() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, null, true); + Npm commandlet = new Npm(context); + context.getFileAccess().mkdirs(context.getSoftwarePath().resolve("npm")); + Path softwareNpm = context.getSoftwarePath().resolve("npm"); + ToolInstallation installation = new ToolInstallation(softwareNpm, softwareNpm, softwareNpm, VersionIdentifier.of("9.9.2"), false); + + // act + commandlet.setEnvironment(new RecordingEnvironmentContext(), installation, false); + + // assert + assertThat(Files.isDirectory(context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER))).isFalse(); + } + + /** + * Tests that {@link Npm#cleanupGlobalPackagesFolder()} removes the per-project prefix once the package container no longer holds a package (e.g. after the + * last global package was uninstalled) (see issue #2381). + */ + @Test + void testCleanupGlobalPackagesFolderRemovesPrefixWhenNoPackageInstalled() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, null, true); + Npm commandlet = new Npm(context); + context.getFileAccess().mkdirs(getPackageContainer(context)); + + // act + commandlet.cleanupGlobalPackagesFolder(); + + // assert + assertThat(Files.isDirectory(context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER))).isFalse(); + } + + /** + * Tests that {@link Npm#cleanupGlobalPackagesFolder()} keeps the per-project prefix if a global package is still installed, so that only the last + * uninstallation actually removes it (see issue #2381). + */ + @Test + void testCleanupGlobalPackagesFolderKeepsPrefixWhenPackageStillInstalled() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, null, true); + Npm commandlet = new Npm(context); + Path somePackage = context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER).resolve("lib").resolve("node_modules").resolve("some-package"); + context.getFileAccess().mkdirs(somePackage); + context.getFileAccess().touch(somePackage.resolve("package.json")); + + // act + commandlet.cleanupGlobalPackagesFolder(); + + // assert + assertThat(Files.isDirectory(context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER))).isTrue(); + } + + /** + * Tests that {@link Npm#cleanupGlobalPackagesFolder()} removes the per-project prefix even if npm left an empty scope folder behind after removing a scoped + * package, since no actual package file remains (see + * issue #2381). + */ + @Test + void testCleanupGlobalPackagesFolderRemovesPrefixWhenOnlyEmptyScopeFolderRemains() { + + // arrange + IdeTestContext context = newContext(PROJECT_NPM, null, true); + Npm commandlet = new Npm(context); + context.getFileAccess().mkdirs(getPackageContainer(context).resolve("@go-task")); + + // act + commandlet.cleanupGlobalPackagesFolder(); + + // assert + assertThat(Files.isDirectory(context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER))).isFalse(); + } + + /** + * Tests that {@link Npm#postExtract(Path)} rewrites the npm launcher shims (that the npm registry tarball ships in the node-bundled layout) to the flat + * layout, so that {@code npm}/{@code npx} resolve to this pristine installation instead of the npm bundled with node (see issue #2381). + */ + @Test + void testPostExtractRepairsFlatLayoutShims() { + + // arrange - a flat npm installation (as extracted from the npm registry tarball) with the broken node-bundled-layout shims + IdeTestContext context = newContext(PROJECT_NPM, (String) null, false); + Npm commandlet = new Npm(context); + FileAccess fileAccess = context.getFileAccess(); + Path extractedDir = null; + try { + extractedDir = Files.createTempDirectory("npm-shim-repair"); + Path bin = extractedDir.resolve("bin"); + fileAccess.mkdirs(bin); + // flat-layout CLI entry points (these are the ones the shims should launch) + fileAccess.touch(bin.resolve("npm-cli.js")); + fileAccess.touch(bin.resolve("npx-cli.js")); + // the node-bundled-layout shims that npm's registry tarball ships (broken - they point at node_modules/npm/bin/npm-cli.js) + String bundledShim = "node node_modules\\npm\\bin\\npm-cli.js"; + for (String shim : new String[] { "npm.cmd", "npx.cmd", "npm.ps1", "npx.ps1", "npm", "npx" }) { + fileAccess.writeFileContent(bundledShim, bin.resolve(shim), false); + } + + // act + commandlet.postExtract(extractedDir); + + // assert - only the shims of the current platform were repaired and now launch this installation's own CLI entry + // points instead of the broken node-bundled layout (node_modules\npm\bin\npm-cli.js) + boolean windows = context.getSystemInfo().isWindows(); + if (windows) { + String npmCmd = fileAccess.readFileContent(bin.resolve("npm.cmd")); + org.assertj.core.api.Assertions.assertThat(npmCmd).contains("npm-cli.js").doesNotContain("node_modules"); + String npxCmd = fileAccess.readFileContent(bin.resolve("npx.cmd")); + org.assertj.core.api.Assertions.assertThat(npxCmd).contains("npx-cli.js").doesNotContain("node_modules"); + String npmPs1 = fileAccess.readFileContent(bin.resolve("npm.ps1")); + org.assertj.core.api.Assertions.assertThat(npmPs1).contains("npm-cli.js").doesNotContain("node_modules"); + } else { + String npmPosix = fileAccess.readFileContent(bin.resolve("npm")); + org.assertj.core.api.Assertions.assertThat(npmPosix).contains("npm-cli.js").doesNotContain("node_modules").doesNotContain("\r"); + String npxPosix = fileAccess.readFileContent(bin.resolve("npx")); + org.assertj.core.api.Assertions.assertThat(npxPosix).contains("npx-cli.js").doesNotContain("node_modules").doesNotContain("\r"); + } + // the CLI entry points themselves are left untouched + org.assertj.core.api.Assertions.assertThat(Files.isRegularFile(bin.resolve("npm-cli.js"))).isTrue(); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + if (extractedDir != null) { + fileAccess.delete(extractedDir); + } + } + } + private void checkInstallation(IdeTestContext context) { assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed npm in version 9.9.2"); - assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful"); + } + + /** + * @param context the {@link IdeTestContext}. + * @return a {@link Path} to a folder inside the per-project global npm prefix that represents where an installed global package would live. The cleanup under + * test searches the prefix as a whole for a {@code package.json} (independent of npm's platform-specific layout), so the exact location does not matter + * for the test. + */ + private Path getPackageContainer(IdeTestContext context) { + + return context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER).resolve(Npm.NPM_GLOBAL_FOLDER); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java new file mode 100644 index 00000000..4962944f --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/obsidian/ObsidianTest.java @@ -0,0 +1,91 @@ +package com.devonfw.tools.ide.tool.obsidian; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoMock; + +/** + * Test of {@link Obsidian}. + */ +class ObsidianTest extends AbstractIdeContextTest { + + /** + * Test that the {@link Obsidian} commandlet is registered and properly classified. + */ + @Test + void testObsidianCommandletIsRegistered() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + + // act + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // assert + assertThat(obsidian).isNotNull(); + assertThat(obsidian.getName()).isEqualTo("obsidian"); + assertThat(obsidian.getTags()).containsExactly(Tag.MARK_DOWN); + } + + /** + * Test that the Windows download is not extracted since it is an installer executable that has to be started. + */ + @Test + void testIsExtractIsFalseOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.isExtract()).isFalse(); + } + + /** + * Test that the macOS download is extracted since the *.app has to be taken out of the DMG image. + */ + @Test + void testIsExtractIsTrueOnMac() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.isExtract()).isTrue(); + } + + /** + * Test that the Linux download is extracted since it is a tar.gz archive. + */ + @Test + void testIsExtractIsTrueOnLinux() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.isExtract()).isTrue(); + } + + /** + * Test that the name used to find an existing installation in the Windows registry is not the lower-case tool name. + */ + @Test + void testGetWindowsRegistryAppName() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Obsidian obsidian = context.getCommandletManager().getCommandlet(Obsidian.class); + + // act + assert + assertThat(obsidian.getWindowsRegistryAppName()).isEqualTo("Obsidian"); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java new file mode 100644 index 00000000..77b96bf1 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/ExamplePipBasedIdeToolCommandlet.java @@ -0,0 +1,52 @@ +package com.devonfw.tools.ide.tool.pip; + +import java.util.List; +import java.util.Set; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.process.ProcessResultImpl; +import com.devonfw.tools.ide.tool.ToolInstallRequest; + +/** + * Test double of {@link PipBasedIdeToolCommandlet} that records the workspace configuration and short-circuits the actual installation and process launch for + * testing. + */ +public class ExamplePipBasedIdeToolCommandlet extends PipBasedIdeToolCommandlet { + + private boolean workspaceConfigured; + + /** + * The constructor. + * + * @param context the {@link IdeTestContext}. + * @param tool the {@link #getName() tool name}. + * @param tags the {@link #getTags() tags} classifying the tool. + */ + public ExamplePipBasedIdeToolCommandlet(IdeTestContext context, String tool, Set tags) { + + super(context, tool, tags); + } + + @Override + public void configureWorkspace() { + // only record the call, do not perform the real workspace configuration + this.workspaceConfigured = true; + } + + @Override + public ProcessResult runTool(ToolInstallRequest request, ProcessMode processMode, List args) { + // skip the actual installation and process launch + return new ProcessResultImpl(getName(), getName(), ProcessResult.SUCCESS, List.of()); + } + + /** + * @return {@code true} if {@link #configureWorkspace()} has been triggered, {@code false} otherwise. + */ + public boolean wasWorkspaceConfigured() { + + return this.workspaceConfigured; + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java new file mode 100644 index 00000000..15ea5f86 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pip/PipBasedIdeToolCommandletTest.java @@ -0,0 +1,86 @@ +package com.devonfw.tools.ide.tool.pip; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.context.ProcessContextTestImpl; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; +import com.devonfw.tools.ide.tool.python.Python; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +/** + * Test of {@link PipBasedIdeToolCommandlet}. + */ +@WireMockTest +class PipBasedIdeToolCommandletTest extends AbstractIdeContextTest { + + private static final String PROJECT_PIP = "pip"; + + /** The (dummy) name of the tool managed by the test commandlet. */ + private static final String TOOL = "mockedide"; + + /** + * Tests that the plugin installation path of a pip-based IDE points at the shared python environment (the parent tool) that holds the IDE itself. + */ + @Test + void testPluginsInstallationPathIsSharedPythonEnvironment() { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP); + ExamplePipBasedIdeToolCommandlet commandlet = new ExamplePipBasedIdeToolCommandlet(context, TOOL, Set.of(Tag.PYTHON)); + Python python = context.getCommandletManager().getCommandlet(Python.class); + + // assert + assertThat(commandlet.getPluginsInstallationPath()).isEqualTo(python.getToolPath()); + assertThat(commandlet.getPluginsInstallationPath()).isEqualTo(commandlet.getToolPath()); + } + + /** + * Tests that installing a plugin of a pip-based IDE is delegated to the package manager (pip): the plugin's python package id is used and the installation is + * reported as successful. + * + * @param wireMockRuntimeInfo wireMock server on a random port providing the mocked PyPI index. + */ + @Test + void testInstallPluginIsDelegatedToPackageManager(WireMockRuntimeInfo wireMockRuntimeInfo) { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP, wireMockRuntimeInfo); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + ExamplePipBasedIdeToolCommandlet commandlet = new ExamplePipBasedIdeToolCommandlet(context, TOOL, Set.of(Tag.PYTHON)); + ToolPluginDescriptor plugin = new ToolPluginDescriptor("testplugin", "TestPlugin", null, "1.0.0", true, null, null); + Step step = context.newStep("Install plugin TestPlugin"); + + // act + step.run(() -> commandlet.installPlugin(plugin, step, new ProcessContextTestImpl(context))); + + // assert + assertThat(context).logAtSuccess().hasMessage("Successfully installed plugin: TestPlugin"); + } + + /** + * Tests that {@link PipBasedIdeToolCommandlet#runTool(List)} (the entry point of {@code ide run «tool»}) triggers + * {@link PipBasedIdeToolCommandlet#configureWorkspace()}. + */ + @Test + void testRunToolTriggersWorkspaceConfiguration() { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP); + ExamplePipBasedIdeToolCommandlet commandlet = new ExamplePipBasedIdeToolCommandlet(context, TOOL, Set.of(Tag.PYTHON)); + + // act + commandlet.runTool(List.of()); + + // assert - runTool triggered the workspace configuration as part of the run flow + assertThat(commandlet.wasWorkspaceConfigured()).isTrue(); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/ExamplePluginBasedCommandlet.java b/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/ExamplePluginBasedCommandlet.java index 1ab70784..1c451cf7 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/ExamplePluginBasedCommandlet.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/ExamplePluginBasedCommandlet.java @@ -8,9 +8,9 @@ import com.devonfw.tools.ide.step.Step; /** - * Example implementation of {@link PluginBasedCommandlet} for testing. + * Example implementation of {@link AbstractPluginBasedCommandlet} for testing. */ -public class ExamplePluginBasedCommandlet extends PluginBasedCommandlet { +public class ExamplePluginBasedCommandlet extends AbstractPluginBasedCommandlet { /** * The constructor. diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandletTest.java index f0ad3f24..ac9e8fcc 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/plugin/PluginBasedCommandletTest.java @@ -15,7 +15,7 @@ import com.devonfw.tools.ide.context.ProcessContextTestImpl; /** - * Test of {@link PluginBasedCommandlet}. + * Test of {@link AbstractPluginBasedCommandlet}. */ class PluginBasedCommandletTest extends AbstractIdeContextTest { diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java index a34683bf..bb917ac3 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java @@ -94,7 +94,7 @@ public void testInstallRestoresMissingVersionFileAndPreservesPackages(WireMockRu // simulate that uv or python has removed our version file from the virtual environment Files.delete(versionFile); // the version is still determined from the installation itself (e.g. for "ide get-version python") - assertThat(python.getInstalledVersion(pythonPath)).isEqualTo(VersionIdentifier.of("3.14.6")); + assertThat(python.getInstalledVersion()).isEqualTo(VersionIdentifier.of("3.14.6")); // act python.install(); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ToolRepositoryMock.java b/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ToolRepositoryMock.java index 06a53d77..8933cf88 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ToolRepositoryMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ToolRepositoryMock.java @@ -17,7 +17,7 @@ import org.slf4j.LoggerFactory; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.tool.AbstractToolCommandlet; import com.devonfw.tools.ide.url.model.file.UrlDownloadFile; import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata; import com.devonfw.tools.ide.version.GenericVersionRange; @@ -55,14 +55,14 @@ public ToolRepositoryMock(IdeContext context, Path repositoryFolder, WireMockRun } @Override - public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, ToolCommandlet toolCommandlet) { + public VersionIdentifier resolveVersion(String tool, String edition, GenericVersionRange version, AbstractToolCommandlet toolCommandlet) { return super.resolveVersion(tool, edition, version, toolCommandlet); } @Override - public Path download(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) { + public Path download(String tool, String edition, VersionIdentifier version, AbstractToolCommandlet toolCommandlet) { Path editionFolder = this.repositoryFolder.resolve(tool).resolve(edition); String versionString = version.toString(); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java index 1c9326d2..50ba8e92 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/spyder/SpyderTest.java @@ -1,12 +1,15 @@ package com.devonfw.tools.ide.tool.spyder; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.CapturingProcessContextMock; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext; import com.devonfw.tools.ide.tool.pip.PipBasedCommandlet; @@ -62,8 +65,11 @@ void testSpyderIsPipBasedIdeToolCommandlet(WireMockRuntimeInfo wireMockRuntimeIn assertThat(commandlet).isInstanceOf(PipBasedIdeToolCommandlet.class); } + /** + * Tests that {@link Spyder#setEnvironment} points SPYDER_CONFDIR to the workspace-specific config directory. + */ @Test - void testSpyderSetEnvironmentUsesIsolatedConfigDir() { + void testSpyderSetEnvironmentUsesWorkspaceConfigDir() { // arrange IdeTestContext context = newContext(PROJECT_PIP); @@ -75,7 +81,25 @@ void testSpyderSetEnvironmentUsesIsolatedConfigDir() { // act commandlet.setEnvironment(environmentContext, installation, false); - // assert - assertThat(environmentContext.set).containsEntry("SPYDER_CONFIG_DIR", context.getConfPath().resolve("spyder").toString()); + // assert — SPYDER_CONFDIR points to workspace/.spyder-py3 + assertThat(environmentContext.set).containsEntry("SPYDER_CONFDIR", context.getWorkspacePath().resolve(".spyder-py3").toString()); + } + + /** + * Tests that {@link Spyder#configureToolArgs} points the IDE at the current workspace by adding the {@code --project} argument. + */ + @Test + void testSpyderConfigureToolArgsAddsProjectArg() { + + // arrange + IdeTestContext context = newContext(PROJECT_PIP); + Spyder commandlet = new Spyder(context); + CapturingProcessContextMock pc = new CapturingProcessContextMock(context); + + // act + commandlet.configureToolArgs(pc, ProcessMode.DEFAULT, List.of()); + + // assert — spyder is started with --project pointing to the workspace + assertThat(pc.getArgs()).containsExactly("--project", context.getWorkspacePath().toString()); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/task/TaskTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/task/TaskTest.java index 9771c923..b3aea52f 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/task/TaskTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/task/TaskTest.java @@ -5,6 +5,7 @@ import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.tool.npm.Npm; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -59,5 +60,8 @@ private void checkInstallation(IdeTestContext context) { assertThat(context).logAtInfo().hasMessageContaining("npm install -gf @go-task/cli@3.43.3"); assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed task in version 3.43.3"); + // the success message must report the actual location (the per-project global npm prefix) and not the parent tool path (software/node) + assertThat(context).logAtSuccess() + .hasMessageContaining("at " + context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER)); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java index c6ecaada..e0657cfd 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/vscode/VscodeTest.java @@ -15,12 +15,14 @@ import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.context.ProcessContextTestImpl; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.process.ProcessResultImpl; import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.plugin.AbstractPluginBasedCommandlet; import com.devonfw.tools.ide.tool.plugin.ToolPluginDescriptor; /** @@ -155,8 +157,9 @@ void testConfigureToolArgsDoesNotSetWslEnvVarOnNonWsl() { } /** - * Tests that {@link Vscode#configureToolArgs(ProcessContext, ProcessMode, List)} points {@code --user-data-dir} to the IDE metadata folder - * ({@code $IDE_HOME/.ide/vscode/«workspace»/config}) instead of a {@code .vscode} folder inside the workspace. + * Tests that by default (feature toggle {@code VSCODE_PROFILE_ENABLED} disabled) {@link Vscode#configureToolArgs(ProcessContext, ProcessMode, List)} points + * {@code --user-data-dir} to the IDE metadata folder ({@code $IDE_HOME/.ide/vscode/«workspace»/config}) instead of a {@code .vscode} folder inside the + * workspace. */ @Test void testConfigureToolArgsUsesIdeMetadataPathForUserData() { @@ -172,11 +175,104 @@ void testConfigureToolArgsUsesIdeMetadataPathForUserData() { Path expectedUserData = context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve("vscode").resolve(context.getWorkspaceName()).resolve("config"); assertThat(pc.capturedArgs).contains("--user-data-dir=" + expectedUserData); assertThat(pc.capturedArgs).noneMatch(arg -> arg.contains(".vscode")); + assertThat(pc.capturedArgs).noneMatch(arg -> arg.startsWith("--profile=")); } /** - * Tests that {@code VSCODE_OPTIONS} is honoured by appending its tokens as additional command-line arguments when starting the IDE (analogue to the - * global {@code IDE_OPTIONS} used for IDEasy itself, see issue #788). + * Tests that with the feature toggle {@code VSCODE_PROFILE_ENABLED} enabled VS Code is launched with a named {@code --profile} and without a custom + * {@code --user-data-dir}. + *

        + * Using {@code --profile} keeps auth and extension state isolated per project and workspace while keeping the VS Code IPC lock at the default user-data-dir + * location, so the OS-level {@code vscode://} protocol handler (e.g. GitHub/Copilot OAuth callbacks) can find and reuse the already-running instance. + */ + @Test + void testConfigureToolArgsUsesProfileIfFeatureToggleEnabled() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + context.getVariables().getByType(EnvironmentVariablesType.CONF).set("VSCODE_PROFILE_ENABLED", "true"); + Vscode commandlet = new Vscode(context); + ArgCapturingProcessContext pc = new ArgCapturingProcessContext(context); + // act + commandlet.configureToolArgs(pc, ProcessMode.DEFAULT, List.of()); + // assert + assertThat(pc.capturedArgs).noneMatch(arg -> arg.startsWith("--user-data-dir=")); + // the profile has to contain the project name so that different projects with the same workspace name do not share a single profile (see #2058) + assertThat(pc.capturedArgs).contains("--profile=ideasy-" + context.getProjectName() + "-" + context.getWorkspaceName()); + } + + /** + * Tests that the user settings template {@code .vscode/.userdata} from the settings repository is merged into the VS Code user-data folder + * ({@code $IDE_HOME/.ide/vscode/«workspace»/config}) passed via {@code --user-data-dir} instead of the workspace, while the other templates are still merged + * into the workspace (see #2509). + */ + @Test + void testConfigureWorkspaceMergesUserDataTemplateIntoUserDataFolder() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + Vscode commandlet = new Vscode(context); + Path workspace = context.getWorkspacePath(); + // act + commandlet.configureWorkspace(); + // assert + assertThat(getUserDataPath(context).resolve("User/settings.json")).exists().content().contains("\"telemetry.telemetryLevel\": \"off\"") + .contains("\"update.mode\": \"none\""); + assertThat(workspace.resolve(".vscode/.userdata")).doesNotExist(); + assertThat(workspace.resolve(".vscode/settings.json")).exists().content().contains("\"editor.formatOnSave\": true"); + } + + /** + * Tests that a {@code .vscode/.userdata} folder left in the workspace is moved to the VS Code user-data folder if that does not exist yet. + */ + @Test + void testConfigureWorkspaceMovesLegacyUserDataIfUserDataFolderIsMissing() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + FileAccess fileAccess = context.getFileAccess(); + Path legacyUserData = context.getWorkspacePath().resolve(".vscode/.userdata"); + fileAccess.writeFileContent("legacy", legacyUserData.resolve("state.json"), true); + Vscode commandlet = new Vscode(context); + // act + commandlet.configureWorkspace(); + // assert + assertThat(getUserDataPath(context).resolve("state.json")).exists().hasContent("legacy"); + assertThat(legacyUserData).doesNotExist(); + } + + /** + * Tests that a {@code .vscode/.userdata} folder left in the workspace is removed from the workspace (via backup) without touching the VS Code user-data + * folder if that already exists. + */ + @Test + void testConfigureWorkspaceRemovesLegacyUserDataIfUserDataFolderExists() { + + // arrange + IdeTestContext context = newContext(PROJECT_VSCODE); + FileAccess fileAccess = context.getFileAccess(); + Path legacyUserData = context.getWorkspacePath().resolve(".vscode/.userdata"); + fileAccess.writeFileContent("legacy", legacyUserData.resolve("state.json"), true); + Path userData = getUserDataPath(context); + fileAccess.writeFileContent("current", userData.resolve("state.json"), true); + Vscode commandlet = new Vscode(context); + // act + commandlet.configureWorkspace(); + // assert + assertThat(userData.resolve("state.json")).exists().hasContent("current"); + assertThat(legacyUserData).doesNotExist(); + assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS)).exists(); + } + + private static Path getUserDataPath(IdeTestContext context) { + + return context.getIdeHome().resolve(IdeContext.FOLDER_DOT_IDE).resolve("vscode").resolve(context.getWorkspaceName()).resolve("config"); + } + + /** + * Tests that {@code VSCODE_OPTIONS} is honoured by appending its tokens as additional command-line arguments when starting the IDE (analogue to the global + * {@code IDE_OPTIONS} used for IDEasy itself, see issue #788). */ @Test void testRunAddsVscodeOptions() { @@ -291,12 +387,13 @@ public ProcessResult runTool(ProcessContext pc, ProcessMode processMode, List plugins, ProcessContext pc) { installPlugins(plugins, pc); } } + /** * {@link ProcessContextTestImpl} subclass that captures calls to {@link #withEnvVar(String, String)} for test assertions. */ diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java index 0750c3b1..0917d0be 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java @@ -5,6 +5,7 @@ import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.tool.node.Node; +import com.devonfw.tools.ide.tool.npm.Npm; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -105,7 +106,9 @@ void testYarnRun(WireMockRuntimeInfo wireMockRuntimeInfo) { private void checkInstallation(IdeTestContext context) { assertThat(context).logAtInfo().hasMessage("npm install -gf yarn@2.4.3"); - assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful"); assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed yarn in version 2.4.3"); + // the success message must report the actual location (npm's per-project global prefix) and not the parent tool path (software/node) + assertThat(context).logAtSuccess() + .hasMessageContaining("at " + context.getSoftwarePath().resolve(Npm.NPM_GLOBAL_FOLDER)); } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/url/model/file/UrlSecurityFileTest.java b/cli/src/test/java/com/devonfw/tools/ide/url/model/file/UrlSecurityFileTest.java new file mode 100644 index 00000000..3a93fc2b --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/url/model/file/UrlSecurityFileTest.java @@ -0,0 +1,272 @@ +package com.devonfw.tools.ide.url.model.file; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.context.AbstractIdeTestContext; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.os.SystemArchitecture; +import com.devonfw.tools.ide.os.SystemInfoImpl; +import com.devonfw.tools.ide.url.model.AbstractUrlModelTest; +import com.devonfw.tools.ide.url.model.file.json.Cve; +import com.devonfw.tools.ide.url.model.file.json.ToolSecurity; +import com.devonfw.tools.ide.url.model.folder.UrlEdition; +import com.devonfw.tools.ide.url.model.folder.UrlRepository; +import com.devonfw.tools.ide.url.model.folder.UrlTool; +import com.devonfw.tools.ide.version.VersionIdentifier; +import com.devonfw.tools.ide.version.VersionRange; + +/** + * Test of {@link UrlSecurityFile}. + */ +class UrlSecurityFileTest extends AbstractUrlModelTest { + + /** + * @return a new, not yet loaded {@link UrlEdition} backed by the urls test-fixture that does not exist on disk yet. + */ + private UrlEdition newEdition() { + + return newRepo().getOrCreateChild("test-tool").getOrCreateChild("test-edition"); + } + + /** + * @param tempDir the {@link Path} to use as repository. + * @return a new {@link UrlEdition} backed by the given {@code tempDir} whose folder already exists on disk. + * @throws IOException if the directory of the {@link UrlEdition} could not be created. + */ + private UrlEdition newTempEdition(Path tempDir) throws IOException { + + UrlEdition edition = new UrlRepository(tempDir).getOrCreateChild("test-tool").getOrCreateChild("test-edition"); + Files.createDirectories(edition.getPath()); + return edition; + } + + /** + * @param range the single {@link VersionRange} of the CVE to add. + * @return a new, empty {@link UrlSecurityFile} with one CVE for the given {@code range}. + */ + private UrlSecurityFile newFileWithRange(String range) { + + UrlSecurityFile file = newEdition().getSecurityFile(); + file.addCve(new Cve("CVE-TEST", 5.0, List.of(VersionRange.of(range)))); + return file; + } + + /** + * Verifies that {@link UrlSecurityFile#getSecurity()} returns the empty {@link ToolSecurity} before any data was loaded. + */ + @Test + void testGetSecurityEmptyBeforeLoad() { + + // arrange + UrlSecurityFile file = newEdition().getSecurityFile(); + + // assert + assertThat(file.getSecurity()).isSameAs(ToolSecurity.getEmpty()); + } + + /** + * Verifies that an existing {@code security.json} file is loaded correctly. + */ + @Test + void testLoadExistingSecurityFile() { + + // arrange + UrlTool intellij = newRepo().getChild("intellij"); + + // act + ToolSecurity security = intellij.getSecurityFile().getSecurity(); + + // assert + assertThat(security.getIssues()).contains(new Cve("CVE-2024-32002", 9.0, + List.of(VersionRange.of("(0,2.39.4)"), VersionRange.of("[2.40.0,2.40.2)")))); + } + + /** + * Verifies that {@link UrlSecurityFile#setSecurity(ToolSecurity)} replaces the current security data and marks the file as modified. + */ + @Test + void testSetSecurityReplacesData() { + + // arrange + UrlSecurityFile file = newEdition().getSecurityFile(); + ToolSecurity initial = new ToolSecurity(List.of(new Cve("CVE-INITIAL", 5.0, List.of(VersionRange.of("[1.0,2.0]"))))); + ToolSecurity replacement = new ToolSecurity(List.of(new Cve("CVE-REPLACEMENT", 4.0, List.of(VersionRange.of("[3.0,4.0]"))))); + + // act + file.setSecurity(initial); + assertThat(file.getSecurity()).isSameAs(initial); + file.setSecurity(replacement); + + // assert + assertThat(file.getSecurity()).isSameAs(replacement); + assertThat(file.getSecurity().getIssues()).hasSize(1); + assertThat(file.modified).isTrue(); + } + + /** + * Verifies that {@link UrlSecurityFile#addCve(Cve)} adds a new CVE and does not create duplicates for an already contained CVE. + */ + @Test + void testAddCve() { + + // arrange + UrlSecurityFile file = newEdition().getSecurityFile(); + Cve cve = new Cve("CVE-NEW", 5.0, List.of(VersionRange.of("[1.0,2.0]"))); + + // act + file.addCve(cve); + file.addCve(new Cve("CVE-NEW", 5.0, List.of(VersionRange.of("[1.0,2.0]")))); + + // assert + assertThat(file.getSecurity().getIssues()).containsExactly(cve); + } + + /** + * Verifies that {@link UrlSecurityFile#addCve(Cve)} marks the file as modified for a new CVE, but not for an already contained CVE. + */ + @Test + void testAddCveSetsModified() { + + // arrange + UrlSecurityFile file = newRepo().getChild("intellij").getSecurityFile(); // loaded from fixture, not modified + Cve existing = new Cve("Test1", 1.0, List.of(VersionRange.of("[2022.3]"))); + + // act + file.addCve(existing); + + // assert: duplicate CVE does not mark the file as modified + assertThat(file.modified).isFalse(); + assertThat(file.getSecurity().getIssues()).hasSize(7); + + // act + file.addCve(new Cve("CVE-NEW", 5.0, List.of(VersionRange.of("[9.9.9]")))); + + // assert: new CVE marks the file as modified + assertThat(file.modified).isTrue(); + } + + /** + * Verifies that {@link UrlSecurityFile#clearSecurityWarnings()} removes all CVEs and marks the file as modified. It must be a no-op when no security was + * loaded. + */ + @Test + void testClearSecurityWarnings() { + + // arrange + UrlSecurityFile file = newRepo().getChild("intellij").getSecurityFile(); + + // act + file.clearSecurityWarnings(); + + // assert + assertThat(file.getSecurity().getIssues()).isEmpty(); + assertThat(file.modified).isTrue(); + + // arrange: file that was never loaded has no security + UrlSecurityFile fresh = new UrlSecurityFile(newEdition()); + + // act + assert: must be a no-op + fresh.clearSecurityWarnings(); + assertThat(fresh.getSecurity()).isSameAs(ToolSecurity.getEmpty()); + } + + /** + * Verifies that {@link UrlSecurityFile#contains(VersionIdentifier)} respects the version ranges of the CVEs including inclusive and exclusive boundaries and + * returns {@code false} when no security is present. + */ + @Test + void testContainsVersionInCveRange() { + + // arrange + UrlSecurityFile bounded = newFileWithRange("[2.0,3.0)"); + UrlSecurityFile openStart = newFileWithRange("(,3.0]"); + UrlSecurityFile openEnd = newFileWithRange("[1.0,)"); + UrlSecurityFile empty = newEdition().getSecurityFile(); + + // assert + assertThat(bounded.contains(VersionIdentifier.of("2.0"))).isTrue(); // inclusive lower bound + assertThat(bounded.contains(VersionIdentifier.of("2.5"))).isTrue(); // within range + assertThat(bounded.contains(VersionIdentifier.of("3.0"))).isFalse(); // exclusive upper bound + assertThat(bounded.contains(VersionIdentifier.of("1.0"))).isFalse(); // below range + assertThat(openStart.contains(VersionIdentifier.of("3.0"))).isTrue(); // inclusive upper bound + assertThat(openStart.contains(VersionIdentifier.of("4.0"))).isFalse(); // above range + assertThat(openEnd.contains(VersionIdentifier.of("1.0"))).isTrue(); // inclusive lower bound + assertThat(openEnd.contains(VersionIdentifier.of("99.0"))).isTrue(); // above (unbounded) range + assertThat(empty.contains(VersionIdentifier.of("2.5"))).isFalse(); // no security loaded + } + + /** + * Verifies that {@link UrlSecurityFile#contains(VersionIdentifier, boolean, IdeContext, UrlEdition)} ignores warnings affecting all available versions when + * {@code ignoreWarningsThatAffectAllVersions} is {@code true}, while warnings covering only part of the versions are still detected. + */ + @Test + void testContainsIgnoresAllVersionWarnings() { + + // arrange + IdeContext context = newContext(); + ((AbstractIdeTestContext) context).setSystemInfo(new SystemInfoImpl("Linux", "12", SystemArchitecture.X64.toString())); + UrlEdition edition = context.getUrls().getEdition("intellij", "intellij"); + UrlSecurityFile file = edition.getParent().getSecurityFile(); + file.clearSecurityWarnings(); + VersionIdentifier version = VersionIdentifier.of("2023.3.3"); + + // act: CVE spanning the oldest (2022.3) to the newest (2025.1.1.1) available version is ignored + file.addCve(new Cve("CVE-ALL", 5.0, List.of(VersionRange.of("[2022.3,2025.1.1.1]")))); + boolean spanningAllVersions = file.contains(version, true, context, edition); + + // act: CVE only covering part of the available versions is still detected + file.clearSecurityWarnings(); + file.addCve(new Cve("CVE-PARTIAL", 5.0, List.of(VersionRange.of("[2022.3,2023.3.3]")))); + boolean partialVersions = file.contains(version, true, context, edition); + + // assert + assertThat(spanningAllVersions).isFalse(); + assertThat(partialVersions).isTrue(); + } + + /** + * Verifies that {@link UrlSecurityFile#save()} does not create the file when there are no warnings and the file does not exist yet. + */ + @Test + void testSaveSkipsWhenNoWarningsAndFileDoesNotExist(@TempDir Path tempDir) throws IOException { + + // arrange + UrlSecurityFile file = newTempEdition(tempDir).getSecurityFile(); + // mark as modified so that doSave() is actually executed and its skip branch is tested + file.setSecurity(ToolSecurity.getEmpty()); + + // act + file.save(); + + // assert + assertThat(file.getPath()).doesNotExist(); + } + + /** + * Verifies that {@link UrlSecurityFile#save()} writes the security data to disk and that the written file can be loaded again. + */ + @Test + void testSaveRoundTrip(@TempDir Path tempDir) throws IOException { + + // arrange + UrlEdition edition = newTempEdition(tempDir); + UrlSecurityFile file = edition.getSecurityFile(); + Cve cve = new Cve("CVE-NEW", 5.0, List.of(VersionRange.of("[1.0,2.0]"))); + file.addCve(cve); + + // act + file.save(); + + // assert + assertThat(file.getPath()).exists(); + UrlSecurityFile reloaded = new UrlSecurityFile(edition); + reloaded.load(false); + assertThat(reloaded.getSecurity().getIssues()).containsExactly(cve); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java b/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java index b6617caa..952b6bef 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/variable/IdeVariablesTest.java @@ -39,6 +39,33 @@ void testHttpProtocols() { assertThat(httpVersions2_11).containsExactly(Version.HTTP_2, Version.HTTP_1_1); } + /** Test of {@link IdeVariables#USER} on Linux and macOS where the USER environment variable is defined. */ + @Test + void testUserFromEnvironmentVariable() { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.getSystem().setEnv("USER", "unix-login"); + context.getSystem().setEnv("USERNAME", "windows-login"); + // act + String user = IdeVariables.USER.get(context); + // assert + assertThat(user).isEqualTo("unix-login"); + } + + /** Test of {@link IdeVariables#USER} on Windows where only the USERNAME environment variable is defined. */ + @Test + void testUserFallsBackToUsername() { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.getSystem().setEnv("USERNAME", "windows-login"); + // act + String user = IdeVariables.USER.get(context); + // assert + assertThat(user).isEqualTo("windows-login"); + } + /** Test of {@link IdeVariables#IDE_TOOLS} with bash array syntax using commas. */ @Test void testIdeToolsWithCommasInBashArray() { diff --git a/cli/src/test/resources/code-settings/settings/ide.properties b/cli/src/test/resources/code-settings/settings/ide.properties new file mode 100644 index 00000000..28913aee --- /dev/null +++ b/cli/src/test/resources/code-settings/settings/ide.properties @@ -0,0 +1 @@ +IDE_TOOLS=java,mvn diff --git a/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..a7dbf910 --- /dev/null +++ b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm @@ -0,0 +1,29 @@ +#!/bin/bash +# Simulates npm for tests. Global packages go into the per-project global prefix (npm_config_prefix). +# npm places the package launcher in the prefix root on Windows but in /bin on POSIX, so the +# launcher location is chosen per platform (this must mirror Npm#setEnvironment, which adds the same +# platform-specific folder to the PATH). +npm_global_bin="$npm_config_prefix" +if [ "$OS" != "Windows_NT" ]; then + npm_global_bin="$npm_config_prefix/bin" +fi +if [ "$1" = "--version" ]; then + echo "9.9.2" + exit +fi +if [ "$1" = "list" ] && [ "$2" = "-g" ]; then + # Report a version only if the package launcher is actually present in the per-project global prefix + # (pristine npm reports nothing for a package that is not installed yet). + if [ -n "$npm_config_prefix" ] && [ -e "$npm_global_bin/$3" ]; then + echo "-- $3@0.34.0" + fi + exit 1 +fi +if [ "$1" = "install" ]; then + # Pristine: global packages are installed into the per-project global prefix. + if [ -n "$npm_config_prefix" ]; then + mkdir -p "$npm_global_bin" + cp target/ide-projects/corepack/repository/corepack/corepack/default/corepack "$npm_global_bin/corepack" + fi +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..f5a0cfa6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm @@ -0,0 +1,6 @@ +#!/bin/bash +if [ "${1}" == "--version" ]; then + echo "9.9.2" + exit +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json new file mode 100644 index 00000000..2d5a24e6 --- /dev/null +++ b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/.userdata/User/settings.json @@ -0,0 +1,4 @@ +{ + "telemetry.telemetryLevel": "off", + "update.mode": "none" +} diff --git a/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json new file mode 100644 index 00000000..ad92582b --- /dev/null +++ b/cli/src/test/resources/ide-projects/vscode/project/settings/vscode/workspace/update/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls new file mode 100644 index 00000000..290f63d9 --- /dev/null +++ b/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls @@ -0,0 +1 @@ +${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz diff --git a/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm new file mode 100755 index 00000000..34e17df5 --- /dev/null +++ b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm @@ -0,0 +1,29 @@ +#!/bin/bash +# Simulates npm for tests. Global packages go into the per-project global prefix (npm_config_prefix). +# npm places the package launcher in the prefix root on Windows but in /bin on POSIX, so the +# launcher location is chosen per platform (this must mirror Npm#setEnvironment, which adds the same +# platform-specific folder to the PATH). +npm_global_bin="$npm_config_prefix" +if [ "$OS" != "Windows_NT" ]; then + npm_global_bin="$npm_config_prefix/bin" +fi +if [ "$1" = "--version" ]; then + echo "9.9.2" + exit +fi +if [ "$1" = "list" ] && [ "$2" = "-g" ]; then + # Report a version only if the package launcher is actually present in the per-project global prefix + # (pristine npm reports nothing for a package that is not installed yet). + if [ -n "$npm_config_prefix" ] && [ -e "$npm_global_bin/$3" ]; then + echo "-- $3@9.9.2" + fi + exit 1 +fi +if [ "$1" = "install" ]; then + # Pristine: global packages are installed into the per-project global prefix. + if [ -n "$npm_config_prefix" ]; then + mkdir -p "$npm_global_bin" + cp target/ide-projects/yarn/repository/yarn/yarn/default/yarn "$npm_global_bin/yarn" + fi +fi +echo "npm $*" diff --git a/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx new file mode 100755 index 00000000..0723c204 --- /dev/null +++ b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx @@ -0,0 +1,2 @@ +#!/bin/bash +echo "npx $*" diff --git a/cli/src/test/resources/settings/ide.properties b/cli/src/test/resources/settings/ide.properties new file mode 100644 index 00000000..e69de29b diff --git a/cli/src/test/resources/templates/setup/main.prefs b/cli/src/test/resources/templates/setup/main.prefs index c20cafbb..29f8d754 100644 --- a/cli/src/test/resources/templates/setup/main.prefs +++ b/cli/src/test/resources/templates/setup/main.prefs @@ -1,2 +1,2 @@ theme=dark -ui=classic \ No newline at end of file +ui=classic diff --git a/cli/src/test/resources/templates/update/main.prefs b/cli/src/test/resources/templates/update/main.prefs index 58cce161..89102af4 100644 --- a/cli/src/test/resources/templates/update/main.prefs +++ b/cli/src/test/resources/templates/update/main.prefs @@ -1,2 +1,2 @@ java.version=1.11 -java.home=${IDE_HOME}/software/java \ No newline at end of file +java.home=${IDE_HOME}/software/java diff --git a/documentation/LICENSE.adoc b/documentation/LICENSE.adoc index 735801f7..ec98bd0e 100644 --- a/documentation/LICENSE.adoc +++ b/documentation/LICENSE.adoc @@ -108,6 +108,7 @@ The column `inclusion` indicates the way the component is included: |https://tomcat.apache.org/[tomcat]|Optional|https://www.apache.org/licenses/LICENSE-2.0[Apache 2.0] |https://www.oracle.com/java/technologies/jdk-mission-control.html[Java Mission Control]|Optional|https://github.com/openjdk/jmc/blob/master/license/LICENSE.txt[UPL 1.0] and https://github.com/openjdk/jmc/blob/master/license/THIRDPARTYREADME.txt[EPL 2.0] |https://www.pgadmin.org/[pgAdmin]|Optional|https://www.pgadmin.org/licence/#postgresql[The PostgreSQL Licence] +|https://obsidian.md/[Obsidian]|Optional|https://obsidian.md/license[Obsidian Terms of Service (proprietary, free for commercial use)] |https://docs.astral.sh/uv/[uv] |Optional|https://docs.astral.sh/uv/reference/policies/license/[Apache 2.0] |https://squirrel-sql.sourceforge.io/[SQuirreL SQL Client]|Optional|https://github.com/squirrel-sql-client/squirrel-sql-stable-releases/blob/main/LICENSE[LGPL 2.1] |https://www.soapui.org/[SoapUI]|Optional|https://github.com/SmartBear/soapui/blob/next/LICENSE.txt[EUPL 1.1] diff --git a/documentation/cli.adoc b/documentation/cli.adoc index 295c1ec3..05d726f6 100644 --- a/documentation/cli.adoc +++ b/documentation/cli.adoc @@ -18,7 +18,7 @@ In case you are not inside of a `IDEasy` folder the command will echo a message You are not inside an IDEasy installation: / [~]$ cd projects/my-project/workspaces/test/my-git-repo [my-git-repo]$ ide -IDEasy environment variables have been set for /projects/my-project in workspace main +IDEasy environment variables have been set for /projects/my-project with workspace set to test [my-git-repo]$ echo $IDE_HOME ~/projects/my-project [my-git-repo]$ echo $JAVA_HOME @@ -129,11 +129,11 @@ IDEasy also ships with the `icd` command that can be used as fancy improved vers ```bash [/tmp]$ icd -p myproject -w myworkspace -IDE environment variables have been set for /~/projects/myproject in workspace myworkspace +IDE environment variables have been set for /~/projects/myproject with workspace set to myworkspace [~/projects/myproject/workspaces/myworkspace]$ cd myrepo/mymodule/src/main/java/com/foo/bar/some/path [.../some/path]$ echo "do something here but now I want to go back to my project root dir" [.../some/path]$ icd -IDE environment variables have been set for /~/projects/myproject in workspace main +IDE environment variables have been set for /~/projects/myproject with workspace set to main (fallback to default) [~/projects/myproject]$ echo "Wow! IDEasy is awesome!" ``` @@ -144,7 +144,7 @@ If the repository is given explicitly but not found in any workspace, `icd` fail ```bash [/tmp]$ icd -p myproject -r myrepo -IDE environment variables have been set for /~/projects/myproject in workspace main +IDE environment variables have been set for /~/projects/myproject with workspace set to main [~/projects/myproject/workspaces/main/myrepo]$ echo "Found and jumped straight into myrepo!" ``` diff --git a/documentation/contributing/README.adoc b/documentation/contributing/README.adoc index 3389ed19..374c42d4 100644 --- a/documentation/contributing/README.adoc +++ b/documentation/contributing/README.adoc @@ -21,3 +21,7 @@ Further, we documented link:junit-testing.adoc[JUnit-testing] and link:integrati For security mapping in URL updater development, see link:cpe-url-updater.adoc[CPE integration for URL updaters]. +== GUI + +If you want to contribute to the GUI, please follow the link:ui-contribution-process.adoc[GUI contribution process] and read about the link:ui-structure.adoc[GUI architecture and structure]. + diff --git a/documentation/contributing/coding-conventions.adoc b/documentation/contributing/coding-conventions.adoc index 1d4d8c91..91546c7e 100644 --- a/documentation/contributing/coding-conventions.adoc +++ b/documentation/contributing/coding-conventions.adoc @@ -7,6 +7,22 @@ The code should follow general conventions for Java (see http://www.oracle.com/t We consider this as common sense instead of repeating this here. The following sections give us additional conventions that we consider additional. +== Consistency + +We value homogeneity: when in doubt, follow what is already there rather than introducing a +different way of doing the same thing. Concretely: + +* Follow the existing layout. Place a build, script or artifact next to what it belongs to (locality), +not in a detached central location. E.g. the macOS PKG build script lives at +`macos-installer/build-pkg.sh` and the Windows MSI build script at `windows-installer/build-msi.sh` +— each next to the installer it builds, not under `.github/workflows/`. (#2344) +* A later change follows the established pattern. If the repo already does something one way, a +later PR should follow that way instead of adding a second one. +* Prefer reusing existing infrastructure over reinventing the wheel — if Maven (or another build +tool) can do the job, use it. +* Mirror the counterpart for symmetry. When a peer module has something (a `README.adoc`, a +`pom.xml`), a newly added peer should carry the same so the layout stays homogeneous. + == Naming We follow these additional naming rules: diff --git a/documentation/contributing/commandlet-and-url-updater.adoc b/documentation/contributing/commandlet-and-url-updater.adoc index de271e21..e69de29b 100644 --- a/documentation/contributing/commandlet-and-url-updater.adoc +++ b/documentation/contributing/commandlet-and-url-updater.adoc @@ -1,223 +0,0 @@ -= Creating New Commandlets and UrlUpdaters - -== Overview - -This document explains how to implement a new `Commandlet` and `UrlUpdater` for IDEasy. -A `Commandlet` is a CLI sub-command and in case of a `ToolCommandlet` manages a tool (install, uninstall, run), while an `UrlUpdater` is responsible for discovering and maintaining available versions of a tool to sync the download-metadata in [ide-urls](http://github.com/ide-urls). - -Not all tools require a UrlUpdater, as tools using certain package manager ecosystems (npm, Maven, pip) don't need one because their package managers provide version discovery automatically. - -== Understanding Commandlets - -=== What is a Commandlet? - -A commandlet is a subcommand of the IDEasy CLI that manages a tool's lifecycle. -The base class is `Commandlet`, and for tools, the relevant base classes are: - -* `ToolCommandlet`: Base class for all tools -* `LocalToolCommandlet`: For tools installed locally into IDEasy -* `GlobalToolCommandlet`: For tools installed globally on the system - -Each commandlet implements: - -* Tool installation and uninstallation -* Edition and version management -* Retrieval of installed version information -* Binary execution and configuration - - -==== Tags - -Tags are used to classify and categorize tools. -When creating a commandlet, you must specify appropriate tags for your tool. - -Tags are defined in `cli/src/main/java/com/devonfw/tools/ide/common/Tag.java` as static constants in a hierarchical structure (e.g., `Tag.JAVA`, `Tag.PYTHON`, `Tag.BUILD`, etc.) and can be extended with new tags as needed. - -Use tags in your commandlet constructor: - -[source,java] ----- -public MyCommandlet(IdeContext context) { - super(context, "mycommandlet", Set.of(Tag.JAVA, Tag.BUILD)); // Use one or more tags -} ----- - -== Package Manager Ecosystems - -For tools that are part of established package manager ecosystems, IDEasy provides specialized base classes. -These ecosystems handle version discovery automatically via the package manager's registry and **no UrlUpdater is needed**. - -=== Maven Ecosystem - -**When to use:** For tools distributed via Maven Central Repository (or other Maven repositories) - -To check if an artifact is available on Maven Central, visit https://mvnrepository.com/ - -**Base class:** `MvnBasedLocalToolCommandlet` - -=== npm Ecosystem - -**When to use:** For JavaScript/Node.js tools distributed via npm registry (npmjs.org) - -To check if a package is available on npm, visit https://www.npmjs.com/ - -**Base classes:** - -* `NodeBasedCommandlet`: For Node.js itself -* `NpmBasedCommandlet`: For npm packages - -The npm registry provides automatic version discovery via its REST API. - -When implementing a new npm-based tool, you extend `NpmBasedCommandlet` (not `NodeBasedCommandlet` directly), as npm is based on Node.js and requires Node.js to be installed. - -=== pip Ecosystem - -**When to use:** For Python tools distributed via PyPI (Python Package Index) - -To check if a package is available on PyPI, visit https://pypi.org/ - -**Base class:** `PipBasedCommandlet` - -The pip repository provides automatic version discovery via its JSON API. - -== UrlUpdaters - -For tools that don't use package manager ecosystems, you need to implement an UrlUpdater, which is responsible to: - -1. Discover available versions of a tool -2. Find download URLs for each version -3. Validate that downloads are accessible -4. Handle multiple editions (if applicable) -5. Support multiple OS/architecture combinations - -=== Architecture Overview - -The main UrlUpdater types are: - -* `JsonUrlUpdater`: For REST APIs returning JSON -** `AbstractGithubUrlUpdater`: Base for GitHub-based updaters -*** `GithubUrlReleaseUpdater`: Preferred for GitHub releases -*** `GithubUrlTagUpdater`: Fallback if no releases available -* `MavenBasedUrlUpdater`: For Maven repository tools -* `NpmBasedUrlUpdater`: For npm registry tools -* `WebsiteUrlUpdater`: Last resort, web scraping only (avoid if possible!) - -=== JsonUrlUpdater (Recommended for REST APIs) - -Use `JsonUrlUpdater` when you have access to a REST API that returns JSON with version information. - -**Examples of tools using JsonUrlUpdater:** - -* JavaUrlUpdater -* AndroidStudioUrlUpdater -* IdeaBasedUrlUpdater: which is used for IntelliJ, Pycharm - -**What you need to implement:** - -* `getTool()` - Return the tool name -* `doGetVersionUrl()` - Return the API endpoint URL -* `getJsonObjectType()` - Return the root JSON class (create with Jackson annotations) -* `getVersionItems(T)` - Extract version items from the JSON object -* `addVersion(UrlVersion, V)` - Process each version and call `doAddVersion()` with the download URL - -=== GitHub Release Updater (Preferred for GitHub-hosted Tools) - -Use `GithubUrlReleaseUpdater` when: - -* The tool is hosted on GitHub -* Releases are published using GitHub's Release feature - -**Examples of tools using GithubUrlReleaseUpdater:** - -* Gradle -* Maven -* Mvnd -* Claude -* SquirrelSQL -* Inso - -**How to identify if a tool uses GitHub Releases:** - -* Check if the GitHub repository has a "Releases" section -* Example: https://api.github.com/repos/anthropics/claude-code/releases - -=== GitHub Tag Updater (When No Releases Available) - -Use `GithubUrlTagUpdater` only as a fallback when: - -* The tool doesn't use GitHub Releases -* Version discovery from git tags is the only option - -As git tags are less structured than releases and download URLs must be reconstructed manually. - -**Examples of tools using GithubUrlTagUpdater:** - -* Go -* Sonar -* Helm -* VsCode -* Uv - -=== WebsiteUrlUpdater (To Avoid!) - -Use `WebsiteUrlUpdater` only when previous options are not available. -This UrlUpdater relies on HTML and Minor HTML changes break the updater and require maintenance. - -**Examples of tools using WebsiteUrlUpdater (last resort):** - -* Docker Desktop -* Eclipse -* Jenkins -* Kotlinc -* OC (OpenShift Client) -* PgAdmin - -== Use Case: Creating a Commandlet and UrlUpdater for a new tool - -=== Create an UrlUpdater for the new tool (when needed) - -**1. Choose the right base class:** - -* If the tool is part of a package manager ecosystem, you don't need a UrlUpdater. -* If the tool has a REST API with JSON responses, use `JsonUrlUpdater`. -* If the tool is hosted on GitHub with proper releases, use `GithubUrlReleaseUpdater`. -* If the tool is hosted on GitHub but only has tags, use `GithubUrlTagUpdater`. -* Only if none of the above options are available, use `WebsiteUrlUpdater`. - -**2. Implement the UrlUpdater:** - -* UrlUpdater class: `MyToolUrlUpdater` located in: `url-updater/src/main/java/com/devonfw/tools/ide/url/tool/mytool/` -* Implement required methods based on the chosen base class (e.g., `getTool()`, `doGetVersionUrl()`, etc.) -* Register your updater by adding it to `UpdateManager.updaters` list in: `url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java` -* Add a test for the created UrlUpdater with a mock class in `src/test/java/com/devonfw/tools/ide/url/tool/mytool/` -** MyToolUrlUpdaterTest -** MyToolUrlUpdaterMock: this file test requires a JSON file in `src/test/resources/com/devonfw/tools/ide/url/tool/mytool/` containing a sample response from the API. -The file format and name depend on the base class used. - - -To test the UrlUpdater, you can run in addition to the unit test the `UpdateInitiator` class located in `url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateInitiator.java` which allows you to run the updater and see the discovered versions and URLs in the console. -The arguments to pass are: - -* Path to ide-urls project (where the url-updater module is located) -* Time to wait for the updater to finish (e.g., PT1H) -* Name of the updater to run (e.g., mytool) - -Once a day, the `UpdateInitiator` will run the updaters through GitHub Actions and fill the https://github.com/devonfw/ide-urls[ide-URL] repository with new version/tools that are identified by the Updaters. - -=== Create a Commandlet for the new tool - -After merging the changes for the created UrlUpdater (if needed), you can check in https://github.com/devonfw/ide-urls if the new tool versions have been discovered and added to the `ide-urls` repository. - -Then you can proceed to create the Commandlet for the new tool: - -* Commandlet class: `MyTool.java` located in: `cli/src/main/java/com/devonfw/tools/ide/cli/tool/mytool/` -* Extend the appropriate base class (`ToolCommandlet`, `LocalToolCommandlet`, or `GlobalToolCommandlet`) -* Use the tags defined in `Tag.java` to classify your tool -* Add the new tool to `cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java` by adding it to the `commandlets` list in the constructor. -* Add a test for the created Commandlet in `src/test/java/com/devonfw/tools/ide/cli/tool/mytool/` with name `MyToolTest.java` -* For the test some resource files are needed in `cli/src/test/resources/ide-projects/mytool/` (depends on the base class used. -For an example of the required files, you can check the tests of other commandlets with the same base class). -* Proper help texts for all supported languages are added in `cli/src/main/resources/nls/` (`Help.properties`, `Help_de.properties`). -* The new tool is added to the table of tools in LICENSE.asciidoc with the correct license information and tags. - - diff --git a/documentation/contributing/commandlet.adoc b/documentation/contributing/commandlet.adoc new file mode 100644 index 00000000..3de68066 --- /dev/null +++ b/documentation/contributing/commandlet.adoc @@ -0,0 +1,110 @@ += Creating New Commandlets + +== Overview + +This document explains how to implement a new `Commandlet` for IDEasy. +A `Commandlet` is a CLI sub-command and in case of a `ToolCommandlet` manages a tool (install, uninstall, run). + +== Understanding Commandlets + +=== What is a Commandlet? + +A commandlet is a subcommand of the IDEasy CLI that manages a tool's lifecycle. +The base class is `Commandlet`, and for tools, the relevant base classes are: + +* `ToolCommandlet`: Base class for all tools +* `LocalToolCommandlet`: For tools installed locally into IDEasy +* `GlobalToolCommandlet`: For tools installed globally on the system + +Each commandlet implements: + +* Tool installation and uninstallation +* Edition and version management +* Retrieval of installed version information +* Binary execution and configuration + +While the LocalToolCommandlet allows mutltiple installations at the same time, GlobalToolCommandlet is restricted to one installation per machine. + +==== Tags + +Tags are used to classify and categorize tools. +When creating a commandlet, you must specify appropriate tags for your tool. + +Tags are defined in `cli/src/main/java/com/devonfw/tools/ide/common/Tag.java` as static constants in a hierarchical structure (e.g., `Tag.JAVA`, `Tag.PYTHON`, `Tag.BUILD`, etc.) and can be extended with new tags as needed. + +Use tags in your commandlet constructor: + +[source,java] +---- +public MyCommandlet(IdeContext context) { + super(context, "mycommandlet", Set.of(Tag.JAVA, Tag.BUILD)); // Use one or more tags +} +---- + +== Package Manager Ecosystems + +For tools that are part of established package manager ecosystems, IDEasy provides specialized base classes. +These ecosystems handle version discovery automatically via the package manager's registry and **no UrlUpdater is needed**. + +=== Maven Ecosystem + +**When to use:** For tools distributed via Maven Central Repository (or other Maven repositories) + +To check if an artifact is available on Maven Central, visit https://mvnrepository.com/ + +**Base class:** `MvnBasedLocalToolCommandlet` + +=== npm Ecosystem + +**When to use:** For JavaScript/Node.js tools distributed via npm registry (npmjs.org) + +To check if a package is available on npm, visit https://www.npmjs.com/ + +**Base classes:** + +* `NodeBasedCommandlet`: For Node.js itself +* `NpmBasedCommandlet`: For npm packages + +The npm registry provides automatic version discovery via its REST API. + +When implementing a new npm-based tool, you extend `NpmBasedCommandlet` (not `NodeBasedCommandlet` directly), as npm is based on Node.js and requires Node.js to be installed. + +=== pip Ecosystem + +**When to use:** For Python tools distributed via PyPI (Python Package Index) + +To check if a package is available on PyPI, visit https://pypi.org/ + +**Base class:** `PipBasedCommandlet` + +The pip repository provides automatic version discovery via its JSON API. + +== Create a Commandlet for the new tool + +After merging the changes for the created UrlUpdater (if needed), you can check in https://github.com/devonfw/ide-urls if the new tool versions have been discovered and added to the `ide-urls` repository. + +Then you can proceed to create the Commandlet for the new tool: + +* Commandlet class: `MyTool.java` located in: `cli/src/main/java/com/devonfw/tools/ide/cli/tool/mytool/` +* Extend the appropriate base class (`ToolCommandlet`, `LocalToolCommandlet`, or `GlobalToolCommandlet`) +* Use the tags defined in `Tag.java` to classify your tool +* Add the new tool to `cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java` by adding it to the `commandlets` list in the constructor. +* Add a test for the created Commandlet in `src/test/java/com/devonfw/tools/ide/cli/tool/mytool/` with name `MyToolTest.java` +* For the test some resource files are needed in `cli/src/test/resources/ide-projects/mytool/` (depends on the base class used. +For an example of the required files, you can check the tests of other commandlets with the same base class). +* Proper help texts for all supported languages are added in `cli/src/main/resources/nls/` (`Help.properties`, `Help_de.properties`). +* The new tool is added to the table of tools in LICENSE.asciidoc with the correct license information and tags. + +=== Test a Commandlet locally (before the UrlUpdater is merged) +You can test a new Commandlet locally even if the corresponding UrlUpdater has not yet been merged. + +Create a branch containing both your Commandlet changes and the UrlUpdater changes. Then follow the steps described in `url-updater.adoc` to run the `UpdateInitiator`. + +1. Ensure that `ide-urls-status` is cloned under `projects/_ide`. +2. Create and checkout a dedicated branch in `urls`. +3. Run the `UpdateInitiator` as described in `url-updater.adoc`. +4. Commit the generated changes in `urls`. +5. Run `ide install ` or `ide new ` to test the Commandlet. + +>[!Important] +Commit the generated changes before running ide install or ide new . Otherwise, the generated content may be discarded, and the Commandlet cannot be tested locally. diff --git a/documentation/contributing/contributing.adoc b/documentation/contributing/contributing.adoc index 8ec277a3..ce4c8adc 100644 --- a/documentation/contributing/contributing.adoc +++ b/documentation/contributing/contributing.adoc @@ -52,14 +52,24 @@ For further steps and details see link:pull-request.adoc[pull-request documentat == Create a new commandlet -For detailed information on implementing new commandlets and UrlUpdaters, see the comprehensive guide link:commandlet-and-url-updater.adoc[Creating New Commandlets and UrlUpdaters]. +For detailed information on implementing new commandlets, see the comprehensive guide link:commandlet.adoc[Creating New Commandlets]. That guide covers: -* Understanding the architecture of commandlets and UrlUpdaters -* When to use package manager ecosystems (npm, Maven, pip) vs. creating a custom UrlUpdater +* Understanding the architecture of commandlets +* When to use package manager ecosystems (npm, Maven, pip) +* Steps to implement and register new commandlets + +== URL Updaters + +For detailed information on implementing UrlUpdaters, see the comprehensive guide link:url-updater.adoc[Creating New UrlUpdaters]. + +That guide covers: + +* Creating a custom UrlUpdater * Choosing the right UrlUpdater base class (JsonUrlUpdater, GithubUrlReleaseUpdater, etc.) -* Steps to implement and register new commandlets and UrlUpdaters +* Steps to implement and register new UrlUpdaters + === LICENSE diff --git a/documentation/contributing/retest.adoc b/documentation/contributing/retest.adoc new file mode 100644 index 00000000..24eaa8cb --- /dev/null +++ b/documentation/contributing/retest.adoc @@ -0,0 +1,71 @@ +:toc: +toc::[] + +== Retesting Pull Requests + +After a pull request (PR) has been merged into `main`, it should be retested against the latest upstream snapshot to ensure that it continues to work +correctly after integration. + +Missing or delayed retests can introduce regressions, destabilize upcoming releases and delay the delivery of a new IDEasy version. +Therefore, retesting should be performed continuously as part of the regular QA process. + +=== When to retest + +* After PR was merged +* and you are the PR author +* or you are the reviewer and the original author is unavailable. + +=== Prerequisites + +Before starting the retest process, update your local IDEasy installation to the latest snapshot version so its contains the merged changes: + +[source,bash] +---- +ide upgrade --mode=snapshot +---- + +=== Retest Workflow + +. Ensure the milestone is pointing to the current release (it was correctly updated after the last release). +. https://github.com/orgs/devonfw/projects/5/views/12=[Open the Retests IDEasy board]. +. Select your GitHub username in the *Author* filter. +. Open all listed pull requests in separate browser tabs. +. Review and retest each pull request individually. + +=== Retesting a Pull Request + +For every pull request: + +. Understand the original change and determine how it can be verified. +. Perform the retest using the IDEasy command line. +. Document the result by adding a comment to the pull request. ++ +Example: ++ +---- +Successfully tested on Windows 11 + +ide foo bar + + +---- +. If you discover an issue during retesting: +.. immediately create a new bug issue. +.. Reference the bug issue in your pull request comment. +. Add the `QA approved` label to the pull request once the retest is completed (even if you created and linked a bug) + +NOTE: Sometimes a PR only includes a documentation change, a code cleanup, a test improvement, a workflow or URL-updater change, +or something else that cannot be tested end-to-end on the commandline. In such case comment your observation e.g. `test changes merged and +CI is still green` or `URL-Updater passed and result can be found here` (deep-link to ide-urls pointing to the new tool/edition added). + +=== Retest Result Expectations + +A successful retest comment should contain: + +* The operating system used for testing. +* The executed command(s). +* Relevant output or observations. +* A clear statement whether the retest passed or failed. + +If the retest fails, always create and link a dedicated bug issue before continuing. +This ensures that regressions are tracked and can be addressed before the next IDEasy release. diff --git a/documentation/contributing/ui-contribution-process.adoc b/documentation/contributing/ui-contribution-process.adoc new file mode 100644 index 00000000..c9c89e9c --- /dev/null +++ b/documentation/contributing/ui-contribution-process.adoc @@ -0,0 +1,67 @@ += Contributing to the IDEasy GUI +:toc: +:toclevels: 4 +:sectnums: + +== Introduction + +When contributing to the UI, a few special guidelines are relevant in order to ensure a high quality and consistency. +This guide acts as an overview. + +== Contribution workflow + +GUI contributions should follow a short design-first workflow before implementation of the actual task starts. +This ensures consistency of parallel work on UI contributions and makes reviews easier. + +. Pick an issue +. Create a design (see details) +. Review the design with the team +. Start with the actual implementation + +From here on, continue with the normal IDEasy contribution workflow (open PR, team review, review, merge, retest). + +=== Pick an issue + +Before implementing anything: + +. We use the `GUI` label to mark issues related to the graphical user interface. +. Follow our usual link:issue.adoc#working-on-an-issue[process to pick an issue]. + +=== Create a design proposal + +For visible GUI changes, do *not* start directly with JavaFX implementation. +First create a design proposal. + +Recommended flow: + +. Create a design sub-issue for the parent GUI issue (e.g.: Parent GUI issue: "Add project config screen", design sub-issue: "Design: Add project config screen"). +. Mark the parent issue as blocked by the design sub-task in GitHub. +. Plan the screen or interaction in the Figma template. +. Move the design sub-task into team review when ready (same process as with other tasks, however, we move the issue into review, as we do the design on Figma, we do *not* need Pull Requests!). +. Move the design sub-task into review when passed the team review. +. When passed the review, close the design sub-issue. + +=== Final implementation + +. Implement the actual GUI change in JavaFX just like any other contribution. +. Follow the conventions described in link:ui-structure.adoc[UI Architecture and Structure] (MVVM, localization, wiring). + +==== Create a new tab + +If your contribution adds a new tab to the main window: + +. Add the tab's title key and all other user-facing labels to `gui/src/main/resources/nls/messages.properties` and provide the translations. +. Create the ViewModel, the View with its FXML, and a class extending `TabComponent` that wires them together (see link:ui-structure.adoc#tabs[Tabs]). +. Add a public method to `TabFactory` that opens your tab by passing a new instance of your component to the factory's internal `open` method, analogous to `openLauncherTab()`: ++ +[source,java] +---- +public void openMyFeatureTab() { + open(new MyFeatureTab(this.guiStateManager)); +} +---- +. Trigger the tab from the UI via the `TabFactory` provided by the link:ui-structure.adoc#gui-state-manager[GuiStateManager], e.g. `guiStateManager.getTabFactory().openMyFeatureTab()`. + +== Figma link + +You can access the Figma board here: https://www.figma.com/design/cEcWlvnNOS5xVE2kQFZe0R/IDEasy?t=K358vlISaq7t2Nfp-1 diff --git a/documentation/contributing/ui-structure.adoc b/documentation/contributing/ui-structure.adoc new file mode 100644 index 00000000..d5c1652d --- /dev/null +++ b/documentation/contributing/ui-structure.adoc @@ -0,0 +1,307 @@ += IDEasy UI Architecture and Structure + +This document describes the architecture, structure, and best practices for the IDEasy GUI module. +It serves as a reference for contributors working on the UI layer. + +== Architecture Overview + +The IDEasy GUI follows the **Model-View-ViewModel (MVVM)** pattern with a feature-based package organization. +The architecture emphasizes: + +* *Model-View-ViewModel pattern (MVVM)*: separation of concerns into Models, Views, and ViewModels +* *Component Reusability*: Reusable components through a shared component library (e.g.: Console component) +* *UI Reactivity*: via Observer patterns and property bindings +* *Maintainability*: through localization, programmatic wiring, and comprehensive testing +* *Scalability*: via feature-based organization allowing independent development +* [[gui-state-manager]]*State management*: The state of the UI is stored in the central `GuiStateManager`. +It creates and provides the shared objects and holds the GUI state, such as: +** all *service classes* of the application (e.g. `CommandletService`, `NlsService`) +** all *factory classes* of the application (e.g. `TabFactory`) +** the selected project and workspace +** the language selected by the user (via the `NlsService`) + +For a visual overview of the main classes and how the GUI is launched, see the PlantUML diagrams link:diagrams/gui/gui-structure-overview.puml[GUI structure overview] and link:diagrams/gui/gui-launch-sequence.puml[GUI launch sequence] (viewing them requires PlantUML support, e.g. via the IntelliJ plugin). + +=== Roles + +The GUI is organized around a small set of roles. Each has a single responsibility: + +[cols="1,4",options="header"] +|==== +|Role|Responsibility + +|*Factory* +|Creates ViewModels and Views while holding the services needed to build them. E.g. `TabFactory` creates the tabs of the main window. Created by the `GuiStateManager`. + +|*Service* +|Encapsulates business logic and access to data/functionality, independently of the UI. E.g. `CommandletService` runs commandlets in the selected context; `NlsService` handles localization. Created by the `GuiStateManager`. + +|*View* +|The UI representation without business logic. It displays data from the ViewModel through bindings and translates user gestures back onto it. E.g. `MainWindowView`, `IdeLauncherView`. + +|*ViewModel* +|Acts as a bridge between the View and the Services/Factory/Data layer. It maintains the current (observable) state and contains the UI-related logic. E.g. `MainWindowViewModel`, `IdeLauncherViewModel`. +|==== + +Note: the term *Model* in MVVM refers to the observable application state. In practice this lives in the `context` package (e.g. `GuiStateManager`, `TaskManager`) and is exposed to the UI as JavaFX `Property`s. + +[#tabs] +=== Tabs + +The main window is organized into tabs (e.g. the IDE launcher). +Each tab is a self-contained `TabComponent` that bundles everything belonging to that tab: its title key, its ViewModel (extends `TabViewModel`), its View (extends `TabView`), the JavaFX `Tab` tying them together, and the lifecycle hooks around opening, closing and focusing. + +Therefore the `TabFactory` does *not* need to know any concrete ViewModel or View — it only opens `TabComponent`s and sends the resulting `Tab`s as events to the `MainWindowView`. +This follows the *Tell, Don't Ask* principle: the caller tells the factory to open a component instead of asking it for its title key and assembling the parts itself. + +Consequently, to add a tab you create a new class extending `TabComponent` (see link:ui-contribution-process.adoc[Contributing to the IDEasy GUI] for the steps). + +== Observer / Observable Pattern + +IDEasy uses the **Observer/Observable pattern** (and JavaFX `Property` bindings) to propagate changes between the observable state (the Model), the ViewModels, and the Views (not to be confused with EventBus!). + +* The **Model** (the `context` layer, e.g. `GuiStateManager`, `TaskManager`) and the **ViewModels** act as *Observables*: they hold the current state as JavaFX `Property`s and notify observers when the state changes. +ViewModels typically derive their state from the Model by binding to its properties. +* **Views** act as *Observers*: they bind to the ViewModel's properties or register listeners, and react to changes by updating the UI. +* JavaFX `Property` and `Binding` APIs are used for fine-grained reactivity within the UI layer. + +=== Implementation Guidelines + +* Expose state as JavaFX `Property`s (`SimpleStringProperty`, `SimpleBooleanProperty`, `SimpleObjectProperty`, `ObservableList`, …) and expose them through `...Property()` accessors. +* Views bind to view-model properties (bidirectionally where appropriate, e.g. a `ComboBox` value) and register listeners where imperative reaction is required. +* Prefer JavaFX `Property` bindings for View-Model synchronization over manual polling or ad-hoc callbacks. +* Remove listeners when a component is discarded to avoid memory leaks. + +=== Example + +A view-model exposes observable state that a view binds to directly: + +[source,java] +---- +// View model: exposes the "a workspace is selected" state as an observable property +public class IdeLauncherViewModel extends TabViewModel { + + private final BooleanProperty isIdeButtonsDisabled = + new SimpleBooleanProperty(); + + public IdeLauncherViewModel(GuiStateManager guiStateManager) { + super(guiStateManager); + + // The launcher buttons stay disabled until a project/workspace is selected. + this.isIdeButtonsDisabled.bind(this.guiStateManager.workspaceSelectedProperty().not()); + } + + public BooleanProperty isIdeButtonsDisabledProperty() { + return this.isIdeButtonsDisabled; + } +} + +// View: binds each button's disabled state to the view model — no imperative code needed +@FXML +private void initialize() { + this.eclipseOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.eclipseOpen.setOnAction(_ -> this.viewModel.runCommandlet("eclipse")); + // ...same for the other IDE buttons +} +---- + +== Package Structure per Feature + +The codebase follows a **layer- and feature-based package organization** rather than a purely layer-based one. +Each feature owns its View/ViewModel pair and related components, while cross-cutting concerns live in dedicated top-level packages. + +=== Packages + +All packages live below `com.devonfw.ide.gui`: + +* `App` / `AppLauncher` — application entry point. +* `core` — cross-cutting code shared by all features: +** `context` — the observable application state (the Model): project/workspace selection, task management, and the `IdeGuiContext`. +** `event` — the `GuiEventBus` and its events (e.g. `TabChangeEvent`). +** `factory` — factories that create Views/ViewModels (e.g. `TabFactory` for the tabs of the main window). +** `helper` — reusable JavaFX helpers, e.g. `FxHelper` (FXML loading, FX-thread-safe execution) and `ComboBoxViewModel`. +** `mainwindow` — the main window including the `console` and `navigation` panels. +** `modal` — dialogs (e.g. `IdeDialog`). +** `progress` — progress tracking of running tasks and the task overview window. +** `service` — business logic and data access independent of the UI (e.g. `CommandletService`, `NlsService`). +** `tab` — the base classes for tabs (`TabComponent`, `TabView`, `TabViewModel`). +* `feature` — one package per feature containing its `TabComponent`, View and ViewModel (e.g. `feature.ide.launcher`). + +[source] +---- +gui +└── src/main/java + └── com/devonfw/ide/gui + ├── App.java + ├── core + │ ├── context + │ ├── event + │ ├── factory + │ ├── helper + │ ├── mainwindow + │ │ ├── console + │ │ └── navigation + │ ├── modal + │ ├── progress + │ │ └── taskwindow + │ ├── service + │ └── tab + └── feature + └── ide + └── launcher +---- + +=== FXML Resources + +FXML files live under `gui/src/main/resources/com/devonfw/ide/gui/`, mirroring the Java package of their View/Controller class (e.g. `core/mainwindow/MainWindowView.fxml` for `core.mainwindow.MainWindowView`). +The `%key` placeholders are resolved against the resource bundle provided by `NlsService`. +Note that the localization bundles in `nls` are located directly below `src/main/resources`. + +[source] +---- +gui +└── src/main/resources + ├── com/devonfw/ide/gui + │ ├── assets + │ │ ├── icons + │ │ │ └── ... + │ │ └── ... (logos, e.g. eclipse.png, intellij.png) + │ ├── core + │ │ ├── mainwindow + │ │ │ ├── MainWindowView.fxml + │ │ │ ├── console + │ │ │ │ └── console.fxml + │ │ │ └── navigation + │ │ │ └── NavigationPanel.fxml + │ │ └── progress + │ │ └── taskwindow + │ │ └── task_overview_window.fxml + │ ├── feature + │ │ └── ide + │ │ └── launcher + │ │ └── IdeLauncher.fxml + │ └── style + │ ├── center.css + │ └── navigation.css + └── nls + ├── messages.properties + ├── messages_de.properties + └── messages_en.properties +---- + +=== Benefits + +* **High cohesion**: All code for a feature is co-located +* **Low coupling**: Features communicate via well-defined interfaces/events +* **Scalability**: New features can be added without touching existing ones +* **Team autonomy**: Teams can own features end-to-end + +== Localization as a Best Practice + +All user-facing strings **must** be externalized for localization (i18n). +This is implemented in code via resource bundles; handled by the `NlsService` class. + +* **Resource Bundles**: Each language has its own `messages.properties` (like `messages_de.properties`) +* **Key Naming**: For new keys, use hierarchical names: `feature.component.action` (e.g., `tab.ide_launcher`, `settings.dialog.save`). Some older keys (e.g. `project`, `ideasy_ready_msg`) do not follow this scheme yet. +* **No Hardcoded Strings**: Never embed user-visible text in Java code or FXML +* **Interpolation**: Use `MessageFormat` for dynamic values: `{0} packages updated` + +=== Implementation + +* In FXML layout files, we use the `%key` pattern for localized strings. (example: `

        Collects log entries produced by the application and exposes them as an observable, read-only list + * suitable for binding to UI controls. Messages are buffered to avoid flooding the JavaFX application thread and flushed in batches via FxHelper.

        + */ +public class ConsoleViewModel { + + private final ObservableList logEntries = FXCollections.observableArrayList(); + private final ObservableList readOnlyLogEntries = FXCollections.unmodifiableObservableList(logEntries); + private final SimpleBooleanProperty autoScrollEnabled = new SimpleBooleanProperty(true); + private final SimpleIntegerProperty lineCount = new SimpleIntegerProperty(logEntries.size()); + + /** Whether a batch flush is already scheduled on the FX thread. */ + private boolean flushPending; + + /** + * Thread-safe buffer collecting messages that arrive while a batched UI update is already pending. Keeps the FX thread from being overwhelmed by individual + * {@link javafx.application.Platform#runLater(Runnable)} submissions. + */ + private final Deque outputBuffer = new ArrayDeque<>(); + + + /// @param eventBus event bus to listen for log events + public ConsoleViewModel(GuiEventBus eventBus) { + logEntries.addListener((ListChangeListener) change -> { + while (change.next()) { + if (change.wasAdded() || change.wasRemoved()) { + lineCount.set(logEntries.size()); + } + } + }); + + eventBus.addListener(LogEvent.class, event -> { + IdeLogEntry entry = event.logEntry(); + appendOutput(entry); + }); + } + + /** + * Prints a log entry to the console. + * + * @param entry the log entry to append + */ + public void appendOutput(IdeLogEntry entry) { + + synchronized (outputBuffer) { + outputBuffer.add(entry); + if (!flushPending) { + flushPending = true; + FxHelper.runFxSafe(this::flushBuffer); + } + } + } + + /** + * Prints a simple message to the console. + * + * @param message message to be printed + */ + public void appendOutput(String message) { + + appendOutput(new IdeLogEntry(IdeLogLevel.INFO, message)); + } + + /** + * Prints a message with a log level to the console. + * + * @param level the log level + * @param message the message + */ + public void appendOutput(IdeLogLevel level, String message) { + + appendOutput(new IdeLogEntry(level, message)); + } + + /** + * Flushes all buffered messages to the console. + */ + private void flushBuffer() { + + List entriesToAdd; + synchronized (outputBuffer) { + if (outputBuffer.isEmpty()) { + flushPending = false; + return; + } + entriesToAdd = outputBuffer.stream().toList(); + outputBuffer.clear(); + flushPending = false; + } + + logEntries.addAll(entriesToAdd); + } + + /** + * Gets an unmodifiable observable list of log entries currently shown in the console. + * + * @return read-only observable list of IdeLogEntry instances suitable for UI binding + */ + public ObservableList logEntries() { + return readOnlyLogEntries; + } + + /** + * Clears the console. + */ + protected void clearConsole() { + + synchronized (outputBuffer) { + outputBuffer.clear(); + flushPending = false; + } + + logEntries.clear(); + } + + /** + * Gets the current output that is on the console. + * + * @return a list of the current console output lines + */ + public List getConsoleOutputSnapshot() { + + return logEntries.stream().map(IdeLogEntry::displayValue).collect(Collectors.toList()); + } + + /** + * Property that controls whether the console should automatically scroll to the end when new entries are appended. Bind this to the UI toggle controlling + * auto-scroll behaviour. + * + * @return the auto-scroll enabled boolean property + */ + public SimpleBooleanProperty autoScrollEnabledProperty() { + return autoScrollEnabled; + } + + /** + * Property exposing the current number of displayed log entries. Updated automatically when entries are added or removed; suitable for binding to UI labels + * or status indicators. + * + * @return integer property containing the current line count + */ + public SimpleIntegerProperty lineCountProperty() { + return lineCount; + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java new file mode 100644 index 00000000..45dbd6af --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelView.java @@ -0,0 +1,60 @@ +package com.devonfw.ide.gui.core.mainwindow.navigation; + + +import javafx.fxml.FXML; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.VBox; + +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.service.NlsService; + +public class NavigationPanelView extends VBox { + + @FXML + private ComboBox projects; + + @FXML + private ComboBox workspaces; + + @FXML + private ComboBox languages; + + private final NlsService nlsService; + + private final NavigationPanelViewModel viewModel; + + /** + * Creates the navigation panel and loads its FXML, resolving the default-locale bundle for the {@code %key} text. + */ + public NavigationPanelView(NavigationPanelViewModel viewModel, NlsService nlsService) { + super(); + + this.nlsService = nlsService; + this.viewModel = viewModel; + + FxHelper.loadFxml("NavigationPanel.fxml", nlsService, this); + } + + @FXML + private void initialize() { + this.projects.setItems(viewModel.getProjectsViewModel().getItems()); + this.projects.valueProperty().bindBidirectional(viewModel.getProjectsViewModel().selectedItemProperty()); + this.projects.getSelectionModel().selectedItemProperty().addListener((_, _, new_value) -> { + if (new_value != null) { + viewModel.populateWorkspaceComboBox(); + } + }); + + this.workspaces.setItems(viewModel.getWorkspacesViewModel().getItems()); + this.workspaces.valueProperty().bindBidirectional(viewModel.getWorkspacesViewModel().selectedItemProperty()); + this.workspaces.disableProperty().bind(viewModel.getProjectsViewModel().selectedItemProperty().isNull()); + this.workspaces.getSelectionModel().selectedItemProperty().addListener((_, _, new_value) -> { + if (new_value != null) { + viewModel.updateContext(); + } + }); + + this.languages.setItems(viewModel.getLanguagesViewModel().getItems()); + this.languages.valueProperty().bindBidirectional(viewModel.getLanguagesViewModel().selectedItemProperty()); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java new file mode 100644 index 00000000..0d8f71ca --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModel.java @@ -0,0 +1,142 @@ +package com.devonfw.ide.gui.core.mainwindow.navigation; + +import java.io.FileNotFoundException; +import java.nio.file.NotDirectoryException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javafx.scene.control.Alert.AlertType; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.context.ProjectManager; +import com.devonfw.ide.gui.core.helper.combobox.ComboBoxViewModel; +import com.devonfw.ide.gui.core.modal.IdeDialog; +import com.devonfw.ide.gui.core.service.NlsService; + +public class NavigationPanelViewModel { + + private final ComboBoxViewModel projectsViewModel = new ComboBoxViewModel<>(); + + private final ComboBoxViewModel workspacesViewModel = new ComboBoxViewModel<>(); + + private final ComboBoxViewModel languagesViewModel = new ComboBoxViewModel<>(); + + private final Map languageMap = new LinkedHashMap<>(); + + private final GuiStateManager guiStateManager; + + private final ProjectManager projectManager; + + private final NlsService nlsService; + + public NavigationPanelViewModel(GuiStateManager guiStateManager) { + this.guiStateManager = guiStateManager; + this.projectManager = guiStateManager.getProjectManager(); + this.nlsService = guiStateManager.getNlsService(); + + initProjectsComboBox(); + initLanguageComboBox(); + } + + private void initProjectsComboBox() { + this.projectsViewModel.getItems().clear(); + this.projectsViewModel.getItems().addAll(this.projectManager.getProjectNames()); + } + + public void populateWorkspaceComboBox() { + + List loadedWorkspaces = loadWorkspaces(this.projectsViewModel.getSelectedItem().orElse(null)); + + this.workspacesViewModel.clearSelection(); + this.workspacesViewModel.getItems().clear(); + this.workspacesViewModel.getItems().addAll(loadedWorkspaces); + + // Default to the "main" workspace when it exists. + if (loadedWorkspaces.contains(GuiStateManager.DEFAULT_WORKSPACE)) { + this.workspacesViewModel.select(GuiStateManager.DEFAULT_WORKSPACE); + } + } + + private List loadWorkspaces(String project) { + + try { + return this.projectManager.getWorkspaceNames(project); + } catch (NotDirectoryException e) { + throw new RuntimeException(e); + } + } + + private void initLanguageComboBox() { + + this.languageMap.clear(); + this.languagesViewModel.getItems().clear(); + + for (Locale locale : this.nlsService.getAvailableLocales()) { + String displayName = this.nlsService.getLanguageDisplayName(locale); + this.languageMap.put(displayName, locale); + } + + this.languagesViewModel.getItems().addAll(this.languageMap.keySet()); + //initial value + this.languagesViewModel.select(resolveLanguageSelection(this.nlsService.getLocale())); + } + + private String resolveLanguageSelection(Locale currentLocale) { + + if (currentLocale == null) { + return this.languageMap.keySet().stream().findFirst().orElse(null); + } + + String languageMatch = null; + + for (Map.Entry entry : this.languageMap.entrySet()) { + Locale entryLocale = entry.getValue(); + // Exact language tag match takes priority + if (entryLocale.toLanguageTag().equalsIgnoreCase(currentLocale.toLanguageTag())) { + return entry.getKey(); + } + // Track language-only match as fallback + if (languageMatch == null) { + if (entryLocale.getLanguage().equalsIgnoreCase(currentLocale.getLanguage())) { + languageMatch = entry.getKey(); + } + } + } + + // Return language-only match if found, otherwise first available + return languageMatch != null ? languageMatch : this.languageMap.keySet().stream().findFirst().orElse(null); + } + + public void updateContext() { + + String project = this.projectsViewModel.getSelectedItem().orElse(null); + String workspace = this.workspacesViewModel.getSelectedItem().orElse(null); + + if (project == null || workspace == null) { + this.guiStateManager.workspaceSelectedProperty().set(false); + return; + } + + try { + this.guiStateManager.switchContext(project, workspace); + } catch (FileNotFoundException e) { + IdeDialog errorDialog = new IdeDialog(AlertType.ERROR, e.getMessage()); + errorDialog.showAndWait(); + } + } + + public ComboBoxViewModel getProjectsViewModel() { + return projectsViewModel; + } + + public ComboBoxViewModel getWorkspacesViewModel() { + return workspacesViewModel; + } + + public ComboBoxViewModel getLanguagesViewModel() { + return languagesViewModel; + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java b/gui/src/main/java/com/devonfw/ide/gui/core/modal/IdeDialog.java similarity index 89% rename from gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java rename to gui/src/main/java/com/devonfw/ide/gui/core/modal/IdeDialog.java index f152d30c..4bd6ea5f 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/modal/IdeDialog.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/modal/IdeDialog.java @@ -1,10 +1,12 @@ -package com.devonfw.ide.gui.modal; +package com.devonfw.ide.gui.core.modal; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.scene.image.Image; import javafx.stage.Stage; +import com.devonfw.ide.gui.App; + /** * Custom Alert class for IDEasy to allow interaction via the CLIs questions/modals/selections. */ @@ -36,7 +38,7 @@ private void setupDefaultProperties() { setOnShowing(event -> { Stage stage = (Stage) getDialogPane().getScene().getWindow(); - stage.getIcons().add(new Image("com/devonfw/ide/gui/assets/devonfw.png")); + stage.getIcons().add(new Image(App.ICON_PATH)); }); } } diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/ProgressBarTask.java similarity index 91% rename from gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/ProgressBarTask.java index 7f2ac00b..754a268e 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/ProgressBarTask.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/ProgressBarTask.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress; +package com.devonfw.ide.gui.core.progress; import javafx.beans.property.BooleanProperty; import javafx.beans.property.LongProperty; @@ -10,8 +10,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.FxHelper; -import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.helper.FxHelper; import com.devonfw.tools.ide.io.AbstractIdeProgressBar; /** @@ -78,8 +78,12 @@ protected void doStepBy(long stepSize, long currentProgress) { FxHelper.runFxSafe(() -> progressProperty.setValue(getCurrentProgress())); } + /** + * Exposed publicly (wider than the {@code protected} declaration in {@link AbstractIdeProgressBar}) so the task window tests, which live in a separate + * subpackage, can drive the progress bar to a precise position. + */ @Override - protected void doStepTo(long stepPosition) { + public void doStepTo(long stepPosition) { LOG.debug("Updating progress bar to {}", getCurrentProgress()); diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindow.java similarity index 92% rename from gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindow.java index 39474f24..19dc6ced 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindow.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindow.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress.taskwindow; +package com.devonfw.ide.gui.core.progress.taskwindow; import java.io.IOException; @@ -12,8 +12,7 @@ import javafx.stage.Stage; import javafx.stage.StageStyle; -import com.devonfw.ide.gui.App; -import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.core.context.TaskManager; /** * This window is displayed when the user clicks on the task label in main-view.fxml. @@ -41,7 +40,7 @@ public static TaskOverviewWindow getInstance(TaskManager taskManager) { */ private TaskOverviewWindow(TaskManager taskManager) { - FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("task_overview_window.fxml")); + FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("task_overview_window.fxml")); fxmlLoader.setController(new TaskOverviewWindowController(taskManager)); Parent root; diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindowController.java similarity index 88% rename from gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindowController.java index 19776bf0..89bf320b 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskOverviewWindowController.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskOverviewWindowController.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress.taskwindow; +package com.devonfw.ide.gui.core.progress.taskwindow; import javafx.beans.Observable; import javafx.collections.FXCollections; @@ -6,8 +6,8 @@ import javafx.fxml.FXML; import javafx.scene.control.ListView; -import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Controller for the task overview window, which shows all currently running tasks and their progressbar. diff --git a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowCellFactory.java similarity index 96% rename from gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java rename to gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowCellFactory.java index c7b943da..c39edd7d 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/progress/taskwindow/TaskWindowCellFactory.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowCellFactory.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress.taskwindow; +package com.devonfw.ide.gui.core.progress.taskwindow; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -16,7 +16,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Cell factory for displaying a list of tasks in the {@link TaskOverviewWindow} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java b/gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java new file mode 100644 index 00000000..dd9a2073 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/service/CommandletService.java @@ -0,0 +1,126 @@ +package com.devonfw.ide.gui.core.service; + +import java.nio.file.Path; +import java.util.Objects; + +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.scene.control.Alert.AlertType; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.ide.gui.core.context.GuiOutputListener; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.context.IdeGuiContext; +import com.devonfw.ide.gui.core.context.IdeGuiLogListener; +import com.devonfw.ide.gui.core.event.GuiEventBus; +import com.devonfw.ide.gui.core.event.console.LogEvent; +import com.devonfw.ide.gui.core.modal.IdeDialog; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; +import com.devonfw.tools.ide.context.IdeStartContextImpl; +import com.devonfw.tools.ide.log.IdeLogEntry; +import com.devonfw.tools.ide.log.IdeLogLevel; + +/** + * Service to run a commandlet in the currently selected project/workspace context. + */ +public class CommandletService { + + private static final Logger LOG = LoggerFactory.getLogger(CommandletService.class); + + private final GuiStateManager guiStateManager; + + private IdeGuiLogListener guiLogListener; + private GuiOutputListener guiOutputListener; + private GuiEventBus eventBus; + + /** + * Optional action invoked before a commandlet is launched, e.g. to make the console pane visible. Defaults to a no-op. Part of the hack to make the Console + * auto-show when an IDE is launched, should be removed when Console is reworked. + */ + private Runnable preLaunchAction = () -> { + }; + + /** + * Creates the service. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public CommandletService(GuiStateManager guiStateManager) { + this.guiStateManager = Objects.requireNonNull(guiStateManager); + this.eventBus = guiStateManager.getEventBus(); + this.guiLogListener = new IdeGuiLogListener(eventBus); + this.guiOutputListener = new GuiOutputListener(eventBus); + } + + /** + * Sets the action that is run before each commandlet launch. Part of the hack to make the Console auto-show when an IDE is launched, should be removed when + * Console is reworked. + * + * @param preLaunchAction the action to run before launching a commandlet (must not be null). + */ + public void setPreLaunchAction(Runnable preLaunchAction) { + this.preLaunchAction = Objects.requireNonNull(preLaunchAction); + } + + /** + * Runs the given commandlet in the currently selected project/workspace context. + * + * @param commandlet the commandlet to run. + */ + public void runCommandlet(String commandlet) { + + this.preLaunchAction.run(); + + Task commandletTask = runCommandletTask(commandlet); + + this.guiLogListener = new IdeGuiLogListener(guiStateManager.getEventBus()); + this.guiOutputListener = new GuiOutputListener(guiStateManager.getEventBus()); + + Thread commandletThread = new Thread(commandletTask); + commandletThread.setDaemon(true); + commandletThread.start(); + } + + private Task runCommandletTask(String commandlet) { + + try (ProgressBarTask task = (ProgressBarTask) this.guiStateManager.getCurrentContext() + .newProgressBarIndeterminate("Starting " + commandlet)) { + Task commandletTask = new Task<>() { + @Override + protected Void call() { + + try { + IdeStartContextImpl startContext = new IdeStartContextImpl(IdeLogLevel.INFO, guiLogListener); + Path workspacePath = guiStateManager.getCurrentContext().getWorkspacePath(); + IdeGuiContext context = new IdeGuiContext(startContext, workspacePath, CommandletService.this.guiStateManager.getTaskManager()); + + context.setOutputListener(guiOutputListener); + + LOG.info("Running commandlet {}", commandlet); + + context.getCommandletManager().getCommandlet(commandlet).run(); + + LOG.info("Commandlet {} completed successfully.", commandlet); + } catch (Exception e) { + LOG.error("Failed to run commandlet {}: {}", commandlet, e.getMessage(), e); + + eventBus.sendEvent(new LogEvent( + new IdeLogEntry(IdeLogLevel.ERROR, "[ERROR] Failed to launch " + commandlet + ": " + e.getMessage()) + )); + } + return null; + } + }; + + commandletTask.setOnFailed(_ -> Platform.runLater(() -> { + task.close(); + new IdeDialog(AlertType.ERROR, "Error occurred while launching " + commandlet).showAndWait(); + })); + commandletTask.setOnSucceeded(_ -> Platform.runLater(task::close)); + return commandletTask; + } + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/nls/NlsService.java b/gui/src/main/java/com/devonfw/ide/gui/core/service/NlsService.java similarity index 85% rename from gui/src/main/java/com/devonfw/ide/gui/nls/NlsService.java rename to gui/src/main/java/com/devonfw/ide/gui/core/service/NlsService.java index bf4d42a1..570347ee 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/nls/NlsService.java +++ b/gui/src/main/java/com/devonfw/ide/gui/core/service/NlsService.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.nls; +package com.devonfw.ide.gui.core.service; import java.io.IOException; import java.net.URI; @@ -10,6 +10,7 @@ import java.util.List; import java.util.Locale; import java.util.MissingResourceException; +import java.util.Objects; import java.util.ResourceBundle; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; @@ -58,15 +59,39 @@ public class NlsService { private volatile EnvironmentVariables userEnvironmentVariables; + /** + * The {@link IdeContext} used to resolve the user environment variables. If {@code null}, an {@link IdeContextConsole} is created lazily on first + * persistence, so that merely constructing the service has no side effects. + */ + private final IdeContext context; + private static final Pattern USER_LANG_PATTERN = Pattern.compile("-Duser\\.language=(\\S*)"); /** - * Creates the service and initializes it with the given locale. + * Creates the service and initializes it with the given locale. The user environment variables are resolved from an {@link IdeContextConsole} that is created + * lazily on first persistence, so that merely constructing the service has no side effects. * * @param locale the preferred locale, or {@code null} to use persisted or default locale. */ public NlsService(Locale locale) { + this.context = null; + initialize(locale); + } + + /** + * Creates the service using the given {@link IdeContext} to resolve the user configuration and initializes it with the given locale. + *

        + * This constructor enables "design for testability" (see {@code documentation/contributing/junit-testing.adoc}): by injecting an {@link IdeContext} (for + * example an {@link com.devonfw.tools.ide.context.IdeTestContext}) the service can be isolated from the real user home and the environment of the machine + * running the test. + * + * @param context the {@link IdeContext} to use for resolving the user environment variables. + * @param locale the preferred locale, or {@code null} to use persisted or default locale. + */ + public NlsService(IdeContext context, Locale locale) { + + this.context = Objects.requireNonNull(context, "context must not be null"); initialize(locale); } @@ -325,7 +350,9 @@ private void persistLocale(Locale localeToPersist) { EnvironmentVariables environmentVariables = getUserEnvironmentVariables(); try { - String ideOptions = environmentVariables.get(IDE_OPTIONS); + // read only from the user configuration file, not from the machine-wide environment (see getFlat): the persisted GUI locale must not + // be influenced by a global IDE_OPTIONS environment variable + String ideOptions = environmentVariables.getFlat(IDE_OPTIONS); String userLangFlag = "-Duser.language=" + localeToPersist.toLanguageTag(); String updated = updateLocale(ideOptions, userLangFlag); environmentVariables.set(IDE_OPTIONS, updated); @@ -361,7 +388,8 @@ private EnvironmentVariables getUserEnvironmentVariables() { private EnvironmentVariables createUserEnvironmentVariables() { - IdeContext context = new IdeContextConsole(); + // if no context was injected (see NlsService(Locale)) we create a console context lazily here, so constructing the service has no side effects + IdeContext context = (this.context != null) ? this.context : new IdeContextConsole(); EnvironmentVariables userVariables = context.getVariables().getByType(EnvironmentVariablesType.USER); if (userVariables == null) { throw new IllegalStateException("Failed to resolve USER environment variables from IDE context"); diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java new file mode 100644 index 00000000..f73f86ff --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabComponent.java @@ -0,0 +1,57 @@ +package com.devonfw.ide.gui.core.tab; + +import javafx.scene.control.Tab; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.TabChangeEvent; +import com.devonfw.ide.gui.core.service.CommandletService; +import com.devonfw.ide.gui.core.service.NlsService; + +/** + * Base class for the tabs of the main window. Each tab combines a {@link TabViewModel} with a {@link TabView} that renders it. + */ +public abstract class TabComponent { + + protected final GuiStateManager guiStateManager; + protected final CommandletService commandletService; + protected final NlsService nlsService; + + protected Tab tab; + protected TabView view; + protected T viewModel; + + /** + * Creates the tab. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public TabComponent(GuiStateManager guiStateManager) { + this.commandletService = guiStateManager.getCommandletService(); + this.nlsService = guiStateManager.getNlsService(); + this.guiStateManager = guiStateManager; + } + + /** + * Creates the tab's view and its view model. + * + * @return the created view. + */ + public abstract TabView createTab(); + + public abstract TabChangeEvent createTabChangeEvent(); + + /** + * @return the localization key used as the title (and identity) of this tab. + */ + public abstract String getTabTitleKey(); + + /** + * @return the view + */ + public abstract TabView getView(); + + /** + * @return the view model + */ + public abstract T getViewModel(); +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java new file mode 100644 index 00000000..393e4cd8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabView.java @@ -0,0 +1,23 @@ +package com.devonfw.ide.gui.core.tab; + +import javafx.scene.control.ScrollPane; + +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.service.NlsService; + +/** + * Base class for the views of all tabs. + */ +public abstract class TabView extends ScrollPane { + + /** + * Loads this view's FXML file. + * + * @param fxmlName the FXML file name co-located with the concrete tab view (e.g. {@code "IdeLauncher.fxml"}). + * @param nlsService the localization service; its resource bundle resolves the {@code %key} text keys in the FXML. + */ + protected void loadFxml(String fxmlName, NlsService nlsService) { + FxHelper.loadFxml(fxmlName, nlsService, this); + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java new file mode 100644 index 00000000..3ac96006 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/core/tab/TabViewModel.java @@ -0,0 +1,21 @@ +package com.devonfw.ide.gui.core.tab; + +import com.devonfw.ide.gui.core.context.GuiStateManager; + +/** + * Base for the view models of all tabs. + */ +public abstract class TabViewModel { + + protected final GuiStateManager guiStateManager; + + protected TabViewModel(GuiStateManager guiStateManager) { + this.guiStateManager = guiStateManager; + } + + /** + * @return the localization key used as the title of this tab. + */ + public abstract String getTabTitleKey(); + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java new file mode 100644 index 00000000..4f4b3bb7 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherTab.java @@ -0,0 +1,58 @@ +package com.devonfw.ide.gui.feature.ide.launcher; + +import javafx.scene.control.Tab; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.TabChangeEvent; +import com.devonfw.ide.gui.core.tab.TabComponent; +import com.devonfw.ide.gui.core.tab.TabView; + +/** + * Tab that lets the user launch the supported IDEs from the main window. + */ +public class IdeLauncherTab extends TabComponent { + + private final String tabTitleKey = "tab.ide_launcher"; + + /** + * Creates the IDE launcher tab. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public IdeLauncherTab(GuiStateManager guiStateManager) { + super(guiStateManager); + } + + @Override + public TabView createTab() { + this.viewModel = new IdeLauncherViewModel(this.guiStateManager); + this.view = new IdeLauncherView(this.viewModel, this.nlsService); + this.tab = new Tab(this.nlsService.get(tabTitleKey), this.view); + this.tab.setUserData(tabTitleKey); + this.tab.setClosable(false); + + return this.view; + } + + @Override + public TabChangeEvent createTabChangeEvent() { + createTab(); + return new TabChangeEvent(this.tab); + } + + @Override + public String getTabTitleKey() { + return this.tabTitleKey; + } + + @Override + public TabView getView() { + return view; + } + + @Override + public IdeLauncherViewModel getViewModel() { + return viewModel; + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java new file mode 100644 index 00000000..b56ed470 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherView.java @@ -0,0 +1,52 @@ +package com.devonfw.ide.gui.feature.ide.launcher; + +import javafx.fxml.FXML; +import javafx.scene.control.Button; + +import com.devonfw.ide.gui.core.service.NlsService; +import com.devonfw.ide.gui.core.tab.TabView; + +/** + * View for the IDE launcher tab. + */ +public class IdeLauncherView extends TabView { + + @FXML + private Button androidStudioOpen; + + @FXML + private Button eclipseOpen; + + @FXML + private Button intellijOpen; + + @FXML + private Button vsCodeOpen; + + private final IdeLauncherViewModel viewModel; + + /** + * Creates the launcher view and loads its FXML. + * + * @param viewModel the view model of the launcher tab. + * @param nlsService the localization service. + */ + public IdeLauncherView(IdeLauncherViewModel viewModel, NlsService nlsService) { + this.viewModel = viewModel; + loadFxml("IdeLauncher.fxml", nlsService); + } + + @FXML + private void initialize() { + + this.androidStudioOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.androidStudioOpen.setOnAction(_ -> this.viewModel.runCommandlet("android-studio")); + this.eclipseOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.eclipseOpen.setOnAction(_ -> this.viewModel.runCommandlet("eclipse")); + this.intellijOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.intellijOpen.setOnAction(_ -> this.viewModel.runCommandlet("intellij")); + this.vsCodeOpen.disableProperty().bind(this.viewModel.isIdeButtonsDisabledProperty()); + this.vsCodeOpen.setOnAction(_ -> this.viewModel.runCommandlet("vscode")); + } + +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java new file mode 100644 index 00000000..4b35f9b8 --- /dev/null +++ b/gui/src/main/java/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncherViewModel.java @@ -0,0 +1,49 @@ +package com.devonfw.ide.gui.feature.ide.launcher; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.service.CommandletService; +import com.devonfw.ide.gui.core.tab.TabViewModel; + +/** + * View model of the IDE launcher tab. It exposes the state that drives the launcher buttons, which stay disabled until a project/workspace is selected. + */ +public class IdeLauncherViewModel extends TabViewModel { + + private final CommandletService commandletService; + + private final BooleanProperty isIdeButtonsDisabledProperty = new SimpleBooleanProperty(); + + /** + * Creates the view model. + * + * @param guiStateManager the app-wide selection and context holder. + */ + public IdeLauncherViewModel(GuiStateManager guiStateManager) { + super(guiStateManager); + this.commandletService = guiStateManager.getCommandletService(); + + this.isIdeButtonsDisabledProperty.bind(this.guiStateManager.workspaceSelectedProperty().not()); + } + + @Override + public String getTabTitleKey() { + return "tab.ide_launcher"; + } + + /** + * Runs the given IDE commandlet in the currently selected project/workspace context. + * + * @param commandlet the commandlet (IDE) to start. + */ + public void runCommandlet(String commandlet) { + this.commandletService.runCommandlet(commandlet); + } + + public BooleanProperty isIdeButtonsDisabledProperty() { + return isIdeButtonsDisabledProperty; + } + +} diff --git a/gui/src/main/resources/com/devonfw/ide/gui/assets/devonfw.png b/gui/src/main/resources/com/devonfw/ide/gui/assets/devonfw.png deleted file mode 100644 index b6867b41..00000000 Binary files a/gui/src/main/resources/com/devonfw/ide/gui/assets/devonfw.png and /dev/null differ diff --git a/gui/src/main/resources/com/devonfw/ide/gui/assets/ideasy.png b/gui/src/main/resources/com/devonfw/ide/gui/assets/ideasy.png new file mode 100644 index 00000000..b18c4fc4 Binary files /dev/null and b/gui/src/main/resources/com/devonfw/ide/gui/assets/ideasy.png differ diff --git a/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml new file mode 100644 index 00000000..b3455413 --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/MainWindowView.fxml @@ -0,0 +1,54 @@ + + + + + + + + +

        + +
        + + + + + + + +
        + + + + + + + + + + + + + + + + + + + + + +
        +
        + diff --git a/gui/src/main/resources/com/devonfw/ide/gui/console/console.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/console/console.fxml similarity index 87% rename from gui/src/main/resources/com/devonfw/ide/gui/console/console.fxml rename to gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/console/console.fxml index 10c2c7f8..c50dd84b 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/console/console.fxml +++ b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/console/console.fxml @@ -14,9 +14,9 @@ xmlns:fx="http://javafx.com/fxml" xmlns="http://javafx.com/javafx" fx:id="consoleRoot" - fx:controller="com.devonfw.ide.gui.console.ConsoleController" + fx:controller="com.devonfw.ide.gui.core.mainwindow.console.ConsoleView" prefWidth="Infinity" - style="-fx-background-color: #FFFFF; -fx-border-color: #cccccc; -fx-border-width: 1;" + style="-fx-background-color: #FFFFFF; -fx-border-color: #cccccc; -fx-border-width: 1;" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0" AnchorPane.leftAnchor="0" AnchorPane.rightAnchor="0" minHeight="0" @@ -46,7 +46,6 @@ style="-fx-font-family: 'Consolas', monospace; -fx-font-size: 11; -fx-control-inner-background: #f5f5f5;"/> - diff --git a/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanel.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanel.fxml new file mode 100644 index 00000000..2da1652d --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanel.fxml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml b/gui/src/main/resources/com/devonfw/ide/gui/core/progress/taskwindow/task_overview_window.fxml similarity index 100% rename from gui/src/main/resources/com/devonfw/ide/gui/task_overview_window.fxml rename to gui/src/main/resources/com/devonfw/ide/gui/core/progress/taskwindow/task_overview_window.fxml diff --git a/gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml b/gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml new file mode 100644 index 00000000..cdb87c11 --- /dev/null +++ b/gui/src/main/resources/com/devonfw/ide/gui/feature/ide/launcher/IdeLauncher.fxml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml b/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml deleted file mode 100644 index 0ba39ccd..00000000 --- a/gui/src/main/resources/com/devonfw/ide/gui/main-view.fxml +++ /dev/null @@ -1,335 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        - -
        - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        - - - - - - - - - - - - - - - - - - - - - -
        -
        -
        diff --git a/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css b/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css index 5e4d2ca2..df411eb5 100644 --- a/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css +++ b/gui/src/main/resources/com/devonfw/ide/gui/style/navigation.css @@ -1,4 +1,4 @@ -VBox { +.navigationPanel { -fx-background-color: #18151e; } diff --git a/gui/src/main/resources/nls/messages.properties b/gui/src/main/resources/nls/messages.properties index a92dfae8..4916bd73 100644 --- a/gui/src/main/resources/nls/messages.properties +++ b/gui/src/main/resources/nls/messages.properties @@ -15,11 +15,11 @@ language=Language chooseLanguage=Choose language # Status Bar ideasy_ready_msg=IDEasy is ready. +# Tabs +tab.ide_launcher=IDEs # Console console_togglebutton_tooltip=Toggle console console_title=Console console_clear=Clear console_auto_scroll=Auto-scroll -console_status_ready=Ready console_line_count=Lines: {0} -console_cleared=Console cleared diff --git a/gui/src/main/resources/nls/messages_de.properties b/gui/src/main/resources/nls/messages_de.properties index 05b5abeb..ba751cd7 100644 --- a/gui/src/main/resources/nls/messages_de.properties +++ b/gui/src/main/resources/nls/messages_de.properties @@ -15,11 +15,11 @@ language=Sprache chooseLanguage=Sprache wählen # Status Bar ideasy_ready_msg=IDEasy ist bereit +# Tabs +tab.ide_launcher=IDEs # Console console_togglebutton_tooltip=Konsole öffnen/schließen console_title=Konsole console_clear=Leeren console_auto_scroll=Auto-scroll -console_status_ready=Bereit console_line_count=Zeilen: {0} -console_cleared=Konsole geleert diff --git a/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java b/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java index 8a16c513..69e613c9 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/AppBaseTest.java @@ -4,13 +4,10 @@ import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; import java.io.IOException; -import java.net.URL; import java.nio.file.Path; import java.util.Arrays; import java.util.Locale; -import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; @@ -30,12 +27,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.ide.gui.console.ConsoleController; -import com.devonfw.ide.gui.context.GuiStateManager; -import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.nls.NlsService; -import com.devonfw.ide.gui.progress.ProgressBarTask; -import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.mainwindow.MainWindowView; +import com.devonfw.ide.gui.core.mainwindow.MainWindowViewModel; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; +import com.devonfw.ide.gui.core.progress.taskwindow.TaskOverviewWindow; /** * Basic UI Test for the main screen @@ -44,6 +42,11 @@ public class AppBaseTest extends HeadlessApplicationTest { private static final Logger LOGGER = LoggerFactory.getLogger(AppBaseTest.class); + /** Scene size for the test window. The console SplitPane needs real space, or its divider stays pinned near 1.0. */ + private static final double SCENE_WIDTH = 1280; + + private static final double SCENE_HEIGHT = 800; + private Button androidStudioOpen, eclipseOpen, intellijOpen, vsCodeOpen; private ToggleButton consolePaneToggleButton; private ComboBox selectedProject, selectedWorkspace; @@ -54,47 +57,42 @@ public class AppBaseTest extends HeadlessApplicationTest { @TempDir private static Path mockIdeRoot; - private static final TaskManager taskManager = new TaskManager(); - private static GuiStateManager guiStateManager; + /** + * Both are recreated for every test in {@link #start(Stage)}: a shared GuiStateManager leaks the project selection into the next test, and a shared + * TaskManager accumulates a status-bar listener per view model, so discarded windows keep reacting to task changes. + */ + private TaskManager taskManager; + + private GuiStateManager guiStateManager; + + private MainWindowViewModel viewModel; @Override - public void start(Stage stage) throws IOException { - - NlsService nlsService = new NlsService(Locale.ENGLISH); - - URL mainViewUrl = getClass().getResource("main-view.fxml"); - assertThat(mainViewUrl).as("Cannot resolve main UI FXML resource!").isNotNull(); - - FXMLLoader fxmlLoader = new FXMLLoader(mainViewUrl); - MainController mainController = new MainController(mockIdeRoot.toString(), guiStateManager, nlsService); - fxmlLoader.setControllerFactory(clazz -> { - if (clazz == ConsoleController.class) { - return new ConsoleController(nlsService); - } else if (clazz == MainController.class) { - return mainController; - } - return null; - }); - fxmlLoader.setResources(nlsService.getResourceBundle()); - Parent root = fxmlLoader.load(); - stage.setScene(new Scene(root)); + public void start(Stage stage) { + + this.guiStateManager = new GuiStateManager(mockIdeRoot.toString()); + this.guiStateManager.getNlsService().setLocale(Locale.ENGLISH); + this.taskManager = guiStateManager.getTaskManager(); + this.viewModel = new MainWindowViewModel(guiStateManager); + MainWindowView mainWindow = new MainWindowView(this.viewModel, guiStateManager); + stage.setScene(new Scene(mainWindow, SCENE_WIDTH, SCENE_HEIGHT)); stage.requestFocus(); //sometimes needed for headless setup to work stage.show(); - androidStudioOpen = FxHelper.lookup(root, "#androidStudioOpen"); - eclipseOpen = FxHelper.lookup(root, "#eclipseOpen"); - intellijOpen = FxHelper.lookup(root, "#intellijOpen"); - vsCodeOpen = FxHelper.lookup(root, "#vsCodeOpen"); - selectedProject = FxHelper.lookup(root, "#selectedProject"); - selectedWorkspace = FxHelper.lookup(root, "#selectedWorkspace"); - consolePaneToggleButton = FxHelper.lookup(root, "#consolePaneToggleButton"); - centerSplitPane = FxHelper.lookup(root, "#centerSplitPane"); - statusText = FxHelper.lookup(root, "#statusLabel"); - taskProgressBar = FxHelper.lookup(root, "#statusProgressBar"); + androidStudioOpen = FxHelper.lookup(mainWindow, "#androidStudioOpen"); + eclipseOpen = FxHelper.lookup(mainWindow, "#eclipseOpen"); + intellijOpen = FxHelper.lookup(mainWindow, "#intellijOpen"); + vsCodeOpen = FxHelper.lookup(mainWindow, "#vsCodeOpen"); + selectedProject = FxHelper.lookup(mainWindow, "#projects"); + selectedWorkspace = FxHelper.lookup(mainWindow, "#workspaces"); + consolePaneToggleButton = FxHelper.lookup(mainWindow, "#consolePaneToggleButton"); + centerSplitPane = FxHelper.lookup(mainWindow, "#centerSplitPane"); + statusText = FxHelper.lookup(mainWindow, "#statusLabel"); + taskProgressBar = FxHelper.lookup(mainWindow, "#statusProgressBar"); } /** - * Generate temporary project directories to be able to test on any device (including GitHub CI). This is required for the {@link MainController} to work in + * Generate temporary project directories to be able to test on any device (including GitHub CI). This is required for the {@link MainWindowView} to work in * the test context. Generates a structure like this: /project-[0..6]/workspaces/main */ @BeforeAll @@ -104,9 +102,6 @@ public static void generateProjectFolderStructure() throws IOException { FakeProjectFolderStructureHelper.createFakeProjectFolderStructure(mockIdeRoot); LOGGER.debug("project folders: {}", Arrays.toString(mockIdeRoot.toFile().list())); - guiStateManager = new GuiStateManager(taskManager, mockIdeRoot.toString()); - //We set the project root directory to the temporary directory before all tests so that the IDE can find the projects in the test. - guiStateManager.switchContext("project-1", "main"); } @BeforeEach @@ -244,8 +239,61 @@ protected void testStatusTextOpensTaskOverviewWindow() { .isTrue(); } + /** + * The status label now carries a permanently installed click handler that is gated on the view model's clickable state, where it previously had the handler + * attached only while more than one task was running. This pins that gate. + */ + @Test + protected void testStatusTextDoesNotOpenTaskOverviewWindowForSingleTask() { + + // The window is a JVM-wide singleton, so an earlier test may have left it showing. getInstance builds the Stage, so it has to run on the FX thread. + interact(() -> TaskOverviewWindow.getInstance(this.taskManager).getStage().hide()); + + this.taskManager.addTask(new ProgressBarTask(this.taskManager, "task-1", "Test Task")); + waitForFxEvents(); + + interact(() -> statusText.fireEvent( + new MouseEvent(MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, null, 1, false, false, false, false, false, false, false, false, false, false, null))); + + assertThat(TaskOverviewWindow.getInstance(this.taskManager).getStage().isShowing()) + .as("Task overview window should not open while only a single task is running").isFalse(); + } + //===Console panel tests=== + /** + * The pre-launch action registered on the TabFactory opens the console by calling {@code setConsoleVisible(true)} on the view model. Launching a commandlet + * for real would install an IDE, so this covers the mechanism that action drives rather than the launch itself. + */ + @Test + void testConsoleOpensWhenRequestedThroughTheViewModel() { + + Divider mainPanelDivider = centerSplitPane.getDividers().getFirst(); + assertThat(mainPanelDivider.getPosition()).as("The console should start collapsed").isGreaterThan(0.99); + + interact(() -> this.viewModel.setConsoleVisible(true)); + waitForFxEvents(); + + assertThat(mainPanelDivider.getPosition()).as("Console panel should be extended when the view model requests it").isEqualTo(0.75, Offset.offset(0.01)); + assertThat(consolePaneToggleButton.isSelected()).as("The toggle button should follow the view model").isTrue(); + } + + /** + * Dragging the divider must not be fought by the view writing the position back. Crossing the visible threshold used to snap the divider straight to 0.75. + */ + @Test + void testDraggingDividerDoesNotSnapTheConsoleOpen() { + + Divider mainPanelDivider = centerSplitPane.getDividers().getFirst(); + + // Just past the point where the console counts as visible, as if the user were dragging it open. + interact(() -> mainPanelDivider.setPosition(0.985)); + waitForFxEvents(); + + assertThat(mainPanelDivider.getPosition()).as("The divider must stay where the drag put it").isEqualTo(0.985, Offset.offset(0.001)); + assertThat(consolePaneToggleButton.isSelected()).as("The console counts as visible past the threshold").isTrue(); + } + @Test void testConsoleToggleButton() { diff --git a/gui/src/test/java/com/devonfw/ide/gui/console/ConsolePanelTest.java b/gui/src/test/java/com/devonfw/ide/gui/console/ConsolePanelTest.java deleted file mode 100644 index aff1c167..00000000 --- a/gui/src/test/java/com/devonfw/ide/gui/console/ConsolePanelTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.devonfw.ide.gui.console; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; - -import java.io.IOException; -import java.net.URL; -import java.util.List; -import java.util.Locale; - -import javafx.application.Platform; -import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; -import javafx.scene.Scene; -import javafx.stage.Stage; - -import org.junit.jupiter.api.Test; - -import com.devonfw.ide.gui.HeadlessApplicationTest; -import com.devonfw.ide.gui.nls.NlsService; -import com.devonfw.tools.ide.log.IdeLogLevel; - -class ConsolePanelTest extends HeadlessApplicationTest { - - private ConsoleController consoleController; - - @Override - public void start(Stage stage) throws IOException { - - URL consoleViewUrl = getClass().getResource("console.fxml"); - assertThat(consoleViewUrl).as("Cannot resolve console UI FXML resource!").isNotNull(); - - NlsService nlsService = new NlsService(Locale.ENGLISH); - - FXMLLoader fxmlLoader = new FXMLLoader(consoleViewUrl); - fxmlLoader.setResources(nlsService.getResourceBundle()); - fxmlLoader.setControllerFactory(clazz -> { - if (clazz == ConsoleController.class) { - return new ConsoleController(nlsService); - } - return null; - }); - Parent root = fxmlLoader.load(); - stage.setScene(new Scene(root)); - stage.requestFocus(); // sometimes needed for headless setup to work - stage.show(); - - consoleController = fxmlLoader.getController(); - } - - /** - * Tests whether appendOutput() methods actually print to the console. - */ - @Test - void testConsoleOutputPrintsCorrectly() { - - // Simulate output to the console - Platform.runLater(() -> { - consoleController.appendOutput("Hello World!"); - consoleController.appendOutput("Test"); - }); - waitForFxEvents(); - - // Verify that the output is displayed in the ListView - List snapshot = consoleController.getConsoleOutputSnapshot(); - assertThat(snapshot).anyMatch(s -> s.contains("Hello World!")); - assertThat(snapshot).anyMatch(s -> s.contains("Test")); - } - - /** - * Tests console output with log levels. - */ - @Test - void testConsoleOutputWithLogLevels() { - - Platform.runLater(() -> { - consoleController.appendOutput(IdeLogLevel.INFO, "Info message"); - consoleController.appendOutput(IdeLogLevel.ERROR, "Error message"); - consoleController.appendOutput(IdeLogLevel.WARNING, "Warning message"); - consoleController.appendOutput(IdeLogLevel.DEBUG, "Debug message"); - }); - waitForFxEvents(); - - List snapshot = consoleController.getConsoleOutputSnapshot(); - assertThat(snapshot).hasSize(4); - // Check that all log levels appear in the output (format is "HH:mm:ss | [LEVEL] message") - // INFO has 3 spaces after bracket, ERROR has 2, WARN has 2, DEBUG has 2 - assertThat(snapshot).anyMatch(s -> s.contains("[INFO] Info message")); - assertThat(snapshot).anyMatch(s -> s.contains("[ERROR] Error message")); - assertThat(snapshot).anyMatch(s -> s.contains("[WARNING] Warning message")); - assertThat(snapshot).anyMatch(s -> s.contains("[DEBUG] Debug message")); - } - - @Test - void testConsoleClear() { - - // Simulate output to the console - Platform.runLater(() -> { - consoleController.appendOutput("Hello World!"); - consoleController.appendOutput("Test"); - }); - waitForFxEvents(); - - // Clear the console - Platform.runLater(() -> consoleController.clearConsole()); - waitForFxEvents(); - - // Verify that the console is empty - assertThat(consoleController.getConsoleOutputSnapshot()).isEmpty(); - } - - @Test - void testLineCountUpdates() { - Platform.runLater(() -> { - consoleController.appendOutput("Line 1"); - consoleController.appendOutput("Line 2"); - consoleController.appendOutput("Line 3"); - }); - waitForFxEvents(); - - // Line count should be 3 - assertThat(consoleController.getConsoleOutputSnapshot()).hasSize(3); - } - - @Test - void testAutoScrollCheckboxEnabledByDefault() { - // Just verify auto-scroll is enabled by default - assertThat(consoleController.isAutoScrollEnabled()).isTrue(); - } -} diff --git a/gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/context/GuiStateManagerTest.java similarity index 91% rename from gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/context/GuiStateManagerTest.java index dcfb3312..98dee7fb 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/context/GuiStateManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/context/GuiStateManagerTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.io.FileNotFoundException; import java.io.IOException; @@ -26,7 +26,6 @@ public class GuiStateManagerTest extends AbstractIdeContextTest { private static GuiStateManager guiStateManager; private static ProjectManager projectManager; - private TaskManager taskManager; @BeforeAll static void setup() { @@ -38,8 +37,7 @@ static void setup() { @BeforeEach void reset() { - TaskManager taskManager = new TaskManager(); - guiStateManager = new GuiStateManager(taskManager, context.getIdeRoot().toString()); + guiStateManager = new GuiStateManager(context.getIdeRoot().toString()); projectManager = guiStateManager.getProjectManager(); } @@ -47,7 +45,7 @@ void reset() { void testThrowsIfIdeRootDoesNotExist() { try { - new GuiStateManager(taskManager, "nonExistingIdeRoot"); + new GuiStateManager("nonExistingIdeRoot"); fail("IllegalArgumentException expected"); } catch (IllegalArgumentException e) { assertThat(e.getMessage()).contains("Root directory does not exist"); diff --git a/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/context/ProjectManagerTest.java similarity index 99% rename from gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/context/ProjectManagerTest.java index ee7161a5..e3a18aa1 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/context/ProjectManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/context/ProjectManagerTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.context; +package com.devonfw.ide.gui.core.context; import java.io.IOException; import java.nio.file.Files; diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java new file mode 100644 index 00000000..c2eeed8d --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/event/GuiEventBusTest.java @@ -0,0 +1,36 @@ +package com.devonfw.ide.gui.core.event; + +import java.util.concurrent.atomic.AtomicReference; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Pins the threading contract of the {@link GuiEventBus}: it dispatches events synchronously on the thread that sends them and performs no JavaFX-thread + * marshalling, so non-GUI threads (e.g. the CLI/worker threads that will route logs to the GUI console) can publish without blocking on or hopping to the FX + * thread. + */ +public class GuiEventBusTest extends Assertions { + + private record SomeEvent() { + + } + + /** + * An event sent from a plain (non-FX) thread must reach its listener synchronously, on that very same thread. + */ + @Test + public void testEventIsDispatchedSynchronouslyOnTheSendingThread() { + + GuiEventBus eventBus = new GuiEventBus(); + AtomicReference listenerThread = new AtomicReference<>(); + eventBus.addListener(SomeEvent.class, event -> listenerThread.set(Thread.currentThread())); + + Thread sender = Thread.currentThread(); + eventBus.sendEvent(new SomeEvent()); + + assertThat(listenerThread.get()) + .as("The listener must have run before sendEvent returned, on the thread that sent the event") + .isSameAs(sender); + } +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java new file mode 100644 index 00000000..df033159 --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/factory/TabFactoryTest.java @@ -0,0 +1,85 @@ +package com.devonfw.ide.gui.core.factory; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Tab; +import javafx.stage.Stage; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.ide.gui.HeadlessApplicationTest; +import com.devonfw.ide.gui.core.context.GuiStateManager; +import com.devonfw.ide.gui.core.event.GuiEventBus; +import com.devonfw.ide.gui.core.event.TabChangeEvent; + +/** + * Tests for {@link TabFactory}. < + */ +class TabFactoryTest extends HeadlessApplicationTest { + + @TempDir + Path ideRoot; + + private static final String LAUNCHER_TITLE_KEY = "tab.ide_launcher"; + + private List emittedTabs; + + @Override + public void start(Stage stage) { + stage.setScene(new Scene(new Group())); + stage.show(); + } + + /** + * Builds a {@link TabFactory} wired to a bus with a capturing listener, opens the IDE launcher tab {@code times} times and captures the emitted tabs. + */ + private void openLauncherTab(int times) { + interact(() -> { + GuiStateManager guiStateManager = new GuiStateManager(this.ideRoot.toString()); + GuiEventBus eventBus = guiStateManager.getEventBus(); + this.emittedTabs = new ArrayList<>(); + eventBus.addListener(TabChangeEvent.class, event -> this.emittedTabs.add(event.tab())); + TabFactory factory = guiStateManager.getTabFactory(); + + for (int i = 0; i < times; i++) { + factory.openLauncherTab(); + } + }); + } + + /** + * The opened tab must carry its NLS key as its stable identity, kept distinct from its rendered (translated) title, so de-duplication does not depend on the + * rendered title. + */ + @Test + void testOpenStoresTheTitleKeyAsTheTabIdentity() { + openLauncherTab(1); + + Tab tab = this.emittedTabs.get(0); + assertThat(tab.getUserData()).as("The tab must be identifiable by its NLS key, not its translated title").isEqualTo(LAUNCHER_TITLE_KEY); + assertThat(tab.getText()).as("The visible title must be the localized text, not the raw key").isNotEqualTo(LAUNCHER_TITLE_KEY); + } + + /** + * Each open must publish a {@link TabChangeEvent} carrying a fully built tab (with its view as content and closable enabled). + */ + @Test + void testOpenPublishesAKeyEventPerOpenWithABuiltTab() { + openLauncherTab(2); + + assertThat(this.emittedTabs).as("The factory must publish a tab for each open").hasSize(2); + for (Tab tab : this.emittedTabs) { + assertThat(tab.getContent()).as("The published tab must carry its view as content").isInstanceOf(Node.class); + assertThat(tab.isClosable()).as("The published launcher tab must be non-closable").isFalse(); + } + } + +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/console/ConsolePanelTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/console/ConsolePanelTest.java new file mode 100644 index 00000000..ad5f0584 --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/console/ConsolePanelTest.java @@ -0,0 +1,175 @@ +package com.devonfw.ide.gui.core.mainwindow.console; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; + +import java.io.IOException; +import java.net.URL; +import java.util.List; +import java.util.Locale; + +import javafx.application.Platform; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Stage; + +import org.junit.jupiter.api.Test; + +import com.devonfw.ide.gui.HeadlessApplicationTest; +import com.devonfw.ide.gui.core.context.IdeGuiLogListener; +import com.devonfw.ide.gui.core.event.GuiEventBus; +import com.devonfw.ide.gui.core.service.NlsService; +import com.devonfw.tools.ide.log.IdeLogLevel; + +class ConsolePanelTest extends HeadlessApplicationTest { + + private ConsoleViewModel consoleViewModel; + + private GuiEventBus eventBus; + + @Override + public void start(Stage stage) throws IOException { + + URL consoleViewUrl = getClass().getResource("console.fxml"); + assertThat(consoleViewUrl).as("Cannot resolve console UI FXML resource!").isNotNull(); + + NlsService nlsService = new NlsService(Locale.ENGLISH); + eventBus = new GuiEventBus(); + + FXMLLoader fxmlLoader = new FXMLLoader(consoleViewUrl); + fxmlLoader.setResources(nlsService.getResourceBundle()); + fxmlLoader.setControllerFactory(clazz -> { + if (clazz == ConsoleView.class) { + consoleViewModel = new ConsoleViewModel(eventBus); + return new ConsoleView(consoleViewModel, nlsService); + } + return null; + }); + Parent root = fxmlLoader.load(); + stage.setScene(new Scene(root)); + stage.requestFocus(); // sometimes needed for headless setup to work + stage.show(); + } + + /** + * Tests whether appendOutput() methods actually print to the console. + */ + @Test + void testConsoleOutputPrintsCorrectly() { + + // Simulate output to the console + Platform.runLater(() -> { + consoleViewModel.appendOutput("Hello World!"); + consoleViewModel.appendOutput("Test"); + }); + waitForFxEvents(); + + // Verify that the output is displayed in the ListView + List snapshot = consoleViewModel.getConsoleOutputSnapshot(); + assertThat(snapshot).anyMatch(s -> s.contains("Hello World!")); + assertThat(snapshot).anyMatch(s -> s.contains("Test")); + } + + /** + * Tests console output with log levels. + */ + @Test + void testConsoleOutputWithLogLevels() { + + Platform.runLater(() -> { + consoleViewModel.appendOutput(IdeLogLevel.INFO, "Info message"); + consoleViewModel.appendOutput(IdeLogLevel.ERROR, "Error message"); + consoleViewModel.appendOutput(IdeLogLevel.WARNING, "Warning message"); + consoleViewModel.appendOutput(IdeLogLevel.DEBUG, "Debug message"); + }); + waitForFxEvents(); + + List snapshot = consoleViewModel.getConsoleOutputSnapshot(); + assertThat(snapshot).hasSize(4); + // Check that all log levels appear in the output (format is "HH:mm:ss | [LEVEL] message") + // INFO has 3 spaces after bracket, ERROR has 2, WARN has 2, DEBUG has 2 + assertThat(snapshot).anyMatch(s -> s.contains("[INFO] Info message")); + assertThat(snapshot).anyMatch(s -> s.contains("[ERROR] Error message")); + assertThat(snapshot).anyMatch(s -> s.contains("[WARNING] Warning message")); + assertThat(snapshot).anyMatch(s -> s.contains("[DEBUG] Debug message")); + } + + @Test + void testConsoleClear() { + + // Simulate output to the console + Platform.runLater(() -> { + consoleViewModel.appendOutput("Hello World!"); + consoleViewModel.appendOutput("Test"); + }); + waitForFxEvents(); + + // Clear the console + Platform.runLater(() -> consoleViewModel.clearConsole()); + waitForFxEvents(); + + // Verify that the console is empty + assertThat(consoleViewModel.getConsoleOutputSnapshot()).isEmpty(); + } + + @Test + void testLineCountUpdates() { + + Platform.runLater(() -> { + consoleViewModel.appendOutput("Line 1"); + consoleViewModel.appendOutput("Line 2"); + consoleViewModel.appendOutput("Line 3"); + }); + waitForFxEvents(); + + // Line count should be 3 + assertThat(consoleViewModel.getConsoleOutputSnapshot()).hasSize(3); + } + + @Test + void testAutoScrollCheckboxEnabledByDefault() { + + // Just verify auto-scroll is enabled by default + assertThat(consoleViewModel.autoScrollEnabledProperty().get()).isTrue(); + } + + /** + * Tests the event-bus path of the rework: a log forwarded by {@link IdeGuiLogListener} over the {@link GuiEventBus} ends up in the console, and a non-null + * {@code Throwable} yields an additional error line. + */ + @Test + void testLogEventArrivingOverTheEventBusIsShown() { + + // Forward logs over the event bus - the path used since the rework instead of a direct appendOutput call + IdeGuiLogListener listener = new IdeGuiLogListener(eventBus); + Platform.runLater(() -> { + listener.onLog(IdeLogLevel.INFO, "Info via EventBus", null, null, null); + listener.onLog(IdeLogLevel.ERROR, "Error via EventBus", null, null, new RuntimeException("boom")); + }); + waitForFxEvents(); + + List snapshot = consoleViewModel.getConsoleOutputSnapshot(); + assertThat(snapshot).anyMatch(s -> s.contains("[INFO] Info via EventBus")); + assertThat(snapshot).anyMatch(s -> s.contains("[ERROR] Error via EventBus")); + assertThat(snapshot).anyMatch(s -> s.contains("[ERROR] Error: boom")); + } + + /** + * Tests the off-FX-thread path of the rework: a log emitted from a non-FX worker thread (as commandlet logs are) must not be lost. It is buffered by the + * view-model and flushed to the console on the FX application thread (see {@link javafx.application.Platform#runLater(Runnable)}). + */ + @Test + void testLogEventArrivingOnOffFxThreadIsShown() throws InterruptedException { + + Thread worker = new Thread(() -> { + new IdeGuiLogListener(eventBus).onLog(IdeLogLevel.INFO, "Off FX", null, null, null); + }); + worker.start(); + worker.join(); // ensure the log was emitted before asserting + waitForFxEvents(); + + List snapshot = consoleViewModel.getConsoleOutputSnapshot(); + assertThat(snapshot).anyMatch(s -> s.contains("[INFO] Off FX")); + } +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java new file mode 100644 index 00000000..1b6161c0 --- /dev/null +++ b/gui/src/test/java/com/devonfw/ide/gui/core/mainwindow/navigation/NavigationPanelViewModelTest.java @@ -0,0 +1,71 @@ +package com.devonfw.ide.gui.core.mainwindow.navigation; + +import java.io.IOException; +import java.nio.file.Path; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.ide.gui.FakeProjectFolderStructureHelper; +import com.devonfw.ide.gui.core.context.GuiStateManager; + +/** + * Tests for {@link NavigationPanelViewModel}. + *

        + * These run without starting the JavaFX toolkit. The project→workspace→context orchestration is driven by the view (see {@link NavigationPanelView}), so these + * tests exercise the view model's own public methods the way the view calls them. + */ +public class NavigationPanelViewModelTest extends Assertions { + + private static final String PROJECT = "project-1"; + + private static final String OTHER_PROJECT = "project-2"; + + private static final String DEFAULT_WORKSPACE = "main"; + + @TempDir + private Path mockIdeRoot; + + private GuiStateManager guiStateManager; + + private NavigationPanelViewModel viewModel; + + @BeforeEach + void setUp() throws IOException { + + FakeProjectFolderStructureHelper.createFakeProjectFolderStructure(this.mockIdeRoot); + + this.guiStateManager = new GuiStateManager(this.mockIdeRoot.toString()); + this.viewModel = new NavigationPanelViewModel(this.guiStateManager); + } + + /** + * Populating the workspaces for a selected project has to include {@value #DEFAULT_WORKSPACE} and preselect it. + */ + @Test + void testPopulatingWorkspacesPreselectsMainWorkspace() { + + this.viewModel.getProjectsViewModel().select(PROJECT); + this.viewModel.populateWorkspaceComboBox(); + + assertThat(this.viewModel.getWorkspacesViewModel().getItems()).contains(DEFAULT_WORKSPACE); + assertThat(this.viewModel.getWorkspacesViewModel().getSelectedItem()).contains(DEFAULT_WORKSPACE); + } + + /** + * Updating the context for a selected project/workspace has to switch the {@link GuiStateManager} to the corresponding working directory. + */ + @Test + void testUpdatingContextSwitchesToTheSelectedProjectAndWorkspace() { + + this.viewModel.getProjectsViewModel().select(OTHER_PROJECT); + this.viewModel.populateWorkspaceComboBox(); + this.viewModel.updateContext(); + + assertThat(this.guiStateManager.isWorkspaceSelected()).isTrue(); + assertThat(this.guiStateManager.getCurrentContext().getCwd()).endsWith(Path.of(OTHER_PROJECT, "workspaces", DEFAULT_WORKSPACE)); + } + +} diff --git a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/progress/TaskManagerTest.java similarity index 95% rename from gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/progress/TaskManagerTest.java index 01c13ac1..b7094fd9 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskManagerTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/progress/TaskManagerTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress; +package com.devonfw.ide.gui.core.progress; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; import com.devonfw.ide.gui.HeadlessApplicationTest; -import com.devonfw.ide.gui.context.TaskManager; +import com.devonfw.ide.gui.core.context.TaskManager; /** * Tests for the {@link TaskManager} class. We extend HeadlessApplicationTest because all TaskManager mutations run on the JavaFX Application Thread via diff --git a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowTest.java similarity index 93% rename from gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowTest.java index 533c5687..86c149f3 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/progress/TaskWindowTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/progress/taskwindow/TaskWindowTest.java @@ -1,4 +1,4 @@ -package com.devonfw.ide.gui.progress; +package com.devonfw.ide.gui.core.progress.taskwindow; import static org.assertj.core.api.Assertions.assertThat; import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; @@ -17,12 +17,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.devonfw.ide.gui.App; -import com.devonfw.ide.gui.FxHelper; import com.devonfw.ide.gui.HeadlessApplicationTest; -import com.devonfw.ide.gui.context.TaskManager; -import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindow; -import com.devonfw.ide.gui.progress.taskwindow.TaskOverviewWindowController; +import com.devonfw.ide.gui.core.context.TaskManager; +import com.devonfw.ide.gui.core.helper.FxHelper; +import com.devonfw.ide.gui.core.progress.ProgressBarTask; /** * Tests for the TaskOverviewWindow. We check whether the window is displayed correctly and whether it properly reacts to changes in the TaskManager. @@ -35,7 +33,7 @@ public class TaskWindowTest extends HeadlessApplicationTest { @Override public void start(Stage stage) throws Exception { - URL taskOverviewWindowUrl = App.class.getResource("task_overview_window.fxml"); + URL taskOverviewWindowUrl = TaskOverviewWindowController.class.getResource("task_overview_window.fxml"); assertThat(taskOverviewWindowUrl).as("Cannot resolve task overview window FXML resource!").isNotNull(); FXMLLoader fxmlLoader = new FXMLLoader(taskOverviewWindowUrl); diff --git a/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java b/gui/src/test/java/com/devonfw/ide/gui/core/service/NlsServiceTest.java similarity index 63% rename from gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java rename to gui/src/test/java/com/devonfw/ide/gui/core/service/NlsServiceTest.java index adb9cac1..3385dcdf 100644 --- a/gui/src/test/java/com/devonfw/ide/gui/nls/NlsServiceTest.java +++ b/gui/src/test/java/com/devonfw/ide/gui/core/service/NlsServiceTest.java @@ -1,6 +1,5 @@ -package com.devonfw.ide.gui.nls; +package com.devonfw.ide.gui.core.service; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.params.provider.Arguments.arguments; import java.io.IOException; @@ -20,7 +19,6 @@ import java.util.jar.JarOutputStream; import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -28,31 +26,61 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.environment.IdeSystemTestImpl; + /** * Tests for {@link NlsService} - verifies locale switching, bundle loading, and fallback behavior. + * + *

        + * This test is isolated from the machine it runs on (see {@code documentation/contributing/junit-testing.adoc}): the {@link NlsService} is created with an + * isolated {@link IdeTestContext} whose system environment and user home are stubbed. The user home is a per-test {@link TempDir @TempDir} created for + * every test (and cleaned up afterwards), so the persisted GUI locale is written to a throw-away {@code ~/.ide/ide.properties} and can never leak between + * tests or be influenced by the developer's real user home, the {@code IDE_OPTIONS} environment variable or any other global configuration. */ -public class NlsServiceTest { +public class NlsServiceTest extends AbstractIdeContextTest { + + /** The name of the {@code ide.properties} file in the user home. */ + private static final String IDE_PROPERTIES_FILE = "ide.properties"; + /** + * The throw-away user home of the isolated {@link IdeTestContext}. A fresh directory is created for every test so the persisted + * {@code ~/.ide/ide.properties} never leaks between tests and the test works on any operating system. + */ @TempDir - Path tempUserHome; + Path userHome; - private String originalUserHome; + /** The isolated {@link IdeTestContext} that the {@link NlsService} under test persists into. */ + private IdeTestContext context; + /** + * Prepares an isolated {@link IdeTestContext} for every test so the test is not influenced by global configuration. + */ @BeforeEach public void setUp() { - this.originalUserHome = System.getProperty("user.home"); - System.setProperty("user.home", this.tempUserHome.toString()); + this.context = new IdeTestContext(); + // isolate from the real System: a decoupled, empty environment (no IDE_OPTIONS) so the persisted selection is not influenced by the machine environment + this.context.setSystem(new IdeSystemTestImpl()); + // point the isolated context at a fresh, throw-away user home (created anew per test) instead of the developer's real home + this.context.setUserHome(this.userHome); } - @AfterEach - public void tearDown() { + /** + * @return the {@code ~/.ide/ide.properties} file of the isolated {@link #context}. + */ + private Path getUserIdeProperties() { - if (this.originalUserHome == null) { - System.clearProperty("user.home"); - } else { - System.setProperty("user.home", this.originalUserHome); - } + return this.userHome.resolve(".ide").resolve(IDE_PROPERTIES_FILE); + } + + /** + * Creates a {@link NlsService} that persists the locale into the isolated {@link #context} instead of the real user home. + */ + private NlsService newService(Locale locale) { + + return new NlsService(this.context, locale); } @ParameterizedTest @@ -82,7 +110,7 @@ static Stream testGetInstanceWithLocaleProvider() { @Test public void testSetLocale() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); service.setLocale(Locale.GERMAN); assertThat(service.getLocale().getLanguage()).isEqualTo("de"); @@ -93,7 +121,7 @@ public void testSetLocale() { @Test public void testAllLocalizationBundlesContainExactlyTheEnglishKeys() throws IOException { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); Set englishKeys = loadBundleProperties(Locale.ENGLISH).stringPropertyNames(); for (Locale locale : service.getAvailableLocales()) { @@ -119,7 +147,7 @@ public void testAllLocalizationBundlesContainExactlyTheEnglishKeys() throws IOEx @ParameterizedTest @MethodSource("testLanguageDisplayShowsLocaleNameProvider") public void testLanguageDisplayShowsLocaleName(Locale serviceLocale, Locale displayLocale, String expectedString) { - NlsService service = new NlsService(serviceLocale); + NlsService service = newService(serviceLocale); assertThat(service.getLanguageDisplayName(displayLocale)).isEqualTo(expectedString); } @@ -138,7 +166,7 @@ static Stream testLanguageDisplayShowsLocaleNameProvider() { @Test public void testLocaleChangeListenerIsInvokedAndCanBeRemoved() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); AtomicInteger counter = new AtomicInteger(); Runnable listener = counter::incrementAndGet; @@ -154,74 +182,77 @@ public void testLocaleChangeListenerIsInvokedAndCanBeRemoved() { @Test public void testSetLocalePersistsSelectionInUserHomeIdeProperties() throws IOException { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); service.setLocale(Locale.GERMAN); - Path propertiesFile = this.tempUserHome.resolve(".ide").resolve("ide.properties"); + Path propertiesFile = getUserIdeProperties(); assertThat(Files.exists(propertiesFile)).isTrue(); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(propertiesFile)) { - properties.load(inputStream); - } - assertThat(properties.getProperty("IDE_OPTIONS")).isEqualTo("-Duser.language=de"); + Properties properties = loadUserIdeProperties(propertiesFile); + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Duser.language=de"); + } + + @Test + public void testSetLocaleIsNotInfluencedByIdeOptionsEnvironmentVariable() throws IOException { + + // simulate a machine where IDE_OPTIONS is set globally (e.g. SSL trust store flags): the persisted selection must not be polluted by it + this.context.getSystem().setEnv(NlsService.IDE_OPTIONS, + "-Djavax.net.ssl.trustStore=C:Usershohwille.ide ruststore ruststore.p12 -Djavax.net.ssl.trustStorePassword=changeit"); + + NlsService service = newService(Locale.ENGLISH); + service.setLocale(Locale.GERMAN); + + Path propertiesFile = getUserIdeProperties(); + assertThat(Files.exists(propertiesFile)).isTrue(); + + Properties properties = loadUserIdeProperties(propertiesFile); + // only the language selection is persisted - the global IDE_OPTIONS environment variable must not leak into the user configuration + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Duser.language=de"); } @Test public void testPersistLocaleUpdatesExistingUserLangInIdeOptions() throws IOException { - Path userIdeFolder = this.tempUserHome.resolve(".ide"); - Files.createDirectories(userIdeFolder); - Path userProperties = userIdeFolder.resolve("ide.properties"); + Path userProperties = getUserIdeProperties(); + this.context.getFileAccess().mkdirs(userProperties.getParent()); Files.writeString(userProperties, "IDE_OPTIONS=-Duser.language=de\n"); - NlsService service = new NlsService(null); + NlsService service = newService(null); service.setLocale(Locale.ENGLISH); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(userProperties)) { - properties.load(inputStream); - } + Properties properties = loadUserIdeProperties(userProperties); - assertThat(properties.getProperty("IDE_OPTIONS")).isEqualTo("-Duser.language=en"); + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Duser.language=en"); } @Test public void testPersistLocaleAppendsWhenIdeOptionsHasOtherOptions() throws IOException { - Path userIdeFolder = this.tempUserHome.resolve(".ide"); - Files.createDirectories(userIdeFolder); - Path userProperties = userIdeFolder.resolve("ide.properties"); + Path userProperties = getUserIdeProperties(); + this.context.getFileAccess().mkdirs(userProperties.getParent()); Files.writeString(userProperties, "IDE_OPTIONS=-Dfoo=bar\n"); - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); service.setLocale(Locale.GERMAN); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(userProperties)) { - properties.load(inputStream); - } + Properties properties = loadUserIdeProperties(userProperties); - assertThat(properties.getProperty("IDE_OPTIONS")).isEqualTo("-Dfoo=bar -Duser.language=de"); + assertThat(properties.getProperty(NlsService.IDE_OPTIONS)).isEqualTo("-Dfoo=bar -Duser.language=de"); } @Test public void testPersistLocalePreservesUnrelatedIdeOptions() throws IOException { - Path userIdeFolder = this.tempUserHome.resolve(".ide"); - Files.createDirectories(userIdeFolder); - Path userProperties = userIdeFolder.resolve("ide.properties"); + Path userProperties = getUserIdeProperties(); + this.context.getFileAccess().mkdirs(userProperties.getParent()); Files.writeString(userProperties, "IDE_OPTIONS=-Dfoo=bar -Duser.language=de\n"); - NlsService service = new NlsService(null); + NlsService service = newService(null); service.setLocale(Locale.ENGLISH); - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(userProperties)) { - properties.load(inputStream); - } + Properties properties = loadUserIdeProperties(userProperties); - String ideOptions = properties.getProperty("IDE_OPTIONS"); + String ideOptions = properties.getProperty(NlsService.IDE_OPTIONS); assertThat(ideOptions).contains("-Dfoo=bar"); assertThat(ideOptions).contains("-Duser.language=en"); assertThat(ideOptions).doesNotContain("-Duser.language=de"); @@ -230,7 +261,7 @@ public void testPersistLocalePreservesUnrelatedIdeOptions() throws IOException { @Test public void testNoEmptyTranslations() throws IOException { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); for (Locale locale : service.getAvailableLocales()) { Properties props = loadBundleProperties(locale); @@ -245,7 +276,7 @@ public void testNoEmptyTranslations() throws IOException { @Test public void testGetAvailableLocalesAlwaysContainsEnglish() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()).contains(Locale.ENGLISH); } @@ -253,7 +284,7 @@ public void testGetAvailableLocalesAlwaysContainsEnglish() { @Test public void testGetAvailableLocalesDetectsExistingBundleFiles() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); //confirmed languages till now assertThat(service.getAvailableLocales()) .contains(Locale.GERMAN, Locale.ENGLISH); @@ -262,7 +293,7 @@ public void testGetAvailableLocalesDetectsExistingBundleFiles() { @Test public void testGetAvailableLocalesDoesNotContainAbsentLocales() { - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()) .doesNotContain(Locale.FRENCH, Locale.JAPANESE, Locale.forLanguageTag("zh")); @@ -279,7 +310,7 @@ public void testGetAvailableLocalesFromDirectory(@TempDir Path bundleRoot) throw ClassLoader original = Thread.currentThread().getContextClassLoader(); try (URLClassLoader loader = new URLClassLoader(new URL[] { bundleRoot.toUri().toURL() }, ClassLoader.getPlatformClassLoader())) { Thread.currentThread().setContextClassLoader(loader); - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()) .containsExactlyInAnyOrder(Locale.ENGLISH, Locale.FRENCH); } finally { @@ -299,7 +330,7 @@ public void testGetAvailableLocalesFromJar(@TempDir Path jarRoot) throws Excepti ClassLoader original = Thread.currentThread().getContextClassLoader(); try (URLClassLoader loader = new URLClassLoader(new URL[] { jarFile.toUri().toURL() }, ClassLoader.getPlatformClassLoader())) { Thread.currentThread().setContextClassLoader(loader); - NlsService service = new NlsService(Locale.ENGLISH); + NlsService service = newService(Locale.ENGLISH); assertThat(service.getAvailableLocales()) .containsExactlyInAnyOrder(Locale.ENGLISH, Locale.FRENCH); } finally { @@ -307,6 +338,15 @@ public void testGetAvailableLocalesFromJar(@TempDir Path jarRoot) throws Excepti } } + private static Properties loadUserIdeProperties(Path userProperties) throws IOException { + + Properties properties = new Properties(); + try (InputStream inputStream = Files.newInputStream(userProperties)) { + properties.load(inputStream); + } + return properties; + } + private Properties loadBundleProperties(Locale locale) throws IOException { String resourceName = locale.equals(Locale.ENGLISH) diff --git a/macos-installer/README.adoc b/macos-installer/README.adoc new file mode 100644 index 00000000..95afd139 --- /dev/null +++ b/macos-installer/README.adoc @@ -0,0 +1,56 @@ +:toc: macro +toc::[] + +== Building IDEasy PKG Package with pkgbuild/productbuild + +=== Prerequisites + +The build runs on a macOS host (macOS GitHub runners). It needs: + +- macOS with `pkgbuild` and `productbuild` (both ship with the system) +- Maven (supported by IDEasy itself) +- `RELEASE_VERSION` set as an environment variable — provided by the workflow, not set by hand + +=== Steps to Build the PKG Package + +See the `Build MacOS PKG` step in `.github/workflows/release.yml` (and `nightly-build.yml`); +it runs `./macos-installer/build-pkg.sh` **from the repository root** with `RELEASE_VERSION` +provided via `env`. The script assumes the repository root as working directory (it builds +`documentation/` and `cli/` and copies their outputs into `macos-installer/`), so do not run +it from inside `macos-installer/` — that fails with `cd: documentation: No such file or directory`. + +The script assembles `pkg-root/` (generated PDF, `LICENSE.rtf`, the CLI package and the +`ideasy` binary, with the Windows/Linux `system` folders pruned), lets Maven filter +`src/Distribution.xml` into the module (replacing `$[project.version]` with the release version), +then runs `pkgbuild` to build `IDEasyComponent.pkg` from `pkg-root/` and `productbuild` to wrap it +into the distributable `ideasy-.pkg` (arm64 or x64, detected from the runner). The result is +copied to the fixed name `ideasy.pkg` that the `Upload unsigned PKG` step expects. + +=== Files + +link:build-pkg.sh[build-pkg.sh] + +The build script. Fails fast on any error (a PKG built from an incomplete `pkg-root/` would install +a broken IDEasy), requires `RELEASE_VERSION` to be set, and must be run from the repository root. + +link:src/Distribution.xml[src/Distribution.xml] + +The installer GUI configuration (title, license, conclusion, background, `pkg-ref`). Maven filters +this at build time, replacing the `$[project.version]` placeholder with the actual version so the +installer title and the `pkgbuild --version` stay in sync. + +link:Resources[Resources/] + +Installer UI resources: `background.png` and `CONCLUSION.rtf`. `LICENSE.rtf` is copied here at build +time from the generated documentation. + +link:scripts/postinstall[scripts/postinstall] + +Run by the PKG during installation (via `pkgbuild --scripts`); performs the actual IDEasy setup in +the logged-in user's session and shows a modal dialog if it fails. + +link:pom.xml[pom.xml] + +The `ide-macos-installer` module. It filters `src/Distribution.xml` into the module directory and +cleans up the build artifacts (`Distribution.xml`, `*.pkg`, `pkg-root/`, `Resources/LICENSE.rtf`) +for local work. diff --git a/.github/workflows/macos/generate_pkg.sh b/macos-installer/build-pkg.sh similarity index 100% rename from .github/workflows/macos/generate_pkg.sh rename to macos-installer/build-pkg.sh diff --git a/pom.xml b/pom.xml index 6a95ab32..464138a1 100644 --- a/pom.xml +++ b/pom.xml @@ -26,6 +26,7 @@ 2.11.0 3.11.0 25 + 0.9.13 4.0.18 0.10.6 3.8.0 @@ -171,6 +172,11 @@ javafx-controls ${javafx.version} + + io.github.m-m-m + mmm-event + ${mmm.version} + @@ -182,6 +188,7 @@ url-updater security macos-installer + windows-installer diff --git a/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java b/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java new file mode 100644 index 00000000..e491ec35 --- /dev/null +++ b/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdater.java @@ -0,0 +1,68 @@ +package com.devonfw.tools.ide.url.tool.agy; + +import com.devonfw.tools.ide.url.model.folder.UrlVersion; +import com.devonfw.tools.ide.url.updater.GithubUrlReleaseUpdater; + +/** + * {@link GithubUrlReleaseUpdater} for Anti-Gravity + */ +public class AgyUrlUpdater extends GithubUrlReleaseUpdater { + + /** + * The Constructor + */ + public AgyUrlUpdater() { + super(); + } + + /** + * Package-private constructor used for testing {@link AgyUrlUpdater} + * + * @param downloadBaseUrl mock url used for download base. + * @param versionBaseUrl mock url used for version base. + */ + AgyUrlUpdater(String downloadBaseUrl, String versionBaseUrl) { + super(downloadBaseUrl, versionBaseUrl); + } + + @Override + public String getTool() { + return "agy"; + } + + @Override + protected String getGithubOrganization() { + return "google-antigravity"; + } + + @Override + protected String getGithubRepository() { + return "antigravity-cli"; + } + + + @Override + protected void addVersion(UrlVersion urlVersion) { + String baseUrl = createGithubReleaseDownloadUrl("${version}", ""); + + doAddVersion(urlVersion, baseUrl + "agy_cli_windows_x64.zip", WINDOWS, X64); + doAddVersion(urlVersion, baseUrl + "agy_cli_windows_arm64.zip", WINDOWS, ARM64); + + doAddVersion(urlVersion, baseUrl + "agy_cli_mac_x64.tar.gz", MAC, X64); + doAddVersion(urlVersion, baseUrl + "agy_cli_mac_arm64.tar.gz", MAC, ARM64); + + doAddVersion(urlVersion, baseUrl + "agy_cli_linux_x64.tar.gz", LINUX, X64); + doAddVersion(urlVersion, baseUrl + "agy_cli_linux_arm64.tar.gz", LINUX, ARM64); + } + + @Override + public String getCpeVendor() { + return "google"; + } + + @Override + public String getCpeProduct() { + return "antigravity-cli"; + } + +} diff --git a/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/docker/DockerDesktopUrlUpdater.java b/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/docker/DockerDesktopUrlUpdater.java index 01417d03..39a9ff87 100644 --- a/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/docker/DockerDesktopUrlUpdater.java +++ b/url-updater/src/main/java/com/devonfw/tools/ide/url/tool/docker/DockerDesktopUrlUpdater.java @@ -1,5 +1,6 @@ package com.devonfw.tools.ide.url.tool.docker; + import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -39,17 +40,14 @@ public String getTool() { protected void addVersion(UrlVersion urlVersion) { VersionIdentifier vid = VersionIdentifier.of(urlVersion.getName()); - String version = urlVersion.getName().replaceAll("\\.", ""); - // get Code for version String body = doGetResponseBodyAsString(getVersionUrl()); - String regex = "href=#" + version - // .......1.........................................................2................. - + ".{8,12}(\r\n|\r|\n).{0,350}href=https://desktop\\.docker\\.com.*?(\\d{5,6}).*\\.exe"; + String regex = "## " + Pattern.quote(urlVersion.getName()) + + ".*?\\[Windows]\\(https://desktop\\.docker\\.com/win/main/amd64/(\\d{5,6})/"; Pattern pattern = Pattern.compile(regex, Pattern.DOTALL); Matcher matcher = pattern.matcher(body); - String code; + if (matcher.find()) { - code = matcher.group(2); + String code = matcher.group(1); boolean success = doAddVersion(urlVersion, getDownloadBaseUrl() + "/win/main/amd64/" + code + "/Docker%20Desktop%20Installer.exe", WINDOWS); if (!success) { @@ -58,6 +56,7 @@ protected void addVersion(UrlVersion urlVersion) { if (WINDOWS_ONLY_VERSIONS.stream().noneMatch(i -> vid.compareVersion(i).isEqual())) { doAddVersion(urlVersion, getDownloadBaseUrl() + "/mac/main/amd64/" + code + "/Docker.dmg", MAC, X64); doAddVersion(urlVersion, getDownloadBaseUrl() + "/mac/main/arm64/" + code + "/Docker.dmg", MAC, ARM64); + doAddVersion(urlVersion, getDownloadBaseUrl() + "/linux/main/amd64/" + code + "/docker-desktop-amd64.deb", LINUX); } } } @@ -65,7 +64,7 @@ protected void addVersion(UrlVersion urlVersion) { @Override protected String getVersionUrl() { - return getVersionBaseUrl() + "/desktop/release-notes/"; + return getVersionBaseUrl() + "/desktop/release-notes.md"; } @Override diff --git a/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java b/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java index 609a27a4..eaf047f6 100644 --- a/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java +++ b/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/UpdateManager.java @@ -13,6 +13,7 @@ import com.devonfw.tools.ide.url.model.folder.UrlRepository; import com.devonfw.tools.ide.url.model.folder.UrlVersion; import com.devonfw.tools.ide.url.model.report.UrlFinalReport; +import com.devonfw.tools.ide.url.tool.agy.AgyUrlUpdater; import com.devonfw.tools.ide.url.tool.androidstudio.AndroidStudioUrlUpdater; import com.devonfw.tools.ide.url.tool.aws.AwsUrlUpdater; import com.devonfw.tools.ide.url.tool.az.AzureUrlUpdater; @@ -85,16 +86,17 @@ public class UpdateManager extends AbstractProcessorWithTimeout { private final Map statusFiles = new HashMap<>(); private final List updaters = List.of( - new AndroidStudioUrlUpdater(), new AwsUrlUpdater(), new AzureUrlUpdater(), new ClaudeUrlUpdater(), new CopilotUrlUpdater(), new CorepackUrlUpdater(), - new DockerDesktopUrlUpdater(), new DotNetUrlUpdater(), new EclipseCppUrlUpdater(), new EclipseJeeUrlUpdater(), new EclipseJavaUrlUpdater(), - new GcLogAnalyzerUrlUpdater(), new GCloudUrlUpdater(), new GcViewerUrlUpdater(), new GhUrlUpdater(), new GoUrlUpdater(), new GraalVmCommunityUpdater(), - new GraalVmOracleUrlUpdater(), new GradleUrlUpdater(), new HelmUrlUpdater(), new InsoUrlUpdater(), new IntellijUrlUpdater(), new JasyptUrlUpdater(), - new JavaAzulUrlUpdater(), new JavaUrlUpdater(), new JenkinsUrlUpdater(), new JmcUrlUpdater(), new KotlincUrlUpdater(), new KotlincNativeUrlUpdater(), - new LazyDockerUrlUpdater(), new MvnUrlUpdater(), new MvndUrlUpdater(), new NgUrlUpdater(), new NodeUrlUpdater(), new NpmUrlUpdater(), - new ObsidianUrlUpdater(), new OcUrlUpdater(), new PgAdminUrlUpdater(), new PipUrlUpdater(), new PycharmUrlUpdater(), new QuarkusUrlUpdater(), - new RubyUrlUpdater(), new RubyJdxUrlUpdater(), new RustUrlUpdater(), new DockerRancherDesktopUrlUpdater(), new SonarUrlUpdater(), - new SquirrelSqlUrlUpdater(), new SoapUiUrlUpdater(), new TerraformUrlUpdater(), new TomcatUrlUpdater(), new UvUrlUpdater(), new VsCodeUrlUpdater(), - new VsCodiumUrlUpdater()); + new AgyUrlUpdater(), new AndroidStudioUrlUpdater(), new AwsUrlUpdater(), new AzureUrlUpdater(), + new ClaudeUrlUpdater(), new CopilotUrlUpdater(), new CorepackUrlUpdater(), new DockerDesktopUrlUpdater(), new DotNetUrlUpdater(), + new EclipseCppUrlUpdater(), new EclipseJeeUrlUpdater(), new EclipseJavaUrlUpdater(), new GcLogAnalyzerUrlUpdater(), new GCloudUrlUpdater(), + new GcViewerUrlUpdater(), new GhUrlUpdater(), new GoUrlUpdater(), new GraalVmCommunityUpdater(), new GraalVmOracleUrlUpdater(), new GradleUrlUpdater(), + new HelmUrlUpdater(), new InsoUrlUpdater(), new IntellijUrlUpdater(), new JasyptUrlUpdater(), new JavaAzulUrlUpdater(), new JavaUrlUpdater(), + new JenkinsUrlUpdater(), new JmcUrlUpdater(), new KotlincUrlUpdater(), new KotlincNativeUrlUpdater(), new LazyDockerUrlUpdater(), new MvnUrlUpdater(), + new MvndUrlUpdater(), new NgUrlUpdater(), new NodeUrlUpdater(), new NpmUrlUpdater(), new ObsidianUrlUpdater(), new OcUrlUpdater(), + new PgAdminUrlUpdater(), new PipUrlUpdater(), new PycharmUrlUpdater(), new QuarkusUrlUpdater(), new RubyUrlUpdater(), new RubyJdxUrlUpdater(), + new RustUrlUpdater(), new DockerRancherDesktopUrlUpdater(), new SonarUrlUpdater(), new SquirrelSqlUrlUpdater(), new SoapUiUrlUpdater(), + new TerraformUrlUpdater(), new TomcatUrlUpdater(), new UvUrlUpdater(), new VsCodeUrlUpdater(), new VsCodiumUrlUpdater() + ); /** * The constructor. @@ -203,7 +205,8 @@ public UrlRepository getUrlRepository() { /** * @param urlVersion the {@link UrlVersion} to get the {@link UrlStatusFile} for. - * @param create {@code true} to create the {@link UrlStatusFile} if the {@code status.json} does not exist yet, {@code false} to return {@code null} instead. + * @param create {@code true} to create the {@link UrlStatusFile} if the {@code status.json} does not exist yet, {@code false} to return {@code null} + * instead. * @return the cached {@link UrlStatusFile} for the given {@link UrlVersion} or {@code null}. */ public UrlStatusFile getStatusFile(UrlVersion urlVersion, boolean create) { diff --git a/url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java b/url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java new file mode 100644 index 00000000..b991ba7c --- /dev/null +++ b/url-updater/src/test/java/com/devonfw/tools/ide/url/tool/agy/AgyUrlUpdaterTest.java @@ -0,0 +1,62 @@ +package com.devonfw.tools.ide.url.tool.agy; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.url.model.folder.UrlRepository; +import com.devonfw.tools.ide.url.updater.AbstractUrlUpdaterTest; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +/** + * Test of {@link AgyUrlUpdater}. + */ +@WireMockTest +public class AgyUrlUpdaterTest extends AbstractUrlUpdaterTest { + + /** + * Test of {@link AgyUrlUpdater} for the creation of download URLs and checksums + * + * @param tempDir Path to temporary directory + * @param wmRuntimeInfo the {@link WireMockRuntimeInfo}. + */ + @Test + void testAgyUrlUpdater(@TempDir Path tempDir, WireMockRuntimeInfo wmRuntimeInfo) { + //arrange + stubFor(get(urlMatching("/repos/google-antigravity/antigravity-cli/releases")) + .willReturn(aResponse() + .withStatus(200) + .withBody(readAndResolve(PATH_INTEGRATION_TEST.resolve("AgyUrlUpdater") + .resolve("agy-releases.json"), wmRuntimeInfo)))); + + stubFor(any(urlMatching( + "/google-antigravity/antigravity-cli/releases/download/\\d+\\.\\d+\\.\\d+/agy_cli_(windows|mac|linux)_(x64|arm64)\\.(zip|tar\\.gz)")) + .willReturn(aResponse() + .withStatus(200) + .withBody(DOWNLOAD_CONTENT))); + + UrlRepository urlRepository = UrlRepository.load(tempDir); + AgyUrlUpdater updater = new AgyUrlUpdater(wmRuntimeInfo.getHttpBaseUrl(), wmRuntimeInfo.getHttpBaseUrl()); + + // act + update(updater, urlRepository); + + // assert + List expectedPlatforms = List.of("windows_x64", "windows_arm64", "mac_x64", "mac_arm64", "linux_x64", "linux_arm64"); + Path agyDir = tempDir.resolve("agy").resolve("agy"); + assertUrlVersion(agyDir.resolve("1.2.3"), expectedPlatforms); + assertUrlVersion(agyDir.resolve("1.2.2"), expectedPlatforms); + assertUrlVersion(agyDir.resolve("1.2.1"), expectedPlatforms); + assertUrlVersion(agyDir.resolve("1.2.0"), expectedPlatforms); + + } +} diff --git a/url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json b/url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json new file mode 100644 index 00000000..366ad5b4 --- /dev/null +++ b/url-updater/src/test/resources/integrationtest/AgyUrlUpdater/agy-releases.json @@ -0,0 +1,1086 @@ +[ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/388820804", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/388820804/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/388820804/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.3", + "id": 388820804, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XLO9E", + "tag_name": "1.2.3", + "target_commitish": "main", + "name": "1.2.3", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-15T02:03:06Z", + "updated_at": "2026-09-15T02:03:18Z", + "published_at": "2026-09-15T02:03:04Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665047", + "id": 564665047, + "node_id": "RA_kwDOScoSV84hqBrX", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54306993, + "digest": "sha256:e500d8b5d61bf8420cd397a1dabfa80899817b850b57982a1f5d4742be2b5d05", + "download_count": 39, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665049", + "id": 564665049, + "node_id": "RA_kwDOScoSV84hqBrZ", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57891892, + "digest": "sha256:57afb34f2a4be9296beb477e600761b6ac7401eb3a54a64a0014d573b7fc3af4", + "download_count": 274, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665044", + "id": 564665044, + "node_id": "RA_kwDOScoSV84hqBrU", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 50217630, + "digest": "sha256:c244ec966f5d8c22d845117332a6858f3abaf0fb5c8058e337d51b77b2c93e50", + "download_count": 68, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665046", + "id": 564665046, + "node_id": "RA_kwDOScoSV84hqBrW", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 55184535, + "digest": "sha256:5384ba8533bc40899526e4ee1d0ac6289e18503ccd28ca49ca5765fd3efea15e", + "download_count": 39, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665158", + "id": 564665158, + "node_id": "RA_kwDOScoSV84hqBtG", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 49471592, + "digest": "sha256:b41b4d5e1c7b96c6fb95e3142533a20614eb9d2c4e48ad4e284375c9459a9789", + "download_count": 5, + "created_at": "2026-09-15T02:03:01Z", + "updated_at": "2026-09-15T02:03:04Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/564665050", + "id": 564665050, + "node_id": "RA_kwDOScoSV84hqBra", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 54292962, + "digest": "sha256:be6614b216cfa9ce002c94d6aaf9f96535b5d8e3480a96f45414ae2f63d3aa34", + "download_count": 51, + "created_at": "2026-09-15T02:02:57Z", + "updated_at": "2026-09-15T02:03:01Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.3/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.3", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.3", + "body": "- Added the `/copy btw` slash subcommand to copy the full text of the active `/btw` side-question response to the clipboard even when the response card is collapsed or scrolled, along with argument ghost hints when typing `/copy`.\n- Improved tool-calling fidelity by translating per-request tool-choice constraints (`any`, `required`, and named functions) into native backend function-calling configurations.\n- Fixed the `/hooks` command and hook inspection utilities omitting hooks bundled inside enabled plugins when listing active `hooks.json` configurations.\n- Fixed custom subagents created with `enable_mcp_tools: true` receiving an empty MCP server list instead of inheriting the parent agent's configured MCP servers, and fixed declarative subagent configurations failing to resolve relative config file paths (`relative_path_to_config`) against the parent agent's directory.\n- Fixed proactive feedback prompts triggering prematurely during the first few turns of a session or on transient, recovered tool errors.\n", + "reactions": { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/388820804/reactions", + "total_count": 6, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 1, + "confused": 0, + "heart": 3, + "rocket": 1, + "eyes": 1 + } + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/387445796", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/387445796/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/387445796/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.2", + "id": 387445796, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XF_Qk", + "tag_name": "1.2.2", + "target_commitish": "main", + "name": "1.2.2", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-12T03:51:10Z", + "updated_at": "2026-09-12T03:51:14Z", + "published_at": "2026-09-12T03:51:08Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553053", + "id": 558553053, + "node_id": "RA_kwDOScoSV84hStfd", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54016481, + "digest": "sha256:00fc5cb50cd714b81cdf1298fcc90e63a59e3e564a4f8076454ab8457d36e6eb", + "download_count": 335, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553052", + "id": 558553052, + "node_id": "RA_kwDOScoSV84hStfc", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57596853, + "digest": "sha256:2cfa5c9a4a1edd96db6d4058f34970be60d3bcacda866e2bdce6aefb2451b48e", + "download_count": 1761, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553056", + "id": 558553056, + "node_id": "RA_kwDOScoSV84hStfg", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 49929659, + "digest": "sha256:f90ff6094a196f1be3854ac45d999a542d47ec66a1513aa882a8505b947b9a0f", + "download_count": 646, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553050", + "id": 558553050, + "node_id": "RA_kwDOScoSV84hStfa", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54877358, + "digest": "sha256:2b44ed726a73a0ef0a39956c80e07b5898c0b4fe21f8ecd383be74f1c0b65e5d", + "download_count": 310, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:06Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553154", + "id": 558553154, + "node_id": "RA_kwDOScoSV84hSthC", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 49127588, + "digest": "sha256:00c4d0cc4d05372588eb4a016147e4a3d4a5b00ebdc692eaf46042e8de3cf6fd", + "download_count": 33, + "created_at": "2026-09-12T03:51:05Z", + "updated_at": "2026-09-12T03:51:08Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/558553054", + "id": 558553054, + "node_id": "RA_kwDOScoSV84hStfe", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 53917493, + "digest": "sha256:50ce727a034980d341ee03773d2b81a78fb3c7897ac2ff138f55db88cead4c03", + "download_count": 492, + "created_at": "2026-09-12T03:51:01Z", + "updated_at": "2026-09-12T03:51:05Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.2/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.2", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.2", + "body": "- Improved the startup warning for deprecated `unsandboxed` permission rules across CLI, shared, and project configuration files to list each affected file path, up to five offending rules, and step-by-step instructions for migrating them to `command` rules.\n- Improved `/resume` startup responsiveness when opening a large conversation history with a cold or stale summary cache.\n- Fixed MCP servers bundled inside plugins colliding with each other or with user-configured servers in `mcp_config.json` when they shared the same server name by automatically namespacing plugin MCP servers as `_`.\n- Fixed a memory leak where opening or scanning conversations left background step-cache eviction goroutines running for the rest of the session, significantly reducing memory usage after opening `/resume` or switching conversations.\n- Fixed `view_file` attempting to parse non-UTF-8 binary files as text or loading files larger than 100 MB into the model context; unsupported binary formats and oversized files are now rejected with a clear error before overflowing the context window.\n- Fixed Gemini API (`GEMINI_API_KEY`) sessions dropping model thinking blocks from prior turns on subsequent user messages and failing to propagate thought signatures on text and thought parts.\n- Fixed artifact review failing to trigger when a directory name above `.gemini/` matched a skipped path component such as `scratch`, prevented conversation forks and snapshot reverts from copying internal `.system_generated/subagents` and `.system_generated/worktrees` directories, and cleaned up subagent metadata records when deleting a conversation.\n- Fixed deleted conversations being recreated as empty, schema-less SQLite database files when background queries reconnected after deletion.\n- Fixed conversations launched without a workspace folder inheriting workspace paths and customizations from other open sessions.\n", + "reactions": { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/387445796/reactions", + "total_count": 10, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 2, + "confused": 0, + "heart": 2, + "rocket": 6, + "eyes": 0 + } + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/386833647", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/386833647/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/386833647/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.1", + "id": 386833647, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XDpzv", + "tag_name": "1.2.1", + "target_commitish": "main", + "name": "1.2.1", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-11T06:46:50Z", + "updated_at": "2026-09-11T06:46:55Z", + "published_at": "2026-09-11T06:46:48Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672095", + "id": 556672095, + "node_id": "RA_kwDOScoSV84hLiRf", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 53888431, + "digest": "sha256:882d9160972d09a721422baa554999ad969bc9b3fa680e866a981398f937f19f", + "download_count": 118, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672096", + "id": 556672096, + "node_id": "RA_kwDOScoSV84hLiRg", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57458208, + "digest": "sha256:6a2c53db6c681fc114f9a1e499e7b4771357ab2852242e56acbd43197d4807f9", + "download_count": 785, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672093", + "id": 556672093, + "node_id": "RA_kwDOScoSV84hLiRd", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 49797347, + "digest": "sha256:b80425e10a7b92f20679eee5df3bb18e3f9154b3653a373fad47e2f72614248e", + "download_count": 257, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672092", + "id": 556672092, + "node_id": "RA_kwDOScoSV84hLiRc", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54716166, + "digest": "sha256:c77c6f782c6f79dc4ad831e833ce71249f90cfddf8e2e4579719c51709f08a16", + "download_count": 108, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672233", + "id": 556672233, + "node_id": "RA_kwDOScoSV84hLiTp", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 48990159, + "digest": "sha256:208378db43a1f386acf54657d641237afa31683d4fb8def1c89c0d67e80577b5", + "download_count": 12, + "created_at": "2026-09-11T06:46:45Z", + "updated_at": "2026-09-11T06:46:48Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/556672091", + "id": 556672091, + "node_id": "RA_kwDOScoSV84hLiRb", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 53754147, + "digest": "sha256:233c86abbbf589b9f4c983294eaee4767fbbdc7d469ebfd2638bc968a4cea216", + "download_count": 169, + "created_at": "2026-09-11T06:46:41Z", + "updated_at": "2026-09-11T06:46:45Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.1/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.1", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.1", + "body": "- Added support for `excludeDefaultComponents: true` in custom agent Markdown frontmatter, allowing custom agents to opt out of default prompt sections and built-in tools while preserving post-invocation hooks.\n- Improved model API error resilience and diagnostics: transient `genai.APIError` failures (`502`, `503`, `504`, per-minute `429` rate limits, and mid-stream interruptions) automatically retry in-process with exponential backoff while preserving completed tool call outputs, and unrecovered `503` and `429` responses surface clear user-facing error messages.\n- Improved MCP and provider tool schema validation to preserve open object schemas (such as `{\"type\": \"object\"}` or explicit `additionalProperties: true`) instead of rejecting undeclared arguments on schemas that allow them.\n- Improved per-turn responsiveness and reduced memory usage when opening large conversations.\n- Fixed `--continue` starting a brand-new conversation when launched from a subdirectory, after a crash, or while another session is open in the same workspace; it now falls back to the most recent non-empty conversation in the current workspace or its parent/child directories.\n- Fixed full-screen blank flashes in inline mode when content first pushes to scrollback, at the end of a turn, or when clearing the screen with `Ctrl+L`.\n- Fixed remote companion UIs connected to an interactive CLI session via Remote Control displaying an unauthenticated sign-in screen instead of the active session's signed-in state.\n- Fixed the status line reporting the terminal sandbox as disabled when the session was launched with the `--sandbox` command-line flag.\n- Fixed image zoom keys (`Ctrl+=` and `Ctrl+-`) and the footer zoom hint appearing in the artifact viewer when a Mermaid diagram is rendered in ASCII mode instead of as a Kitty image.\n", + "reactions": { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/386833647/reactions", + "total_count": 5, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 1, + "confused": 0, + "heart": 3, + "rocket": 0, + "eyes": 1 + } + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/385950232", + "assets_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/385950232/assets", + "upload_url": "https://uploads.github.com/repos/google-antigravity/antigravity-cli/releases/385950232/assets{?name,label}", + "html_url": "https://github.com/google-antigravity/antigravity-cli/releases/tag/1.2.0", + "id": 385950232, + "author": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOScoSV84XASIY", + "tag_name": "1.2.0", + "target_commitish": "main", + "name": "1.2.0", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-10T01:43:31Z", + "updated_at": "2026-09-10T01:43:35Z", + "published_at": "2026-09-10T01:43:29Z", + "assets": [ + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988479", + "id": 553988479, + "node_id": "RA_kwDOScoSV84hBTF_", + "name": "agy_cli_linux_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 53647388, + "digest": "sha256:0a8e61f6548865029c4238b9310c686b7a733db5864cae619656abdf48090594", + "download_count": 100, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_linux_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988482", + "id": 553988482, + "node_id": "RA_kwDOScoSV84hBTGC", + "name": "agy_cli_linux_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 57224342, + "digest": "sha256:d9bfee1ae6e4329562cb87da1f5fc3c886d18594837e73e25c3aae00a49499b9", + "download_count": 1052, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_linux_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988478", + "id": 553988478, + "node_id": "RA_kwDOScoSV84hBTF-", + "name": "agy_cli_mac_arm64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 49578312, + "digest": "sha256:8fee3c120142490f2eddd286ff7d532b3e660c92d17e60a3612cc0ad1122478f", + "download_count": 275, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:27Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_mac_arm64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988480", + "id": 553988480, + "node_id": "RA_kwDOScoSV84hBTGA", + "name": "agy_cli_mac_x64.tar.gz", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-gtar", + "state": "uploaded", + "size": 54477008, + "digest": "sha256:79799297510167ed46ab40add47f2360fe2bb08fc7495f1de02452dd8a109eb7", + "download_count": 137, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_mac_x64.tar.gz" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988591", + "id": 553988591, + "node_id": "RA_kwDOScoSV84hBTHv", + "name": "agy_cli_windows_arm64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 48769448, + "digest": "sha256:139d665de51a5c04b42dfdba8f7829f1e0cebaaca0e8ca069c205a9b19ec6dfc", + "download_count": 11, + "created_at": "2026-09-10T01:43:26Z", + "updated_at": "2026-09-10T01:43:29Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_windows_arm64.zip" + }, + { + "url": "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/assets/553988481", + "id": 553988481, + "node_id": "RA_kwDOScoSV84hBTGB", + "name": "agy_cli_windows_x64.zip", + "label": "", + "uploader": { + "login": "haofan", + "id": 3281311, + "node_id": "MDQ6VXNlcjMyODEzMTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/3281311?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/haofan", + "html_url": "https://github.com/haofan", + "followers_url": "https://api.github.com/users/haofan/followers", + "following_url": "https://api.github.com/users/haofan/following{/other_user}", + "gists_url": "https://api.github.com/users/haofan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/haofan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/haofan/subscriptions", + "organizations_url": "https://api.github.com/users/haofan/orgs", + "repos_url": "https://api.github.com/users/haofan/repos", + "events_url": "https://api.github.com/users/haofan/events{/privacy}", + "received_events_url": "https://api.github.com/users/haofan/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/zip", + "state": "uploaded", + "size": 53523622, + "digest": "sha256:27a2096a255c829acc0ca98e6305226e23016bfd69298f60e1060ecff983f907", + "download_count": 234, + "created_at": "2026-09-10T01:43:22Z", + "updated_at": "2026-09-10T01:43:26Z", + "browser_download_url": "https://github.com/google-antigravity/antigravity-cli/releases/download/1.2.0/agy_cli_windows_x64.zip" + } + ], + "tarball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/tarball/1.2.0", + "zipball_url": "https://api.github.com/repos/google-antigravity/antigravity-cli/zipball/1.2.0", + "body": "- Added the `remote-control start`, `remote-control status`, and `remote-control stop` subcommands to run the CLI as a background service registered with your operating system's service manager so your machine stays reachable across logouts and reboots; `remote-control start --name