Hướng dẫn 18
The CLI
Trang này chưa được dịch, nên đang hiển thị bằng tiếng Anh. Dịch trên GitHub
Trong trang này
tug makes and runs tug apps:
tug new <dir> make a new app in dir, ready to run
tug dev run the app, rebuilding and reloading it as it changes
tug gen write the TypeScript of the app's pages and routes
tug routes [text]
list the app's routes, or those whose path or name has text in it
tug lang <lang> write the texts the app says into lang/<lang>.json, to translate
tug migrate new <name>
write a new migration, migrations/<when>_<name>.sql, for the app to run
tug build build the app into one binary, with its frontend in it
tug key print a new key for APP_KEY
tug version print tug's versiontug <command> -h prints a command's flags. A command that fails says why
after tug:, and exits with status 1.
tug dev, tug gen, tug routes, tug lang and tug build run in the
app's directory, the one with its main package and its package.json,
and stop, saying which is missing, anywhere else. The directory needn't have a go.mod of its own,
as an app inside a bigger Go module doesn't.
tug dev, tug gen and tug build read the app's .env, and run go,
npm and the app with what it sets. A variable set in tug's own environment
wins over the file's, so ADDR=127.0.0.1:9000 tug dev runs the app there
whatever .env says. The file is lines of KEY=value:
# A comment is a line that starts with #.
APP_KEY=base64:...
export MAIL_FROM_NAME="The blog"A leading export and the quotes around a value are dropped, and blank
lines are skipped. A # after a value is part of the value. A line that
isn't KEY=value stops the command, which names the line. A missing
.env is the same as an empty one.
The app itself never reads .env. Built and deployed, it takes its
settings from its environment.
The variables tug itself uses:
ADDRandPORT: wheretug devruns the app.APP_URL: the app's address, whichtug devsets to where it shows the app, unless.envor the environment has one.TLS_DOMAINS,TLS_CERT,TLS_KEYandHTTP_ADDR: a deploy's HTTPS, whichtug devleaves out of the app's environment.TUG_GEN: set by tug when it runs the app for its types; seetug gen.NO_COLOR: when it's set,tug dev's labels aren't colored. They're only colored on a terminal.CGO_ENABLED: set to0bytug buildfor the binary.
tug dev and tug build run the frontend's scripts with the package
manager whose lockfile the app has: Bun for bun.lock or bun.lockb, pnpm
for pnpm-lock.yaml, Yarn for yarn.lock, and npm otherwise. tug new
installs with npm, and the starter's Dockerfile builds with it.
.tug/ is where tug works in the app. .tug/app is the app's binary, as
tug gen and tug dev build it; .tug/gen.json is the TypeScript the app
writes for tug gen; .tug/reload is the file tug dev writes to
reload the browser; and .tug/devtools has the app's entries for Inertia's
DevTools under tug dev (pages.md). The starter's
.gitignore and .dockerignore leave it out.
tug new [flags] <dir>-auth: with accounts: registering and verifying an email, logging in with a second factor if the user likes, or a passkey, resetting a password by email, and settings, a photo among them, with the users in SQLite and a frontend of Tailwind and shadcn's components.-postgres,-mysql: with-auth, the users in Postgres or in MySQL, rather than SQLite: the same app, with its SQL written for the database, and acompose.yamlthat runs it in development, which the.envnames, asDB_URL. An app has one of them at most.-vue,-svelte: the frontend in Vue or in Svelte, rather than React: the same app, with its pages as.vueor.sveltecomponents, and the same Go. An app has one of them at most.-ssr: with server-side rendering: a first visit's page is rendered on the server as well as in the browser, by Node, which runs beside the app.-module path: the app's Go module path. Default: the directory's name.-no-install: don't install the app's packages or write its types.-tug-dir dir: a checkout of tug to build the app against, rather than a release.
The flags and the directory can come in any order: tug new blog -auth
is tug new -auth blog.
tug new stops when dir is there and isn't empty. Otherwise it:
- works out which tug the app requires (below);
- writes the starter, from layers in tug's source, each laid over the
ones before it, whose files replace theirs of the same name and add
the rest:
cmd/tug/starter, the Go and what every frontend uses, then the frontend's own,react,vueorsvelte; and with-auth,starter-auth, thenreact-auth,vue-authorsvelte-auth, then the database's,sqlite,postgresormysql. The directory's name is the app's: inpackage.json, the page titles, and theappNameprop, and on Postgres or MySQL, its database's, as SQL takes a name without quotes; - writes
.env, readable by you alone, withAPP_KEY=base64:and 32 random bytes in base64, andAPP_DEBUG=true, and on Postgres or MySQL,DB_URL, the databasecompose.yamlruns; - unless
-no-installsays not to, runsgo mod tidyandnpm install, then builds the app and writes its types, astug gendoes, which needs no database running.
Each starter has an AGENTS.md, for a coding agent: how to change the
app as tug has it done, its rules, and the commands that check a change,
with the app's frontend, database and -ssr in it, and a link to this
guide at the tug the app requires: a release's tag, the commit of a
pseudo-version, or main for a checkout's. Its CLAUDE.md is
@AGENTS.md, which has Claude Code read it once the app has a
CLAUDE.md of its own, as Claude Code reads AGENTS.md itself only
while there's none.
After -no-install, run go mod tidy before tug dev, as tug new says:
the app doesn't build until that has filled in its go.mod and go.sum.
tug dev installs the npm packages itself when node_modules isn't
there, and writes the types.
With -auth, the Go files for accounts are added, auth.go,
verify.go, twofactor.go, passkeys.go, settings.go, photos.go,
tokens.go, broadcasts.go, notifications.go, mail.go, users.go
and jobs.go, with their tests, and the database's: db.go,
users_db.go, passkeys_db.go, jobs_db.go, throttles_db.go,
cache_db.go, tokens_db.go, broadcasts_db.go, notifications_db.go
and db_test.go, the SQL, and on Postgres or MySQL, compose.yaml. So is a frontend of Tailwind and shadcn's components:
its layouts, components and hooks, the pages in resources/js/pages/Auth
and resources/js/pages/Settings, and shadcn's components.json. The
plain starter's Layout.tsx (Layout.vue, Layout.svelte) is left out,
and most of its other files are the auth starter's own: main.go,
main_test.go, app.html, package.json, vite.config.ts,
tsconfig.json, app.tsx (app.ts), app.css, the pages it has,
Dockerfile, .env.example, .gitignore, .dockerignore, README.md
and AGENTS.md. Accounts goes through them.
With -ssr, either starter gets resources/js/ssr.tsx (ssr.ts in Vue
and Svelte) and ssr/.gitkeep,
and its main.go, main_test.go, app.html, package.json,
vite.config.ts, Dockerfile, .env.example, .gitignore,
.dockerignore and README.md render pages on the server too.
Server-side rendering goes through them.
The app's go.mod requires the tug that tug new is:
-
A release, installed with
go install github.com/cuonggt/tug/cmd/tug@latestor@v0.45.0, makes apps that require that version. -
A commit the go command fetched, as
go install github.com/cuonggt/tug/cmd/tug@<commit>does, makes apps that require its pseudo-version, which the go command can fetch for them too. The checksum in tug's build info says it came that way. -
A build from a checkout of tug, whose version is
(devel)or a pseudo-version, makes apps that build against that checkout:require github.com/cuonggt/tug v0.0.0 replace github.com/cuonggt/tug => /home/you/src/tugtug finds the checkout by its own source path, which the compiler records unless tug was built with
-trimpath. When it can't,tug newstops: "this tug isn't a release: pass -tug-dir with the checkout of tug to build the app against".
-tug-dir builds the app against the checkout it names, whichever tug
runs it. The directory's go.mod must be module github.com/cuonggt/tug,
or tug new stops, saying it isn't a checkout of tug.
tug devRuns the app for development. It has no flags.
-
It reads
.env, and installs the frontend's packages whennode_modulesisn't there. -
It starts Vite's dev server:
npm run dev. -
It picks the app's address (below), and passes it to the app as
ADDR, and asAPP_URL, where it shows it,http://localhost:8080, unless one is set already, withTUG_DEV=1, which turns on the app's entries for Inertia's DevTools (pages.md), and its mailbox, at/_tug/mail, wheremail.FromEnvkeeps the mail the app sends with noMAIL_HOST(Accounts), and tells an app with server-side rendering that the dev server renders its pages, so it runs no Node of its own. It leavesTLS_DOMAINS,TLS_CERT,TLS_KEYandHTTP_ADDRout, and says so, as a.envcopied from a deploy would have a laptop serve HTTPS on:443, with the deploy's certificates from Let's Encrypt (Deployment): undertug dev, the app serves HTTP. -
It builds the app into
.tug/app, writes its types astug gendoes, starts it, and waits for it to take connections. Then it says where the app is, and reloads the browser:tug │ serving http://localhost:8080, built in 268ms -
It waits for a change, and goes back to 4.
Its output, and that of what it runs, comes a line at a time, labeled
tug, app (the build's and the app's) or vite. Ctrl-C or SIGTERM stops
Vite and the app, and tug dev with them. Each runs in a process group of
its own, so that what it starts stops too: npm runs Vite as a child. On
Windows, which has no process groups, the process is killed.
With ADDR or PORT set, in .env or the environment, the app runs
where they say, as tug.ConfigFromEnv reads them. A port alone,
ADDR=:9000 or PORT=9000, is 127.0.0.1:9000 under tug dev. When
something else listens there, tug dev stops and says so.
Without either, it's 127.0.0.1:8080, or when that's taken, the next free
port up to 8099, as php artisan serve does:
tug │ 127.0.0.1:8080 is taken, so the app is on 127.0.0.1:8081It listens on 127.0.0.1 alone, so nothing outside the machine reaches it,
and it's shown at localhost, as http://localhost:8080: browsers make
passkeys for a domain, and not for an IP address. That's the app's
APP_URL too, which the links it makes start with, as in the auth
starter's mail, unless .env names another, as a tunnel's to a phone.
tug dev looks at the app's files every 300 milliseconds, rather than
depend on a library that watches them, for the ones the Go server needs a
rebuild for:
.gofiles,go.modandgo.sum;- templates Go embeds:
.html,.tmpland.gohtmlfiles; - the languages in
lang/, aslang/vi.json, and the migrations inmigrations/, which the binary embeds: a new migration runs as the app starts again.
It looks in every directory but node_modules, vendor, testdata and
public, and those whose names start with . or _. After a change, it
waits 100 milliseconds for the rest of the save, such as an editor saving
several files, or a formatter after it, and builds once:
tug │ main.go and 2 more changedThe frontend isn't among them: Vite watches it (below). Nor is .env,
which is read once, when tug dev starts: restart tug dev after changing
it.
- The build fails. The compiler's errors show under
app,tug devsays "the build failed: fix it, and tug builds again when it's saved", and the app from the last build that worked keeps running, so the browser still has a server to talk to. - The build works. The types are written again, but only the files
whose content changed, so that one that hasn't doesn't set off Vite, or
an editor. The old app gets SIGTERM, which lets it finish the requests
it's answering, and is killed if it's still there 5 seconds later. The
new one starts, and once it takes connections, the browser reloads. An
app that isn't listening within 10 seconds keeps running, but the
browser isn't reloaded. When the types can't be written,
tug devsays why and starts the app anyway. - The app stops by itself, as when
maincallslog.Fatal: "the app stopped (exit status 1): tug starts it again when a file is saved". - Vite stops:
tug devstops too.
The browser loads the page from the Go server, and its scripts from Vite.
The starter's vite.config.ts has a plugin called tug that ties the two
together while the dev server runs:
public/hot. Once Vite is listening, the plugin writes its address,http://localhost:5173, topublic/hot, and removes the file when Vite exits. The Go server'svitepackage reads that file each time it renders a page. While it's there, pages load their scripts from the dev server, with React's refresh; otherwise, from the build inpublic/build. So the Go server needn't restart when Vite starts or stops. The starters' Content-Security-Policy reads it too, and lets in the dev server's styles, images and fonts, and the connection it reloads the pages by, while it runs (Security headers)..tug/reload. The plugin watches this file, whichtug devwrites after each restart of the app, and has Vite tell the browser to reload the page.
A change to the frontend doesn't go through tug dev at all: Vite sends
the changed module to the browser, and a React component updates in place,
keeping its state. When tug dev writes resources/js/tug again, Vite
sees those files change too, and updates what imports them.
A public/hot left behind by a Vite that didn't exit cleanly points the
Go server at a dev server that isn't there, and the pages' scripts don't
load: delete it.
Vite's port is 5173, and fixed (strictPort), because public/hot and
server.origin name it: when another app's Vite has it, this one exits,
and tug dev with it. To run two apps at once, give one of them another
port in its vite.config.ts, in both devServer and server.port.
tug genWrites the TypeScript of the app's pages and named routes into
resources/js/tug, and says which files it wrote, or "the types are up to
date". It has no flags.
pages.tshas the props of each page declared withtug.Page, and of the error page,Config.ErrorPage; the props shared withShare; andPageProps<'Component'>, a page's props and the shared ones together.routes.tshas the named routes, androute(), which builds a route's path from its name and values, asapp.URLdoes in Go. A route without a name isn't in it.
TypeScript has what each Go type becomes.
tug learns the pages and routes by running the app, rather than by reading
its source: route groups and prefixes only exist at run time. tug gen
builds the app into .tug/app, with .env read as tug dev reads it, and
runs it with TUG_GEN set to a file, .tug/gen.json. app.Run sees
TUG_GEN, and writes the TypeScript there and returns, instead of serving.
Then tug gen writes each of the two files whose content has changed.
So whatever main does before app.Run, it does for tug gen too. The
starter's main reads APP_KEY, and stops without it:
tug: the app stopped before writing its types (exit status 1):
2026/09/25 16:33:11 session: APP_KEY isn't set; make one with `tug key`, and set APP_KEY to what it printsWhat only serving needs, main can leave out while tug.Generating()
says tug gen started the app. The auth starter's leaves its database
alone, and asks for no APP_URL, which only its links need, so tug gen
needs neither, as when tug new makes the app, or tug build builds it
in CI. Where there's no .env, as in CI, set what main needs:
APP_KEY=$(tug key) tug genThe app has 30 seconds to write its types. One that runs longer, or exits
without calling app.Run, stops tug gen with a question: "does main
call app.Run?".
tug new, tug dev and tug build write the types too, so tug gen by
hand is for changes made while tug dev isn't running. The starter's
.gitignore leaves resources/js/tug in, to commit with the Go it's
written from. A CI step can check that it's current, as tug's own CI does
for its example:
tug gen && git diff --exit-code resources/js/tugtug routes [-json] [text]Lists the app's routes, by path, then method: each one's method, path,
name, the struct it takes, the file and line of the app's that added it,
and its handler, as that line has it. With a text, only the routes whose
path or name has it, in any case. In an app made with tug new -auth:
$ tug routes login
METHOD PATH NAME TAKES ADDED HANDLER
GET /login login main.go:391 a.guestsOnly(loginPage)
POST /login login.store LoginInput main.go:392 a.guestsOnly(a.login)
POST /login/passkey login.passkey PasskeyInput main.go:394 a.guestsOnly(a.passkeyLogin)
POST /login/passkey/options login.passkey.options main.go:393 a.guestsOnly(a.passkeyLoginOptions)
GET /two-factor-challenge two-factor.login main.go:395 a.guestsOnly(twoFactorChallengePage)
POST /two-factor-challenge two-factor.login.store TwoFactorChallengeInput main.go:396 a.guestsOnly(a.twoFactorChallenge)- Every route of the app's, named or not, a group's, and one of any
method, as
ANY; not tug's own, as the misses' catch-all, or the endpoints of Inertia's DevTools. - Its handler as the app wrote it: a route's function is often a
wrapper's closure,
main.newApp.(*app).guestsOnly.func15, which says nothing of the handler inside it; the line that added the route says it, sotug routesreads it, on one line, a function literal without its body. A route a helper of the app's adds, whose line gives the helper's own variable, is listed by its function's name, and at the helper's line. TAKESis the struct the routeTakes(TypeScript), andADDEDwhere the app added it.
tug routes builds the app and runs it, as tug gen does, and the app
says its routes as it writes its types: the routes are the ones main
adds as it starts, with the app's .env, so a route the environment
leaves out, as the auth starter's /files without a disk of its own, is
left out here too. -json writes the same as JSON, with the function's
name beside the handler, for a script:
[
{
"method": "POST",
"path": "/login",
"name": "login.store",
"handler": "a.guestsOnly(a.login)",
"function": "main.newApp.(*app).guestsOnly.func15",
"takes": "LoginInput",
"file": "main.go",
"line": 392
}
]tug lang viWrites lang/vi.json with every text the app says to a person, in
English, for its translation, as Languages has them:
tug's own, as a form's errors, a 429's wait, and the statuses an error
page says; the messages of the app's validate.Rules; the names of the
fields its forms bind and check, by their label tags or their keys made
into words; the file types their uploads take, as "a PNG or JPEG image";
and the texts its Go gives T and Choice as they're written, in quotes.
wrote lang/vi.json: 96 texts to translate, of 96; one left empty is said in EnglishEach text is a key, with no words yet, which says it in English until
someone writes them. A file that's there keeps what it has, and gets the
texts it hasn't, so tug lang vi again, after the app has changed, adds
the new ones; a text the app no longer says stays, for its translator to
delete. A file with nothing to add is left as it is.
For tug's texts, and the app's rules, it builds the app and runs it as
tug gen does, so it needs what tug gen needs; for the rest, it reads
the app's Go, but its tests and the directories tug dev doesn't look in.
A text made as the app runs, as c.T(message) of a variable, it can't
see: a translator adds it by hand.
tug migrate new create_postsWrites a new migration, migrations/20261001093000_create_posts.sql,
named for when it's made, to the second, in UTC, and for what it does, in
lower-case letters, digits and underscores, for the change to the
database's tables in its SQL, and after its -- down line, what undoes
it (Migrations). Two made in one second take the next
second. It runs in the app's directory, where migrations/ is, and needs
no database: the app runs its migrations, as it starts and by its
binary's migrate command, with its own driver.
tug migrate: wrote migrations/20261001093000_create_posts.sqltug build [-o binary]-o binary: the binary to write, such as-o bin/blog. Default: the directory's name.
Builds the app into one binary with its frontend in it. It says each step as it starts it:
tug build: the types: it builds the app and writes its types, astug gendoes, so it needs whatmainneeds too.tug build: type-checking the frontend:npm run typecheck, whenpackage.jsonhas atypecheckscript. The starter's istsc --noEmit.tug build: the frontend:npm run build. Vite writes the build, with its manifest, topublic/build, which the Go server serves under/build/. With server-side rendering, the script runsvite build --ssrtoo, which writes the SSR build tossr/build.tug build: the binary:go build -trimpath -ldflags="-s -w", withCGO_ENABLED=0. That makes it static, so it runs with nothing else installed, and leaves out the debug information and the paths of the machine it was built on.
Then it says what it made:
tug build: blog, 9.6 MB, frontend includedA step that fails stops it, and names the command. The frontend is inside
because the starter's main.go embeds public/, with
//go:embed all:public, and app.html, and with server-side rendering
ssr/ too, which Node runs beside the app.
tug build makes a binary for the system it runs on. With GOOS or
GOARCH set for another, the first step can't run the app it has built,
and stops. The starter's Dockerfile builds on Linux, with the same
go build as step 4: Deployment covers it.
tug keyPrints a new key for APP_KEY: 32 random bytes, in base64, after
base64:, as Laravel writes them and the app reads them. It writes it
nowhere: .env has the one tug new made, and a deployed app's goes
where its platform keeps secrets. It's how to make a key to rotate to:
Encryption has the steps.
tug versionPrints tug's version: tug v0.45.0 for that release. A tug installed at a
commit prints its pseudo-version, and one built from a checkout prints
(devel) or a pseudo-version, with +dirty when the checkout had changes.
It's the version tug new goes by.
tug help, tug -h and tug --help print the list of commands.