Skip to content

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

Latest commit

 

History

3 Commits

Folders and files

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

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.

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

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:

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

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

CI

- run: npx @textsetu/cli@latest push
  env:
    TEXTSETU_API_KEY: ${{ secrets.TEXTSETU_API_KEY }}

# Block a release on incomplete translations
- run: npx @textsetu/cli@latest status --check --threshold 95
  env:
    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):

# 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

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