Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +50 XP

Итоговый проект: CLI-менеджер задач

Соберём полноценное cli приложение go пример — менеджер задач tasks, в котором сходится весь курс: команды add, list, done и rm, JSON-хранилище в домашнем каталоге, цветной вывод, коды выхода, версия и тесты Execute в памяти. Ни одна деталь здесь не новая: каждая уже разобрана в своём уроке, и финал — это сборка, а не открытие. К концу у репозитория будут чеклист сборки, тесты и два маршрута роста — TUI-режим и структурные логи.

Содержание урока · 11 разделов

Каталог проекта пуст, в редакторе открыт main.go с парой строк, в терминале мигает приглашение — и на всё про всё двадцать четыре минуты. Это нормальная точка старта итогового проекта: новых знаний он не требует, он требует умения складывать старые в один репозиторий так, чтобы через месяц не было стыдно. Утилита tasks: задачи добавляются командой add, список показывается list с фильтрами, done закрывает задачу по номеру, rm удаляет. Данные лежат в одном JSON-файле в домашнем каталоге, вывод раскрашен, ошибки возвращают честные коды выхода, а вшитая версия отвечает на --version. Готовое cli приложение go пример такого сорта собирается за вечер и живёт в системе годами; весь код ниже вы уже видели по частям — теперь он встретится целиком, и я буду звать предыдущие уроки по именам, а не переписывать их.

Каркас репозитория: cmd/, internal/tasks и main.go#

Раскладка из урока про структуру проекта переносится без изменений, и это лучшая проверка того, что она была не теорией. Модуль называется github.com/you/tasks; внутри main.go на тридцать строк, каталог cmd с одним файлом на команду — root.go, add.go, list.go, done.go, rm.go — плюс app.go с приёмником зависимостей, errors.go с ExitError и colors.go со стилями. Домен живёт в internal/tasks: модель Task, хранилище Store, сентинел ErrNotFound — и ни одного импорта cobra, домен не знает про терминал. Конфигурация — в internal/config, единственном пакете, где живёт viper. Связка — структура App: фабрики команд принимают её аргументом и замыкают на себя, команды тянут из неё Store и Config и не лезут в глобальные переменные. Если ваша раскладка после двадцати четырёх уроков получилась другой — это не провал: раскладка обслуживает проект, а не наоборот. Обоснование каждой полки — в уроке про структуру проекта.

main.go: тридцать строк, держащих весь проект

GO
package main

import (
        "context"
        "errors"
        "fmt"
        "os"
        "os/signal"
        "syscall"

        "github.com/you/tasks/cmd"
        "github.com/you/tasks/internal/config"
        "github.com/you/tasks/internal/tasks"
)

var (
        version = "dev"
        commit  = "unknown"
)

func main() {
        cfg, err := config.Load()
        if err != nil {
                fmt.Fprintln(os.Stderr, err)
                os.Exit(2)
        }

        ctx, stop := signal.NotifyContext(context.Background(),
                os.Interrupt, syscall.SIGTERM)
        defer stop()

        app := cmd.NewApp(tasks.NewStore(cfg.TasksFile), cfg)
        root := cmd.NewRootCmd(app)
        root.Version = version
        if err := root.ExecuteContext(ctx); err != nil {
                var ee *cmd.ExitError
                if errors.As(err, &ee) {
                        os.Exit(ee.Code)
                }
                os.Exit(1)
        }
}

Точка входа держит три контракта курса. Конфиг: config.Load собирает viper-источники и возвращает структуру с путём хранилища, ошибка даёт код 2 — это проблема использования, а не сбой. Сигналы: NotifyContext из урока про graceful shutdown отменяет контекст по Ctrl+C, ExecuteContext доставляет его каждой RunE. Коды выхода: errors.As разбирает ExitError и отдаёт словарю право голоса, общий провал — единица. Поле Version заполнит линковщик, commit ждёт релиза.

Хранилище: JSON в домашнем каталоге#

Путь к данным решается одной функцией stdlib: os.UserConfigDir() возвращает ~/.config на Linux, ~/Library/Application Support на macOS и %AppData% на Windows, и хранилище ложится в подкаталог tasks рядом с config.yaml. Пуристы XDG отправят данные в ~/.local/share через os.UserHomeDir — по букве стандарта они правы, отделять данные от настроек положено; я сознательно беру UserConfigDir, потому что одна функция stdlib вместо ручной склейки путей для утилиты на триста строк — честная сделка, а не лень. Контракт Store прост: Load читает файл и молчит, если его нет, — первый запуск не ошибка, а пустой список; Save маршалит срез с отступами, создаёт каталог и пишет с правами 0o600, потому что в задачах бывают личные вещи. Про формат — спорное мнение: пока задач меньше нескольких сотен, JSON с MarshalIndent читается глазами и правится руками, и я не тяну SQLite в утилиту, которая обязана собираться за секунду; порог, когда плоский файл перестаёт поспевать, — тысячи записей и частичные обновления на каждый чих, тогда и переедем.

internal/tasks/store.go: Load, Save, Done

GO
var ErrNotFound = errors.New("задача не найдена")

type Store struct {
        path  string
        items []Task
        next  int
}

func NewStore(path string) *Store { return &Store{path: path, next: 1} }

func (s *Store) Load() error {
        data, err := os.ReadFile(s.path)
        if errors.Is(err, os.ErrNotExist) {
                return nil // первый запуск: пусто — это не ошибка
        }
        if err != nil {
                return fmt.Errorf("читаю %s: %w", s.path, err)
        }
        return json.Unmarshal(data, &s.items)
}

func (s *Store) Save() error {
        data, err := json.MarshalIndent(s.items, "", "  ")
        if err != nil {
                return err
        }
        if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
                return err
        }
        return os.WriteFile(s.path, data, 0o600)
}

func (s *Store) Done(id int) error {
        for i := range s.items {
                if s.items[i].ID == id {
                        s.items[i].Status = StatusDone
                        return s.Save()
                }
        }
        return fmt.Errorf("done %d: %w", id, ErrNotFound)
}

Load прощает только отсутствие файла: errors.Is различает «пусто» и «сломано», битый JSON вернёт ошибку с путём через %w. Save создаёт каталог с 0o700 и файл с 0o600 — разница в том, кто может листать каталог и кто читать данные. Done меняет статус по номеру и оборачивает промах в ErrNotFound; этот сентинел вы увидите снова в тестах, где errors.Is отличит его от сбоя диска.

Проверка: первый запуск

Первый запуск tasks на чистой машине: файла tasks.json ещё нет. Что вернёт Store.Load()?

Команды add, done, rm и коды выхода#

Команда add берёт ровно один аргумент — текст задачи — через Args: cobra.ExactArgs(1) и печатает строку вида добавлено: 3, чтобы номер был виден сразу же. Done и rm ищут запись по номеру; промах оборачивается fmt.Errorf с %w вокруг ErrNotFound, и main переводит это в код 1 — общий провал. Словарь кодов из урока про ошибки живёт в документации, а не в голове: 0 — успех, 1 — провал, 2 — ошибка использования вроде неизвестного статуса в --status, 3 — частичный успех, пригодится будущей команде sync. Rm опаснее остальных: в терминале он спрашивает подтверждение через диалог из урока про интерактивный ввод, в скриптах требует флаг --yes, а на отказ возвращает сентинел errDeclined — тест из урока про тестирование ловит его через errors.Is, не читая текст. На корне включён SilenceUsage: опечатка во флаге не должна вываливать страницу команд вслед за сообщением об ошибке — пользователь уже знает, где --help.

list: флаги --status и --all, цветной вывод#

Семантика list — первое проектное решение, которое стоит проговорить вслух. По умолчанию команда показывает только открытые задачи: как git status молчит про чистые файлы, так и list не печатает закрытое — за статусом глаза ходят к тому, что осталось сделать. Флаг --status done покажет выполненное, --status todo повторит умолчание явно, --all снимает фильтр целиком. Значение --status валидируется до обращения к хранилищу: что-то кроме todo и done возвращает ExitError с кодом 2 — ошибка использования ловится на входе, а не после чтения файла. Вывод идёт через text/tabwriter: три колонки — номер, отметка, текст — выравниваются по самой широкой ячейке, и таблица не расползается на длинных заголовках. Цвет берётся из cmd/colors.go, где fatih/color объявляет okStyle и todoStyle один раз: галочка зелёная, точка серая, и это единственное место проекта, знающее про ANSI-байты. Библиотека сама проверяет isatty и NO_COLOR, а глобальный флаг --color со значениями auto, always и never из урока про цветной вывод управляет color.NoColor, когда автоматика угадала не то.

cmd/list.go: валидация, фильтр, таблица

GO
func newListCmd(app *App) *cobra.Command {
        var status string
        var all bool

        cmd := &cobra.Command{
                Use:   "list",
                Short: "Показать задачи",
                RunE: func(cmd *cobra.Command, args []string) error {
                        if status != "" && status != string(tasks.StatusTodo) &&
                                status != string(tasks.StatusDone) {
                                return &ExitError{Code: 2,
                                        Err: fmt.Errorf("--status: неизвестный статус %q", status)}
                        }
                        w := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
                        for _, t := range app.Store.List(status, all) {
                                mark := okStyle.Sprint("✓")
                                if t.Status != tasks.StatusDone {
                                        mark = todoStyle.Sprint("·")
                                }
                                fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, mark, t.Text)
                        }
                        return w.Flush()
                },
        }
        cmd.Flags().StringVar(&status, "status", "", "фильтр: todo или done")
        cmd.Flags().BoolVar(&all, "all", false, "показать всё, включая выполненное")
        return cmd
}

Валидация статуса идёт первой строкой RunE и не стоит ни одного вызова хранилища. Фильтрация живёт в Store.List(status, all), а не в команде: домен умеет отбирать, cmd умеет показывать — та же граница слоёв, что в уроке про структуру. Стили Sprint возвращают строку с кодами или без: решение о цвете приняла библиотека при старте. Flush обязателен — без него tabwriter удержит выровненные строки в буфере, и list напечатает пустоту.

Проверка: цвет в пайпе

Вывод уходит в пайп: tasks list | grep кофе. Что получит grep?

Что окажется в tasks.json

Прочитайте код и выберите, что он выведет:

// internal/tasks/model.go
type Task struct {
        ID     int    `json:"id"`
        Text   string `json:"text"`
        Status Status `json:"status"`
}

data, _ := json.MarshalIndent([]Task{{ID: 1, Text: "звонок", Status: StatusDone}}, "", "  ")
fmt.Println(string(data))

Версия и Ctrl+C: два маленьких блока в main#

Версия в проекте занимает три строки исходника и одну команду сборки. В main объявлены var version = "dev" и commit, поле Version корня заполняется при старте — Cobra сама добавляет команду version и флаг --version с выводом tasks version dev. Релизная сборка подменяет значения линковщиком: go build -ldflags "-X 'main.version=v1.4.2' -X 'main.commit=9f3ac12'" — и утилита начинает отвечать честно. Отдельную команду version писать не нужно: всё уже сделала Cobra, шаблон вывода при желании меняется через SetVersionTemplate. Сигналы — вторая половина блока: NotifyContext превращает Ctrl+C в отмену контекста, ExecuteContext доставляет её каждой RunE, а defer stop() возвращает терминалу контроль после второго нажатия. Для менеджера задач это страховка про запас: add и list отрабатывают за миллисекунды, ловить в них нечего. Но привычка переносится без переделок: когда в задачах появится sync с сервером — она же даст коду 3 частичного успеха работу, — контекст уже будет протянут куда надо, и graceful shutdown сведётся к проверке ctx.Done() в цикле закачки.

Тесты: Execute в памяти, хранилище во временном каталоге#

Каркас из урока про тестирование доезжает до финала почти без изменений, с одной поправкой на DI: runCmd принимает App, потому что корень создаётся фабрикой NewRootCmd(app) на каждый вызов — персистентные флаги pflag не переползают между тестами. Хранилище в тестах указывает на t.TempDir(): каталог тестовый раннер удалит сам, и ни один тест не касается реальных задач пользователя. Сценарий пробегает полный жизненный цикл — add, list, done, list --status done — и на каждом шаге проверяет буфер через strings.Contains. Валидация --status ложится в табличный тест: пустой статус, todo, мусорное значение — три кейса с ожидаемыми wantErr и wantOut. Длинный вывод таблицы закрывают golden-файлы из cmd/testdata: эталон обновляется флагом -update осознанно, дифф читается в ревью как правка интерфейса. Прогон go test ./... требует живого Go с модулями проекта — песочница курса тестовый раннер и файловую систему не эмулирует, поэтому сборку и тесты вы делаете локально, в своём терминале.

cmd/root_test.go: сценарий на весь жизненный цикл

GO
func newTestApp(t *testing.T) *App {
        t.Helper()
        path := filepath.Join(t.TempDir(), "tasks.json")
        return NewApp(tasks.NewStore(path), config.Config{})
}

func TestAdd_Done_Filter(t *testing.T) {
        app := newTestApp(t)

        if _, err := runCmd(app, "add", "купить кофе"); err != nil {
                t.Fatalf("add: %v", err)
        }
        out, err := runCmd(app, "list")
        if err != nil || !strings.Contains(out, "кофе") {
                t.Fatalf("list: err=%v out=%q", err, out)
        }
        if _, err := runCmd(app, "done", "1"); err != nil {
                t.Fatalf("done: %v", err)
        }
        out, _ = runCmd(app, "list", "--status", "done")
        if !strings.Contains(out, "кофе") {
                t.Errorf("выполненная не видна в --status done:\n%s", out)
        }
}

newTestApp прячет рутину: t.TempDir, путь tasks.json внутри, конфиг-заглушка. Тест идёт по сценарию пользователя, а не по функциям пакета: только Execute проходит тот же путь, что и терминал, с парсингом флагов и валидацией. Проверки через Contains устойчивы к правкам форматирования — таблица меняется, смысл нет. Один такой тест заменяет прогон руками после каждой правки.

Соберите Save по строкам

Впишите маршал с отступами, создание каталога и права файла.

func (s *Store) Save() error { data, err := json.(s.items, "", " ") if err != nil { return err } if err := os.(filepath.Dir(s.path), 0o700); err != nil { return err } return os.WriteFile(s.path, data, 0o) }

Соберите config.Load по шагам

Расставьте тело config.Load от создания viper до возврата структуры.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Чеклист сборки проекта#

  • go mod init github.com/you/tasks && go mod tidy — в go.sum ложатся cobra, viper и fatih/color
  • gofmt -l . и go vet ./... — оба молчат
  • go test ./... — зелёный: табличные кейсы, golden-файлы, сценарий add–done–list
  • go build -o tasks . — ./tasks --version печатает tasks version dev
  • go build -ldflags "-X 'main.version=v0.1.0'" — версия вшита, ответ меняется
  • ./tasks add "первая задача" — каталог ~/.config/tasks создан, файл с правами 0o600
  • ./tasks list | cat — вывод без ANSI-кодов: пайп остался чистым
  • ./tasks done 99; echo $? — единица: ErrNotFound дошёл до кода выхода
  • ./tasks completion zsh — скрипт автодополнения генерируется, как в уроке про completion

Карточки: детали проекта

Термин

Store

нажмите, чтобы перевернуть
Определение

Хранилище tasks: путь к файлу, срез задач, счётчик next для ID. Load прощает отсутствие файла, Save пишет MarshalIndent с MkdirAll и правами 0o600.

1 / 6

Поздравление и куда дальше#

Если чеклист зелёный — на диске лежит законченная утилита, а не учебный пример: с конфигом, цветами, кодами выхода, версией и тестами. Курс Cobra пройден целиком, и это видно по репозиторию — поздравляю. Идеи доработок лежат на поверхности: дедлайны у задач с подсветкой просроченных, поиск search по подстроке, экспорт в Markdown, синхронизация между машинами через git — она же оживит код 3 частичного успеха, а когда записей станет много, хранилище переедет в SQLite. Два направления уже ждут в соседних курсах. Первый — TUI-режим: тот же list, но интерактивной таблицей с курсором, поиском и статус-баром — это курс Bubble Tea, и ваш домен из internal/tasks переедет туда без единой правки, потому что не знает про терминал. Второй — структурные логи для sync и отладки: курс Zap покажет, как заменить fmt.Fprintln на уровни и поля. Обзор всех уроков — на странице Cobra. Соберите бинарник локально, положите в PATH — и пусть tasks живёт в вашем терминале.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Репозиторий tasks собирает весь курс: cmd/ с командами, internal/tasks с доменом, main.go на тридцать строк.
  • Хранилище — JSON в ~/.config/tasks/tasks.json: Load прощает отсутствие файла, Save создаёт каталог и пишет с правами 0o600.
  • Коды выхода — контракт для скриптов: 0 успех, 1 провал, 2 ошибка использования, 3 частичный успех; путь через ExitError и errors.As.
  • Цвет через fatih/color: стили объявлены один раз, в пайпе отключаются сами; --status и --all фильтруют list.
  • Версия приходит ldflags-ом в var version; сигналы — NotifyContext и ExecuteContext из урока про graceful shutdown.
  • Тесты гоняют Execute в памяти: runCmd с App, t.TempDir для хранилища, табличные сценарии и golden-файлы.
  • Дальше — TUI на Bubble Tea и структурные логи Zap: CLI остаётся ядром, интерфейсы наращиваются слоями.

Домашнее задание

Довершите утилиту командой search: tasks search TEXT фильтрует задачи по подстроке в тексте, уважает --status и --all, печатает через tabwriter и возвращает код 1 при пустом результате. Добавьте табличный тест на три кейса search во временном каталоге и соберите релизную версию с -ldflags — проверьте, что --version отвечает номером.

Личные заметкиПусто
Хранятся только в вашем браузере (Cobra · Итоговый проект: CLI-менеджер задач)

Вопросы и ответы по уроку

Где tasks хранит задачи и как перенести их на другую машину?

В JSON-файле по пути os.UserConfigDir(): ~/.config/tasks/tasks.json на Linux, ~/Library/Application Support/tasks на macOS, %AppData% на Windows. Перенос — копирование одного файла: это и преимущество плоского хранилища, и его потолок. Путь переопределяется ключом store.file в конфиге или переменной TASKS_STORE_FILE — лестница приоритетов из урока про конфигурацию работает до конца.

Почему list по умолчанию скрывает выполненные задачи?

Это проектное решение: команда, которую вызывают чаще всего, должна показывать остаток работы — как git status показывает изменения, а не всю историю. Выполненное доступно через --status done, полный список — через --all. Если ваша привычка другая, умолчание меняется в одном месте, Store.List, и тест на жизненный цикл это отследит.

Как поставить tasks в систему одной командой?

go install github.com/you/tasks@latest положит бинарник в GOBIN — обычно ~/go/bin; убедитесь, что каталог в PATH. Релизную версию собирайте с -ldflags и тегом, а выпуск на несколько платформ автоматизирует goreleaser из урока про сборку: архивы, контрольные суммы и формула Homebrew делаются одним тегом.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Bubble Tea25 уроков~10 чпрактика25 уроков уже доступны

Bubble Tea: TUI-приложения в терминале

Интерактивный курс Bubble Tea — TUI-фреймворка на Go: Elm-архитектура, bubbles, Lip Gloss, мышь и клавиатура. 25 уроков с примерами и итоговым приложением.

  • Понимать Elm-архитектуру: Model, Update, View, Init
  • Обрабатывать клавиатуру и мышь в терминале
  • Писать асинхронные операции через tea.Cmd, Batch и Sequence
  • Использовать bubbles: list, textinput, textarea, table, viewport, progress, spinner