@chesterbrains/translify-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -50
- package/dist/commands/init.js +82 -74
- package/dist/credentials.js +15 -4
- package/dist/detect/angular.js +80 -0
- package/dist/detect/flutter.js +22 -0
- package/dist/detect/i18next.js +11 -0
- package/dist/detect/index.js +16 -0
- package/dist/detect/locales.js +33 -0
- package/dist/detect/shared.js +8 -0
- package/dist/detect/xcode.js +93 -0
- package/dist/index.js +14 -13
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ In Translify, open your project's API keys, create a **secret key** (it starts w
|
|
|
47
47
|
Give the key to the CLI in one of two ways:
|
|
48
48
|
|
|
49
49
|
- set `TRANSLIFY_SECRET_KEY` (always wins; use this in CI), or
|
|
50
|
-
- run `translify login` and paste it. It is stored in `~/.config/translify/credentials` (or under `$XDG_CONFIG_HOME`).
|
|
50
|
+
- run `translify login` (or let `translify init` ask for it) and paste it. It is stored in `~/.config/translify/credentials` (or under `$XDG_CONFIG_HOME`).
|
|
51
51
|
|
|
52
52
|
Never commit a key. Delivery keys (public keys used by apps to download published translations) do not work here.
|
|
53
53
|
|
|
@@ -61,7 +61,7 @@ locales/
|
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
63
|
export TRANSLIFY_SECRET_KEY=sk_...
|
|
64
|
-
npx translify init # detects your layout, writes translify.json
|
|
64
|
+
npx translify init # detects your layout, asks for your key, writes translify.json
|
|
65
65
|
npx translify push --dry-run # show what would change, write nothing
|
|
66
66
|
npx translify push
|
|
67
67
|
npx translify pull
|
|
@@ -128,7 +128,11 @@ server converts both ways:
|
|
|
128
128
|
{ "files": [{ "pattern": "src/locale/messages.{locale}.xlf", "format": "xliff", "namespace": "web", "xliffDialect": "angular" }] }
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
`translify init` reads `angular.json` and writes this rule for you (one per distinct translation path, namespace = project name); `ng extract-i18n` must be run with `--format=xlf2 --out-file messages.<sourceLocale>.xlf` so the source file matches it.
|
|
132
|
+
|
|
133
|
+
Give every template string `i18n="@@<key>"` so the unit ids are your keys. A translation file's `trgLang` must be the
|
|
134
|
+
Translify locale code: with `"locales": { "it": "it-IT" }` the file is still `messages.it.xlf`, but it declares
|
|
135
|
+
`trgLang="it-IT"`, or `push` is refused with `File targets it but it-IT was requested`. What `pull` writes:
|
|
132
136
|
|
|
133
137
|
| Translify value | Angular unit |
|
|
134
138
|
|---|---|
|
|
@@ -169,6 +173,8 @@ iOS translations get placeholder validation (Xcode's own import accepts a target
|
|
|
169
173
|
"xliffDialect": "xcode", "xliffOriginal": "Sources/App/Localizable.xcstrings" }] }
|
|
170
174
|
```
|
|
171
175
|
|
|
176
|
+
`translify init` finds every `.xcstrings` and writes this rule for you (namespace = catalog name, `xliffOriginal` = its path; `Localization/{locale}.xliff` unless locale-named `.xliff` files already sit in one folder).
|
|
177
|
+
|
|
172
178
|
Workflow:
|
|
173
179
|
|
|
174
180
|
1. Seed once: `xcodebuild -exportLocalizations -localizationPath out -exportLanguage en`, copy
|
|
@@ -217,7 +223,7 @@ fields are refused, and so is any field whose name contains `key`, `secret` or `
|
|
|
217
223
|
| `files[].jsonStyle` | `nested` \| `flat` | no | Only for `json`. `nested` (the default) writes `{ "a": { "b": "…" } }`, `flat` writes `{ "a.b": "…" }`. |
|
|
218
224
|
| `files[].xliffDialect` | `angular` \| `xcode` | no | Only for `xliff`. `angular` writes `@angular/localize` placeholders on pull and reads `ng extract-i18n` output on push ([Angular (XLIFF)](#angular-xliff)); `xcode` writes XLIFF 1.2 for String Catalogs and reads `xcodebuild -exportLocalizations` output ([Xcode (XLIFF)](#xcode-xliff)). |
|
|
219
225
|
| `files[].xliffOriginal` | string | with `xliffDialect: "xcode"` | The String Catalog path Xcode knows, e.g. `Sources/App/Localizable.xcstrings`; written to `<file original>`. Xcode imports nothing when it does not match the project. Only with `xcode`. |
|
|
220
|
-
| `locales` | object | no | Maps a locale name on disk to a Translify locale code, e.g. `{ "en_US": "en-US" }`. Unmapped names pass through unchanged. Two names may not map to the same code, and a target may not itself be a mapped name. |
|
|
226
|
+
| `locales` | object | no | Maps a locale name on disk to a Translify locale code, e.g. `{ "en_US": "en-US" }`. Unmapped names pass through unchanged. Two names may not map to the same code, and a target may not itself be a mapped name. The map renames files only: an XLIFF's `trgLang` (2.0) or `target-language` (1.2), when present, must be the Translify code itself, or `push` is refused with `File targets it but it-IT was requested`. |
|
|
221
227
|
|
|
222
228
|
Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
|
|
223
229
|
otherwise the CLI stops before sending anything and names the files involved.
|
|
@@ -226,7 +232,7 @@ otherwise the CLI stops before sending anything and names the files involved.
|
|
|
226
232
|
|
|
227
233
|
| Command | What it does |
|
|
228
234
|
|---|---|
|
|
229
|
-
| `translify init` | Create `translify.json
|
|
235
|
+
| `translify init` | Create `translify.json`: detects i18next, Flutter, Angular and Xcode layouts, asks for your key (interactive, or `--yes`) |
|
|
230
236
|
| `translify login` | Store a secret key on this machine (interactive) |
|
|
231
237
|
| `translify push` | Upload source and target files |
|
|
232
238
|
| `translify pull` | Download translation files |
|
|
@@ -236,6 +242,7 @@ otherwise the CLI stops before sending anything and names the files involved.
|
|
|
236
242
|
|
|
237
243
|
Options:
|
|
238
244
|
|
|
245
|
+
- `init`: `-y/--yes` (no prompts: every detected rule, the suggested locale map, the key from `TRANSLIFY_SECRET_KEY` or a previous login; fails instead of overwriting), `--api-url <url>`
|
|
239
246
|
- `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
|
|
240
247
|
- `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
|
|
241
248
|
- `publish`: `--fail-on-withheld`, `--allow-errors`
|
|
@@ -300,48 +307,34 @@ When the project has **Require review** turned on in Translify:
|
|
|
300
307
|
|
|
301
308
|
## CI (GitHub Actions)
|
|
302
309
|
|
|
303
|
-
Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
|
|
310
|
+
Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`. Two ready-made workflows live in
|
|
311
|
+
[`recipes/github-actions/`](https://github.com/chesterbrains/translify-cli/tree/main/recipes/github-actions); copy the
|
|
312
|
+
one you need to `.github/workflows/` as it is.
|
|
304
313
|
|
|
305
|
-
|
|
314
|
+
- [`translify-push.yml`](https://github.com/chesterbrains/translify-cli/blob/main/recipes/github-actions/translify-push.yml)
|
|
315
|
+
runs `push --dry-run` on every pull request and keeps its output in one comment on the PR, updated on each push to the
|
|
316
|
+
branch (a refused push lands there too and fails the job); on `main` it pushes for real, with an optional `publish`
|
|
317
|
+
step. Key scope: `PUSH` (and `PUBLISH` for the optional step). Pull requests from forks are skipped: they have no
|
|
318
|
+
access to secrets.
|
|
319
|
+
- [`translify-release.yml`](https://github.com/chesterbrains/translify-cli/blob/main/recipes/github-actions/translify-release.yml)
|
|
320
|
+
is for apps that bundle translations at build time: `lint`, then `pull`, your build, then `publish` to the environment
|
|
321
|
+
chosen in the Actions tab (or on a tag). Key scopes: `PULL` and `PUBLISH`.
|
|
306
322
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
on:
|
|
310
|
-
push:
|
|
311
|
-
branches: [main]
|
|
312
|
-
jobs:
|
|
313
|
-
push:
|
|
314
|
-
runs-on: ubuntu-latest
|
|
315
|
-
steps:
|
|
316
|
-
- uses: actions/checkout@v4
|
|
317
|
-
- uses: actions/setup-node@v4
|
|
318
|
-
with: { node-version: 22 }
|
|
319
|
-
- run: npx translify push
|
|
320
|
-
env:
|
|
321
|
-
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
322
|
-
```
|
|
323
|
+
Both use `npx -y translify`, so they follow every 0.x release; pin a version (`npx -y @chesterbrains/translify-cli@0.6`) if a breaking
|
|
324
|
+
release must not turn the workflow red on its own.
|
|
323
325
|
|
|
324
326
|
Fail a pull request when local files drift from Translify:
|
|
325
327
|
|
|
326
328
|
```yaml
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
status:
|
|
331
|
-
runs-on: ubuntu-latest
|
|
332
|
-
steps:
|
|
333
|
-
- uses: actions/checkout@v4
|
|
334
|
-
- uses: actions/setup-node@v4
|
|
335
|
-
with: { node-version: 22 }
|
|
336
|
-
- run: npx translify status
|
|
337
|
-
env:
|
|
338
|
-
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
329
|
+
- run: npx -y translify status
|
|
330
|
+
env:
|
|
331
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
339
332
|
```
|
|
340
333
|
|
|
341
334
|
Fail CI when translations in Translify have placeholder errors:
|
|
342
335
|
|
|
343
336
|
```yaml
|
|
344
|
-
- run: npx translify lint --max-warnings 0
|
|
337
|
+
- run: npx -y translify lint --max-warnings 0
|
|
345
338
|
env:
|
|
346
339
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
347
340
|
```
|
|
@@ -350,20 +343,6 @@ Fail CI when translations in Translify have placeholder errors:
|
|
|
350
343
|
It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
|
|
351
344
|
project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
|
|
352
345
|
|
|
353
|
-
Pull and publish on deploy:
|
|
354
|
-
|
|
355
|
-
```yaml
|
|
356
|
-
- run: npx translify lint
|
|
357
|
-
env:
|
|
358
|
-
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
359
|
-
- run: npx translify pull
|
|
360
|
-
env:
|
|
361
|
-
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
362
|
-
- run: npx translify publish production
|
|
363
|
-
env:
|
|
364
|
-
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
365
|
-
```
|
|
366
|
-
|
|
367
346
|
## Exit codes
|
|
368
347
|
|
|
369
348
|
| Code | Meaning |
|
package/dist/commands/init.js
CHANGED
|
@@ -1,58 +1,13 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { assertSecret } from '../credentials.js';
|
|
4
|
+
import { detectLayout } from '../detect/index.js';
|
|
5
|
+
import { suggestLocaleMap } from '../detect/locales.js';
|
|
4
6
|
import { CliError, EXIT } from '../errors.js';
|
|
5
7
|
import { findFiles } from '../patterns.js';
|
|
6
8
|
const SCHEMA_URL = 'https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json';
|
|
7
|
-
const I18NEXT_ROOTS = ['locales', 'public/locales', 'src/locales', 'src/i18n'];
|
|
8
9
|
const EXAMPLE_RULE = { pattern: 'locales/{locale}/{namespace}.json', format: 'json', jsonStyle: 'nested' };
|
|
9
|
-
const
|
|
10
|
-
async function detectFlutter(cwd) {
|
|
11
|
-
let yaml;
|
|
12
|
-
try {
|
|
13
|
-
yaml = await readFile(join(cwd, 'l10n.yaml'), 'utf8');
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
yaml = undefined;
|
|
17
|
-
}
|
|
18
|
-
if (yaml === undefined) {
|
|
19
|
-
const arbs = await fg('lib/l10n/*.arb', { cwd, onlyFiles: true });
|
|
20
|
-
return arbs.length > 0 ? [{ pattern: 'lib/l10n/app_{locale}.arb', format: 'arb', namespace: 'app' }] : [];
|
|
21
|
-
}
|
|
22
|
-
const dir = unquote(/^arb-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? 'lib/l10n');
|
|
23
|
-
const template = unquote(/^template-arb-file:\s*(\S+)/m.exec(yaml)?.[1] ?? 'app_en.arb');
|
|
24
|
-
const prefix = /^(.*?)_[a-z]{2,3}(?:[_-](?:[A-Z]{2}|[0-9]{3}|[A-Z][a-z]{3}))*\.arb$/.exec(template)?.[1] ?? 'app';
|
|
25
|
-
return [{ pattern: `${dir}/${prefix}_{locale}.arb`, format: 'arb', namespace: 'app' }];
|
|
26
|
-
}
|
|
27
|
-
/** Recognises i18next and Flutter layouts; an empty list means "unknown, write an example". */
|
|
28
|
-
export async function detectLayout(cwd) {
|
|
29
|
-
const rules = [];
|
|
30
|
-
for (const root of I18NEXT_ROOTS) {
|
|
31
|
-
const found = await fg(`${root}/*/*.json`, { cwd, onlyFiles: true });
|
|
32
|
-
if (found.length > 0)
|
|
33
|
-
rules.push({ pattern: `${root}/{locale}/{namespace}.json`, format: 'json', jsonStyle: 'nested' });
|
|
34
|
-
}
|
|
35
|
-
return [...rules, ...(await detectFlutter(cwd))];
|
|
36
|
-
}
|
|
37
|
-
const normalize = (locale) => locale.toLowerCase().replaceAll('_', '-');
|
|
38
|
-
/** Disk locales that differ from a Translify locale only by case or separator (`en_US` → `en-US`). */
|
|
39
|
-
export function suggestLocaleMap(diskLocales, translifyLocales) {
|
|
40
|
-
const known = new Set(translifyLocales);
|
|
41
|
-
const disk = new Set(diskLocales);
|
|
42
|
-
const map = {};
|
|
43
|
-
const targets = new Set();
|
|
44
|
-
for (const locale of diskLocales) {
|
|
45
|
-
if (known.has(locale))
|
|
46
|
-
continue;
|
|
47
|
-
const target = translifyLocales.find((code) => normalize(code) === normalize(locale));
|
|
48
|
-
// A bijection that cannot chain: loadConfig rejects anything else.
|
|
49
|
-
if (target === undefined || targets.has(target) || disk.has(target))
|
|
50
|
-
continue;
|
|
51
|
-
map[locale] = target;
|
|
52
|
-
targets.add(target);
|
|
53
|
-
}
|
|
54
|
-
return map;
|
|
55
|
-
}
|
|
10
|
+
const NO_KEY_LINE = 'Wrote translify.json. The key is not stored there: run `translify login` or set TRANSLIFY_SECRET_KEY.';
|
|
56
11
|
const isHttpUrl = (value) => {
|
|
57
12
|
try {
|
|
58
13
|
return ['http:', 'https:'].includes(new URL(value).protocol);
|
|
@@ -70,35 +25,54 @@ async function exists(path) {
|
|
|
70
25
|
return false;
|
|
71
26
|
}
|
|
72
27
|
}
|
|
28
|
+
const label = (rule) => (rule.xliffDialect === undefined ? rule.pattern : `${rule.pattern} (${rule.xliffDialect})`);
|
|
73
29
|
export async function runInit(deps) {
|
|
74
|
-
if (!deps.isTty) {
|
|
75
|
-
deps.out('`translify init` needs an interactive terminal.
|
|
30
|
+
if (!deps.isTty && !deps.yes) {
|
|
31
|
+
deps.out('`translify init` needs an interactive terminal. Pass `--yes` to take the detected defaults, or write translify.json by hand.');
|
|
76
32
|
return EXIT.failed;
|
|
77
33
|
}
|
|
78
34
|
const target = join(deps.cwd, 'translify.json');
|
|
79
|
-
if (
|
|
80
|
-
deps.
|
|
35
|
+
if (await exists(target)) {
|
|
36
|
+
if (deps.yes) {
|
|
37
|
+
deps.out('translify.json already exists. Delete it first.');
|
|
38
|
+
return EXIT.failed;
|
|
39
|
+
}
|
|
40
|
+
if (!(await deps.prompt.confirm('translify.json already exists. Overwrite it?', false))) {
|
|
41
|
+
deps.out('Left translify.json unchanged.');
|
|
42
|
+
return EXIT.failed;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const apiUrl = (deps.apiUrl ?? (deps.yes ? deps.apiUrlDefault : await deps.prompt.input('Translify API URL', deps.apiUrlDefault))).trim();
|
|
46
|
+
if (!isHttpUrl(apiUrl)) {
|
|
47
|
+
deps.out('The API URL must start with http:// or https://. Nothing was written.');
|
|
81
48
|
return EXIT.failed;
|
|
82
49
|
}
|
|
83
|
-
|
|
50
|
+
// A typed key that is rejected throws here (exit 2): nothing is written and nothing is saved.
|
|
51
|
+
const me = await identify(deps, apiUrl);
|
|
52
|
+
if (me !== undefined) {
|
|
53
|
+
deps.out(`Project: ${me.project.name}, default locale ${me.project.defaultLocale ?? 'none'}; locales: ${me.locales.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
const detection = await detectLayout(deps.cwd);
|
|
56
|
+
const detected = detection.rules;
|
|
84
57
|
if (detected.length === 0) {
|
|
85
|
-
deps.out(`No i18next or
|
|
58
|
+
deps.out(`No i18next, Flutter, Angular or Xcode layout found in ${deps.cwd} (detection looks only in the current directory). Wrote an example rule (${EXAMPLE_RULE.pattern}): edit files[] to match your project.`);
|
|
86
59
|
}
|
|
87
60
|
else {
|
|
88
61
|
deps.out(`Found: ${detected.map((rule) => `${rule.pattern} (${rule.format})`).join(', ')}`);
|
|
89
62
|
}
|
|
63
|
+
for (const note of detection.notes)
|
|
64
|
+
deps.out(note);
|
|
90
65
|
let files = detected.length > 0 ? detected : [EXAMPLE_RULE];
|
|
91
|
-
if (detected.length > 1) {
|
|
92
|
-
const chosen = await deps.prompt.
|
|
93
|
-
files = detected.filter((rule) => rule
|
|
94
|
-
if (files.length === 0)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return EXIT.failed;
|
|
66
|
+
if (detected.length > 1 && !deps.yes) {
|
|
67
|
+
const chosen = new Set(await deps.prompt.checkbox('Which layouts hold your translations?', detected.map(label)));
|
|
68
|
+
files = detected.filter((rule) => chosen.has(label(rule)));
|
|
69
|
+
if (files.length === 0) {
|
|
70
|
+
deps.out('Nothing selected. Nothing was written.');
|
|
71
|
+
return EXIT.failed;
|
|
72
|
+
}
|
|
73
|
+
const dropped = detected.filter((rule) => !files.includes(rule));
|
|
74
|
+
if (dropped.length > 0)
|
|
75
|
+
deps.out(`Dropped: ${dropped.map((rule) => rule.pattern).join(', ')}`);
|
|
102
76
|
}
|
|
103
77
|
// A config that cannot resolve its own files would fail on the first push or pull: refuse to write it.
|
|
104
78
|
let matches;
|
|
@@ -111,21 +85,53 @@ export async function runInit(deps) {
|
|
|
111
85
|
deps.out(`${error.message} Nothing was written.`);
|
|
112
86
|
return EXIT.failed;
|
|
113
87
|
}
|
|
114
|
-
|
|
115
|
-
const me = await deps.fetchWhoami(apiUrl);
|
|
116
|
-
if (me !== undefined) {
|
|
117
|
-
deps.out(`Project: ${me.project.name}, default locale ${me.project.defaultLocale ?? 'none'}; locales: ${me.locales.join(', ')}`);
|
|
118
|
-
locales = await suggestMap(deps, matches, me.locales);
|
|
119
|
-
}
|
|
88
|
+
const locales = me === undefined ? {} : await suggestMap(deps, matches, me.locales);
|
|
120
89
|
const config = { $schema: SCHEMA_URL, apiUrl, files, locales };
|
|
121
90
|
// `locales` is omitted when empty; the schema defaults it.
|
|
122
91
|
const body = { ...config };
|
|
123
92
|
if (Object.keys(locales).length === 0)
|
|
124
93
|
delete body.locales;
|
|
125
|
-
|
|
126
|
-
deps.out(
|
|
94
|
+
const text = `${JSON.stringify(body, null, 2)}\n`;
|
|
95
|
+
deps.out(text.trimEnd());
|
|
96
|
+
if (!deps.yes && !(await deps.prompt.confirm('Write translify.json?', true))) {
|
|
97
|
+
deps.out('Nothing was written.');
|
|
98
|
+
return EXIT.failed;
|
|
99
|
+
}
|
|
100
|
+
await writeFile(target, text);
|
|
101
|
+
deps.out(me === undefined ? NO_KEY_LINE : 'Wrote translify.json.');
|
|
127
102
|
return EXIT.ok;
|
|
128
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The project this machine's key belongs to. A stored key that fails only costs the locale hints;
|
|
106
|
+
* a typed key that fails stops init (the error propagates), so a wrong key is never saved.
|
|
107
|
+
*/
|
|
108
|
+
async function identify(deps, apiUrl) {
|
|
109
|
+
const stored = await deps.findKey();
|
|
110
|
+
if (stored !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
return await deps.fetchWhoami(apiUrl, stored);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (!(error instanceof CliError))
|
|
116
|
+
throw error;
|
|
117
|
+
deps.out(error.exitCode === EXIT.auth
|
|
118
|
+
? 'Stored key was rejected; run `translify login`. Continuing without project info.'
|
|
119
|
+
: `Could not reach Translify (${error.message}). Continuing without project info.`);
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (deps.yes)
|
|
124
|
+
return undefined;
|
|
125
|
+
const typed = (await deps.prompt.secret('Secret key (sk_…), Enter to skip')).trim();
|
|
126
|
+
if (typed === '')
|
|
127
|
+
return undefined;
|
|
128
|
+
// Before any network call, and the error never echoes the value.
|
|
129
|
+
assertSecret(typed);
|
|
130
|
+
const me = await deps.fetchWhoami(apiUrl, typed);
|
|
131
|
+
const path = await deps.saveKey(typed);
|
|
132
|
+
deps.out(`Logged in to ${me.project.name} (${me.project.slug}) with scopes ${me.key.scopes.join(', ')}. Saved to ${path}.`);
|
|
133
|
+
return me;
|
|
134
|
+
}
|
|
129
135
|
async function suggestMap(deps, matches, translifyLocales) {
|
|
130
136
|
const diskLocales = [...new Set(matches.map((match) => match.locale))];
|
|
131
137
|
const missing = diskLocales.filter((locale) => !translifyLocales.includes(locale));
|
|
@@ -135,6 +141,8 @@ async function suggestMap(deps, matches, translifyLocales) {
|
|
|
135
141
|
}
|
|
136
142
|
if (Object.keys(map).length === 0)
|
|
137
143
|
return {};
|
|
144
|
+
if (deps.yes)
|
|
145
|
+
return map;
|
|
138
146
|
const text = Object.entries(map).map(([from, to]) => `${from} → ${to}`).join(', ');
|
|
139
147
|
return (await deps.prompt.confirm(`Map disk locales to Translify codes (${text})?`, true)) ? map : {};
|
|
140
148
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -10,6 +10,7 @@ export function credentialsPath(env = process.env) {
|
|
|
10
10
|
return join(appData, 'translify', 'credentials');
|
|
11
11
|
return join(absolute(env.XDG_CONFIG_HOME) ?? join(homedir(), '.config'), 'translify', 'credentials');
|
|
12
12
|
}
|
|
13
|
+
const SECRET_KEY_SHAPE = /^sk_[A-Za-z0-9_-]+$/;
|
|
13
14
|
// Messages never echo the value: a mistyped key is still a secret.
|
|
14
15
|
export const assertSecret = (key) => {
|
|
15
16
|
if (key.startsWith('pk_')) {
|
|
@@ -17,9 +18,15 @@ export const assertSecret = (key) => {
|
|
|
17
18
|
}
|
|
18
19
|
if (!key.startsWith('sk_'))
|
|
19
20
|
throw new CliError(EXIT.auth, 'Not a Translify secret key (expected sk_...).');
|
|
21
|
+
// A key is `sk_` plus base64url. Anything else (a pasted `…`, a space, a `/`) is caught here: fetch
|
|
22
|
+
// would refuse it as a header value, which reads as a connection error (TRLIFY-128).
|
|
23
|
+
if (!SECRET_KEY_SHAPE.test(key)) {
|
|
24
|
+
throw new CliError(EXIT.auth, 'The key has characters a secret key never has (only letters, digits, - and _ after sk_). Check TRANSLIFY_SECRET_KEY.');
|
|
25
|
+
}
|
|
20
26
|
return key;
|
|
21
27
|
};
|
|
22
|
-
|
|
28
|
+
/** The key this machine has (env var first, then the credentials file), or undefined. Malformed values throw. */
|
|
29
|
+
export async function findKey(env = process.env) {
|
|
23
30
|
const fromEnv = env.TRANSLIFY_SECRET_KEY?.trim();
|
|
24
31
|
if (fromEnv)
|
|
25
32
|
return assertSecret(fromEnv);
|
|
@@ -28,11 +35,15 @@ export async function resolveKey(env = process.env) {
|
|
|
28
35
|
stored = (await readFile(credentialsPath(env), 'utf8')).trim();
|
|
29
36
|
}
|
|
30
37
|
catch {
|
|
31
|
-
|
|
38
|
+
return undefined;
|
|
32
39
|
}
|
|
33
|
-
|
|
40
|
+
return stored === '' ? undefined : assertSecret(stored);
|
|
41
|
+
}
|
|
42
|
+
export async function resolveKey(env = process.env) {
|
|
43
|
+
const key = await findKey(env);
|
|
44
|
+
if (key === undefined)
|
|
34
45
|
throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
|
|
35
|
-
return
|
|
46
|
+
return key;
|
|
36
47
|
}
|
|
37
48
|
export async function saveKey(key, env = process.env) {
|
|
38
49
|
const path = credentialsPath(env);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { kebab, NONE, posixPath } from './shared.js';
|
|
4
|
+
const DEFAULT_PATTERN = 'src/locale/messages.{locale}.xlf';
|
|
5
|
+
const firstPath = (value) => {
|
|
6
|
+
const raw = typeof value === 'string' || Array.isArray(value) ? value : value.translation;
|
|
7
|
+
const first = Array.isArray(raw) ? raw[0] : raw;
|
|
8
|
+
return typeof first === 'string' ? first : undefined;
|
|
9
|
+
};
|
|
10
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
|
+
/**
|
|
12
|
+
* The last occurrence of `code` bounded by non-alphanumerics, so `es` in `resources/` is not it, and
|
|
13
|
+
* not the language part of a regional name (`pt` in `messages.pt-BR.xlf`), which would split the file.
|
|
14
|
+
*/
|
|
15
|
+
const toPattern = (path, code) => {
|
|
16
|
+
const bounded = new RegExp(`(?<![A-Za-z0-9])${escape(code)}(?![A-Za-z0-9]|[-_][A-Za-z0-9])`, 'g');
|
|
17
|
+
const matches = [...path.matchAll(bounded)];
|
|
18
|
+
const last = matches.at(-1);
|
|
19
|
+
if (last === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
return `${path.slice(0, last.index)}{locale}${path.slice(last.index + code.length)}`;
|
|
22
|
+
};
|
|
23
|
+
const rule = (pattern, namespace) => ({ pattern, format: 'xliff', namespace, xliffDialect: 'angular' });
|
|
24
|
+
/** `angular.json` with an `i18n` block per project; the translation paths give the pattern. */
|
|
25
|
+
export async function detectAngular(cwd) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(join(cwd, 'angular.json'), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return NONE;
|
|
32
|
+
}
|
|
33
|
+
let json;
|
|
34
|
+
try {
|
|
35
|
+
json = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { rules: [], notes: ['angular.json is not valid JSON, skipped.'] };
|
|
39
|
+
}
|
|
40
|
+
const projects = Object.entries(json.projects ?? {});
|
|
41
|
+
const first = projects[0];
|
|
42
|
+
if (first === undefined)
|
|
43
|
+
return NONE;
|
|
44
|
+
const rules = [];
|
|
45
|
+
const notes = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
let sourceLocale;
|
|
48
|
+
for (const [name, project] of projects) {
|
|
49
|
+
const i18n = project.i18n;
|
|
50
|
+
if (i18n === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
const namespace = kebab(name) || 'app';
|
|
53
|
+
const source = typeof i18n.sourceLocale === 'string' ? i18n.sourceLocale : i18n.sourceLocale?.code;
|
|
54
|
+
if (source !== undefined) {
|
|
55
|
+
sourceLocale ??= source;
|
|
56
|
+
notes.push(`Angular source locale for ${name}: ${source}.`);
|
|
57
|
+
}
|
|
58
|
+
for (const [code, value] of Object.entries(i18n.locales ?? {})) {
|
|
59
|
+
const path = firstPath(value);
|
|
60
|
+
if (path === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
const clean = posixPath(path);
|
|
63
|
+
const pattern = toPattern(clean, code);
|
|
64
|
+
if (pattern === undefined) {
|
|
65
|
+
notes.push(`${clean} does not contain its locale code ${code}; add that rule by hand.`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (seen.has(pattern))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(pattern);
|
|
71
|
+
rules.push(rule(pattern, namespace));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (rules.length === 0) {
|
|
75
|
+
rules.push(rule(DEFAULT_PATTERN, kebab(first[0]) || 'app'));
|
|
76
|
+
notes.push('angular.json has no usable i18n locales: the rule assumes the default src/locale/messages.<locale>.xlf layout.');
|
|
77
|
+
}
|
|
78
|
+
notes.push(`\`ng extract-i18n\` writes messages.xlf with no locale in its name; run it with --format=xlf2 --out-file messages.${sourceLocale ?? '<sourceLocale>'}.xlf so the source file matches the rule.`);
|
|
79
|
+
return { rules, notes };
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
const unquote = (value) => value.replace(/^["']|["']$/g, '').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
5
|
+
export async function detectFlutter(cwd) {
|
|
6
|
+
let yaml;
|
|
7
|
+
try {
|
|
8
|
+
yaml = await readFile(join(cwd, 'l10n.yaml'), 'utf8');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
yaml = undefined;
|
|
12
|
+
}
|
|
13
|
+
if (yaml === undefined) {
|
|
14
|
+
const arbs = await fg('lib/l10n/*.arb', { cwd, onlyFiles: true });
|
|
15
|
+
const rules = arbs.length > 0 ? [{ pattern: 'lib/l10n/app_{locale}.arb', format: 'arb', namespace: 'app' }] : [];
|
|
16
|
+
return { rules, notes: [] };
|
|
17
|
+
}
|
|
18
|
+
const dir = unquote(/^arb-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? 'lib/l10n');
|
|
19
|
+
const template = unquote(/^template-arb-file:\s*(\S+)/m.exec(yaml)?.[1] ?? 'app_en.arb');
|
|
20
|
+
const prefix = /^(.*?)_[a-z]{2,3}(?:[_-](?:[A-Z]{2}|[0-9]{3}|[A-Z][a-z]{3}))*\.arb$/.exec(template)?.[1] ?? 'app';
|
|
21
|
+
return { rules: [{ pattern: `${dir}/${prefix}_{locale}.arb`, format: 'arb', namespace: 'app' }], notes: [] };
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
const I18NEXT_ROOTS = ['locales', 'public/locales', 'src/locales', 'src/i18n'];
|
|
3
|
+
export async function detectI18next(cwd) {
|
|
4
|
+
const rules = [];
|
|
5
|
+
for (const root of I18NEXT_ROOTS) {
|
|
6
|
+
const found = await fg(`${root}/*/*.json`, { cwd, onlyFiles: true });
|
|
7
|
+
if (found.length > 0)
|
|
8
|
+
rules.push({ pattern: `${root}/{locale}/{namespace}.json`, format: 'json', jsonStyle: 'nested' });
|
|
9
|
+
}
|
|
10
|
+
return { rules, notes: [] };
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { detectAngular } from './angular.js';
|
|
2
|
+
import { detectFlutter } from './flutter.js';
|
|
3
|
+
import { detectI18next } from './i18next.js';
|
|
4
|
+
import { detectXcode } from './xcode.js';
|
|
5
|
+
const DETECTORS = [detectI18next, detectFlutter, detectAngular, detectXcode];
|
|
6
|
+
/** Looks only in `cwd`. An empty `rules` list means "unknown, write an example". */
|
|
7
|
+
export async function detectLayout(cwd) {
|
|
8
|
+
const rules = [];
|
|
9
|
+
const notes = [];
|
|
10
|
+
for (const detect of DETECTORS) {
|
|
11
|
+
const found = await detect(cwd);
|
|
12
|
+
rules.push(...found.rules);
|
|
13
|
+
notes.push(...found.notes);
|
|
14
|
+
}
|
|
15
|
+
return { rules, notes };
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const normalize = (locale) => locale.toLowerCase().replaceAll('_', '-');
|
|
2
|
+
const LANGUAGE = /^[a-z]{2,3}$/i;
|
|
3
|
+
const language = (code) => normalize(code).split('-')[0] ?? '';
|
|
4
|
+
/**
|
|
5
|
+
* Disk locales that differ from a Translify locale only by case or separator (`en_US` → `en-US`),
|
|
6
|
+
* then bare languages with exactly one regional match (`en` → `en-US`, but not with `en-GB` too).
|
|
7
|
+
*/
|
|
8
|
+
export function suggestLocaleMap(diskLocales, translifyLocales) {
|
|
9
|
+
const known = new Set(translifyLocales);
|
|
10
|
+
const disk = new Set(diskLocales);
|
|
11
|
+
const map = {};
|
|
12
|
+
const targets = new Set();
|
|
13
|
+
// A bijection that cannot chain: loadConfig rejects anything else.
|
|
14
|
+
const take = (locale, target) => {
|
|
15
|
+
if (target === undefined || targets.has(target) || disk.has(target))
|
|
16
|
+
return;
|
|
17
|
+
map[locale] = target;
|
|
18
|
+
targets.add(target);
|
|
19
|
+
};
|
|
20
|
+
for (const locale of diskLocales) {
|
|
21
|
+
if (known.has(locale))
|
|
22
|
+
continue;
|
|
23
|
+
take(locale, translifyLocales.find((code) => normalize(code) === normalize(locale)));
|
|
24
|
+
}
|
|
25
|
+
for (const locale of diskLocales) {
|
|
26
|
+
if (known.has(locale) || map[locale] !== undefined || !LANGUAGE.test(locale))
|
|
27
|
+
continue;
|
|
28
|
+
const regional = translifyLocales.filter((code) => code.includes('-') && language(code) === locale.toLowerCase());
|
|
29
|
+
if (regional.length === 1)
|
|
30
|
+
take(locale, regional[0]);
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const NONE = { rules: [], notes: [] };
|
|
2
|
+
/** `MyApp_Web` → `myapp-web`; an empty result is the caller's problem (it picks a fallback). */
|
|
3
|
+
export const kebab = (name) => name
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
6
|
+
.replace(/^-+|-+$/g, '');
|
|
7
|
+
/** Patterns are written with `/`; a Windows-edited path still becomes one. */
|
|
8
|
+
export const posixPath = (value) => value.replaceAll('\\', '/').replace(/^(\.\/)+/, '');
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, posix } from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { kebab, NONE } from './shared.js';
|
|
5
|
+
const IGNORE = ['**/.build/**', '**/Pods/**', '**/node_modules/**', '**/DerivedData/**', '**/.git/**', '**/*.xcloc/**'];
|
|
6
|
+
/** `LOCALE` from config.ts, with `_` allowed: that is how the locales map is used. */
|
|
7
|
+
const LOCALE_FILE = /^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$/;
|
|
8
|
+
const glob = async (cwd, pattern) => (await fg(pattern, { cwd, onlyFiles: true, dot: false, ignore: IGNORE })).sort();
|
|
9
|
+
/** The one directory holding every `.xliff`, when each is named after a locale; else undefined. */
|
|
10
|
+
const existingDir = async (cwd) => {
|
|
11
|
+
const xliffs = await glob(cwd, '**/*.xliff');
|
|
12
|
+
if (xliffs.length === 0)
|
|
13
|
+
return undefined;
|
|
14
|
+
const dirs = new Set(xliffs.map((path) => posix.dirname(path)));
|
|
15
|
+
if (dirs.size !== 1 || !xliffs.every((path) => LOCALE_FILE.test(posix.basename(path, '.xliff'))))
|
|
16
|
+
return undefined;
|
|
17
|
+
return [...dirs][0];
|
|
18
|
+
};
|
|
19
|
+
/** `localizable`, then `localizable-2` for a second catalog with the same basename. */
|
|
20
|
+
const namespaces = (catalogs, notes) => {
|
|
21
|
+
const counts = new Map();
|
|
22
|
+
const bases = new Map();
|
|
23
|
+
return catalogs.map((catalog) => {
|
|
24
|
+
const base = kebab(posix.basename(catalog, '.xcstrings')) || 'strings';
|
|
25
|
+
const count = (counts.get(base) ?? 0) + 1;
|
|
26
|
+
counts.set(base, count);
|
|
27
|
+
if (count === 1) {
|
|
28
|
+
bases.set(base, posix.basename(catalog));
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
31
|
+
const namespace = `${base}-${count}`;
|
|
32
|
+
notes.push(`Two catalogs are named ${bases.get(base) ?? catalog}: the ${count === 2 ? 'second' : `${count}th`} one is namespace ${namespace}.`);
|
|
33
|
+
return namespace;
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
const PROJECT_MARKER = /\.(xcodeproj|xcworkspace)$|^Package\.swift$/;
|
|
37
|
+
/**
|
|
38
|
+
* The directory Xcode resolves `<file original>` against: the nearest ancestor of the catalog (at or
|
|
39
|
+
* below `cwd`) holding an `.xcodeproj`, `.xcworkspace` or `Package.swift`; `.` when there is none.
|
|
40
|
+
*/
|
|
41
|
+
const projectRoot = async (cwd, catalog) => {
|
|
42
|
+
for (let dir = posix.dirname(catalog);; dir = posix.dirname(dir)) {
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = await readdir(join(cwd, dir));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
entries = [];
|
|
49
|
+
}
|
|
50
|
+
if (entries.some((entry) => PROJECT_MARKER.test(entry)))
|
|
51
|
+
return dir;
|
|
52
|
+
if (dir === '.')
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const rule = (pattern, namespace, original) => ({
|
|
57
|
+
pattern,
|
|
58
|
+
format: 'xliff',
|
|
59
|
+
namespace,
|
|
60
|
+
xliffDialect: 'xcode',
|
|
61
|
+
xliffOriginal: original,
|
|
62
|
+
});
|
|
63
|
+
/** Every `*.xcstrings` is a String Catalog; the XLIFF files Xcode imports live next to it, or will. */
|
|
64
|
+
export async function detectXcode(cwd) {
|
|
65
|
+
const catalogs = await glob(cwd, '**/*.xcstrings');
|
|
66
|
+
const only = catalogs[0];
|
|
67
|
+
if (only === undefined)
|
|
68
|
+
return NONE;
|
|
69
|
+
const notes = [];
|
|
70
|
+
const names = namespaces(catalogs, notes);
|
|
71
|
+
const originals = [];
|
|
72
|
+
const roots = new Set();
|
|
73
|
+
for (const catalog of catalogs) {
|
|
74
|
+
const root = await projectRoot(cwd, catalog);
|
|
75
|
+
originals.push(root === '.' ? catalog : posix.relative(root, catalog));
|
|
76
|
+
if (root !== '.')
|
|
77
|
+
roots.add(root);
|
|
78
|
+
}
|
|
79
|
+
for (const root of roots)
|
|
80
|
+
notes.push(`Xcode project in ${root}/: xliffOriginal is relative to it, as Xcode expects.`);
|
|
81
|
+
if (catalogs.length === 1) {
|
|
82
|
+
const dir = await existingDir(cwd);
|
|
83
|
+
const pattern = dir === undefined ? 'Localization/{locale}.xliff' : dir === '.' ? '{locale}.xliff' : `${dir}/{locale}.xliff`;
|
|
84
|
+
if (dir === undefined) {
|
|
85
|
+
notes.push('No XLIFF files yet: `translify pull` creates Localization/<locale>.xliff; `xcodebuild -importLocalizations` reads them.');
|
|
86
|
+
}
|
|
87
|
+
return { rules: [rule(pattern, names[0] ?? 'strings', originals[0] ?? only)], notes };
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
rules: catalogs.map((catalog, index) => rule(`Localization/${names[index] ?? 'strings'}/{locale}.xliff`, names[index] ?? 'strings', originals[index] ?? catalog)),
|
|
91
|
+
notes: ['Several String Catalogs: each gets its own folder under Localization/, created by `translify pull`.', ...notes],
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { confirm, input, password
|
|
2
|
+
import { checkbox, confirm, input, password } from '@inquirer/prompts';
|
|
3
3
|
import { Command, Option } from 'commander';
|
|
4
4
|
import { runInit } from './commands/init.js';
|
|
5
5
|
import { LINT_SEVERITIES, parseMaxWarnings, runLint } from './commands/lint.js';
|
|
@@ -9,7 +9,7 @@ import { runPull } from './commands/pull.js';
|
|
|
9
9
|
import { PUSH_STATUSES, runPush } from './commands/push.js';
|
|
10
10
|
import { loadConfig } from './config.js';
|
|
11
11
|
import { askConfirm } from './confirm.js';
|
|
12
|
-
import { resolveKey } from './credentials.js';
|
|
12
|
+
import { findKey, resolveKey, saveKey } from './credentials.js';
|
|
13
13
|
import { CliError, EXIT } from './errors.js';
|
|
14
14
|
import { Api } from './http.js';
|
|
15
15
|
import { VERSION } from './version.js';
|
|
@@ -44,25 +44,26 @@ const program = new Command('translify')
|
|
|
44
44
|
program
|
|
45
45
|
.command('init')
|
|
46
46
|
.description('Create translify.json for this repository')
|
|
47
|
-
.
|
|
47
|
+
.option('-y, --yes', 'no prompts: take the detected defaults; the key comes from TRANSLIFY_SECRET_KEY or a previous login; never overwrites')
|
|
48
|
+
.option('--api-url <url>', `Translify API URL (default: ${DEFAULT_API_URL})`)
|
|
49
|
+
.action(async (opts) => {
|
|
48
50
|
process.exitCode = await runInit({
|
|
49
51
|
cwd: process.cwd(),
|
|
50
52
|
isTty,
|
|
51
|
-
|
|
53
|
+
yes: opts.yes === true,
|
|
54
|
+
apiUrl: opts.apiUrl,
|
|
52
55
|
apiUrlDefault: DEFAULT_API_URL,
|
|
56
|
+
out,
|
|
53
57
|
prompt: {
|
|
54
58
|
input: async (message, defaultValue) => guarded(async () => input({ message, default: defaultValue }, { output: process.stderr }), ''),
|
|
55
59
|
confirm: async (message, defaultValue) => guarded(async () => confirm({ message, default: defaultValue }, { output: process.stderr }), false),
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
return await new Api(apiUrl, await resolveKey()).get('/cli/v1/whoami');
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
60
|
+
checkbox: async (message, options) => guarded(async () => checkbox({ message, choices: options.map((value) => ({ value, checked: true })) }, { output: process.stderr }), options),
|
|
61
|
+
// Ctrl+C here is "skip": init goes on without project info.
|
|
62
|
+
secret: async (message) => guarded(async () => password({ message, mask: '*' }, { output: process.stderr }), ''),
|
|
65
63
|
},
|
|
64
|
+
findKey: async () => findKey(),
|
|
65
|
+
saveKey: async (key) => saveKey(key),
|
|
66
|
+
fetchWhoami: async (apiUrl, key) => new Api(apiUrl, key).get('/cli/v1/whoami'),
|
|
66
67
|
});
|
|
67
68
|
});
|
|
68
69
|
program
|
package/dist/version.js
CHANGED