Commands

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with ConsoleKit 4.16.1, Fluent 4.13.0 and fluent-sqlite-driver 4.9.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, the commands were run from the built executable, and the terminal output shown is captured from those runs (the executable path in Usage: lines is shortened to App). The tests at the end were run with VaporTesting and Swift Testing. Vapor 5 is at 5.0.0-beta.4 and is not released; it moves to ConsoleKit 5 (5.0.0-beta.2 at the time of writing), and this page does not claim how that release changes the command types, so re-check the listings against it before upgrading. Swift itself is covered by the Swift Reference. A standalone command-line tool that has no web server is a different problem, treated in the site’s CLI frameworks for Swift and .NET; the open issue #236 tracks command-line applications and is related, because Swift ArgumentParser and Vapor’s ConsoleKit commands solve the same problem in two different libraries.

Why a web application needs commands

Some jobs belong to the application but not to a request: loading sample data, creating the first administrator, rebuilding a cache, sending a nightly digest, exporting a report, applying a data fix. They need the same configuration, database pool and services as the server, and they must be runnable by an operator, a deployment script or a scheduler. Vapor’s answer is that the executable is itself a command line: swift run App starts the server only because serve is the default command, and every other task is another command registered next to it.

Commands are the right tool when a person or a job runner starts the work. They are the wrong tool for work that a user’s request triggers (use a queue for that, see Queues & Background Jobs) and for work that must run every few minutes inside the server (use a scheduled job, see Scheduled Jobs). A command shares the application’s code, so a seeding command can use the same Fluent models and the same Bcrypt configuration as the registration endpoint, and it cannot drift away from them.

flowchart LR A["swift run App seed --books 3"] --> B["Environment.detect (strips --env)"] B --> C["configure(app)"] C --> D["lifecycle willBoot, didBoot"] D --> E["group of all registered commands"] E --> F["global flags: --help, --yes, --no"] F --> G["Signature parsed from the remaining arguments"] G --> H["run(using:signature:)"] H --> I["asyncShutdown"]

Anatomy of a command

A command is a type that conforms to ConsoleKit’s AsyncCommand protocol. The protocol has one associated type and one method:

public protocol AsyncCommand: Sendable, AnyAsyncCommand {
    associatedtype Signature: CommandSignature
    func run(using context: CommandContext, signature: Signature) async throws
}

Besides run, the protocol requires var help: String (shown in listings and in --help). The command value must be Sendable, so it is normally a struct without stored state; anything it needs at run time comes from the CommandContext. The Signature is a struct that conforms to CommandSignature, which requires only init() and is also Sendable. Its stored properties, declared with three property wrappers, are the command’s inputs. ConsoleKit finds them by reflection (Mirror) when it parses the command line, in declaration order.

Wrapper Type of the property Command-line form and behavior

@Argument(name:help:)

Value, required

a positional value: create-admin admin@example.com. A missing one throws CommandError.missingRequiredArgument; a value that does not convert throws .invalidArgumentType. Arguments are taken in declaration order from the tokens that do not start with a dash.

@Option(name:short:help:)

Value?

a named value: --books 3 or -b 3. The property is nil when the option is absent, and $books.isPresent tells "passed with a value" apart from "absent" in the odd case of --books with nothing after it. A value that does not convert throws .invalidOptionType.

@Flag(name:short:help:)

Bool

a switch without a value: --reset or -r; false when absent.

Value for @Argument and @Option must be LosslessStringConvertible & Sendable: String, Int, Double, Bool, a RawRepresentable enum that you make LosslessStringConvertible, and so on. The parsing rules, from CommandInput:

  • An option’s value is the next token. If that token starts with a dash, the option counts as present without a value; to pass a value that starts with a dash (a negative number), prefix it with a backslash, for example --offset '\-5' (quote it, or the shell removes the backslash).

  • There is no --name=value form; write --name value.

  • Short names are single characters with one dash: -b 3. Short flags cannot be bundled (-rb is not -r -b).

  • Any token left over after the signature has taken its values raises CommandError.unknownInput, so a typo such as an extra word fails loudly instead of being ignored.

The help strings feed the generated usage text:

$ swift run App create-admin --help
Usage: App create-admin <email> [--password-env]

Creates (or promotes) an administrator. The password is never an argument.

Arguments:
         email E-mail address of the new administrator

Options:
  password-env Environment variable holding the password (default BOOKSHELF_ADMIN_PASSWORD)

The context

run receives a CommandContext with four members:

Member Meaning

console

the Console for output and prompts (next sections)

input

the CommandInput left after parsing; rarely needed

userInfo

a dictionary for passing values between a command group and its subcommands

application

Vapor’s extension: the running Application. It reads userInfo["application"] and calls fatalError("Application not set on context") if it was never set, which is what happens in a hand-built context in a test (see below)

Through context.application a command reaches everything the server can: app.db, app.password, app.storage, app.client, app.logger, app.environment. Before run is called the application has completed asyncBoot(), that is configure(_:) and the lifecycle handlers' willBoot and didBoot have run, as described in Application Lifecycle and Entrypoint. A slow didBoot therefore slows every command.

Registering commands

Vapor stores commands on app.asyncCommands, a value of ConsoleKit’s AsyncCommands. Register in configure(_:):

app.asyncCommands.use(SeedCommand(), as: "seed")
app.asyncCommands.use(MyServe(), as: "serve", isDefault: true)   // replace the default command
app.asyncCommands.enableAutocomplete = true

use(_:as:isDefault:) stores the command under a name; isDefault: true makes it the command that runs when no name is given (serve has that role out of the box). The name is the first non-option token of the command line, so swift run App seed runs seed. enableAutocomplete = true adds an autocomplete command that prints a bash or zsh completion script for the executable. Three commands are present without any code of yours: serve, routes, and migrate once Fluent is configured. serve and routes are described on the Application Lifecycle and the Entrypoint page; the boot command mentioned in older documentation is not registered in 4.122.2 (see that page).

Vapor also keeps an older, synchronous registry, app.commands, for the blocking Command protocol. When the application builds the final command group it merges app.asyncCommands with app.commands, and for a name present in both the entry from app.commands wins. Write new commands as AsyncCommand and register them on app.asyncCommands: a Command blocks the thread that runs it, and you cannot await inside it.

Names are plain strings with no namespace. Use verbs and short, kebab-case names (create-admin), and avoid reusing a built-in name. To group related commands, use AsyncCommandGroup, shown below.

Output and input: the Console

context.console is a Console (by default Terminal, which writes to standard output). The methods you use every day:

Call Effect

print(_:)

plain text with a newline

info(_:), success(_:), warning(_:), error(_:)

the same with a style (color on a color-capable terminal). They write to standard output, not standard error, and error does not change the exit status

output(_ text: ConsoleText)

styled fragments: "Books: ".consoleText(.info) + "6".consoleText(color: .brightGreen, isBold: true)

ask(_:isSecure:)

reads one line; with isSecure: true the typed characters are not echoed

confirm(_:)

asks y/n> and returns a Bool

choose(_:from:display:)

prints a numbered list and returns the chosen element; invalid input asks again

progressBar(title:), loadingBar(title:)

an ActivityIndicator you start() and then succeed() or fail(); bar.activity.currentProgress takes 0 to 1

clear(_:), center(_:), wait(seconds:)

terminal helpers

Animation and color need an interactive terminal: supportsANSICommands is true only when standard output is a TTY. Piped into a file or a CI log, a progress bar prints two plain lines (Counting [...] then Counting [Done]) and styles are dropped.

Three flags are handled before your command sees the arguments, for every command, so they must not be redefined in a signature:

Flag Meaning

--help, -h

print the command’s usage instead of running it

--yes, -y

answer yes to every confirm(_:) without reading input

--no, -n

answer no to every confirm(_:)

--yes is what makes a confirming command usable from a deployment script: swift run App migrate --yes applies pending migrations without the y/n> prompt (Fluent’s migrate lists them and asks first). A command that prompts for text with ask or choose, on the other hand, waits for input that a script does not provide (or reads an empty answer at end of input), so any command a script will run needs an input that avoids the prompt, as create-admin does below. The global --env/-e option is not part of this list: Environment.detect() strips it before the commands are parsed, so swift run App seed --env production and swift run App --env production seed both work, and VAPOR_ENV is the fallback.

The Bookshelf commands

Bookshelf has three operational needs: sample data for development, the first administrator in a fresh database, and a handful of user-management tasks. The listings use the canonical Bookshelf models (Author, Book, User with email, passwordHash and role) and the CreateBookshelf migration of the Migrations and Schema page. The one change to the application is in configure.swift: a file-based SQLite database, so that separate swift run processes see each other’s data, and the commands registered:

import Fluent
import FluentSQLiteDriver
import Vapor

func configure(_ app: Application) async throws {
    // A file database so that `seed` and `create-admin` share data across process runs.
    let path = Environment.get("BOOKSHELF_DB") ?? "bookshelf.sqlite"
    app.databases.use(.sqlite(app.environment == .testing ? .memory : .file(path)), as: .sqlite)
    app.migrations.add(CreateBookshelf())
    app.passwords.use(app.environment == .testing ? .bcrypt(cost: 4) : .bcrypt)

    app.asyncCommands.use(SeedCommand(), as: "seed")
    app.asyncCommands.use(CreateAdminCommand(), as: "create-admin")
    app.asyncCommands.use(UsersCommand(), as: "users")
    app.asyncCommands.use(StatsCommand(), as: "stats")
    app.asyncCommands.enableAutocomplete = true
}

A fresh database is prepared with swift run App migrate --yes. (The default swift-tools-version 6.3 package of the other pages does not run autoMigrate() in configure; production deployments run migrate as a separate step, see Migrations and Schema.)

seed: an option, a flag and a confirmation

import Fluent
import Vapor

/// `swift run App seed [--books N] [--reset]`
struct SeedCommand: AsyncCommand {
    struct Signature: CommandSignature {
        @Option(name: "books", short: "b", help: "How many books to create (default 6)")
        var books: Int?

        @Flag(name: "reset", short: "r", help: "Delete all books and authors before seeding")
        var reset: Bool

        init() {}
    }

    var help: String { "Inserts sample authors, books and tags. Safe to run twice." }

    func run(using context: CommandContext, signature: Signature) async throws {
        let console = context.console
        let db = context.application.db
        let count = signature.books ?? 6
        guard (1...500).contains(count) else {
            throw Abort(.badRequest, reason: "--books must be between 1 and 500, got \(count).")
        }

        if signature.reset {
            guard console.confirm("Delete ALL books and authors first?") else {
                console.warning("Aborted, nothing was changed.")
                return
            }
            try await Book.query(on: db).delete()
            try await Author.query(on: db).delete()
        }

        let author: Author
        if let existing = try await Author.query(on: db).filter(\.$name == "Ursula K. Le Guin").first() {
            author = existing
        } else {
            author = Author(name: "Ursula K. Le Guin", bio: "Sample author.")
            try await author.save(on: db)
        }

        var created = 0
        for index in 1...count {
            let isbn = String(format: "978-0-00-%06d-0", index)
            if try await Book.query(on: db).filter(\.$isbn == isbn).first() != nil { continue }
            let book = Book(title: "Sample Book \(index)", isbn: isbn, publishedYear: 1960 + index,
                            priceCents: 999 + index, status: .published, authorID: try author.requireID())
            try await book.save(on: db)
            created += 1
        }
        console.success("Seeded \(created) new book(s); \(count - created) already existed.")
    }
}

Three details are worth copying. The option is validated before use (--books 0 would otherwise give a range that traps, and --books 10000000 a very long run). The command is idempotent: it looks up each ISBN first, so running it twice creates nothing the second time. And the destructive --reset flag asks confirm first, which --yes answers automatically and --no declines. Running it:

$ swift run App seed
Seeded 6 new book(s); 0 already existed.
$ swift run App seed -b 8
Seeded 2 new book(s); 6 already existed.
$ swift run App seed --reset --no
Delete ALL books and authors first?
y/n> no
Aborted, nothing was changed.
$ swift run App seed --reset --yes
Delete ALL books and authors first?
y/n> yes
Seeded 6 new book(s); 0 already existed.

A seeding command that must never run against production should check context.application.environment (guard app.environment != .production) and refuse; the Fluent --reset above is exactly the kind of operation that needs it.

create-admin: the password never travels on the command line

/// `BOOKSHELF_ADMIN_PASSWORD=... swift run App create-admin admin@example.com`
struct CreateAdminCommand: AsyncCommand {
    struct Signature: CommandSignature {
        @Argument(name: "email", help: "E-mail address of the new administrator")
        var email: String

        @Option(name: "password-env", help: "Environment variable holding the password (default BOOKSHELF_ADMIN_PASSWORD)")
        var passwordEnv: String?

        init() {}
    }

    var help: String { "Creates (or promotes) an administrator. The password is never an argument." }

    func run(using context: CommandContext, signature: Signature) async throws {
        let app = context.application
        let variable = signature.passwordEnv ?? "BOOKSHELF_ADMIN_PASSWORD"

        let password: String
        if let fromEnvironment = Environment.get(variable), !fromEnvironment.isEmpty {
            password = fromEnvironment
        } else {
            // Interactive fallback: hidden input, only sensible on a terminal.
            password = context.console.ask("Password for \(signature.email)", isSecure: true)
        }
        guard password.utf8.count >= 12 else {
            throw Abort(.badRequest, reason: "The password must be at least 12 bytes long.")
        }
        guard password.utf8.count <= 72 else {
            throw Abort(.badRequest, reason: "bcrypt only uses the first 72 bytes; use a shorter password.")
        }

        let hash = try await app.password.async.hash(password)
        if let existing = try await User.query(on: app.db).filter(\.$email == signature.email).first() {
            existing.role = .admin
            existing.passwordHash = hash
            try await existing.save(on: app.db)
            context.console.success("Updated \(signature.email) and made it an admin.")
        } else {
            try await User(email: signature.email, passwordHash: hash, role: .admin).save(on: app.db)
            context.console.success("Created admin \(signature.email).")
        }
    }
}

The password is not a signature property on purpose. An argument is visible to every user of the machine in the process list, it is stored in shell history, and it lands in the logs of whatever CI system ran the command. The command therefore reads it from an environment variable (the variable’s name is an option, so a deployment can point it at a secret it already has) and falls back to a hidden prompt for an operator at a terminal:

$ BOOKSHELF_ADMIN_PASSWORD='<placeholder-at-least-12-characters>' swift run App create-admin admin@example.com
Created admin admin@example.com.
$ BOOKSHELF_ADMIN_PASSWORD='<placeholder-at-least-12-characters>' swift run App create-admin admin@example.com
Updated admin@example.com and made it an admin.
$ swift run App create-admin
[ WARNING ] .missingRequiredArgument("email")

On a terminal without the variable the command prints Password for ... and > ` and reads the password without echoing it (this path was exercised through a pseudo-terminal). The hash is produced with `app.password.async, which runs bcrypt on the thread pool; see Passwords and Crypto for the cost, the 72-byte limit the command checks, and rehashing. The command is also an upsert: running it for an existing e-mail makes that account an admin and resets its password, which is the recovery path when the last administrator forgot theirs.

A command group: users

AsyncCommandGroup nests commands under one name. A group has commands, an optional defaultCommand and help, and no code of its own to run:

/// A command group: `swift run App users list` and `swift run App users promote <email>`.
struct UsersCommand: AsyncCommandGroup {
    var help: String { "Manage Bookshelf users." }

    var commands: [String: any AnyAsyncCommand] {
        ["list": ListUsersCommand(), "promote": PromoteUserCommand()]
    }

    var defaultCommand: (any AnyAsyncCommand)? { ListUsersCommand() }
}

struct ListUsersCommand: AsyncCommand {
    struct Signature: CommandSignature { init() {} }
    var help: String { "Lists users and their roles." }

    func run(using context: CommandContext, signature: Signature) async throws {
        for user in try await User.query(on: context.application.db).sort(\.$email).all() {
            context.console.print("\(user.role.rawValue.padding(toLength: 10, withPad: " ", startingAt: 0))\(user.email)")
        }
    }
}

struct PromoteUserCommand: AsyncCommand {
    struct Signature: CommandSignature {
        @Argument(name: "email") var email: String
        init() {}
    }
    var help: String { "Changes a user's role after asking which one." }

    func run(using context: CommandContext, signature: Signature) async throws {
        let console = context.console
        guard let user = try await User.query(on: context.application.db)
            .filter(\.$email == signature.email).first() else {
            throw Abort(.notFound, reason: "No user \(signature.email).")
        }
        let role = console.choose("New role for \(user.email)", from: [UserRole.reader, .librarian, .admin]) {
            "\($0.rawValue)".consoleText()
        }
        guard console.confirm("Make \(user.email) a \(role.rawValue)?") else {
            console.warning("Unchanged.")
            return
        }
        user.role = role
        try await user.save(on: context.application.db)
        console.success("\(user.email) is now \(role.rawValue).")
    }
}

PromoteUserCommand shows the interactive tools: choose for a menu and confirm for the last check. With the group registered as users:

$ swift run App users
admin     admin@example.com
librarian librarian@example.com
$ swift run App users promote librarian@example.com
New role for librarian@example.com
1: reader
2: librarian
3: admin
> 2
Make librarian@example.com a librarian?
y/n> y
librarian@example.com is now librarian.
$ swift run App users promote nobody@example.com
[ WARNING ] Abort.404: No user nobody@example.com.

swift run App users with no subcommand runs the default (list); swift run App users --help lists the subcommands. The answers (2 and y) are shown inline; in the run itself they were supplied on standard input, which works for choose and confirm; only isSecure input needs a terminal.

stats: styled output and an activity bar

/// `swift run App stats`: styled output and an activity indicator.
struct StatsCommand: AsyncCommand {
    struct Signature: CommandSignature { init() {} }
    var help: String { "Prints catalogue statistics." }

    func run(using context: CommandContext, signature: Signature) async throws {
        let console = context.console
        let db = context.application.db

        let bar = console.progressBar(title: "Counting")
        bar.start()
        let books = try await Book.query(on: db).count()
        bar.activity.currentProgress = 0.5
        let users = try await User.query(on: db).count()
        bar.succeed()

        var line: ConsoleText = "Books: ".consoleText(.info)
        line += "\(books)".consoleText(color: .brightGreen, isBold: true)
        console.output(line)
        console.print("Users: \(users)")
        if books == 0 { console.warning("The catalogue is empty; run `seed`.") }
    }
}

Run without a terminal (as here, with the output captured) the bar prints two lines and the rest is plain text:

$ swift run App stats
Counting [...]
Counting [Done]
Books: 6
Users: 3

Errors, exit codes and the entrypoint

A command reports failure by throwing. What the user sees, and the process exit status, are decided by the entrypoint, not by the command. The official template catches the error in entrypoint.swift, reports it with the logger, shuts the application down and rethrows it from main. A Swift program whose main throws terminates with a fatal error, so the run above ended with:

$ swift run App seed --books 0
[ WARNING ] Abort.400: --books must be between 1 and 500, got 0.
[ WARNING ] Abort.400: --books must be between 1 and 500, got 0.
Swift/ErrorType.swift:254: Fatal error: Error raised at top level: Abort.400: --books must be between 1 and 500, got 0.

The status was 133 on macOS (a trap, not a clean 1), and the first line appears twice because app.execute() reports the error and the entrypoint’s catch reports it again. That is acceptable in a development terminal but poor for scripts, which want a plain non-zero status and a single message. Replace the final throw error of the template’s catch with an explicit exit:

do {
    try await configure(app)
    try await app.execute()
} catch {
    app.logger.report(error: error)
    try? await app.asyncShutdown()
    exit(1)   // a plain exit status instead of a fatal error trap
}
try await app.asyncShutdown()

With that change the same failures exit with status 1. Which error produced the failure is still visible in the message, and ConsoleKit’s own errors are plain CommandError values, not `Abort`s, so they print in their debug form:

Mistake Output

swift run App create-admin

.missingRequiredArgument("email")

swift run App seed --books many

.invalidOptionType("books", type: Int)

swift run App seed extra

.unknownInput("extra")

swift run App sead

.unknownCommand("sead", available: [...]) (the list of registered names)

A command that wants a friendlier message should catch the expected errors itself, print with console.error(...), and then throw or return according to whether the operation failed: console.error only prints.

Running commands in production

The executable is the same one that serves HTTP, so every environment that can run the server can run a command: with the Swift toolchain (swift run App seed), with the built binary (.build/release/App seed), or in the container image (see Deployment with Docker). In a container the arguments after the image name replace the default command, provided the ENTRYPOINT is the binary and the default CMD is serve; this is the layout of the official template’s Dockerfile, so

docker run --rm --env-file bookshelf.env bookshelf:latest migrate --yes
docker run --rm --env-file bookshelf.env -e BOOKSHELF_ADMIN_PASSWORD bookshelf:latest create-admin admin@example.com

(Not run for this page: no Docker daemon was available. The commands show the shape; check your own image’s entrypoint.) In Kubernetes, a one-off task is a Job with the same image and args: ["migrate", "--yes"], run before the new Deployment rolls out, and a recurring one is a CronJob; see the site’s Jobs and CronJobs page. A command started this way boots the whole application first, including connecting to databases configured in configure, so give the Job the same environment and secrets as the server, and remember that it exits when run returns. For work that has to survive restarts or retry, prefer a queue job (Queues & Background Jobs); for a periodic task you can choose between a scheduled job inside the server (Scheduled Jobs) and a CronJob running a command.

Testing commands

The general VaporTesting setup is on the Testing page. A command is a function of a signature and a console, so it is tested without a process: build a CommandContext with a console that records what the command prints and feeds it prepared answers, set the application on it, and run it through the same Console.run that Application.startup() uses (which is also what applies --help, --yes and --no). Vapor does not ship such a console; ConsoleKit’s Console protocol is small enough to write one:

import ConsoleKit
import Fluent
import NIOConcurrencyHelpers
import Testing
import Vapor
import VaporTesting
@testable import App

/// A console that records output and answers prompts from a queue.
final class CaptureConsole: Console, Sendable {
    private struct State {
        var lines: [String] = []
        var answers: [String]
        var info: [AnySendableHashable: any Sendable] = [:]
    }
    private let state: NIOLockedValueBox<State>

    init(answers: [String] = []) { state = .init(State(answers: answers)) }

    var output: String { state.withLockedValue { $0.lines.joined() } }

    var size: (width: Int, height: Int) { (80, 24) }
    var supportsANSICommands: Bool { false }
    var userInfo: [AnySendableHashable: any Sendable] {
        get { state.withLockedValue { $0.info } }
        set { state.withLockedValue { $0.info = newValue } }
    }
    func input(isSecure: Bool) -> String { state.withLockedValue { $0.answers.isEmpty ? "" : $0.answers.removeFirst() } }
    func output(_ text: ConsoleText, newLine: Bool) {
        state.withLockedValue { $0.lines.append(text.description + (newLine ? "\n" : "")) }
    }
    func report(error: String, newLine: Bool) { output(ConsoleText(stringLiteral: error), newLine: newLine) }
    func clear(_ type: ConsoleClear) {}
}

extension Application {
    /// Runs `args` (without the executable name) against the registered commands, as `swift run App <args>` would.
    @discardableResult
    func runCommand(_ args: [String], answers: [String] = []) async throws -> String {
        let console = CaptureConsole(answers: answers)
        var context = CommandContext(console: console, input: CommandInput(arguments: ["App"] + args))
        context.application = self
        try await console.run(self.asyncCommands.group(), with: context)
        return console.output
    }
}

CaptureConsole is Sendable through a lock-protected state box, as Swift 6 requires of a Console. runCommand mirrors startup(): it merges nothing (it uses app.asyncCommands only) and sets context.application. Tests then read like the terminal sessions above:

@Suite(.serialized) struct CommandTests {
    func withBookshelf(_ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await app.autoMigrate()
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test func seedIsIdempotent() async throws {
        try await withBookshelf { app in
            let first = try await app.runCommand(["seed", "--books", "3"])
            #expect(first.contains("Seeded 3 new book(s); 0 already existed."))
            let second = try await app.runCommand(["seed", "-b", "4"])
            #expect(second.contains("Seeded 1 new book(s); 3 already existed."))
            #expect(try await Book.query(on: app.db).count() == 4)
        }
    }

    @Test func seedValidatesItsOption() async throws {
        try await withBookshelf { app in
            await #expect(throws: Abort.self) { try await app.runCommand(["seed", "--books", "0"]) }
            await #expect(throws: CommandError.self) { try await app.runCommand(["seed", "--books", "many"]) }
            await #expect(throws: CommandError.self) { try await app.runCommand(["nosuchcommand"]) }
        }
    }

    @Test func resetAsksForConfirmation() async throws {
        try await withBookshelf { app in
            try await app.runCommand(["seed", "--books", "2"])
            // The prompt reads "n": nothing is deleted.
            let declined = try await app.runCommand(["seed", "--reset"], answers: ["n"])
            #expect(declined.contains("Aborted"))
            #expect(try await Book.query(on: app.db).count() == 2)
            // --yes (a global flag) answers every confirm() with yes and never reads the input.
            let accepted = try await app.runCommand(["seed", "--reset", "--yes", "--books", "1"])
            #expect(accepted.contains("Seeded 1 new book(s)"))
            #expect(try await Book.query(on: app.db).count() == 1)
        }
    }

    @Test func createAdminReadsThePasswordFromTheEnvironment() async throws {
        try await withBookshelf { app in
            setenv("BOOKSHELF_TEST_PASSWORD", "placeholder-password-1", 1)
            defer { unsetenv("BOOKSHELF_TEST_PASSWORD") }
            let out = try await app.runCommand(["create-admin", "root@example.com", "--password-env", "BOOKSHELF_TEST_PASSWORD"])
            #expect(out.contains("Created admin root@example.com."))
            let user = try #require(try await User.query(on: app.db).filter(\.$email == "root@example.com").first())
            #expect(user.role == .admin)
            #expect(user.passwordHash.hasPrefix("$2"))
            #expect(!user.passwordHash.contains("placeholder"))
            #expect(try app.password.verify("placeholder-password-1", created: user.passwordHash))
        }
    }

    @Test func createAdminFallsBackToThePrompt() async throws {
        try await withBookshelf { app in
            let out = try await app.runCommand(["create-admin", "ask@example.com", "--password-env", "NOT_SET_ANYWHERE"],
                                               answers: ["typed-placeholder-pw"])
            #expect(out.contains("Password for ask@example.com"))
            #expect(try await User.query(on: app.db).count() == 1)
            await #expect(throws: Abort.self) {
                try await app.runCommand(["create-admin", "short@example.com", "--password-env", "NOT_SET_ANYWHERE"], answers: ["short"])
            }
        }
    }

    @Test func promoteChoosesARole() async throws {
        try await withBookshelf { app in
            try await User(email: "reader@example.com", passwordHash: "x").save(on: app.db)
            let out = try await app.runCommand(["users", "promote", "reader@example.com"], answers: ["2", "y"])
            #expect(out.contains("reader@example.com is now librarian."))
            let listing = try await app.runCommand(["users"])
            #expect(listing.contains("librarian reader@example.com"))
        }
    }
}

All six tests pass. A few points about them: the configuration uses an in-memory SQLite database and bcrypt cost 4 in the testing environment so that no file is created and hashing is fast; --yes and --password-env are passed as normal arguments; and the password test sets a process environment variable, which is global, so the suite is .serialized. Because the commands call Environment.get, a command that must be tested with different values in parallel should take its configuration from a Sendable struct stored on the application instead of reading the process environment itself.

Common errors

Symptom Cause and fix

Application not set on context fatal error

a hand-made CommandContext without context.application = app

.unknownCommand("boot", ...)

boot is not registered in Vapor 4.122.2; use routes or your own command as a boot check

a command appears twice or the wrong one runs

the same name is registered on both app.asyncCommands and app.commands; the app.commands entry wins

.unknownInput("--x")

the signature has no x; options and flags are exact names (--name value, no =)

a negative number is read as a flag

prefix the value with a backslash and quote it: --offset '\-5'

the command hangs when run from a script or CI

it calls ask, choose or confirm with no input to read; add a non-interactive path (an environment variable, a flag) and pass --yes for confirmations

error: ... does not conform to Sendable on a command

a command stores a class or a closure; keep commands stateless structs and take shared state from context.application

Version notes

Version Change

Vapor 3

commands were registered through CommandConfig in configure(_:_:_:) and a services container; the console types lived in the separate Console package

Vapor 4.0

ConsoleKit commands with Signature property wrappers, app.commands, the synchronous Command

Vapor 4.x, async/await era

AsyncCommand, AsyncCommandGroup and app.asyncCommands were added; the older Command kept for compatibility

4.122.2

ConsoleKit 4.16.1; app.commands and app.asyncCommands merged at startup, the legacy one winning on a name clash

5.0.0-beta.4

unreleased beta on ConsoleKit 5; verify the command and console types against its release notes

Where the books are outdated

The Kodeco book (Condon et al., chapter 18) seeds the administrator account with the fixed password "password", and Yeung (chapter 9) publishes ready-made bcrypt hashes to paste in for the same purpose. Both put a credential in the source or the documentation and give every deployment the same administrator, which is exactly what the create-admin command above avoids: the password comes from the operator’s environment and is hashed with the application’s configured hasher at run time. Yeung’s Vapor 3 and Patel’s Vapor 2 predate the current command types; in Vapor 3 commands were registered through a configuration object and the services container, and today they are plain values on app.asyncCommands, with async run methods and Sendable signatures. None of the three covers --yes and --no for scripted runs, command groups, or testing a command without starting a process.