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
13 changes: 7 additions & 6 deletions .github/workflows/install-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,23 @@ jobs:
aws configure set default.response_checksum_validation when_required
PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}"

# Bake the CDN as the default MIRROR_URL into the copy we serve from the
# CDN, so `curl <cdn>/install.sh | sh` pulls binaries from the CDN with
# no MIRROR_URL arg. The repo / GitHub copy stays generic (GitHub default).
src_sh=install.sh
src_ps1=install.ps1
if [ -n "${MIRROR_PUBLIC_URL:-}" ]; then
pub="${MIRROR_PUBLIC_URL%/}${PREFIX:+/${PREFIX}}"
sed "s#MIRROR_URL=\"\${MIRROR_URL:-}\"#MIRROR_URL=\"\${MIRROR_URL:-${pub}}\"#" install.sh > /tmp/install.sh
grep -q "MIRROR_URL:-${pub}" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; }
sed "s#^DEFAULT_MIRROR_URL=.*#DEFAULT_MIRROR_URL=\"${pub}\"#" install.sh > /tmp/install.sh
grep -q "DEFAULT_MIRROR_URL=\"${pub}\"" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; }
src_sh=/tmp/install.sh
sed "s#^\$DefaultMirrorUrl = .*#\$DefaultMirrorUrl = \"${pub}\"#" install.ps1 > /tmp/install.ps1
grep -q "\$DefaultMirrorUrl = \"${pub}\"" /tmp/install.ps1 || { echo "ERROR: MIRROR_URL default not injected (install.ps1 default line changed?)" >&2; exit 1; }
src_ps1=/tmp/install.ps1
fi
sh_key="${PREFIX:+${PREFIX}/}install.sh"
aws --endpoint-url="$ENDPOINT" s3 cp "$src_sh" "s3://${BUCKET}/${sh_key}" \
--cache-control "public, max-age=300" \
--content-type "text/x-shellscript; charset=utf-8"

ps1_key="${PREFIX:+${PREFIX}/}install.ps1"
aws --endpoint-url="$ENDPOINT" s3 cp install.ps1 "s3://${BUCKET}/${ps1_key}" \
aws --endpoint-url="$ENDPOINT" s3 cp "$src_ps1" "s3://${BUCKET}/${ps1_key}" \
--cache-control "public, max-age=300" \
--content-type "text/plain; charset=utf-8"
13 changes: 7 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,21 +103,22 @@ jobs:
# ships a stale/missing installer (install-scripts.yml only fires when
# install.sh/.ps1 change on main; the scripts are version-agnostic, so
# re-uploading the current copy here is the belt-and-suspenders guarantee).
# Bake the CDN as the default MIRROR_URL into the served copy so
# `curl <cdn>/install.sh | sh` pulls binaries from the CDN with no
# MIRROR_URL arg. The repo / GitHub copy stays generic (GitHub default).
src_sh=install.sh
src_ps1=install.ps1
if [ -n "${MIRROR_PUBLIC_URL:-}" ]; then
pub="${MIRROR_PUBLIC_URL%/}${PREFIX:+/${PREFIX}}"
sed "s#MIRROR_URL=\"\${MIRROR_URL:-}\"#MIRROR_URL=\"\${MIRROR_URL:-${pub}}\"#" install.sh > /tmp/install.sh
grep -q "MIRROR_URL:-${pub}" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; }
sed "s#^DEFAULT_MIRROR_URL=.*#DEFAULT_MIRROR_URL=\"${pub}\"#" install.sh > /tmp/install.sh
grep -q "DEFAULT_MIRROR_URL=\"${pub}\"" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; }
src_sh=/tmp/install.sh
sed "s#^\$DefaultMirrorUrl = .*#\$DefaultMirrorUrl = \"${pub}\"#" install.ps1 > /tmp/install.ps1
grep -q "\$DefaultMirrorUrl = \"${pub}\"" /tmp/install.ps1 || { echo "ERROR: MIRROR_URL default not injected (install.ps1 default line changed?)" >&2; exit 1; }
src_ps1=/tmp/install.ps1
fi
sh_key="${PREFIX:+${PREFIX}/}install.sh"
aws --endpoint-url="$ENDPOINT" s3 cp "$src_sh" "s3://${BUCKET}/${sh_key}" \
--cache-control "public, max-age=300" \
--content-type "text/x-shellscript; charset=utf-8"
ps1_key="${PREFIX:+${PREFIX}/}install.ps1"
aws --endpoint-url="$ENDPOINT" s3 cp install.ps1 "s3://${BUCKET}/${ps1_key}" \
aws --endpoint-url="$ENDPOINT" s3 cp "$src_ps1" "s3://${BUCKET}/${ps1_key}" \
--cache-control "public, max-age=300" \
--content-type "text/plain; charset=utf-8"
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ A command-line interface for the [Flashduty](https://flashcat.cloud) platform. M
### macOS / Linux

```bash
curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh
curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | sh
```

### Windows (PowerShell)

```powershell
irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.ps1 | iex
irm https://static.flashcat.cloud/flashduty-cli/install.ps1 | iex
```

### Manual Download
Expand All @@ -33,6 +33,8 @@ Download the latest release for your platform from [GitHub Releases](https://git
|----------|-------------|---------|
| `FLASHDUTY_VERSION` | Install a specific version (e.g. `v0.1.2`) | latest |
| `FLASHDUTY_INSTALL_DIR` | Custom install directory | `/usr/local/bin` (shell), `~\.flashduty\bin` (PowerShell) |
| `MIRROR_URL` | Override installer release asset mirror | `https://static.flashcat.cloud/flashduty-cli` |
| `FLASHDUTY_UPDATE_BASE_URL` | Override `flashduty update` and auto update-check base URL | `https://static.flashcat.cloud/flashduty-cli` |

## Agent Skills

Expand Down
6 changes: 4 additions & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
### macOS / Linux

```bash
curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh
curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | sh
```

### Windows (PowerShell)

```powershell
irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.ps1 | iex
irm https://static.flashcat.cloud/flashduty-cli/install.ps1 | iex
```

### 手动下载
Expand All @@ -33,6 +33,8 @@ irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.p
|------|------|--------|
| `FLASHDUTY_VERSION` | 安装指定版本(如 `v0.1.2`) | 最新版 |
| `FLASHDUTY_INSTALL_DIR` | 自定义安装目录 | `/usr/local/bin`(Shell)、`~\.flashduty\bin`(PowerShell) |
| `MIRROR_URL` | 覆盖安装脚本使用的 release 资源镜像 | `https://static.flashcat.cloud/flashduty-cli` |
| `FLASHDUTY_UPDATE_BASE_URL` | 覆盖 `flashduty update` 和自动更新检查的 base URL | `https://static.flashcat.cloud/flashduty-cli` |

## Agent Skills(AI 代理技能)

Expand Down
16 changes: 11 additions & 5 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Flashduty CLI installer for Windows
# Usage: irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.ps1 | iex
# Usage: irm https://static.flashcat.cloud/flashduty-cli/install.ps1 | iex
#
# Environment variables:
# FLASHDUTY_VERSION - specific version to install (e.g. "v0.1.2")
# FLASHDUTY_INSTALL_DIR - install directory (default: $HOME\.flashduty\bin)
# MIRROR_URL - fetch release assets from this https mirror prefix
# instead of github.com. The mirror must replicate
# MIRROR_URL - fetch release assets from this https mirror prefix.
# Default: https://static.flashcat.cloud/flashduty-cli.
# The mirror must replicate
# GitHub's release layout
# (<MIRROR_URL>/releases/download/<tag>/<asset>) and
# expose a plain-text <MIRROR_URL>/releases/latest file
Expand All @@ -18,8 +19,13 @@ $Repo = "flashcatcloud/flashduty-cli"
$Binary = "flashduty-cli.exe"
$InstalledName = "flashduty.exe"

# When set, all release downloads are fetched from this prefix instead of github.com.
$MirrorUrl = $env:MIRROR_URL
# By default release downloads are fetched from the Flashcat CDN. Set MIRROR_URL
# to another prefix to override, or to an empty string to force GitHub fallback.
$DefaultMirrorUrl = "https://static.flashcat.cloud/flashduty-cli"
$MirrorUrl = [Environment]::GetEnvironmentVariable("MIRROR_URL")
if ($null -eq $MirrorUrl) {
$MirrorUrl = $DefaultMirrorUrl
}
if ($MirrorUrl) {
$MirrorUrl = $MirrorUrl.TrimEnd('/')
if ($MirrorUrl -notlike "https://*") {
Expand Down
13 changes: 8 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#!/bin/sh
# Flashduty CLI installer
# Usage: curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh
# Usage: curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | sh
#
# Environment:
# FLASHDUTY_VERSION Install a specific version (e.g. v0.1.2). Default: latest.
# FLASHDUTY_INSTALL_DIR Install directory. Default: /usr/local/bin.
# MIRROR_URL Fetch release assets from this https mirror prefix
# instead of github.com. The mirror must replicate
# MIRROR_URL Fetch release assets from this https mirror prefix.
# Default: https://static.flashcat.cloud/flashduty-cli.
# The mirror must replicate
# GitHub's release layout
# (<MIRROR_URL>/releases/download/<tag>/<asset>) and expose
# a plain-text <MIRROR_URL>/releases/latest file containing
Expand All @@ -18,8 +19,10 @@ BINARY="flashduty-cli"
INSTALLED_NAME="${INSTALLED_NAME:-flashduty}"
INSTALL_DIR="${FLASHDUTY_INSTALL_DIR:-/usr/local/bin}"

# When set, all release downloads are fetched from this prefix instead of github.com.
MIRROR_URL="${MIRROR_URL:-}"
# By default release downloads are fetched from the Flashcat CDN. Set MIRROR_URL
# to another prefix to override, or to an empty string to force GitHub fallback.
DEFAULT_MIRROR_URL="https://static.flashcat.cloud/flashduty-cli"
MIRROR_URL="${MIRROR_URL-${DEFAULT_MIRROR_URL}}"
MIRROR_URL="${MIRROR_URL%/}"
if [ -n "${MIRROR_URL}" ]; then
case "${MIRROR_URL}" in
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ func saveAndResetGlobals(t *testing.T) {
origFlagAppKey := flagAppKey
origFlagBaseURL := flagBaseURL
origFlagOutputFormat := flagOutputFormat
origUpdateNotice := updateNotice
origUpdateCheckWarning := updateCheckWarning
origStdinReader := stdinReader

// Reset to defaults so tests start clean.
Expand All @@ -32,6 +34,8 @@ func saveAndResetGlobals(t *testing.T) {
flagAppKey = ""
flagBaseURL = ""
flagOutputFormat = ""
updateNotice = nil
updateCheckWarning = ""

t.Cleanup(func() {
newClientFn = origNewClientFn
Expand All @@ -40,6 +44,8 @@ func saveAndResetGlobals(t *testing.T) {
flagAppKey = origFlagAppKey
flagBaseURL = origFlagBaseURL
flagOutputFormat = origFlagOutputFormat
updateNotice = origUpdateNotice
updateCheckWarning = origUpdateCheckWarning
stdinReader = origStdinReader
})
}
Expand Down
39 changes: 28 additions & 11 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ var (
)

var updateNotice *update.CheckResult
var updateCheckWarning string
var isTerminalFn = term.IsTerminal
var checkForUpdateAutoFn = update.CheckForUpdateAuto

var rootCmd = &cobra.Command{
Use: "flashduty",
Expand All @@ -50,27 +53,41 @@ var rootCmd = &cobra.Command{
return err
}
}
updateNotice = nil
updateCheckWarning = ""
if cmd.CommandPath() == "flashduty update" {
return nil
}
if !term.IsTerminal(int(os.Stderr.Fd())) {
if !isTerminalFn(int(os.Stderr.Fd())) {
return nil
}
updateNotice = update.StateHasUpdate(versionStr)
if update.ShouldCheck(versionStr) {
go func() {
_, _ = update.CheckForUpdate(versionStr)
}()
result, err := checkForUpdateAutoFn(versionStr)
if err != nil {
if update.IsTimeout(err) {
updateCheckWarning = "auto update check timeout, please run 'flashduty update --check' manually"
} else {
updateNotice = update.StateHasUpdate(versionStr)
}
return nil
}
if result.UpdateAvailable {
updateNotice = result
}
return nil
}
updateNotice = update.StateHasUpdate(versionStr)
return nil
},
PersistentPostRun: func(_ *cobra.Command, _ []string) {
if updateNotice == nil {
return
PersistentPostRun: func(cmd *cobra.Command, _ []string) {
if updateCheckWarning != "" {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n%s\n", updateCheckWarning)
}
if updateNotice != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nA new version of flashduty is available: v%s -> %s\n",
update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion)
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "To update, run: flashduty update\n")
}
_, _ = fmt.Fprintf(os.Stderr, "\nA new version of flashduty is available: v%s -> %s\n",
update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion)
_, _ = fmt.Fprintf(os.Stderr, "To update, run: flashduty update\n")
},
}

Expand Down
54 changes: 54 additions & 0 deletions internal/cli/root_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cli

import (
"context"
"runtime"
"strings"
"testing"

"github.com/flashcatcloud/flashduty-cli/internal/update"
)

func TestRootAutoUpdateCheckTimeoutWarnsAfterCommand(t *testing.T) {
saveAndResetGlobals(t)
tmp := t.TempDir()
t.Setenv("HOME", tmp)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", tmp)
}
t.Setenv("CI", "")
t.Setenv("GITHUB_ACTIONS", "")
t.Setenv("JENKINS_URL", "")
t.Setenv("GITLAB_CI", "")
t.Setenv("FLASHDUTY_NO_UPDATE_CHECK", "")

origVersion := versionStr
versionStr = "0.6.0"
t.Cleanup(func() { versionStr = origVersion })

origIsTerminal := isTerminalFn
isTerminalFn = func(int) bool { return true }
t.Cleanup(func() { isTerminalFn = origIsTerminal })

called := false
origCheck := checkForUpdateAutoFn
checkForUpdateAutoFn = func(string) (*update.CheckResult, error) {
called = true
return nil, context.DeadlineExceeded
}
t.Cleanup(func() { checkForUpdateAutoFn = origCheck })

out, err := execCommand("version")
if err != nil {
t.Fatalf("version command should still run when auto update check times out: %v", err)
}
if !called {
t.Fatal("auto update check was not called")
}
if !strings.Contains(out, "flashduty version 0.6.0") {
t.Fatalf("version output missing, got:\n%s", out)
}
if !strings.Contains(out, "auto update check timeout, please run 'flashduty update --check' manually") {
t.Fatalf("timeout guidance missing, got:\n%s", out)
}
}
29 changes: 21 additions & 8 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,13 @@ func newUpdateCmd() *cobra.Command {
}

func runInstaller(cmd *cobra.Command) error {
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.Command("powershell", "-Command",
fmt.Sprintf("irm %s | iex", update.InstallPowerShellURL()))
} else {
c = exec.Command("sh", "-c",
fmt.Sprintf("curl -fsSL %s | sh", update.InstallShellURL()))
}
name, args := installerCommandSpec(runtime.GOOS, update.InstallShellURL(), update.InstallPowerShellURL())
c := exec.Command(name, args...)

c.Stdout = cmd.OutOrStdout()
c.Stderr = cmd.ErrOrStderr()
c.Stdin = os.Stdin
c.Env = update.InstallerEnv(os.Environ())

if err := c.Run(); err != nil {
return fmt.Errorf("update failed: %w", err)
Expand All @@ -70,3 +65,21 @@ func runInstaller(cmd *cobra.Command) error {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n")
return nil
}

func installerCommandSpec(goos, shellURL, powerShellURL string) (string, []string) {
if goos == "windows" {
return "powershell", []string{
"-ExecutionPolicy",
"Bypass",
"-Command",
"$u = $args[0]; irm $u | iex",
powerShellURL,
}
}
return "sh", []string{
"-c",
`curl -fsSL "$1" | sh`,
"flashduty-installer",
shellURL,
}
}
36 changes: 36 additions & 0 deletions internal/cli/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cli

import (
"slices"
"strings"
"testing"
)

func TestInstallerCommandSpecPassesInstallerURLAsArgument(t *testing.T) {
shellURL := `https://mirror.example.com/fduty/install.sh; echo injected`
psURL := `https://mirror.example.com/fduty/install.ps1; Write-Host injected`

name, args := installerCommandSpec("linux", shellURL, psURL)
if name != "sh" {
t.Fatalf("unix installer command = %q, want sh", name)
}
if len(args) == 0 || args[len(args)-1] != shellURL {
t.Fatalf("unix installer URL should be passed as the last argument, got %#v", args)
}
if strings.Contains(strings.Join(args[:len(args)-1], " "), "mirror.example.com") {
t.Fatalf("unix installer URL was interpolated into shell command args: %#v", args)
}

name, args = installerCommandSpec("windows", shellURL, psURL)
if name != "powershell" {
t.Fatalf("windows installer command = %q, want powershell", name)
}
if !slices.Contains(args, psURL) {
t.Fatalf("windows installer URL should be passed as an argument, got %#v", args)
}
for _, arg := range args {
if arg != psURL && strings.Contains(arg, "mirror.example.com") {
t.Fatalf("windows installer URL was interpolated into PowerShell command: %#v", args)
}
}
}
Loading
Loading