Skip to content

Repository files navigation

@textsetu/cli

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 push

Or install it: npm i -g @textsetu/cli, then textsetu <command>.

Commands

CommandWhat it does
textsetu initCreate a textsetu.json (prompts for what it can't guess)
textsetu loginAuthorize this machine in your browser
textsetu logoutRemove the stored credential for a server
textsetu whoamiShow which token is in use, and verify it still works
textsetu pushUpload source strings (--translations sends targets too)
textsetu pullDownload translations into your repo
textsetu statusPer-language completeness (--check gates a build on it)
textsetu formatsList 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.

Logging in

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-keyTEXTSETU_API_KEY → the stored file, so a workflow is never affected by a stored credential on the runner.

Tokens do not refresh. A 401 means run textsetu login again.

Configuration

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:

localeStylept-BR becomesUse for
bcp47(default)pt-BRweb, Xcode .lproj
underscorept_BRJava, Rails, gettext
two-letterptlanguage-only trees
android-pt-rBR, and "" for the sourceAndroid 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:

{ "localeStyle": "android", "localeMap": { "zh-Hans": "-b+zh+Hans" } }

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.

The lockfile

textsetu.lock is committed, and it buys two things:

  • push is incremental. A source file byte-identical to the last push is skipped — no request, no payload.
  • pull will not silently destroy a hand edit. If a target file changed since the last pull, the run is refused (exit 5) and every affected file is named. --force overwrites.

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, which adapters.determinism.test.ts pins for all 37 formats. If that test ever goes red, pull starts reporting drift that isn't there.

Exit codes

Part of the contract with CI — a pipeline can tell "translations aren't ready" from "the token expired".

CodeMeaning
0Success
1Unexpected error
2Bad config or bad flags
3Not authenticated, or the token was rejected
4status --check ran fine and the answer was "not complete enough"
5pull would have overwritten locally-edited files
130You cancelled a prompt (Ctrl-C / Esc). 128 + SIGINT

CI

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

Things worth knowing

  • push sends sources only by default.--translations is opt-in because pushing targets is the destructive direction: a stale local target overwrites work a reviewer already approved.
  • pull defaults to --status approved. If you see fewer strings than the web editor shows, that's why. --status all includes drafts and pending review.
  • --dry-run prints the resolved file plan for both push and pull — 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 direct push is refused and you must target a branch.

Verifying a change to this package

Unit tests cover the pure logic — path resolution, the lock decisions, batch packing, the device-flow state machine:

pnpm --filter @textsetu/cli test

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

End to end against a local server (pnpm dev + pnpm db:setup for the demo org):

#CheckExpect
1textsetu whoamitoken prefix, source, valid — N readable project(s)
2textsetu formats37 formats + the ambiguous-extension report
3textsetu init with >1 project, --non-interactiverefuses, lists ids, exit 2
3btextsetu init with >1 project, at a terminalprompts, then writes the config
3cas 3b, Ctrl-C at the promptCancelled., exit 130, no config written
4textsetu init --project <id> --format json-nestedwrites the config
5textsetu push --dry-runthe file plan, nothing uploaded
6textsetu pushkey/value counts matching status
7textsetu push againevery file skipped from the lock, zero requests
8textsetu pull --status allfiles at the configured paths, not fr.json
9textsetu pull againall unchanged
10hand-edit a target, textsetu pullrefuses, names the file, exit 5
11textsetu pull --forceoverwrites it
12textsetu status --check --threshold 100exit 4 on an incomplete project
13textsetu login --no-browserprints a code; the server records a TextSetu CLI request
14a config with three formats, push --translations --dry-runvalues/ for the source, values-fr/ for a target

License

MIT

About

Official CLI for TextSetu. Push source strings and pull translations from your repo or your CI.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages