tinka alpha

Creating Commands

This guide covers creating your own Commands. You will learn how to:

Create your first Command

A Command is a *cli.Command (github.com/mucolabs/tinka/cli). It needs a Name, which people type, and a Run function. Add it to the App with app.Command:

main.go
package main

import (
	"context"
	"os"

	"github.com/mucolabs/tinka"
	"github.com/mucolabs/tinka/cli"
)

// greeting is the greet Command's result.
type greeting string

func (g greeting) String() string { return string(g) }

func main() {
	app := tinka.New(tinka.HTTPConfig{})
	app.Command(&cli.Command{
		Name:    "greet",
		Summary: "Greet someone",
		Args:    []cli.Arg{{Name: "name", Usage: "Who to greet"}},
		Run: func(ctx context.Context, c *cli.Call) (cli.Result, error) {
			name := "world"
			if len(c.Args) > 0 {
				name = c.Args[0]
			}
			return greeting("Hello, " + name + "!"), nil
		},
	})
	os.Exit(app.Main(os.Args[1:]))
}

Run it with act:

Terminal
act greet Bea
Output
Hello, Bea!

Run receives a context and a *cli.Call, which holds the arguments and the prompts and output the Command uses.

Return a result

Run returns what the Command did as a cli.Result, instead of printing it. A Result is any type with a String method: people see its String form on stdout. Agents, and anyone passing --json, get its exported fields as JSON:

go
type published struct {
	Count int `json:"count"`
}

func (p published) String() string { return fmt.Sprintf("Published %d posts", p.Count) }
Output
$ act posts:publish
Published 3 posts

$ act posts:publish --json
{"command":"posts:publish","result":"ok","count":3,"duration_ms":12}

Return nil when there's nothing to report. Keep the Result to what the Command did; what it's doing while it runs is progress, which goes to stderr.

Name and describe Commands

Group related Commands under a namespace with a colon, such as posts:publish and posts:import. Fill in the rest of the fields for help:

go
var greet = &cli.Command{
	Name:    "greet",
	Summary: "Greet someone",
	Help:    "Greets the name given, or the world.\n\nact greet Bea",
	Aliases: []string{"hello"},
	Run:     runGreet,
}
Summarystring

The one-line description shown in the list of Commands.

Helpstring

The longer description shown by the Command's own help, such as examples.

Aliases[]string

Other names that run the Command.

Hiddenbool

Leaves the Command out of help and lists. It still runs.

Commands[]*cli.Command

Commands nested under this one, run as act db seed. A Command with Commands and no Run only groups them.

Use the App's services

The App starts before one of its Commands runs, and shuts down after the Command returns, so the Command can use the App's services, such as a database pool, from the container. Build the Command in a function that receives the App:

publish.go
package main

import (
	"context"
	"fmt"

	"github.com/mucolabs/tinka"
	"github.com/mucolabs/tinka/cli"
)

type published struct {
	Count int `json:"count"`
}

func (p published) String() string { return fmt.Sprintf("Published %d posts", p.Count) }

func publishCommand(app *tinka.App) *cli.Command {
	var limit int
	return &cli.Command{
		Name:    "posts:publish",
		Summary: "Publish the posts scheduled until now",
		Flags: func(f *cli.FlagSet) {
			f.IntVar(&limit, "limit", 100, "Publish at most this many posts")
		},
		Run: func(ctx context.Context, c *cli.Call) (cli.Result, error) {
			posts, err := app.Container().Make[*Posts]()
			if err != nil {
				return nil, err
			}
			n, err := posts.PublishScheduled(ctx, limit)
			if err != nil {
				return nil, err
			}
			return published{Count: n}, nil
		},
	}
}

Add it in main with app.Command(publishCommand(app)). Starting runs the providers' Start and Ready hooks, and the App's own start and ready hooks, without serving requests. See Application lifecycle.

Report errors

Return an error from Run when the Command fails: it exits with status 1 and shows the error. Return a *cli.Error when you know how the reader can recover, since people and agents both read its fields:

go
		if !strings.HasSuffix(file, ".json") {
			return nil, &cli.Error{
				Code:    "unsupported_file",
				Message: "posts:import reads JSON files only",
				Fix:     "Export the posts as JSON, then run the Command again",
			}
		}
Output
Error: posts:import reads JSON files only.
  Fix: Export the posts as JSON, then run the Command again
Codestring

A stable snake_case identifier that scripts and agents match on.

Messagestring

What went wrong, in one sentence without a final period.

Hintstring

Why it happened, or what to check.

Fixstring

The command or change that resolves it.

Docsstring

A link to the page that explains it.

When the Command was called wrongly, such as with a value out of range, return cli.UsageError. It exits with status 2 and points to the Command's help:

go
			if port < 1 || port > 65535 {
				return nil, cli.UsageError("--port must be between 1 and 65535, got %d", port)
			}

Stop on Ctrl+C

When the person presses Ctrl+C, or the process receives SIGTERM, the context Run received is cancelled. A long-running Command, such as a queue worker, watches it to finish the work in hand and return:

go
		Run: func(ctx context.Context, c *cli.Call) (cli.Result, error) {
			for {
				select {
				case <-ctx.Done():
					return nil, nil // finish the job in hand, then stop
				case job := <-jobs:
					process(ctx, job)
				}
			}
		},

The Command then exits with status 130 after Ctrl+C, or 143 after SIGTERM. A second Ctrl+C ends the process at once.

Run other programs

c.Exec runs a program and waits for it. Its output goes to the Command's progress, and when the context is cancelled the program is interrupted, then killed if it hasn't exited five seconds later:

go
			if err := c.Exec(ctx, "go", "mod", "tidy"); err != nil {
				return nil, err
			}

Guard Commands that change data

Mark a Command that changes or deletes data in ways that are hard to undo as Destructive. When the App runs in production, it asks for confirmation first. With nobody to answer, it refuses to run until --force is passed. The Command gains the --force flag on its own:

go
var wipe = &cli.Command{
	Name:        "db:wipe",
	Summary:     "Drop every table",
	Destructive: true,
	Run:         wipeTables,
}
Output
$ APP_ENV=production act db:wipe
 WARN  The App is in production.
? Are you sure you want to run db:wipe? (y/N)

Production here is any APP_ENV other than development or test, so a mistyped Mode still asks.

To refuse a Command entirely, even with --force, prohibit it. Call Prohibit before Main:

go
	if e.Mode() == env.Production {
		app.Prohibit("db:wipe")
	}

Run one at a time

Mark a Command Isolated when two runs at once would do harm, such as an import that a scheduler starts again before the last one finished. It gains an --isolated flag: with it, the run is skipped while another --isolated run of the same Command holds the lock, on the same machine and for the same App. A skipped run succeeds, so a scheduler doesn't report a failure, and says so; --isolated=<code> exits with that status instead:

Terminal
act posts:import feed.json --isolated

The lock is a file lock, so the operating system releases it when the process ends, even after a crash.

See also: Arguments, Flags, Prompts, Terminal output, Testing Commands