tug v0.45.0

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
  1. What the commands share
  2. .env and the environment
  3. The package manager
  4. .tug
  5. tug new
  6. Which tug the app requires
  7. tug dev
  8. What it runs
  9. The address
  10. What it watches
  11. After a change
  12. How the browser follows
  13. tug gen
  14. tug routes
  15. tug lang
  16. tug migrate
  17. tug build
  18. tug key
  19. tug version

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 version

tug <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.

What the commands share

.env and the environment

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:

Shell
# 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:

  • ADDR and PORT: where tug dev runs the app.
  • APP_URL: the app's address, which tug dev sets to where it shows the app, unless .env or the environment has one.
  • TLS_DOMAINS, TLS_CERT, TLS_KEY and HTTP_ADDR: a deploy's HTTPS, which tug dev leaves out of the app's environment.
  • TUG_GEN: set by tug when it runs the app for its types; see tug gen.
  • NO_COLOR: when it's set, tug dev's labels aren't colored. They're only colored on a terminal.
  • CGO_ENABLED: set to 0 by tug build for the binary.

The package manager

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

.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

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 a compose.yaml that runs it in development, which the .env names, as DB_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 .vue or .svelte components, 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:

  1. works out which tug the app requires (below);
  2. 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, vue or svelte; and with -auth, starter-auth, then react-auth, vue-auth or svelte-auth, then the database's, sqlite, postgres or mysql. The directory's name is the app's: in package.json, the page titles, and the appName prop, and on Postgres or MySQL, its database's, as SQL takes a name without quotes;
  3. writes .env, readable by you alone, with APP_KEY=base64: and 32 random bytes in base64, and APP_DEBUG=true, and on Postgres or MySQL, DB_URL, the database compose.yaml runs;
  4. unless -no-install says not to, runs go mod tidy and npm install, then builds the app and writes its types, as tug gen does, 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.

Which tug the app requires

The app's go.mod requires the tug that tug new is:

  • A release, installed with go install github.com/cuonggt/tug/cmd/tug@latest or @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/tug

    tug finds the checkout by its own source path, which the compiler records unless tug was built with -trimpath. When it can't, tug new stops: "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 dev

tug dev

Runs the app for development. It has no flags.

What it runs

  1. It reads .env, and installs the frontend's packages when node_modules isn't there.

  2. It starts Vite's dev server: npm run dev.

  3. It picks the app's address (below), and passes it to the app as ADDR, and as APP_URL, where it shows it, http://localhost:8080, unless one is set already, with TUG_DEV=1, which turns on the app's entries for Inertia's DevTools (pages.md), and its mailbox, at /_tug/mail, where mail.FromEnv keeps the mail the app sends with no MAIL_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 leaves TLS_DOMAINS, TLS_CERT, TLS_KEY and HTTP_ADDR out, and says so, as a .env copied from a deploy would have a laptop serve HTTPS on :443, with the deploy's certificates from Let's Encrypt (Deployment): under tug dev, the app serves HTTP.

  4. It builds the app into .tug/app, writes its types as tug gen does, 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
  5. 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.

The address

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:8081

It 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.

What it watches

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:

  • .go files, go.mod and go.sum;
  • templates Go embeds: .html, .tmpl and .gohtml files;
  • the languages in lang/, as lang/vi.json, and the migrations in migrations/, 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 changed

The 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.

After a change

  • The build fails. The compiler's errors show under app, tug dev says "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 dev says why and starts the app anyway.
  • The app stops by itself, as when main calls log.Fatal: "the app stopped (exit status 1): tug starts it again when a file is saved".
  • Vite stops: tug dev stops too.

How the browser follows

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, to public/hot, and removes the file when Vite exits. The Go server's vite package 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 in public/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, which tug dev writes 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 gen

tug gen

Writes 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.ts has the props of each page declared with tug.Page, and of the error page, Config.ErrorPage; the props shared with Share; and PageProps<'Component'>, a page's props and the shared ones together.
  • routes.ts has the named routes, and route(), which builds a route's path from its name and values, as app.URL does 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 prints

What 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:

Shell
APP_KEY=$(tug key) tug gen

The 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:

Shell
tug gen && git diff --exit-code resources/js/tug

tug routes

tug 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, so tug routes reads 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.
  • TAKES is the struct the route Takes (TypeScript), and ADDED where 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:

JSON
[
  {
    "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

tug lang vi

Writes 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 English

Each 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

tug migrate new create_posts

Writes 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.sql

tug build

tug 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:

  1. tug build: the types: it builds the app and writes its types, as tug gen does, so it needs what main needs too.
  2. tug build: type-checking the frontend: npm run typecheck, when package.json has a typecheck script. The starter's is tsc --noEmit.
  3. tug build: the frontend: npm run build. Vite writes the build, with its manifest, to public/build, which the Go server serves under /build/. With server-side rendering, the script runs vite build --ssr too, which writes the SSR build to ssr/build.
  4. tug build: the binary: go build -trimpath -ldflags="-s -w", with CGO_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 included

A 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 key

tug key

Prints 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 version

tug version

Prints 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.

tug