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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ $ jup init
Initialized javaup project.

$ jup run mvn test
? Select a Maven project
> myproject /work/myproject
[INFO] BUILD SUCCESS
```

Expand All @@ -56,7 +58,7 @@ With `jup`, each project gets one explicit, inspectable toolchain:
| Switch projects | Edit `JAVA_HOME` and `PATH` | Use the JDK saved for the project |
| Select Maven | Depend on whatever is on PATH | Prefer the project Wrapper, otherwise save Maven from PATH |
| Use private repositories | Repeat `--settings` or replace a global file | Bind a named `settings.xml` to the project |
| Build from a submodule | Return to the root and reconstruct the environment | Resolve the initialized project from any descendant directory |
| Build from anywhere | Change directories and reconstruct the environment | Select any initialized project from a ranked list |
| Preserve the shell | Risk affecting later commands | Change only the spawned build process |

### How it fits with existing tools
Expand Down Expand Up @@ -163,9 +165,15 @@ Java home: /opt/jdks/temurin-17
Maven settings: default
```

`jup run mvn` connects Maven directly to the current terminal, preserving
interactive input, logs, and exit codes. It starts Maven in the current
directory, so module-specific builds work as expected.
In an interactive terminal, `jup run mvn` lists every initialized Maven project
regardless of the current directory. Use the arrow keys and Enter to select a
project; Maven starts in that project's saved root directory. Frequently and
recently used projects rise to the top. Maven remains connected directly to the
terminal, preserving interactive input, logs, and exit codes.

In non-interactive environments such as CI and redirected pipelines, `jup`
keeps the previous behavior: it resolves the nearest initialized project from
the current directory and starts Maven there without prompting.

## Highlights

Expand All @@ -179,6 +187,8 @@ directory, so module-specific builds work as expected.
- Sets `JAVA_HOME` and puts the selected JDK's `bin` first on PATH only for the
spawned build process.
- Binds reusable Maven `settings.xml` aliases to individual projects.
- Selects initialized projects globally and ranks them by decaying recent-use
frequency.
- Resolves `status`, `run`, `settings use/unset`, and `uninit` from any
descendant directory.
- Runs on Windows, macOS, and Linux, with CI verification on all three.
Expand Down
12 changes: 10 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ $ jup init
Initialized javaup project.

$ jup run mvn test
? Select a Maven project
> myproject /work/myproject
[INFO] BUILD SUCCESS
```

Expand Down Expand Up @@ -153,8 +155,13 @@ Java home: /opt/jdks/temurin-17
Maven settings: default
```

`jup run mvn` 会将 Maven 直接连接到当前终端,保留交互输入、日志和退出码,并从
当前目录而不是强制从项目根目录启动,因此可以正常执行针对子模块的构建。
在交互式终端中,`jup run mvn` 会列出所有已经初始化的 Maven 项目,不受当前目录
限制。使用上下方向键和回车选择项目后,Maven 会从该项目保存的根目录启动。最近且
频繁使用的项目会自动排在前面。Maven 仍会直接连接到当前终端,保留交互输入、日志
和退出码。

在 CI、输入重定向等非交互环境中,`jup` 保持原有行为:从当前目录向上寻找最近的
已初始化项目,并直接从当前目录启动 Maven,不显示选择器。

## 功能亮点

Expand All @@ -165,6 +172,7 @@ Maven settings: default
- 为每个项目保存 Maven 可执行文件、JDK 路径、版本和初始化时间。
- 只在构建子进程中设置 `JAVA_HOME`,并把对应 JDK 的 `bin` 放到 PATH 首位。
- 支持将可复用的 Maven `settings.xml` 别名绑定到不同项目。
- 可以从任意目录选择已初始化项目,并按带时间衰减的最近使用频率排序。
- 可以从项目任意子目录执行 `status`、`run`、`settings use/unset` 和 `uninit`。
- 支持 Windows、macOS 和 Linux,CI 会在三个平台执行完整验证。

Expand Down
43 changes: 28 additions & 15 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,26 @@ jup run mvn clean package -DskipTests
jup run mvn dependency:tree
```

For every invocation, `jup`:
In an interactive terminal, every invocation:

1. searches upward from the current directory for the nearest initialized
1. loads every initialized Maven project, even when the terminal is outside a
project;
2. checks that the saved Maven executable still exists;
3. sets the saved `JAVA_HOME` for the child process;
4. places that JDK's `bin` first on the child process PATH;
5. prepends `--settings <path>` when the project uses a settings alias;
6. starts Maven in the current directory rather than forcing the project root.
2. orders projects by a time-decaying recent-use frequency score;
3. lets you choose with the Up/Down keys and Enter;
4. checks that the selected Maven executable still exists;
5. sets the saved `JAVA_HOME` for the child process;
6. places that JDK's `bin` first on the child process PATH;
7. prepends `--settings <path>` when the project uses a settings alias;
8. starts Maven in the selected project's saved root directory.

The ranking score has a 14-day half-life. Repeatedly used projects rise quickly,
while the influence of projects that have not been used fades over time. The
project's full path is shown so projects with the same directory name remain
unambiguous.

In non-interactive environments such as CI or redirected pipelines, `jup` does
not prompt. It searches upward for the nearest initialized project and starts
Maven in the current directory, preserving the existing automation behavior.

Maven's standard input, output, and error streams connect directly to the
current terminal. Interactive behavior, logs, and exit codes are preserved.
Expand Down Expand Up @@ -183,10 +194,10 @@ that it is missing. Bind another alias or run `jup settings unset` to recover.
jup uninit
```

The command removes the nearest initialized project configuration found from
the current directory. It is idempotent: if no configuration exists, it
reports that there is nothing to remove. Project files, JDKs, Maven
installations, and settings files are never modified.
The command removes the nearest initialized project configuration and its usage
ranking record. It is idempotent: if no configuration exists, it reports that
there is nothing to remove. Project files, JDKs, Maven installations, and
settings files are never modified.

## Help and version

Expand Down Expand Up @@ -276,10 +287,12 @@ Directory layout:
.javaup/
├── bin/
│ └── jup # jup.exe on Windows
└── config/
├── projects/ # one JSON document per initialized project
└── maven/
└── settings.json # Maven settings alias registry
├── config/
│ ├── projects/ # one JSON document per initialized project
│ └── maven/
│ └── settings.json # Maven settings alias registry
└── state/
└── project-usage.json # recent-frequency project ranking
```

Set `JAVAUP_HOME` to an absolute path before installing or running `jup` to use
Expand Down
37 changes: 24 additions & 13 deletions docs/user-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,22 @@ jup run mvn clean package -DskipTests
jup run mvn dependency:tree
```

每次运行时,`jup` 都会:
在交互式终端中,每次运行时 `jup` 都会:

1. 从当前目录向上查找最近的已初始化项目;
2. 检查保存的 Maven 可执行文件是否仍然存在;
3. 为子进程设置保存的 `JAVA_HOME`;
4. 将对应 JDK 的 `bin` 放到子进程 PATH 首位;
5. 如果项目绑定了 settings 别名,在参数前加入 `--settings <path>`;
6. 从当前目录启动 Maven,而不是强制切换到项目根目录。
1. 加载所有已初始化的 Maven 项目,即使当前终端不在项目目录中;
2. 按带时间衰减的最近使用频率排序;
3. 让用户使用上下方向键和回车选择项目;
4. 检查所选项目保存的 Maven 可执行文件是否仍然存在;
5. 为子进程设置保存的 `JAVA_HOME`;
6. 将对应 JDK 的 `bin` 放到子进程 PATH 首位;
7. 如果项目绑定了 settings 别名,在参数前加入 `--settings <path>`;
8. 从所选项目保存的根目录启动 Maven。

排序分数采用 14 天半衰期。短时间内反复使用的项目会快速靠前,长期未使用项目的
历史权重会逐渐降低。列表同时显示完整路径,因此同名项目也可以明确区分。

在 CI、输入重定向等非交互环境中,`jup` 不显示选择器,而是从当前目录向上查找
最近的已初始化项目,并从当前目录启动 Maven,以保持现有自动化行为。

Maven 的标准输入、标准输出和标准错误会直接连接到当前终端,交互行为、日志和退出码
都会保留。当前 shell 的环境变量不会被修改。
Expand Down Expand Up @@ -166,8 +174,9 @@ jup settings remove intranet
jup uninit
```

该命令会删除从当前目录向上找到的最近一个已初始化项目配置。重复执行是安全的;如果
没有配置,会提示无需删除。项目文件、JDK、Maven 安装和 settings 文件都不会被修改。
该命令会删除从当前目录向上找到的最近一个已初始化项目配置及其使用排序记录。重复
执行是安全的;如果没有配置,会提示无需删除。项目文件、JDK、Maven 安装和 settings
文件都不会被修改。

## 帮助与版本

Expand Down Expand Up @@ -248,10 +257,12 @@ Release 安装器也会将可执行文件放在其中的 `bin` 目录。
.javaup/
├── bin/
│ └── jup # Windows 上为 jup.exe
└── config/
├── projects/ # 每个已初始化项目一个 JSON 文件
└── maven/
└── settings.json # Maven settings 别名注册表
├── config/
│ ├── projects/ # 每个已初始化项目一个 JSON 文件
│ └── maven/
│ └── settings.json # Maven settings 别名注册表
└── state/
└── project-usage.json # 项目最近使用频率排序
```

安装或运行 `jup` 前,可以将 `JAVAUP_HOME` 设置为其他绝对路径。项目配置保存的是
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ go 1.26.5

require (
github.com/fatih/color v1.19.0
github.com/gofrs/flock v0.13.0
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-isatty v0.0.20
github.com/olekukonko/tablewriter v1.1.4
github.com/spf13/cobra v1.10.2
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
Expand Down
19 changes: 19 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
Expand All @@ -23,13 +35,20 @@ github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
115 changes: 115 additions & 0 deletions internal/cli/project_picker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
"os"

"github.com/manifoldco/promptui"
"github.com/mattn/go-isatty"

"github.com/codeboyzhou/javaup/internal/buildtool"
"github.com/codeboyzhou/javaup/internal/project"
)

type projectCatalog interface {
List(tool buildtool.Type) ([]project.Candidate, []error, error)
}

type terminalProjectPicker struct {
catalog projectCatalog
}

func newTerminalProjectPicker(catalog projectCatalog) *terminalProjectPicker {
return &terminalProjectPicker{catalog: catalog}
}

func (p *terminalProjectPicker) Pick(
_ context.Context,
tool buildtool.Type,
streams project.Streams,
) (string, error) {
candidates, warnings, err := p.catalog.List(tool)
if err != nil {
return "", err
}
for _, warning := range warnings {
if streams.Stderr != nil {
_, _ = fmt.Fprintf(streams.Stderr, "jup: warning: %v\n", warning)
}
}
if len(candidates) == 0 {
return "", fmt.Errorf("no configured %s projects found; run jup init in a project first", tool.DisplayName())
}

selector := promptui.Select{
Label: "Select a " + tool.DisplayName() + " project",
Items: candidates,
Size: min(len(candidates), 10),
Templates: &promptui.SelectTemplates{
Active: `> {{ .Name | cyan }} {{ .ProjectRoot }}`,
Inactive: ` {{ .Name }} {{ .ProjectRoot }}`,
Selected: `{{ "Selected" | green }}: {{ .Name }} {{ .ProjectRoot }}`,
Help: "↑/↓ move • enter select • ctrl+c cancel",
},
Stdin: promptInput(streams.Stdin),
Stdout: promptOutput(streams.Stdout),
}
index, _, err := selector.Run()
if err != nil {
if errors.Is(err, promptui.ErrInterrupt) || errors.Is(err, promptui.ErrAbort) || errors.Is(err, promptui.ErrEOF) {
return "", canceledCommandError{}
}
return "", fmt.Errorf("select project: %w", err)
}
return candidates[index].ProjectRoot, nil
}

type readCloser struct {
io.Reader
}

func (readCloser) Close() error { return nil }

type writeCloser struct {
io.Writer
}

func (writeCloser) Close() error { return nil }

func promptInput(input io.Reader) io.ReadCloser {
if file, ok := input.(*os.File); ok && file == os.Stdin {
// A nil prompt input selects readline's platform-specific stdin. On
// Windows this is a console event reader that translates arrow keys.
return nil
}
return readCloser{Reader: input}
}

func promptOutput(output io.Writer) io.WriteCloser {
if file, ok := output.(*os.File); ok && file == os.Stdout {
return nil
}
return writeCloser{Writer: output}
}

type canceledCommandError struct{}

func (canceledCommandError) Error() string { return "project selection canceled" }

func (canceledCommandError) ExitCode() int { return 130 }

func isInteractiveTerminal(stdin io.Reader, stdout io.Writer) bool {
input, inputOK := stdin.(*os.File)
output, outputOK := stdout.(*os.File)
if !inputOK || !outputOK {
return false
}
return isTerminalDescriptor(input.Fd()) && isTerminalDescriptor(output.Fd())
}

func isTerminalDescriptor(descriptor uintptr) bool {
return isatty.IsTerminal(descriptor) || isatty.IsCygwinTerminal(descriptor)
}
Loading