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.
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 |
|---|---|---|
|
|
a positional value: |
|
|
a named value: |
|
|
a switch without a value: |
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=valueform; write--name value. -
Short names are single characters with one dash:
-b 3. Short flags cannot be bundled (-rbis 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 |
|---|---|
|
the |
|
the |
|
a dictionary for passing values between a command group and its subcommands |
|
Vapor’s extension: the running |
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
Source: Vapor docs: Commands
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 |
|---|---|
|
plain text with a newline |
|
the same with a style (color on a color-capable terminal). They write to standard output, not standard error, and |
|
styled fragments: |
|
reads one line; with |
|
asks |
|
prints a numbered list and returns the chosen element; invalid input asks again |
|
an |
|
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 |
|---|---|
|
print the command’s usage instead of running it |
|
answer yes to every |
|
answer no to every |
--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
}
Source: Vapor docs: Commands and Vapor docs: Fluent overview
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.")
}
}
Source: Vapor docs: Commands
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).")
}
}
}
Source: Vapor docs: Commands and Vapor docs: Passwords
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()
Source: vapor/template: entrypoint.swift
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 |
|---|---|
|
|
|
|
|
|
|
|
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
Source: Vapor docs: Docker
(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"))
}
}
}
Source: Vapor docs: Testing
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 |
|---|---|
|
a hand-made |
|
|
a command appears twice or the wrong one runs |
the same name is registered on both |
|
the signature has no |
a negative number is read as a flag |
prefix the value with a backslash and quote it: |
the command hangs when run from a script or CI |
it calls |
|
a command stores a class or a closure; keep commands stateless structs and take shared state from |
Version notes
| Version | Change |
|---|---|
Vapor 3 |
commands were registered through |
Vapor 4.0 |
ConsoleKit commands with |
Vapor 4.x, async/await era |
|
4.122.2 |
ConsoleKit 4.16.1; |
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.