diff --git a/internal/shell/commands.go b/internal/shell/commands.go
new file mode 100644
index 0000000..bea286d
--- /dev/null
+++ b/internal/shell/commands.go
@@ -0,0 +1,804 @@
+// commands.go - встроенные команды fush shell (стиль busybox)
+// Реализует базовые команды: exit, ls, cd, mkdir, rm, touch, pwd, cat, echo, mycurl
+// Все команды работают независимо от ОС, используя только стандартную библиотеку Go
+
+package shell
+
+import (
+ "bufio"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "fush/pkg/ansi"
+)
+
+// cmdExit обрабатывает команду exit
+func (s *Shell) cmdExit(args []string) error {
+ s.running.Store(false)
+ s.logger.Info("Выполнена команда exit")
+ return nil
+}
+
+// cmdHelp обрабатывает команду help (обновленная с mycurl)
+func (s *Shell) cmdHelp(args []string) error {
+ fmt.Println()
+ ansi.Println(ansi.Cyan, "╔══════════════════════════════════════════════════════════════╗")
+ ansi.Println(ansi.Cyan, "║ fush shell - Доступные команды ║")
+ ansi.Println(ansi.Cyan, "╚══════════════════════════════════════════════════════════════╝")
+ fmt.Println()
+
+ ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ ansi.Println(ansi.BrightGreen, "ВСТРОЕННЫЕ КОМАНДЫ (BUSYBOX-STYLE):")
+ ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+
+ commands := []struct {
+ name string
+ desc string
+ }{
+ {"exit", "Выход из оболочки"},
+ {"help", "Показать эту справку"},
+ {"ls [path]", "Вывести список файлов в директории"},
+ {"cd [dir]", "Сменить текущую директорию"},
+ {"pwd", "Показать текущую директорию"},
+ {"mkdir [-p]
", "Создать новую директорию"},
+ {"rm [-rf] ", "Удалить файл или директорию"},
+ {"touch ", "Создать файл или обновить время доступа"},
+ {"cat ", "Вывести содержимое файла"},
+ {"echo [text...]", "Вывести текст на экран"},
+ {"exec [args...]", "Выполнить внешнюю команду"},
+ {"mycurl [опции] ", "Скачать файл (аналог curl)"},
+ }
+
+ for _, cmd := range commands {
+ fmt.Printf(" %-20s %s\n", ansi.Colorize(cmd.name, ansi.BrightWhite), cmd.desc)
+ }
+
+ fmt.Println()
+ ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ ansi.Println(ansi.BrightGreen, "ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ:")
+ ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("ls -la", ansi.BrightWhite), "Показать все файлы")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("cd /tmp", ansi.BrightWhite), "Перейти в /tmp")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("pwd", ansi.BrightWhite), "Показать текущий путь")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("mkdir -p a/b/c", ansi.BrightWhite), "Создать вложенные директории")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("rm -rf olddir", ansi.BrightWhite), "Удалить директорию рекурсивно")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("cat file.txt", ansi.BrightWhite), "Показать содержимое файла")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("echo Hello World", ansi.BrightWhite), "Вывести текст")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("exec go version", ansi.BrightWhite), "Выполнить внешнюю команду")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("mycurl -o file.zip 'https://example.com/file.zip'", ansi.BrightWhite), "Скачать файл (URL в кавычках)")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("mycurl -O 'https://example.com/image.jpg'", ansi.BrightWhite), "Скачать с сохранением имени")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("curl -s 'https://example.com/file.dat'", ansi.BrightWhite), "Использовать как curl")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("ls | grep .go", ansi.BrightWhite), "Пайплайн")
+ fmt.Printf(" %-30s %s\n", ansi.Colorize("ls > files.txt", ansi.BrightWhite), "Перенаправление вывода")
+
+ fmt.Println()
+ ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+
+ return nil
+}
+
+// cmdLs обрабатывает команду ls (стиль busybox)
+func (s *Shell) cmdLs(args []string) ([]byte, error) {
+ path := "."
+ showAll := false
+ longFormat := false
+
+ // Парсим аргументы
+ for _, arg := range args {
+ if arg == "-a" || arg == "--all" {
+ showAll = true
+ } else if arg == "-l" {
+ longFormat = true
+ } else if !strings.HasPrefix(arg, "-") {
+ path = arg
+ }
+ }
+
+ dir, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer dir.Close()
+
+ entries, err := dir.Readdir(-1)
+ if err != nil {
+ return nil, err
+ }
+
+ var output strings.Builder
+
+ for _, entry := range entries {
+ name := entry.Name()
+
+ // Пропускаем скрытые файлы если не указан -a
+ if !showAll && strings.HasPrefix(name, ".") {
+ continue
+ }
+
+ if longFormat {
+ // Формат: права ссылки владелец группа размер дата имя
+ perms := entry.Mode().String()
+ nlink := 1 // В Go сложно получить количество жестких ссылок
+ uid := fmt.Sprintf("%d", entry.Sys() != nil) // Упрощённо
+ gid := "users"
+ size := entry.Size()
+ modTime := entry.ModTime().Format("Jan _2 15:04")
+
+ if entry.IsDir() {
+ name = name + "/"
+ } else if entry.Mode()&os.ModeSymlink != 0 {
+ // Для симлинков пытаемся прочитать цель
+ if target, err := os.Readlink(filepath.Join(path, entry.Name())); err == nil {
+ name = name + " -> " + target
+ }
+ }
+
+ fmt.Fprintf(&output, "%s %3d %-8s %-8s %8d %s %s\n",
+ perms, nlink, uid, gid, size, modTime, name)
+ } else {
+ if entry.IsDir() {
+ name = name + "/"
+ }
+ fmt.Fprint(&output, name, "\n")
+ }
+ }
+
+ return []byte(output.String()), nil
+}
+
+// cmdCd обрабатывает команду cd
+func (s *Shell) cmdCd(args []string) error {
+ path := s.GetEnv("HOME")
+ if len(args) > 0 {
+ path = args[0]
+ }
+
+ if path == "~" {
+ path = s.GetEnv("HOME")
+ }
+
+ if err := os.Chdir(path); err != nil {
+ return err
+ }
+
+ pwd, err := os.Getwd()
+ if err == nil {
+ s.SetEnv("PWD", pwd)
+ }
+
+ return nil
+}
+
+// cmdPwd обрабатывает команду pwd
+func (s *Shell) cmdPwd(args []string) ([]byte, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return nil, err
+ }
+ return []byte(dir + "\n"), nil
+}
+
+// cmdMkdir обрабатывает команду mkdir (с поддержкой -p)
+func (s *Shell) cmdMkdir(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("требуется имя директории")
+ }
+
+ createParents := false
+ var dirs []string
+
+ for _, arg := range args {
+ if arg == "-p" || arg == "--parents" {
+ createParents = true
+ } else if !strings.HasPrefix(arg, "-") {
+ dirs = append(dirs, arg)
+ }
+ }
+
+ for _, dir := range dirs {
+ var err error
+ if createParents {
+ err = os.MkdirAll(dir, 0755)
+ } else {
+ err = os.Mkdir(dir, 0755)
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// cmdRm обрабатывает команду rm (с поддержкой -r и -f)
+func (s *Shell) cmdRm(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("требуется имя файла")
+ }
+
+ recursive := false
+ force := false
+ var targets []string
+
+ for _, arg := range args {
+ switch arg {
+ case "-r", "-R", "--recursive":
+ recursive = true
+ case "-f", "--force":
+ force = true
+ default:
+ if !strings.HasPrefix(arg, "-") {
+ targets = append(targets, arg)
+ }
+ }
+ }
+
+ for _, target := range targets {
+ info, err := os.Stat(target)
+ if err != nil {
+ if !force {
+ return err
+ }
+ continue
+ }
+
+ if info.IsDir() && !recursive {
+ return fmt.Errorf("'%s' является директорией, используйте -r для удаления", target)
+ }
+
+ if err := os.RemoveAll(target); err != nil && !force {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// cmdTouch обрабатывает команду touch
+func (s *Shell) cmdTouch(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("требуется имя файла")
+ }
+
+ now := time.Now()
+
+ for _, filename := range args {
+ if strings.HasPrefix(filename, "-") {
+ continue
+ }
+
+ file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ return err
+ }
+ file.Close()
+
+ if err := os.Chtimes(filename, now, now); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// cmdCat обрабатывает команду cat (конкатенация файлов)
+func (s *Shell) cmdCat(args []string) ([]byte, error) {
+ if len(args) == 0 {
+ // Читаем из stdin
+ info, err := os.Stdin.Stat()
+ if err != nil {
+ return nil, err
+ }
+
+ if info.Mode()&os.ModeCharDevice != 0 {
+ return nil, fmt.Errorf("ожидается файл или stdin")
+ }
+
+ reader := bufio.NewReader(os.Stdin)
+ var output strings.Builder
+ for {
+ line, err := reader.ReadString('\n')
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ output.WriteString(line)
+ }
+ return []byte(output.String()), nil
+ }
+
+ var output strings.Builder
+
+ for _, filename := range args {
+ if strings.HasPrefix(filename, "-") {
+ continue
+ }
+
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, fmt.Errorf("ошибка чтения '%s': %v", filename, err)
+ }
+
+ output.Write(data)
+ if len(data) > 0 && data[len(data)-1] != '\n' {
+ output.WriteByte('\n')
+ }
+ }
+
+ return []byte(output.String()), nil
+}
+
+// cmdEcho обрабатывает команду echo
+func (s *Shell) cmdEcho(args []string) ([]byte, error) {
+ newline := true
+ startIdx := 0
+
+ if len(args) > 0 && args[0] == "-n" {
+ newline = false
+ startIdx = 1
+ }
+
+ var output strings.Builder
+ for i := startIdx; i < len(args); i++ {
+ if i > startIdx {
+ output.WriteByte(' ')
+ }
+ output.WriteString(args[i])
+ }
+
+ if newline {
+ output.WriteByte('\n')
+ }
+
+ return []byte(output.String()), nil
+}
+
+// resolveURL преобразует относительный URL в абсолютный
+func resolveURL(baseURL, relURL string) (string, error) {
+ base, err := url.Parse(baseURL)
+ if err != nil {
+ return "", err
+ }
+
+ rel, err := url.Parse(relURL)
+ if err != nil {
+ return "", err
+ }
+
+ resolved := base.ResolveReference(rel)
+ return resolved.String(), nil
+}
+
+// cmdMycurl обрабатывает команду mycurl для скачивания файлов
+func (s *Shell) cmdMycurl(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("использование: mycurl [опции] \n" +
+ "Опции:\n" +
+ " -o <файл> выходной файл (по умолчанию из URL)\n" +
+ " -O использовать имя файла из URL\n" +
+ " -L следовать редиректам (включено по умолчанию)\n" +
+ " -s тихий режим (без прогресс-бара)\n" +
+ " -v подробный вывод\n" +
+ " -A <строка> User-Agent строка\n" +
+ " -e <строка> Referer строка\n" +
+ " -k игнорировать проверку SSL сертификатов\n" +
+ " -h, --help показать справку\n\n" +
+ "Важно: URL должен быть заключён в кавычки, если содержит спецсимволы (&, ?, =)")
+ }
+
+ // Парсим аргументы
+ outputFile := ""
+ silent := false
+ verbose := false
+ urlStr := ""
+ userAgent := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
+ referer := "https://futriix.ru:8083/"
+ insecure := false
+ maxRedirects := 10
+
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch arg {
+ case "-o":
+ if i+1 < len(args) {
+ outputFile = args[i+1]
+ i++
+ } else {
+ return fmt.Errorf("-o требует имя файла")
+ }
+ case "-O":
+ outputFile = "" // Будет определено из URL позже
+ case "-L":
+ // Поддержка редиректов включена по умолчанию, игнорируем
+ case "-s":
+ silent = true
+ case "-v":
+ verbose = true
+ case "-k":
+ insecure = true
+ case "-A":
+ if i+1 < len(args) {
+ userAgent = args[i+1]
+ i++
+ } else {
+ return fmt.Errorf("-A требует строку User-Agent")
+ }
+ case "-e":
+ if i+1 < len(args) {
+ referer = args[i+1]
+ i++
+ } else {
+ return fmt.Errorf("-e требует строку Referer")
+ }
+ case "-h", "--help":
+ return s.cmdMycurlHelp(args)
+ default:
+ if !strings.HasPrefix(arg, "-") && urlStr == "" {
+ urlStr = arg
+ }
+ }
+ }
+
+ if urlStr == "" {
+ return fmt.Errorf("URL не указан")
+ }
+
+ // Удаляем кавычки, если они есть (одинарные или двойные)
+ urlStr = strings.Trim(urlStr, "'\"")
+
+ // Декодируем URL если нужно (обработка экранированных символов)
+ decodedURL, err := url.QueryUnescape(urlStr)
+ if err == nil && decodedURL != urlStr {
+ if verbose {
+ fmt.Printf("Декодированный URL: %s\n", decodedURL)
+ }
+ urlStr = decodedURL
+ }
+
+ // Проверяем валидность URL
+ if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
+ return fmt.Errorf("неверный URL (должен начинаться с http:// или https://): %s", urlStr)
+ }
+
+ // Определяем имя выходного файла
+ if outputFile == "" {
+ outputFile = getFilenameFromURL(urlStr)
+ if outputFile == "" {
+ outputFile = "downloaded_file"
+ }
+ }
+
+ if !silent {
+ fmt.Printf("Загрузка: %s\n", urlStr)
+ fmt.Printf("Сохраняется в: %s\n", outputFile)
+ }
+
+ // Создаем Cookie Jar для сохранения сессии
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return fmt.Errorf("ошибка создания cookie jar: %v", err)
+ }
+
+ // Создаем HTTP клиент с поддержкой кук и редиректов
+ client := &http.Client{
+ Timeout: 60 * time.Second,
+ Jar: jar,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if verbose {
+ fmt.Printf("-> HTTP редирект на: %s\n", req.URL.String())
+ }
+ if len(via) >= maxRedirects {
+ return fmt.Errorf("слишком много HTTP редиректов")
+ }
+ return nil
+ },
+ }
+
+ // Если нужно игнорировать SSL сертификаты
+ if insecure {
+ transport := &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ client.Transport = transport
+ }
+
+ // Функция для выполнения запроса с обработкой редиректов
+ currentURL := urlStr
+
+ for redirectCount := 0; redirectCount < maxRedirects; redirectCount++ {
+ // Создаем запрос
+ req, err := http.NewRequest("GET", currentURL, nil)
+ if err != nil {
+ return fmt.Errorf("ошибка создания запроса: %v", err)
+ }
+
+ // Устанавливаем заголовки
+ req.Header.Set("User-Agent", userAgent)
+ req.Header.Set("Referer", referer)
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("Accept-Encoding", "gzip, deflate, br")
+ req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
+ req.Header.Set("Connection", "keep-alive")
+ req.Header.Set("Cache-Control", "no-cache")
+ req.Header.Set("Pragma", "no-cache")
+
+ if verbose {
+ fmt.Printf("\nЗаголовки запроса:\n")
+ for key, values := range req.Header {
+ fmt.Printf(" %s: %s\n", key, strings.Join(values, ", "))
+ }
+ fmt.Printf("\nВыполнение запроса...\n")
+ }
+
+ // Выполняем запрос
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("ошибка загрузки: %v", err)
+ }
+
+ if verbose {
+ fmt.Printf("\nСтатус ответа: %s\n", resp.Status)
+ fmt.Printf("Заголовки ответа:\n")
+ for key, values := range resp.Header {
+ fmt.Printf(" %s: %s\n", key, strings.Join(values, ", "))
+ }
+ fmt.Println()
+ }
+
+ // Проверяем статус ответа
+ if resp.StatusCode == http.StatusOK {
+ // Успешный ответ - скачиваем файл
+ defer resp.Body.Close()
+
+ // Показываем размер файла если известен
+ if !silent && resp.ContentLength > 0 {
+ sizeMB := float64(resp.ContentLength) / (1024 * 1024)
+ fmt.Printf("Размер файла: %.2f MB\n", sizeMB)
+ }
+
+ // Создаем выходной файл
+ file, err := os.Create(outputFile)
+ if err != nil {
+ return fmt.Errorf("ошибка создания файла: %v", err)
+ }
+ defer file.Close()
+
+ // Скачиваем с прогресс-баром или без него
+ var written int64
+ if silent {
+ written, err = io.Copy(file, resp.Body)
+ } else {
+ written, err = s.downloadWithProgress(file, resp.Body, resp.ContentLength)
+ }
+
+ if err != nil {
+ return fmt.Errorf("ошибка сохранения файла: %v", err)
+ }
+
+ if !silent {
+ fmt.Printf("\n✅ Загрузка завершена! Сохранено %d байт (%s)\n",
+ written, formatBytes(written))
+ }
+
+ s.logger.Info("Файл загружен",
+ "url", urlStr,
+ "output", outputFile,
+ "size", written)
+
+ return nil
+ } else if resp.StatusCode == 500 || resp.StatusCode == 200 {
+ // Читаем тело для поиска meta refresh
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return fmt.Errorf("ошибка чтения тела ответа: %v", err)
+ }
+ bodyStr := string(body)
+
+ // Ищем meta refresh редирект (разные форматы)
+ patterns := []string{
+ `]*http-equiv=["']refresh["'][^>]*content=["']\d+;\s*url=([^"']+)["']`,
+ `]*content=["']\d+;\s*url=([^"']+)["'][^>]*http-equiv=["']refresh["']`,
+ `URL\s*=\s*['"]?([^'"\s>]+)`,
+ }
+
+ redirectURL := ""
+ for _, pattern := range patterns {
+ re := regexp.MustCompile(pattern)
+ matches := re.FindStringSubmatch(bodyStr)
+ if len(matches) > 1 {
+ redirectURL = matches[1]
+ break
+ }
+ }
+
+ if redirectURL != "" {
+ if verbose {
+ fmt.Printf("-> HTML meta refresh редирект на: %s\n", redirectURL)
+ }
+
+ // Преобразуем относительный URL в абсолютный
+ absURL, err := resolveURL(currentURL, redirectURL)
+ if err != nil {
+ return fmt.Errorf("ошибка преобразования URL редиректа: %v", err)
+ }
+
+ if verbose {
+ fmt.Printf("-> Абсолютный URL: %s\n", absURL)
+ }
+
+ currentURL = absURL
+ continue // Делаем следующий запрос
+ } else if resp.StatusCode == 500 {
+ return fmt.Errorf("HTTP ошибка: %s\nТело ответа: %s", resp.Status,
+ func(s string) string {
+ if len(s) > 500 {
+ return s[:500] + "..."
+ }
+ return s
+ }(bodyStr))
+ }
+ } else {
+ resp.Body.Close()
+ return fmt.Errorf("неожиданный HTTP статус: %s", resp.Status)
+ }
+ }
+
+ return fmt.Errorf("превышено максимальное количество редиректов (%d)", maxRedirects)
+}
+
+// cmdMycurlHelp показывает справку по команде mycurl
+func (s *Shell) cmdMycurlHelp(args []string) error {
+ fmt.Println()
+ fmt.Println("mycurl - встроенная утилита для скачивания файлов")
+ fmt.Println()
+ fmt.Println("Использование:")
+ fmt.Println(" mycurl [опции] URL")
+ fmt.Println()
+ fmt.Println("Опции:")
+ fmt.Println(" -o <файл> Сохранить в указанный файл")
+ fmt.Println(" -O Использовать имя файла из URL")
+ fmt.Println(" -L Следовать редиректам (включено по умолчанию)")
+ fmt.Println(" -s Тихий режим (без прогресс-бара)")
+ fmt.Println(" -v Подробный вывод (заголовки запроса/ответа)")
+ fmt.Println(" -A <строка> User-Agent строка")
+ fmt.Println(" -e <строка> Referer строка")
+ fmt.Println(" -k Игнорировать проверку SSL сертификатов")
+ fmt.Println(" -h, --help Показать эту справку")
+ fmt.Println()
+ fmt.Println("Примеры:")
+ fmt.Println(" mycurl 'https://example.com/file.zip'")
+ fmt.Println(" mycurl -o myfile.zip 'https://example.com/file.zip?param=value'")
+ fmt.Println(" mycurl -O 'https://example.com/file.zip'")
+ fmt.Println(" mycurl -s 'https://example.com/largefile.iso'")
+ fmt.Println(" mycurl -v 'https://example.com/debug.zip'")
+ fmt.Println(" mycurl -A 'CustomAgent/1.0' -e 'https://google.com' 'https://example.com/file.zip'")
+ fmt.Println(" curl -o file.zip 'https://example.com/file.zip' # алиас")
+ fmt.Println()
+ fmt.Println("Примечания:")
+ fmt.Println(" - Кавычки (одинарные или двойные) ОБЯЗАТЕЛЬНЫ, если URL содержит &, ?, =")
+ fmt.Println(" - Кавычки автоматически удаляются при обработке")
+ fmt.Println(" - Поддерживаются cookies и сессии")
+ fmt.Println(" - Поддерживаются HTML meta refresh редиректы")
+ fmt.Println(" - Утилита эмулирует поведение curl с заголовками User-Agent и Referer")
+ fmt.Println(" - Поддерживает автоматическое следование редиректам")
+ fmt.Println(" - Работает на Linux и OpenIndiana/Hipster")
+ fmt.Println()
+ return nil
+}
+
+// downloadWithProgress скачивает с отображением прогресса
+func (s *Shell) downloadWithProgress(writer io.Writer, reader io.Reader, total int64) (int64, error) {
+ progress := &progressWriter{
+ total: total,
+ writer: writer,
+ started: time.Now(),
+ }
+
+ return io.Copy(progress, reader)
+}
+
+// progressWriter реализует запись с прогресс-баром
+type progressWriter struct {
+ total int64
+ written int64
+ writer io.Writer
+ started time.Time
+}
+
+func (pw *progressWriter) Write(p []byte) (int, error) {
+ n, err := pw.writer.Write(p)
+ pw.written += int64(n)
+
+ // Обновляем прогресс
+ if pw.total > 0 {
+ percent := float64(pw.written) / float64(pw.total) * 100
+ elapsed := time.Since(pw.started)
+ speed := float64(pw.written) / elapsed.Seconds()
+
+ // Скорость в MB/s
+ speedMBps := speed / (1024 * 1024)
+
+ // Оставшееся время
+ var eta string
+ if speed > 0 {
+ remaining := float64(pw.total-pw.written) / speed
+ if remaining < 60 {
+ eta = fmt.Sprintf("%.0fs", remaining)
+ } else if remaining < 3600 {
+ eta = fmt.Sprintf("%.0fm%.0fs", remaining/60, float64(int(remaining)%60))
+ } else {
+ eta = fmt.Sprintf("%.0fh%.0fm", remaining/3600, (int(remaining)%3600)/60)
+ }
+ } else {
+ eta = "?"
+ }
+
+ fmt.Printf("\rПрогресс: %.1f%% [%s/%s] @ %.2f MB/s ETA: %s",
+ percent,
+ formatBytes(pw.written),
+ formatBytes(pw.total),
+ speedMBps,
+ eta)
+ } else {
+ fmt.Printf("\rЗагружено: %s", formatBytes(pw.written))
+ }
+
+ return n, err
+}
+
+// getFilenameFromURL извлекает имя файла из URL
+func getFilenameFromURL(urlStr string) string {
+ // Убираем параметры запроса
+ if idx := strings.Index(urlStr, "?"); idx != -1 {
+ urlStr = urlStr[:idx]
+ }
+
+ // Извлекаем последний сегмент пути
+ parts := strings.Split(urlStr, "/")
+ if len(parts) > 0 {
+ filename := parts[len(parts)-1]
+ if filename != "" && strings.Contains(filename, ".") {
+ return filename
+ }
+ }
+
+ return ""
+}
+
+// formatBytes форматирует байты в человекочитаемый вид
+func formatBytes(bytes int64) string {
+ const unit = 1024
+ if bytes < unit {
+ return fmt.Sprintf("%d B", bytes)
+ }
+
+ div, exp := int64(unit), 0
+ for n := bytes / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+
+ switch exp {
+ case 0:
+ return fmt.Sprintf("%.1f KB", float64(bytes)/float64(div))
+ case 1:
+ return fmt.Sprintf("%.1f MB", float64(bytes)/float64(div))
+ case 2:
+ return fmt.Sprintf("%.1f GB", float64(bytes)/float64(div))
+ default:
+ return fmt.Sprintf("%.1f TB", float64(bytes)/float64(div))
+ }
+}