Push source strings to TextSetu and pull translations back into your repo — from your machine or from CI.
npx @textsetu/cli@latest login
npx @textsetu/cli@latest init
npx @textsetu/cli@latest pushOr install it: npm i -g @textsetu/cli, then textsetu <command>.
| Command | What it does |
|---|---|
textsetu init | Create a textsetu.json (prompts for what it can't guess) |
textsetu login | Authorize this machine in your browser |
textsetu logout | Remove the stored credential for a server |
textsetu whoami | Show which token is in use, and verify it still works |
textsetu push | Upload source strings (--translations sends targets too) |
textsetu pull | Download translations into your repo |
textsetu status | Per-language completeness (--check gates a build on it) |
textsetu formats | List the file formats the server supports |
Global flags: --api-key, --api-url, --project, --config, --json,
--no-color, --non-interactive, --verbose.
init asks which project and which format when it cannot tell, but only at a
real terminal. With no TTY — CI, a pipe, --json, --non-interactive, or
CI=1 — it prints the options and exits 2 asking for a flag, because a prompt
there hangs forever with nothing explaining why. Passing --project and
--format never prompts either way.
textsetu login opens your browser, you approve the request, and the token is
stored in ~/.textsetu/auth.json (mode 0600) keyed by server URL. Nothing is
written into your repo.
In CI, skip login entirely and set TEXTSETU_API_KEY to a project token.
Precedence is --api-key → TEXTSETU_API_KEY → the stored file, so a workflow
is never affected by a stored credential on the runner.
Tokens do not refresh. A
401means runtextsetu loginagain.
textsetu.json, committed, no secrets — it names the server, never the
token:
{
"$schema": "https://docs.textsetu.com/schema/textsetu-1.json",
"version": 1,
"projectId": "…",
"sourceLocale": "en",
"targetLocales": ["fr", "de"],
"files": [
{ "format": "json-nested", "path": "locales/[locale].json" },
{
"format": "android-xml",
"path": "app/src/main/res/values[locale]/strings.xml",
"localeStyle": "android"
},
{
"format": "apple-strings",
"path": "ios/[locale].lproj/Localizable.strings"
}
]
}Omit targetLocales to mean "every language on the project" — that way adding a
language in the web app doesn't need a config change.
[locale] must appear exactly once per path. localeStyle chooses how a
language code becomes the substituted text:
localeStyle | pt-BR becomes | Use for |
|---|---|---|
bcp47(default) | pt-BR | web, Xcode .lproj |
underscore | pt_BR | Java, Rails, gettext |
two-letter | pt | language-only trees |
android | -pt-rBR, and "" for the source | Android res/values… |
android is the one source-aware style, and it has to be: Android puts the
default language in values/ and translations in values-fr/. That is why the
hyphen belongs to the token (values[locale]/) rather than the path.
Anything a style can't express goes in localeMap, which always wins:
format is required and never inferred..json alone maps to six different
formats (json-nested, json-flat, i18next, formatjs, go-i18n,
chrome-messages); guessing would silently import the wrong shape. Run
textsetu formats to see the catalog and which extensions are ambiguous.
textsetu.lock is committed, and it buys two things:
pushis incremental. A source file byte-identical to the last push is skipped — no request, no payload.pullwill not silently destroy a hand edit. If a target file changed since the last pull, the run is refused (exit5) and every affected file is named.--forceoverwrites.
It tracks whole files, not individual keys, because the 37 format parsers live server-side — the CLI moves files and the server parses them.
⚠️ This depends on the server rendering identical content to identical bytes, whichadapters.determinism.test.tspins for all 37 formats. If that test ever goes red,pullstarts reporting drift that isn't there.
Part of the contract with CI — a pipeline can tell "translations aren't ready" from "the token expired".
| Code | Meaning |
|---|---|
0 | Success |
1 | Unexpected error |
2 | Bad config or bad flags |
3 | Not authenticated, or the token was rejected |
4 | status --check ran fine and the answer was "not complete enough" |
5 | pull would have overwritten locally-edited files |
130 | You cancelled a prompt (Ctrl-C / Esc). 128 + SIGINT |
- run: npx @textsetu/cli@latest pushenv:
TEXTSETU_API_KEY: ${{ secrets.TEXTSETU_API_KEY }}# Block a release on incomplete translations
- run: npx @textsetu/cli@latest status --check --threshold 95env:
TEXTSETU_API_KEY: ${{ secrets.TEXTSETU_API_KEY }}On GitHub, prefer the action — it wraps these calls and can open the translations
pull request for you. See action/.
A project token (tsu_proj_…) is the right credential for CI: it is bound to
one project. Note it never counts as an approver, so on a project that requires
approval its pushes land as pending_review rather than live, and it cannot
complete a branch merge — use a personal access token for either.
pushsends sources only by default.--translationsis opt-in because pushing targets is the destructive direction: a stale local target overwrites work a reviewer already approved.pulldefaults to--status approved. If you see fewer strings than the web editor shows, that's why.--status allincludes drafts and pending review.--dry-runprints the resolved file plan for bothpushandpull— every path, its locale, its format, and what would happen to it. Reach for it whenever a path looks wrong.- Untranslated keys are exported as empty, not filled from the source. The fallback-to-source option isn't on the public API yet.
- Branches: every command takes
--branch <id>. On a project that protects its main branch, a directpushis refused and you must target a branch.
Unit tests cover the pure logic — path resolution, the lock decisions, batch packing, the device-flow state machine:
pnpm --filter @textsetu/cli testThe config JSON Schema published at $schema is DERIVED from the Zod schema, so
it cannot drift:
pnpm --filter @textsetu/cli schema:generate # regenerate schema/textsetu-1.json
pnpm --filter @textsetu/cli schema:check # fail if the committed copy is staleEnd to end against a local server (pnpm dev + pnpm db:setup for the demo
org):
| # | Check | Expect |
|---|---|---|
| 1 | textsetu whoami | token prefix, source, valid — N readable project(s) |
| 2 | textsetu formats | 37 formats + the ambiguous-extension report |
| 3 | textsetu init with >1 project, --non-interactive | refuses, lists ids, exit 2 |
| 3b | textsetu init with >1 project, at a terminal | prompts, then writes the config |
| 3c | as 3b, Ctrl-C at the prompt | Cancelled., exit 130, no config written |
| 4 | textsetu init --project <id> --format json-nested | writes the config |
| 5 | textsetu push --dry-run | the file plan, nothing uploaded |
| 6 | textsetu push | key/value counts matching status |
| 7 | textsetu push again | every file skipped from the lock, zero requests |
| 8 | textsetu pull --status all | files at the configured paths, not fr.json |
| 9 | textsetu pull again | all unchanged |
| 10 | hand-edit a target, textsetu pull | refuses, names the file, exit 5 |
| 11 | textsetu pull --force | overwrites it |
| 12 | textsetu status --check --threshold 100 | exit 4 on an incomplete project |
| 13 | textsetu login --no-browser | prints a code; the server records a TextSetu CLI request |
| 14 | a config with three formats, push --translations --dry-run | values/ for the source, values-fr/ for a target |
MIT
{ "localeStyle": "android", "localeMap": { "zh-Hans": "-b+zh+Hans" } }