Airway ships a scaffolding CLI. Install it globally with:
go install github.com/daqing/airway@latestThis gives you the airway command. Commands auto-load .env from the current
project root. Inside a project, the globally installed airway detects the
host application (go.mod requiring github.com/daqing/airway plus a main.go)
and transparently re-runs every project-scoped command through go run ., so
plugins, REPL models and Go-code migrations always come from the project's own
binary — one command to remember, airway <command>, works everywhere. The
proxy prints a proxying to project binary: go run . ... notice on stderr.
The proxy only runs when the globally installed CLI's version matches the
airway version the project pins in go.mod (a replace onto a local airway
checkout compares against that checkout's VERSION file instead); on a
mismatch the command aborts with an error instead of silently running the
project binary's older CLI logic. new, version and help always run
locally, as does everything outside a project; running go run . <command>
yourself remains equivalent.
airway new [--local[=path]] <module-path | directory> # scaffold a new project skeleton
airway server # start the HTTP server
airway db:create
airway db:drop
airway db:migrate [version]
airway db:rollback [step]
airway db:status
airway plugin:new <module-path> # scaffold a new plugin module
airway plugin:list
airway plugin:install <module>
airway js:add <pkg>[@version] # add a frontend npm dependency (no Node required)
airway js:install # install js.pkg.json deps into app/assets/js/vendor/
airway js:build # bundle the frontend into app/assets/dist
airway generate [action|api|model|migration|service|island|scaffold|cmd] [params]
airway schema:dump
airway schema:show
airway openapi:generate [--out path] # write the OpenAPI 3.2 document (default ./openapi.json)
airway templates:compile # regenerate the templ views, then `go generate ./...`
airway admin:generate [config/admin.toml] # generate the admin backend from a TOML table spec
airway admin:root <username> <password> # create an administrator (role admin)
airway admin:member <username> <password> [--role=r] # create a non-admin account (editor|viewer)
airway desktop:init [--force] # generate the Wails v3 desktop target in ./desktop (see docs/desktop.md)
airway upload /path/to/file
airway repl
airway version # or -v / --version; prints the VERSION file contentsRunning airway with no arguments prints usage.
airway new myapp # directory: myapp
airway new github.com/me/myapp # module path; directory is the last path segment
airway new /path/to/myapp # create at that local path; module: myapp
airway new sites/myapp # relative path: same rule (module: myapp)
airway new --local myapp # develop against the airway checkout in $PWD
airway new --local ~/src/airway myapp--local adds a replace github.com/daqing/airway => <checkout> to the new
project's go.mod, for developing the framework itself: the scaffolded code can
reference APIs that no published version contains yet. The target must be an
airway source checkout (its go.mod declares the framework module and it has a
VERSION file); bare --local uses the current directory. Running new
from inside the framework repository implies --local automatically — the
template comes from that working tree, so the project must depend on it too.
airway new generates a fresh project skeleton based on the framework's app/
scaffold, seeds .env from .env.example, runs go mod tidy, installs the
frontend dependencies (airway js:install), and prints the
follow-up steps:
cd myapp
# edit .env — set DSN and LISTEN
airway db:create
airway db:migrate
airway server # starts the HTTP serverA generated project's binary starts the HTTP server when run with no arguments
(or with server), and dispatches any other arguments to the built-in CLI.
airway server # or, from source: go run . serverThe framework repository's own main.go no longer starts the server by
default — use go run . server when developing Airway itself. The Docker image
already runs the binary with server.
Environment values always win over .env values: .env is loaded as a
fallback for keys the process environment does not set, so
LISTEN=0.0.0.0:1988 go run . server binds that address even when .env
defines LISTEN. The value is host:port (a bare port is rejected); the
default is :1900. The server starts without AIRWAY_ENV in the
environment as long as .env provides it.
Upload a local file using the storage configuration from .env:
airway upload /tmp/foo.pngThe source path becomes a root-relative storage key. In this example the key
is tmp/foo.png (shown as /tmp/foo.png by the command). The content size is
read from the file and its content type is determined from the extension or
file contents.
To choose the storage key explicitly, pass it before the local file path:
airway upload images/foo.png /tmp/foo.pngGenerators read the module path from the current directory's go.mod, so the
generated service/cmd code imports your project's own app/models and
app/services packages — no hard-coded framework paths.
airway generate api adminThis creates:
app/api/admin_api/routes.goapp/api/admin_api/index_action.go
Generated route shape:
func Routes(r *gin.RouterGroup) {
g := r.Group("/admin")
{
g.GET("/index", IndexAction)
}
}Use this when you want to create a new API namespace quickly.
airway generate action admin showThis creates:
app/api/admin_api/show_action.go
Use this when the API folder already exists and you only need a new endpoint handler.
airway generate model postThis creates:
app/models/post.go
The generated model includes:
ID,CreatedAt,UpdatedAtTableName()- REPL registration via
registerREPLModel
airway generate service post title:string published:boolThis creates:
app/services/post.go
The generated file includes:
FindPostCreatePostUpdatePostDeletePost
Field arguments use name:type format.
airway generate cmd post title publishedThis creates:
cmd/post.go
This generator is useful if your project exposes extra custom CLI helpers around generated services.
airway generate migration create_postsThis creates a pair of timestamped SQL files under db/migrate/:
<timestamp>_create_posts.up.sql— the forward migration<timestamp>_create_posts.down.sql— the rollback migration
Both files contain commented-out CREATE TABLE / DROP TABLE examples to get
you started; edit them to define your real schema.
The older Go DSL migration mechanism (schema.RegisterChange in
lib/migrate/schema) is still supported, but DSL migrations only take effect
when they are compiled into the binary that runs the migration. When the CLI
finds timestamp-named .go migration files under ./db/migrate, it prints a
warning to remind you of this.
airway db:migrateairway db:migrate 20260327120000airway db:rollbackairway db:rollback 3airway db:statusMigration commands read:
AIRWAY_DSNDSN
In normal local development, these values can come directly from your project's .env file because the CLI loads it automatically.
The migration commands use the current Airway DSN and work with the databases supported by the project, including PostgreSQL, MySQL, and SQLite.
Scaffold a new plugin module (a standalone Go module; see docs/plugin.md):
airway plugin:new im # directory: im, plugin name: im
airway plugin:new github.com/me/airway-im-plugin # name derived from the last path segmentUnlike plugin:list, plugin:new never touches the project binary — it just
writes files.
Plugins are optional feature modules enabled with blank imports in
plugins.go (see docs/plugin.md):
airway plugin:list # list registered plugins and mount paths
airway plugin:install <module> # install a plugin's SQL migrations, host/ tree, and deps/ directoryPlugins register at compile time, so inside a project the globally installed
airway re-runs these through go run . automatically (the project's binary
is what can see your enabled plugins); go run . plugin:list does the same
thing directly.
plugin:install takes the plugin's module path (e.g.
github.com/daqing/airway-im-plugin); the plugin name is derived from the
last path segment, same as plugin:new. It assigns fresh timestamps to the
copied migrations and skips files that are already installed; afterwards they
are ordinary migrations managed by db:migrate / db:rollback / db:status.
Frontend npm dependencies are managed without a Node toolchain: the CLI
talks to an npm-compatible registry directly and unpacks packages into
app/assets/js/vendor/ (node_modules-compatible layout for esbuild).
airway js:add preact # latest version, pinned exactly
airway js:add @tanstack/react-table@9.2.4 # explicit version
airway js:install # install per js.pkg.json (idempotent)
airway js:build # bundle app/assets/js/app.tsx into app/assets/distWith AIRWAY_ENV=local, airway server additionally builds the bundle in
memory: /assets/* serves the freshest build with ETag revalidation, and
editing any .ts/.tsx/.css file under app/assets/js (vendor/ excluded)
triggers an incremental rebuild plus a livereload page refresh via the app's
WebSocket. just build runs js:build before compiling the binary; the
dist/ output is committed, so deploying never requires the bundler.
js.pkg.json at the project root has two sections: deps (direct
dependencies, exact versions — hand-editable) and lock (the fully resolved
tree including transitive dependencies, each with a sha512 integrity; written
by js:add / js:install). js:install installs exactly the locked
versions and re-resolves only when the lock is missing or stale; already
installed packages are skipped.
The registry defaults to https://registry.npmjs.org; set
AIRWAY_JS_REGISTRY (or JS_REGISTRY) to use a mirror, e.g.
https://registry.npmmirror.com. The vendor/ directory is committed so a
fresh clone builds offline.
airway openapi:generate # write ./openapi.json (OpenAPI 3.2)
airway openapi:generate --out docs/api.jsonWrites a deterministic OpenAPI 3.2 document for every route registered on the
Gin engine (plugin routes included); the output is sorted, so regenerating
after a change keeps diffs clean. The file is a local build artifact
(git-ignored). A running server also serves the document live at
GET /openapi.json (under the URL_PREFIX when one is configured), with a
servers entry derived from the request host.
Routes without declarations are documented with the framework's default JSON
envelope; modules add request/response schemas in an openapi.go file — see
the OpenAPI guide for the declaration API and client-generation
recipes (openapi-typescript / orval for Vue 3 and React,
swift-openapi-generator for SwiftUI).
go run . replThe REPL only sees the models compiled into the binary you run — project models
register through registerREPLModel in app/models, which delegates to
github.com/daqing/airway/lib/replreg. Inside a project the globally installed
airway repl proxies to go run . repl for you; outside a project it only
sees the framework's built-in models.
Here is a minimal workflow for adding a posts feature from scratch.
airway generate migration create_postsThen edit the generated .up.sql file in db/migrate/ and define the table you need.
Example:
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Run the migration:
airway db:migrateairway generate model postThis creates app/models/post.go.
At this point you will usually extend the generated struct with your real fields, for example:
type Post struct {
ID sql.IdType `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Published bool `db:"published" json:"published"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}airway generate service post title:string published:boolThis creates app/services/post.go with basic CRUD helpers.
airway generate api post
airway generate action post create
airway generate action post showThis gives you:
app/api/post_api/routes.goapp/api/post_api/index_action.goapp/api/post_api/create_action.goapp/api/post_api/show_action.go
Open config/routes.go and import the generated package:
import (
"github.com/gin-gonic/gin"
"github.com/daqing/airway/app/api/post_api"
"github.com/daqing/airway/app/api/health_api"
"github.com/daqing/airway/app/websocket"
)Then register it inside apiGroupRoutes:
func apiGroupRoutes(r *gin.Engine) {
v1 := r.Group("/api/v1")
{
post_api.Routes(v1)
}
}With the generated default route file, you will get an endpoint like:
GET /api/v1/post/index
For example, create_action.go is only a scaffold. You still need to:
- define request params
- call
services.CreatePost(...) - return data through
render.OK(...)orrender.Error(...)
justOr:
go run . serverAt that point you have the full skeleton for:
- database migration
- model
- service
- API handlers
- route registration
- Generators do not overwrite existing files. If the target file already exists, the command returns
file already exists. generate apicreates files, but you still need to wire the generatedRoutes(...)into your router setup.generate serviceassumes your project has anapp/servicespackage.- Generated files are starting points. They are meant to be edited after creation.
airway generate scaffold post title:string produces the whole vertical
slice — model with fields, a dialect-aware migration (the auto-increment
primary key follows your configured DSN), the CRUD service, a JSON API
under /api/v1/posts, a templ page at /posts, a server-rendered detail
page at /posts/:id, a CRUD island (DataTable + modal form, wired to the
API through apiFetch), and a static export registration (export_posts.go:
the list page plus one detail page per row; see
docs/static-export.md). It also registers the routes in
config/routes.go. Afterwards run:
airway templates:compile # compile the .templ views (works even on a fresh scaffold)
airway js:build # bundle the new island
airway db:migrate # create the table
airway server # visit /posts
airway static:build # export the pages + detail pages as static HTML (needs DSN)airway generate island chart scaffolds a single interactive island under
app/assets/js/islands/; embed it with @assets.Island("chart", props).
airway admin:generate turns a single TOML file into a complete admin
backend: sign-in with cookie sessions, a dashboard with one card per
resource, sidebar navigation, and full CRUD (JSON API + type-aware CRUD
island) for every table. Config lives in config/admin.toml by default
(an alternate path can be passed as the only argument). Each top-level
table is one resource, keyed by its singular name; each field maps a
column name to a type:
[category]
name = "string"
sort_order = "integer"
parent_id = "references:category" # self-reference (explicit target)
[post]
title = "string"
body = "text"
views = "integer"
score = "float"
published = "boolean"
published_at = "datetime"
status = "enum:draft,published,archived"
cover = "attachment"
category_id = "references" # target inferred from the _id suffixSupported field types: string, text, integer, float, boolean,
datetime, enum:a,b,c, references[:table] and attachment. id,
created_at and updated_at are added to every table automatically and
must not be declared.
Run the generator, then the standard follow-ups:
airway admin:generate
airway templates:compile # compile the .templ views
airway js:build # bundle the CRUD islands
airway db:migrate # create the tables
airway admin:root admin # create the first administrator account
airway server # visit /adminWhat gets generated:
- One model per table in
app/models/(tables with references also getRelations()), plusadmin_user.go/admin_session.gofor auth. app/api/admin_api/: a self-registering resource file per table with CRUD actions and OpenAPI declarations, plus shared files (registry, routes, login/logout, attachment uploads throughstorage.Current()).app/views/admin/: the admin layout with sidebar, dashboard, login page, and one page per table hosting its CRUD island.- One migration pair per run (auth tables on the first run, then new tables in foreign-key dependency order).
Routes mount under /admin (pages) and /api/v1/admin (JSON API). Both
sit behind the AdminAuth middleware: pages redirect to /admin/login
when there is no valid session, API calls get a 401. Administrators are
created with airway admin:root <username> <password>, non-admin accounts
(editor/viewer) with airway admin:member — passwords are
bcrypt-hashed and sessions are stored server-side in admin_sessions.
Roles and hardening are built in:
- Roles —
admin(full access, sees the audit log),editor(default; read/write) andviewer(read-only: writes and uploads get a 403). Mutating routes sit behindAdminRequireWrite. - CSRF — the login form carries a double-submit token that must match
the
airway_admin_csrfcookie. - Rate limiting — five failed sign-ins for the same IP and username lock
the pair out for fifteen minutes (
lib/ratelimit). - Audit log — every create, update and delete is recorded with the
acting account; admins can review it at
/admin/audit-log. - Server-side lists — the list API supports
page,page_size,q(text search),sort/order(whitelisted columns) and exact-match filters on any declared field (?status=published). The CRUD island ships a search box, pagination and an Export CSV button. - Soft delete — declaring
deleted_at = "datetime"on a table makes destroy stamp the column instead of deleting; reads filter it out. - Display labels — an optional
[table.meta]section overrides the sidebar label (label) and per-field labels (labels), so the panel can speak any language the TOML does.
Re-running admin:generate after adding tables to the TOML is additive:
existing files are never rewritten, and the registry picks up new
resources automatically (sidebar and dashboard included). To regenerate an
existing table's code — after editing it in the TOML, or to pick up new
generator features — pass --force (or --force=table1,table2): it
rewrites that table's generated files and discards hand edits, but never
touches migrations, so schema changes still need a hand-written migration.
Removing a table from the TOML does not delete generated code.