Chapter 16: CLI Tools and Edge Computing
Tova gives you first-class language support for two things that usually require mountains of boilerplate: command-line tools and edge-deployed serverless functions. The cli block turns a few lines of Tova into a polished CLI application with argument parsing, help text, and colored output. The edge block deploys your code to the world's edge networks with built-in bindings for KV stores, databases, queues, and more.
By the end of this chapter, you'll build a file organizer CLI tool and understand how to deploy Tova to five different edge platforms.
Part 1: CLI Tools
The cli Block
Every CLI tool in Tova starts with a cli block at the top level of your file:
cli {
name: "greet"
version: "1.0.0"
description: "A friendly greeting tool"
fn hello(name: String) {
print("Hello, {name}!")
}
}That is a complete, working CLI application. Run tova build and you get a standalone executable that handles argument parsing, validation, help text, and error messages. No external libraries, no setup.
$ greet hello Alice
Hello, Alice!
$ greet --help
greet v1.0.0 - A friendly greeting tool
COMMANDS:
hello <name>
$ greet hello --help
Usage: greet hello <name>
ARGUMENTS:
name String (required)Configuration
The config fields at the top of a cli block define your tool's identity:
cli {
name: "deploy"
version: "2.5.0"
description: "Deploy applications to production"
// commands go here...
}| Field | Purpose |
|---|---|
name | Tool name, shown in help text |
version | Shown with --version flag |
description | One-line summary in help output |
All three are optional, but including them makes your tool feel professional. The --version flag is auto-generated when you provide a version field.
Commands as Functions
Each fn inside a cli block becomes a subcommand. The function name is the command name, and the parameters become its arguments:
cli {
name: "todo"
fn add(task: String) {
print("Added: {task}")
}
fn list() {
print("Showing all tasks...")
}
fn remove(id: Int) {
print("Removed task {id}")
}
}$ todo add "Buy groceries"
Added: Buy groceries
$ todo list
Showing all tasks...
$ todo remove 3
Removed task 3Commands can also be async:
cli {
name: "deploy"
async fn push(target: String) {
print("Deploying to {target}...")
await deploy_to(target)
print("Done!")
}
}Parameters
CLI parameters come in several forms. Positional arguments are required by default, while flags are prefixed with --:
cli {
name: "serve"
fn start(directory: String, --port: Int = 3000, --verbose: Bool) {
print("Serving {directory} on port {port}")
if verbose {
print("Verbose logging enabled")
}
}
}$ serve start ./public # port defaults to 3000
$ serve start ./public --port 8080 # override port
$ serve start ./public --port=8080 # equals syntax works too
$ serve start ./public --verbose # boolean flag toggled on
$ serve start ./public --no-verbose # explicitly offHere is the full parameter syntax:
| Syntax | Meaning | Example |
|---|---|---|
name: String | Required positional | fn run(target: String) |
name: String? | Optional positional | fn init(name: String?) |
--flag: Type | Named flag | fn run(--port: Int) |
--flag: Type = val | Flag with default | fn run(--port: Int = 3000) |
--flag: Bool | Boolean toggle | fn run(--verbose: Bool) |
--flag: [String] | Repeated flag (array) | fn build(--include: [String]) |
Boolean flags are implicitly optional (they default to false). Tova also generates a --no- variant for each boolean flag.
Repeated flags collect multiple values into an array:
cli {
fn build(--include: [String]) {
print("Including: {include}")
}
}$ build --include src --include lib --include vendor
Including: ["src", "lib", "vendor"]Type validation happens automatically. If someone passes --port abc, Tova prints a clear error:
Error: --port expects an integer, got "abc"Single-Command Optimization
When your cli block has only one command, Tova skips the subcommand routing entirely. The command's arguments become top-level arguments:
cli {
name: "minify"
version: "1.0.0"
fn run(file: String, --output: String?) {
print("Minifying {file}...")
// minification logic here
}
}# No subcommand needed — "run" is implied:
$ minify style.css
$ minify style.css --output style.min.cssThis is ideal for focused, single-purpose tools. Multi-command tools like git need subcommands; single-purpose tools like minify do not.
Auto-Generated Help
Every cli tool gets --help for free. Tova generates help text from your command names, parameter names, types, and defaults:
$ mytool --help
mytool v1.0.0 - Does great things
COMMANDS:
deploy <target> [options]
status
rollback <version>
Run 'mytool <command> --help' for details.
$ mytool deploy --help
Usage: mytool deploy <target> [options]
ARGUMENTS:
target String (required)
OPTIONS:
--port Int (default: 3000)
--verbose Bool
--env String (default: "staging")You never write help text manually. Rename a parameter, and the help updates automatically.
Interactive Prompts
For tools that need user input, Tova provides built-in prompt functions:
cli {
name: "scaffold"
fn new() {
name = ask("Project name?")
lang = choose("Language:", ["Tova", "JavaScript", "Python"])
features = chooseMany("Features:", ["tests", "linting", "ci", "docker"])
confirmed = confirm("Create project '{name}'?")
if confirmed {
print("Creating {name} with {lang}...")
for feature in features {
print(" Setting up {feature}")
}
}
}
}| Function | Purpose | Returns |
|---|---|---|
ask("prompt") | Free-text input | String |
confirm("prompt") | Yes/no question | Bool |
choose("prompt", options) | Single selection from list | String |
chooseMany("prompt", options) | Multiple selection from list | [String] |
secret("prompt") | Hidden input (passwords) | String |
The secret function hides keystrokes, which is essential for password or API key input:
cli {
fn login() {
username = ask("Username:")
password = secret("Password:")
// password input is hidden from the terminal
print("Logging in as {username}...")
}
}Colored Output
Tova includes color functions that work across terminals:
cli {
fn status() {
print(green("All systems operational"))
print(yellow("Warning: disk usage at 85%"))
print(red("Error: database connection failed"))
print(blue("Info: 42 active connections"))
print(cyan("Debug: request processed in 3ms"))
print(bold("Important notice"))
print(dim("Less important detail"))
}
}Colors nest and compose naturally:
print(bold(red("CRITICAL")) ++ ": " ++ "Server is down")
print(green("PASS") ++ " " ++ dim("test_user_creation (12ms)"))Available color functions: red(), green(), yellow(), blue(), cyan(), magenta(), gray(). Text style functions: bold(), dim(), underline(), strikethrough().
Formatting
For structured output, Tova provides formatting helpers:
cli {
fn report() {
// Table output
data = [
["Name", "Status", "CPU"],
["web-1", "running", "23%"],
["web-2", "running", "45%"],
["db-1", "stopped", "0%"]
]
table(data)
// Panel (boxed section)
panel("Summary", "3 servers total\n2 running, 1 stopped")
// Progress bar — wraps an iterable
items = range(1, 11)
for item in progress(items, { label: "Processing" }) {
// process each item
}
// Spinner for long operations (async, takes callback)
result = await spin("Deploying...", fn() {
// ... deploy logic ...
})
}
}| Function | Purpose |
|---|---|
table(rows) | Formatted table with alignment |
panel(title, content) | Boxed content with a title |
progress(items, opts) | Progress bar wrapping an iterable; opts: {label, width, total} |
spin(label, fn) | Async spinner — runs fn() with animated spinner, resolves to result |
Building Executables
Run tova build on a file with a cli block to produce a standalone executable:
$ tova build organizer.tova
# Creates: organizer.js (with #!/usr/bin/env node shebang, chmod 755)
$ ./organizer.js sort ./downloads --verbose
Sorting files in ./downloads...The output is a self-contained JavaScript file with a shebang line. It needs Node.js or Bun on the target system, but no other dependencies. No node_modules, no package.json.
Project: File Organizer CLI Tool
Let's bring all of this together into a practical tool that organizes files by type:
cli {
name: "organize"
version: "1.0.0"
description: "Organize files by type into folders"
fn sort(directory: String, --dry-run: Bool, --verbose: Bool) {
print(bold("Organizing files in {directory}"))
print("")
// Define category mappings
categories = {
documents: ["pdf", "doc", "docx", "txt", "md"],
images: ["jpg", "jpeg", "png", "gif", "svg", "webp"],
code: ["js", "ts", "tova", "py", "go", "rs"],
data: ["csv", "json", "xml", "yaml", "toml"],
media: ["mp3", "mp4", "wav", "avi", "mkv"]
}
// Scan and categorize files
var moved = 0
var skipped = 0
files = list_files(directory)
for file in files {
ext = file_extension(file)
category = find_category(ext, categories)
match category {
Some(cat) => {
if dry_run {
print(dim(" Would move: {file} -> {cat}/"))
} else {
move_file(file, "{directory}/{cat}/{file}")
if verbose {
print(green(" Moved: {file} -> {cat}/"))
}
}
moved += 1
}
None => {
skipped += 1
if verbose {
print(yellow(" Skipped: {file} (unknown type)"))
}
}
}
}
print("")
if dry_run {
print(yellow("Dry run: {moved} files would be moved, {skipped} skipped"))
} else {
print(green("Done: {moved} files moved, {skipped} skipped"))
}
}
fn stats(directory: String, --format: String = "table") {
files = list_files(directory)
var groups = {}
for file in files {
ext = file_extension(file)
if groups[ext] == undefined {
groups[ext] = 0
}
groups[ext] += 1
}
match format {
"table" => {
rows = [["Extension", "Count"]]
for ext in keys(groups) {
rows.push([".{ext}", toString(groups[ext])])
}
table(rows)
}
"bar" => {
for ext in keys(groups) {
bar = repeat("=", groups[ext] * 2)
print(" .{padEnd(ext, 6)} {bar} ({groups[ext]})")
}
}
_ => print(red("Unknown format: {format}. Use 'table' or 'bar'."))
}
}
fn undo(directory: String) {
confirmed = confirm("Undo last organization of {directory}?")
if confirmed {
print("Reversing file moves...")
// Restore files from category folders
print(green("Done! Files restored to original locations."))
} else {
print("Cancelled.")
}
}
}$ organize sort ~/Downloads --dry-run --verbose
Organizing files in /Users/me/Downloads
Would move: report.pdf -> documents/
Would move: photo.jpg -> images/
Would move: data.csv -> data/
Skipped: mystery.xyz (unknown type)
Dry run: 3 files would be moved, 1 skipped
$ organize stats ~/Downloads --format bar
.pdf ==== (2)
.jpg ====== (3)
.csv == (1)
.js ==== (2)
$ organize sort ~/Downloads
$ organize undo ~/DownloadsPart 2: Edge Computing
The edge Block
The edge block deploys your code to edge networks -- serverless functions running in data centers around the world, close to your users:
edge {
target: "cloudflare"
route GET "/api/hello" => fn(req) {
{ message: "Hello from the edge!" }
}
route GET "/api/time" => fn(req) {
{ timestamp: Date.now() }
}
}This compiles to a Cloudflare Worker, a Deno Deploy function, a Vercel Edge Function, an AWS Lambda, or a Bun server -- depending on the target field. The Tova code stays the same.
Named Edge Blocks
You can define multiple edge blocks in the same file, each with its own name and purpose:
edge "api" {
target: "cloudflare"
route GET "/api/users" => fn(req) { get_users() }
route POST "/api/users" => fn(req) { create_user(req.body) }
}
edge "assets" {
target: "cloudflare"
route GET "/img/:path" => fn(req, params) {
serve_static("images/{params.path}")
}
}Named blocks generate separate output files: app.edge.api.js and app.edge.assets.js. This lets you deploy different parts of your application independently.
Target Platforms
Tova supports five edge deployment targets:
| Target | Platform | Output |
|---|---|---|
"cloudflare" | Cloudflare Workers | export default { fetch() } |
"deno" | Deno Deploy | Deno.serve() |
"vercel" | Vercel Edge Functions | Edge Runtime handler |
"lambda" | AWS Lambda | Lambda handler |
"bun" | Bun server | Bun.serve() |
If you omit target, Tova defaults to Cloudflare. Your route handlers, middleware, and business logic are identical across all five -- the compiler generates the platform-specific wiring.
// Same routes, different targets:
edge {
target: "deno" // Change this one line to switch platforms
route GET "/api/status" => fn(req) {
{ status: "healthy", platform: "deno" }
}
}Routes in Edge
Edge routes use the same syntax as Tova's server block. If you already know how to write server routes, you know edge routes:
edge {
target: "cloudflare"
// Static path
route GET "/api/health" => fn(req) {
{ status: "ok" }
}
// Path parameters
route GET "/api/users/:id" => fn(req, params) {
user = fetch_user(params.id)
{ user: user }
}
// Different HTTP methods
route POST "/api/users" => fn(req) {
created = create_user(req.body)
{ user: created, status: 201 }
}
route DELETE "/api/users/:id" => fn(req, params) {
delete_user(params.id)
{ deleted: true }
}
}You can also define handler functions separately and reference them by name:
edge {
fn get_users() { query_db("SELECT * FROM users") }
fn create_user(req) { insert_db("users", req.body) }
route GET "/api/users" => get_users
route POST "/api/users" => create_user
}Middleware works the same way:
edge {
middleware fn logger(req, next) {
start = Date.now()
response = next(req)
elapsed = Date.now() - start
print("[{req.method}] {req.url} - {elapsed}ms")
response
}
route GET "/api/data" => fn(req) { { data: "value" } }
}Runtime Bindings
Edge platforms provide managed services -- key-value stores, databases, object storage, queues. Tova gives you a declarative way to wire them up:
edge {
target: "cloudflare"
// Key-Value store
kv CACHE
// SQL database
sql DB
// Object storage (R2, S3, etc.)
storage UPLOADS
// Message queue
queue EMAILS
// Environment variable with default
env API_URL = "https://api.example.com"
// Secret (no default, must be set in platform dashboard)
secret JWT_SECRET
route GET "/api/cached/:key" => fn(req, params) {
// Use bindings directly in handlers
value = CACHE.get(params.key)
if value != null {
{ value: value, source: "cache" }
} else {
{ error: "Not found" }
}
}
}| Binding | Syntax | Purpose |
|---|---|---|
kv NAME | Key-value store | Caching, sessions, config |
sql NAME | SQL database | D1, Deno KV, bun:sqlite |
storage NAME | Object storage | File uploads, assets |
queue NAME | Message queue | Background jobs, email |
env NAME = "default" | Environment variable | Configuration |
secret NAME | Secret (no default) | API keys, tokens |
Each binding compiles to the correct platform-specific code. A kv CACHE on Cloudflare uses the env parameter from the fetch handler; on Deno it calls Deno.openKv(); on Bun and Vercel it uses process.env for env/secret bindings.
Binding Support Varies
Not all platforms support all bindings. The Tova analyzer warns you when you use an unsupported binding for your target. For example, queue is only fully supported on Cloudflare. Check the warning messages -- they tell you exactly what works where.
Scheduled Jobs
Run code on a schedule using cron expressions:
edge {
target: "cloudflare"
schedule "cleanup" cron("0 0 * * *") {
// Runs daily at midnight UTC
expired = SESSIONS.list({ prefix: "expired:" })
for key in expired.keys {
SESSIONS.delete(key)
}
print("Cleaned up expired sessions")
}
schedule "report" cron("0 9 * * 1") {
// Runs every Monday at 9am UTC
stats = generate_weekly_report()
send_email("team@company.com", "Weekly Report", stats)
}
route GET "/" => fn(req) { { status: "ok" } }
}The cron expression follows standard cron format: minute hour day month weekday. On Cloudflare, this generates a scheduled() handler. On Deno, it uses Deno.cron().
Platform Support
Scheduled jobs are best supported on Cloudflare and Deno. The analyzer emits W_UNSUPPORTED_SCHEDULE for Vercel, Lambda, and Bun targets where native cron support is limited. For those platforms, use an external scheduler to trigger an HTTP endpoint instead.
Queue Consumers
Process messages from a queue with the consume declaration:
edge {
target: "cloudflare"
queue EMAILS
consume EMAILS fn(messages) {
for msg in messages {
match msg.type {
"welcome" => send_welcome_email(msg.to)
"reset" => send_reset_email(msg.to, msg.token)
_ => print("Unknown email type: {msg.type}")
}
}
}
route POST "/api/send-email" => fn(req) {
// Enqueue instead of sending directly
EMAILS.send(req.body)
{ queued: true }
}
}Queue consumers run asynchronously. When a route handler pushes a message onto a queue, the consumer function processes it in the background. This is the edge equivalent of a background job system.
On Cloudflare, this generates a queue() handler in the Worker and entries in wrangler.toml. Queue consumers are currently only supported on Cloudflare; the analyzer warns you with W_UNSUPPORTED_CONSUME on other targets.
Edge + Security
Tova's security block integrates seamlessly with edge deployments. JWT authentication uses the Web Crypto API, which is available on all edge runtimes:
security {
auth: "jwt"
secret: "my-secret-key"
roles {
admin: ["manage_users", "view_analytics"]
user: ["view_own_profile"]
}
protect {
"/api/admin/*": ["admin"]
"/api/users/:id": ["user", "admin"]
}
}
edge {
target: "cloudflare"
secret JWT_SECRET
route GET "/api/public" => fn(req) {
{ message: "Anyone can see this" }
}
route GET "/api/admin/stats" => fn(req) {
// Only admin users reach here — security block handles auth
{ active_users: 1234, revenue: 56789 }
}
route GET "/api/users/:id" => fn(req, params) {
// User or admin access
{ id: params.id, name: "User {params.id}" }
}
}The compiler generates authentication and authorization middleware using crypto.subtle for JWT verification, which works across Cloudflare Workers, Deno Deploy, Vercel Edge Functions, and all other edge runtimes. No Node.js-specific JWT libraries needed.
Protected routes automatically return:
401 Unauthorizedwhen no valid JWT is present403 Forbiddenwhen the user lacks the required role
The auto_sanitize feature from the security block also works on edge -- response fields are filtered based on user roles and visible_to annotations.
Multi-Target Deployment
A single Tova file can produce deployments for multiple platforms. Use named edge blocks with different targets:
// Shared business logic
fn get_users() {
[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
}
edge "cloudflare" {
target: "cloudflare"
kv CACHE
route GET "/api/users" => fn(req) {
cached = CACHE.get("users")
if cached != null { cached }
else {
users = get_users()
CACHE.put("users", users)
users
}
}
}
edge "deno" {
target: "deno"
kv CACHE
route GET "/api/users" => fn(req) {
cached = CACHE.get("users")
if cached != null { cached }
else { get_users() }
}
}Build produces both outputs:
$ tova build app.tova
# Output: app.edge.cloudflare.js, app.edge.deno.jsBuilding and Deploying
Build your edge application:
$ tova build app.tova
# For Cloudflare: generates app.edge.js + wrangler.toml
# For Deno: generates app.edge.js (deploy with deployctl)
# For Vercel: generates app.edge.js (place in api/)
# For Lambda: generates app.edge.js (deploy with AWS CLI/SAM)
# For Bun: generates app.edge.js (run with bun)Cloudflare deployments also generate a wrangler.toml with KV namespaces, queue consumer bindings, and other configuration.
Part 3: Deploy Blocks
Infrastructure as Code
The deploy block lets you describe your deployment infrastructure directly in your Tova source file. Instead of maintaining separate Dockerfiles, nginx configs, and shell scripts, you declare what you need and Tova generates the provisioning scripts:
deploy "prod" {
server: "root@159.65.100.42"
domain: "myapp.com"
instances: 2
memory: "1gb"
}That's a complete production deployment configuration. Run tova deploy prod and Tova will:
- Infer infrastructure from your code (Bun runtime, Caddy reverse proxy, databases)
- Generate provisioning scripts (idempotent bash, systemd units, Caddy config)
- Deploy your application with zero-downtime rollouts
Deploy Block Syntax
Every deploy block requires a name (the environment) and two required fields:
deploy "prod" {
server: "root@example.com" // Required: SSH target
domain: "myapp.com" // Required: domain for reverse proxy + SSL
// Optional configuration with defaults:
instances: 2 // Number of app instances (default: 1)
memory: "1gb" // Memory limit per instance (default: "512mb")
branch: "main" // Git branch to deploy (default: "main")
health: "/healthz" // Health check endpoint (default: "/healthz")
health_interval: 30 // Seconds between health checks (default: 30)
health_timeout: 5 // Seconds before health check timeout (default: 5)
restart_on_failure: true // Auto-restart on crash (default: true)
keep_releases: 5 // Number of old releases to keep (default: 5)
}| Field | Type | Default | Purpose |
|---|---|---|---|
server | String | required | SSH target (user@host) |
domain | String | required | Domain for reverse proxy + auto-SSL |
instances | Int | 1 | Number of app processes |
memory | String | "512mb" | Memory limit per instance |
branch | String | "main" | Git branch to deploy from |
health | String | "/healthz" | Health check endpoint path |
health_interval | Int | 30 | Seconds between health checks |
health_timeout | Int | 5 | Health check timeout in seconds |
restart_on_failure | Bool | true | Auto-restart crashed processes |
keep_releases | Int | 5 | Old releases to keep for rollback |
Environment Variables
The env sub-block defines environment variables for the deployment:
deploy "prod" {
server: "root@example.com"
domain: "myapp.com"
env {
NODE_ENV: "production"
PORT: 3000
LOG_LEVEL: "info"
API_KEY: "your-api-key"
}
}These are written to .env.production on the server and loaded via the systemd EnvironmentFile directive.
Database Configuration
The db sub-block lets you override database settings for the deployment:
deploy "prod" {
server: "root@example.com"
domain: "myapp.com"
db {
postgres {
name: "myapp_db"
port: 5432
}
redis {
maxmemory: "512mb"
}
}
}Supported database engines: postgres, redis, sqlite. The provisioner automatically installs and configures each database on the target server.
Multiple Environments
Define separate deploy blocks for each environment:
deploy "prod" {
server: "root@prod.example.com"
domain: "myapp.com"
instances: 2
memory: "1gb"
env {
NODE_ENV: "production"
LOG_LEVEL: "warn"
}
db {
postgres { name: "myapp_prod" }
}
}
deploy "staging" {
server: "root@staging.example.com"
domain: "staging.myapp.com"
instances: 1
memory: "512mb"
branch: "develop"
env {
NODE_ENV: "staging"
LOG_LEVEL: "debug"
}
db {
postgres { name: "myapp_staging" }
}
}Deploy to a specific environment:
$ tova deploy prod # Deploy to production
$ tova deploy staging # Deploy to stagingInfrastructure Inference
The compiler scans your entire application to infer what infrastructure is needed. You don't have to specify it manually:
| Your Code Contains | Tova Provisions |
|---|---|
server { } block | Bun runtime, systemd service, Caddy reverse proxy |
server { db { type: "postgres" } } | PostgreSQL, database user, database |
server { db { type: "redis" } } | Redis installation and configuration |
browser { } block | Static file serving via Caddy |
security { auth { secret: env(...) } } | Required secrets validation |
deploy { domain: "..." } | Caddy auto-SSL via Let's Encrypt |
| WebSocket routes | Caddy WebSocket proxy configuration |
Deploy CLI Commands
$ tova deploy prod # Deploy to production
$ tova deploy prod --plan # Show what would happen (dry run)
$ tova deploy prod --rollback # Roll back to previous release
$ tova deploy prod --status # Check app and database status
$ tova deploy prod --logs # Tail application logs
$ tova deploy prod --logs --since "1 hour ago" # Recent logs
$ tova deploy prod --logs --instance 1 # Specific instance
$ tova deploy prod --ssh # SSH into the server
$ tova deploy prod --setup-git # Enable git push deployment
$ tova deploy prod --remove # Remove app from server
$ tova deploy --list --server root@host # List all apps on a serverThe --plan flag is especially useful -- it shows exactly what Tova would do without making any changes:
$ tova deploy prod --plan
Plan for "prod" deployment:
Target: root@159.65.100.42
Domain: myapp.com (auto-SSL via Let's Encrypt)
Instances: 2 (ports 3000, 3001)
Memory: 1gb per instance
Databases: postgres (myapp_db), redis
Health check: /healthz every 30sSecret Management
Manage deployment secrets without storing them in code:
$ tova env prod list # List secret names
$ tova env prod set JWT_SECRET=abc123 # Set a secretSecrets are stored in .env.production on the server and never committed to version control.
What Gets Generated
Behind the scenes, tova deploy generates:
- Provisioning script (
provision.sh) -- Idempotent bash that installs Bun, Caddy, databases, and configures the firewall - systemd service -- Auto-restart, memory limits, environment file loading
- Caddy config -- Reverse proxy with round-robin load balancing, auto-SSL, health checks, WebSocket support
- Release directory structure -- Symlink-based releases for instant rollback
Zero-Config Deployment
The deploy block embodies Tova's philosophy: declare what you want, let the compiler figure out how. You never write nginx configs, Dockerfiles, or CI/CD pipelines for simple deployments. For complex infrastructure, the generated scripts are a starting point you can customize.
Putting It All Together
CLI tools and edge functions often work together. You might build a CLI tool that deploys to an edge platform, or a CLI that manages edge-deployed resources:
// deploy-tool.tova — a CLI that manages edge deployments
cli {
name: "deploy"
version: "1.0.0"
description: "Deploy and manage edge applications"
fn push(app: String, --target: String = "cloudflare", --dry-run: Bool) {
if dry_run {
print(yellow("Dry run: would deploy {app} to {target}"))
return
}
print(bold("Deploying {app} to {target}..."))
await spin("Building...", fn() {
// build logic here
})
print(green("Deployed successfully!"))
}
fn status(app: String) {
print(bold("Status for {app}:"))
rows = [
["Region", "Status", "Latency"],
["us-east", green("healthy"), "12ms"],
["eu-west", green("healthy"), "8ms"],
["ap-south", yellow("degraded"), "45ms"]
]
table(rows)
}
fn logs(app: String, --tail: Bool, --lines: Int = 50) {
print(dim("Showing last {lines} log lines for {app}"))
// stream logs here
}
}Exercises
Exercise 16.1: Build a todo CLI tool with three commands: add (takes a task: String and optional --priority: String = "medium"), list (takes an optional --filter: String?), and done (takes an id: Int). Include colored output: high-priority tasks in red, medium in yellow, low in green.
Exercise 16.2: Create an edge block targeting Cloudflare that implements a URL shortener. Define a kv URLS binding. Add routes: POST /api/shorten (accepts a URL and returns a short code), GET /:code (looks up the code in KV and redirects). Add a schedule that runs daily to clean up expired links.
Exercise 16.3: Write a CLI tool called httpie (a simplified HTTP client) with a single command that takes a url: String positional argument and flags --method: String = "GET", --header: [String], and --body: String?. Use the single-command optimization so users can run httpie https://api.example.com directly without a subcommand.
Challenge
Build a deployment pipeline CLI + edge API in two files:
pipeline.tova -- A CLI tool with commands:
init-- scaffolds a new project withask()andchoose()promptsbuild-- compiles the project withprogress()output and--targetflag for platform selectiondeploy-- deploys withspin()animation, supports--dry-runand--verbosestatus-- shows deployment status across regions withtable()output
api.tova -- An edge block with:
target: "cloudflare"withkv DEPLOYSandsecret DEPLOY_TOKENPOST /api/deploy-- receives deployment payloads (protected by auth)GET /api/status/:app-- returns deployment status from KVschedule "cleanup" cron("0 0 * * 0")-- weekly cleanup of old deployments- A
securityblock protecting the deploy endpoint
The CLI tool should call the edge API. Design the interaction so that pipeline deploy my-app sends a request to the edge API, which stores the deployment record in KV and returns a status URL.
← Previous: Full-Stack Applications | Back to Mastering Tova →