@chesterbrains/translify-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +242 -2
- package/dist/commands/init.js +140 -0
- package/dist/commands/login.js +18 -0
- package/dist/commands/publish.js +8 -0
- package/dist/commands/pull.js +114 -0
- package/dist/commands/push.js +107 -0
- package/dist/config.js +83 -0
- package/dist/confirm.js +12 -0
- package/dist/credentials.js +53 -0
- package/dist/errors.js +12 -0
- package/dist/http.js +178 -0
- package/dist/index.js +151 -0
- package/dist/patterns.js +59 -0
- package/dist/render.js +70 -0
- package/dist/version.js +3 -0
- package/package.json +43 -4
- package/schema/translify.schema.json +55 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { CliError, EXIT } from './errors.js';
|
|
5
|
+
// Same shapes the BE validates, so a bad value fails here with a field path instead of as a 4xx.
|
|
6
|
+
const NAMESPACE = /^[a-z0-9-]+$/;
|
|
7
|
+
const LOCALE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
8
|
+
const fileRule = z
|
|
9
|
+
.object({
|
|
10
|
+
pattern: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.refine((value) => value.includes('{locale}'), { message: 'must contain {locale}' })
|
|
14
|
+
.refine((value) => value.split('{locale}').length <= 2 && value.split('{namespace}').length <= 2, {
|
|
15
|
+
message: 'may use each of {locale} and {namespace} only once',
|
|
16
|
+
}),
|
|
17
|
+
format: z.enum(['json', 'xliff', 'arb']),
|
|
18
|
+
namespace: z.string().regex(NAMESPACE, 'must be lowercase kebab-case').max(64).optional(),
|
|
19
|
+
jsonStyle: z.enum(['nested', 'flat']).optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.superRefine((rule, ctx) => {
|
|
23
|
+
const hasPlaceholder = rule.pattern.includes('{namespace}');
|
|
24
|
+
if (rule.format === 'arb' && rule.namespace === undefined) {
|
|
25
|
+
ctx.addIssue({ code: 'custom', path: ['namespace'], message: 'ARB files need a fixed namespace' });
|
|
26
|
+
}
|
|
27
|
+
else if (!hasPlaceholder && rule.namespace === undefined) {
|
|
28
|
+
ctx.addIssue({ code: 'custom', path: ['namespace'], message: 'needed when the pattern has no {namespace}' });
|
|
29
|
+
}
|
|
30
|
+
if (rule.jsonStyle !== undefined && rule.format !== 'json') {
|
|
31
|
+
ctx.addIssue({ code: 'custom', path: ['jsonStyle'], message: 'only applies to format "json"' });
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.transform((rule) => (rule.format === 'json' ? { ...rule, jsonStyle: rule.jsonStyle ?? 'nested' } : rule));
|
|
35
|
+
const configSchema = z
|
|
36
|
+
.object({
|
|
37
|
+
$schema: z.string().optional(),
|
|
38
|
+
apiUrl: z.url(),
|
|
39
|
+
files: z.array(fileRule),
|
|
40
|
+
// filesystem locale -> Translify code; the target must already be a valid Translify locale
|
|
41
|
+
locales: z.record(z.string(), z.string().regex(LOCALE, 'must be a locale code such as en-US')).default({}),
|
|
42
|
+
})
|
|
43
|
+
.strict()
|
|
44
|
+
.superRefine((config, ctx) => {
|
|
45
|
+
// The map must be a bijection that cannot chain, or pull and push disagree on a cell's file.
|
|
46
|
+
const seen = new Map();
|
|
47
|
+
for (const [disk, code] of Object.entries(config.locales)) {
|
|
48
|
+
const first = seen.get(code);
|
|
49
|
+
if (first !== undefined) {
|
|
50
|
+
ctx.addIssue({ code: 'custom', path: ['locales', disk], message: `maps to ${code}, already the target of ${first}` });
|
|
51
|
+
}
|
|
52
|
+
seen.set(code, disk);
|
|
53
|
+
if (code !== disk && Object.hasOwn(config.locales, code)) {
|
|
54
|
+
ctx.addIssue({ code: 'custom', path: ['locales', disk], message: `target ${code} is itself a mapped disk locale` });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
export async function loadConfig(cwd) {
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = await readFile(join(cwd, 'translify.json'), 'utf8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new CliError(EXIT.failed, 'No translify.json here. Run `translify init` to create one.');
|
|
65
|
+
}
|
|
66
|
+
let json;
|
|
67
|
+
try {
|
|
68
|
+
json = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new CliError(EXIT.failed, 'translify.json is not valid JSON.');
|
|
72
|
+
}
|
|
73
|
+
if (typeof json === 'object' && json !== null && Object.keys(json).some((key) => /key|secret|token/i.test(key))) {
|
|
74
|
+
throw new CliError(EXIT.failed, 'Never put a key in translify.json — set TRANSLIFY_SECRET_KEY or run `translify login`.');
|
|
75
|
+
}
|
|
76
|
+
const parsed = configSchema.safeParse(json);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
const issue = parsed.error.issues[0];
|
|
79
|
+
throw new CliError(EXIT.failed, `translify.json: ${issue?.path.join('.')} ${issue?.message}`);
|
|
80
|
+
}
|
|
81
|
+
const { apiUrl, files, locales } = parsed.data;
|
|
82
|
+
return { apiUrl, files, locales };
|
|
83
|
+
}
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
/** Asks on stderr, so stdout stays clean for `--json`. Defaults to no; Ctrl+C counts as no. */
|
|
3
|
+
export const askConfirm = async (message) => {
|
|
4
|
+
try {
|
|
5
|
+
return await confirm({ message, default: false }, { output: process.stderr });
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof Error && error.name === 'ExitPromptError')
|
|
9
|
+
return false;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { CliError, EXIT } from './errors.js';
|
|
5
|
+
export function credentialsPath(env = process.env) {
|
|
6
|
+
// Empty or relative values would resolve against the cwd (often a repo); ignore them, as the XDG spec says.
|
|
7
|
+
const absolute = (value) => value !== undefined && isAbsolute(value) ? value : undefined;
|
|
8
|
+
const appData = absolute(env.APPDATA);
|
|
9
|
+
if (process.platform === 'win32' && appData !== undefined)
|
|
10
|
+
return join(appData, 'translify', 'credentials');
|
|
11
|
+
return join(absolute(env.XDG_CONFIG_HOME) ?? join(homedir(), '.config'), 'translify', 'credentials');
|
|
12
|
+
}
|
|
13
|
+
// Messages never echo the value: a mistyped key is still a secret.
|
|
14
|
+
export const assertSecret = (key) => {
|
|
15
|
+
if (key.startsWith('pk_')) {
|
|
16
|
+
throw new CliError(EXIT.auth, 'That is a publishable key: pk_ keys are for delivery. Create a secret (sk_) key in Translify.');
|
|
17
|
+
}
|
|
18
|
+
if (!key.startsWith('sk_'))
|
|
19
|
+
throw new CliError(EXIT.auth, 'Not a Translify secret key (expected sk_...).');
|
|
20
|
+
return key;
|
|
21
|
+
};
|
|
22
|
+
export async function resolveKey(env = process.env) {
|
|
23
|
+
const fromEnv = env.TRANSLIFY_SECRET_KEY?.trim();
|
|
24
|
+
if (fromEnv)
|
|
25
|
+
return assertSecret(fromEnv);
|
|
26
|
+
let stored;
|
|
27
|
+
try {
|
|
28
|
+
stored = (await readFile(credentialsPath(env), 'utf8')).trim();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
|
|
32
|
+
}
|
|
33
|
+
if (stored === '')
|
|
34
|
+
throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
|
|
35
|
+
return assertSecret(stored);
|
|
36
|
+
}
|
|
37
|
+
export async function saveKey(key, env = process.env) {
|
|
38
|
+
const path = credentialsPath(env);
|
|
39
|
+
const secret = assertSecret(key.trim());
|
|
40
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
41
|
+
await chmod(dirname(path), 0o700);
|
|
42
|
+
// Temp file created 0600, then renamed over the target: never world-readable, never truncated.
|
|
43
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
await writeFile(temp, `${secret}\n`, { mode: 0o600, flag: 'wx' });
|
|
46
|
+
await rename(temp, path);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await rm(temp, { force: true });
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return path;
|
|
53
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Minimal for Task 6.2; Task 6.3 owns this file and extends it.
|
|
2
|
+
export const EXIT = { ok: 0, failed: 1, auth: 2, quota: 3, network: 4 };
|
|
3
|
+
/** Every expected failure. `index.ts` prints `message` and exits with `exitCode`; anything else is a bug. */
|
|
4
|
+
export class CliError extends Error {
|
|
5
|
+
exitCode;
|
|
6
|
+
details;
|
|
7
|
+
constructor(exitCode, message, details) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
import { USER_AGENT } from './version.js';
|
|
3
|
+
const NOT_JSON = Symbol('notJson');
|
|
4
|
+
const MAX_RETRIES = 3;
|
|
5
|
+
const MAX_WAIT_MS = 60_000;
|
|
6
|
+
/** One request, not the whole command: a stalled server must not hang CI forever. */
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 180_000;
|
|
8
|
+
const UPGRADE = 'npm i -g @chesterbrains/translify-cli@latest, or for the standalone binary: curl -fsSL https://github.com/chesterbrains/translify-cli/releases/latest/download/install.sh | sh';
|
|
9
|
+
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
/** Exit code by the body's `code` first; the status only decides for a code we do not know. */
|
|
11
|
+
const CODE_EXIT = {
|
|
12
|
+
VALIDATION_FAILED: EXIT.failed,
|
|
13
|
+
LOCALE_NOT_FOUND: EXIT.failed,
|
|
14
|
+
NAMESPACE_NOT_FOUND: EXIT.failed,
|
|
15
|
+
JSON_STYLE_REQUIRES_JSON: EXIT.failed,
|
|
16
|
+
BAD_REQUEST: EXIT.failed,
|
|
17
|
+
ENVIRONMENT_NOT_FOUND: EXIT.failed,
|
|
18
|
+
ENVIRONMENT_ARCHIVED: EXIT.failed,
|
|
19
|
+
PROJECT_ARCHIVED: EXIT.failed,
|
|
20
|
+
SECRET_KEY_IN_URL: EXIT.failed,
|
|
21
|
+
KEY_INVALID: EXIT.auth,
|
|
22
|
+
KEY_REVOKED: EXIT.auth,
|
|
23
|
+
KEY_WRONG_KIND: EXIT.auth,
|
|
24
|
+
SCOPE_MISSING: EXIT.auth,
|
|
25
|
+
QUOTA_EXCEEDED: EXIT.quota,
|
|
26
|
+
INTERNAL_ERROR: EXIT.network,
|
|
27
|
+
DATABASE_ERROR: EXIT.network,
|
|
28
|
+
PUSH_TOO_LARGE: EXIT.network,
|
|
29
|
+
CLI_TOO_OLD: EXIT.network,
|
|
30
|
+
ORPHANS_CHANGED: EXIT.failed,
|
|
31
|
+
TRANSACTION_CONFLICT: EXIT.network,
|
|
32
|
+
RATE_LIMITED: EXIT.network,
|
|
33
|
+
};
|
|
34
|
+
const exitFor = (status, code) => {
|
|
35
|
+
const byCode = code === undefined ? undefined : CODE_EXIT[code];
|
|
36
|
+
if (byCode !== undefined)
|
|
37
|
+
return byCode;
|
|
38
|
+
if (status === 401 || status === 403)
|
|
39
|
+
return EXIT.auth;
|
|
40
|
+
if (status === 402)
|
|
41
|
+
return EXIT.quota;
|
|
42
|
+
if (status === 413 || status === 426 || status === 429 || status >= 500)
|
|
43
|
+
return EXIT.network;
|
|
44
|
+
return EXIT.failed;
|
|
45
|
+
};
|
|
46
|
+
const list = (value) => (Array.isArray(value) ? value.map(String).join(', ') : String(value));
|
|
47
|
+
const validationLines = (errors) => {
|
|
48
|
+
if (!Array.isArray(errors) || errors.length === 0)
|
|
49
|
+
return '';
|
|
50
|
+
return errors
|
|
51
|
+
.map((raw) => {
|
|
52
|
+
const e = typeof raw === 'object' && raw !== null ? raw : {};
|
|
53
|
+
const where = `${String(e.file ?? '?')}${e.line === undefined ? '' : `:${String(e.line)}`}`;
|
|
54
|
+
return `${where}${e.key === undefined ? '' : ` ${String(e.key)}`}: ${String(e.reason ?? 'invalid')}`;
|
|
55
|
+
})
|
|
56
|
+
.join('\n');
|
|
57
|
+
};
|
|
58
|
+
const describeBody = (status, body) => {
|
|
59
|
+
const text = Array.isArray(body.message) ? body.message.join('; ') : (body.message ?? '');
|
|
60
|
+
switch (body.code) {
|
|
61
|
+
case 'KEY_INVALID':
|
|
62
|
+
return 'The key was not recognised. Check TRANSLIFY_SECRET_KEY.';
|
|
63
|
+
case 'KEY_REVOKED':
|
|
64
|
+
return 'This key was revoked. Create a new secret key in Translify.';
|
|
65
|
+
case 'KEY_WRONG_KIND':
|
|
66
|
+
return 'This is not a secret key. The CLI needs an sk_ key.';
|
|
67
|
+
case 'SCOPE_MISSING':
|
|
68
|
+
return `This key lacks the ${String(body.required)} scope. Create a key that has it.`;
|
|
69
|
+
case 'QUOTA_EXCEEDED':
|
|
70
|
+
return `Plan limit reached for ${String(body.dimension)}: ${String(body.current)} of ${String(body.limit)} used.`;
|
|
71
|
+
case 'PUSH_TOO_LARGE':
|
|
72
|
+
return `Push too large (${String(body.entries ?? body.bytes)} over the limit of ${String(body.limit)}). Split it with --namespace.`;
|
|
73
|
+
case 'CLI_TOO_OLD':
|
|
74
|
+
return `This CLI is too old; ${String(body.minimum)} or newer is required. Upgrade: ${UPGRADE}`;
|
|
75
|
+
case 'VALIDATION_FAILED': {
|
|
76
|
+
const lines = validationLines(body.errors);
|
|
77
|
+
return lines === '' ? text || 'The push was rejected; nothing was written.' : `The push was rejected; nothing was written.\n${lines}`;
|
|
78
|
+
}
|
|
79
|
+
case 'LOCALE_NOT_FOUND':
|
|
80
|
+
return `Unknown locale(s): ${list(body.locales)}. They are not enabled in this project.`;
|
|
81
|
+
case 'NAMESPACE_NOT_FOUND':
|
|
82
|
+
return `Unknown namespace(s): ${list(body.namespaces)}.`;
|
|
83
|
+
case 'ORPHANS_CHANGED':
|
|
84
|
+
return 'The orphans changed since the dry run (keys were added, removed or published meanwhile). Nothing was written. Run translify push --prune again to review the new list.';
|
|
85
|
+
case 'TRANSACTION_CONFLICT':
|
|
86
|
+
return 'The server hit a transaction conflict. Try again.';
|
|
87
|
+
case 'RATE_LIMITED':
|
|
88
|
+
return 'Rate limited by the server; gave up after retries. Try again shortly.';
|
|
89
|
+
default:
|
|
90
|
+
if (status >= 500)
|
|
91
|
+
return `Translify server error (${status}). Try again shortly.`;
|
|
92
|
+
if (status === 429)
|
|
93
|
+
return 'Rate limited by the server; gave up after retries. Try again shortly.';
|
|
94
|
+
return text || `HTTP ${status}${body.code === undefined ? '' : ` ${body.code}`}`;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const explain = (status, body) => new CliError(exitFor(status, body.code), describeBody(status, body), body);
|
|
98
|
+
/** Retry-After is delta-seconds or an HTTP date; anything else falls back to exponential backoff. Capped. */
|
|
99
|
+
export const retryDelayMs = (header, attempt, now = Date.now()) => {
|
|
100
|
+
let ms = 2 ** attempt * 1000;
|
|
101
|
+
if (header !== null && header.trim() !== '') {
|
|
102
|
+
const seconds = Number(header);
|
|
103
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
104
|
+
ms = seconds * 1000;
|
|
105
|
+
else {
|
|
106
|
+
const date = Date.parse(header);
|
|
107
|
+
if (!Number.isNaN(date))
|
|
108
|
+
ms = Math.max(0, date - now);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return Math.min(ms, MAX_WAIT_MS);
|
|
112
|
+
};
|
|
113
|
+
const safeJson = (text) => {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(text);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { message: text.slice(0, 200), [NOT_JSON]: true };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
export class Api {
|
|
122
|
+
apiUrl;
|
|
123
|
+
key;
|
|
124
|
+
fetchImpl;
|
|
125
|
+
constructor(apiUrl, key, fetchImpl = fetch) {
|
|
126
|
+
this.apiUrl = apiUrl;
|
|
127
|
+
this.key = key;
|
|
128
|
+
this.fetchImpl = fetchImpl;
|
|
129
|
+
}
|
|
130
|
+
async get(path, query = {}) {
|
|
131
|
+
const search = new URLSearchParams(query).toString();
|
|
132
|
+
return this.send(`${path}${search ? `?${search}` : ''}`, { method: 'GET' });
|
|
133
|
+
}
|
|
134
|
+
async post(path, body) {
|
|
135
|
+
return body instanceof FormData
|
|
136
|
+
? this.send(path, { method: 'POST', body })
|
|
137
|
+
: this.send(path, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
headers: { 'content-type': 'application/json' },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async send(path, init) {
|
|
144
|
+
const url = `${this.apiUrl.replace(/\/$/, '')}${path}`;
|
|
145
|
+
const headers = {
|
|
146
|
+
...init.headers,
|
|
147
|
+
authorization: `Bearer ${this.key}`,
|
|
148
|
+
'user-agent': USER_AGENT,
|
|
149
|
+
accept: 'application/json',
|
|
150
|
+
};
|
|
151
|
+
for (let attempt = 0;; attempt += 1) {
|
|
152
|
+
let response;
|
|
153
|
+
try {
|
|
154
|
+
response = await this.fetchImpl(url, { ...init, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
158
|
+
throw new CliError(EXIT.network, `Request to ${this.apiUrl} timed out after ${REQUEST_TIMEOUT_MS / 1000}s. Try again, or split the push with --namespace.`);
|
|
159
|
+
}
|
|
160
|
+
// The cause is dropped on purpose: it can carry the request URL or headers.
|
|
161
|
+
throw new CliError(EXIT.network, `Could not reach ${this.apiUrl}. Check apiUrl and your connection.`);
|
|
162
|
+
}
|
|
163
|
+
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
164
|
+
await sleep(retryDelayMs(response.headers.get('retry-after'), attempt));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const text = await response.text();
|
|
168
|
+
const body = text === '' ? {} : safeJson(text);
|
|
169
|
+
if (response.ok) {
|
|
170
|
+
if (typeof body === 'object' && body !== null && NOT_JSON in body) {
|
|
171
|
+
throw new CliError(EXIT.network, `Unexpected non-JSON response from ${this.apiUrl}; check apiUrl in translify.json.`);
|
|
172
|
+
}
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
throw explain(response.status, (typeof body === 'object' && body !== null ? body : {}));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { confirm, input, password, select } from '@inquirer/prompts';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { runInit } from './commands/init.js';
|
|
5
|
+
import { runLogin } from './commands/login.js';
|
|
6
|
+
import { runPublish } from './commands/publish.js';
|
|
7
|
+
import { runPull } from './commands/pull.js';
|
|
8
|
+
import { runPush } from './commands/push.js';
|
|
9
|
+
import { loadConfig } from './config.js';
|
|
10
|
+
import { askConfirm } from './confirm.js';
|
|
11
|
+
import { resolveKey } from './credentials.js';
|
|
12
|
+
import { CliError, EXIT } from './errors.js';
|
|
13
|
+
import { Api } from './http.js';
|
|
14
|
+
import { VERSION } from './version.js';
|
|
15
|
+
const out = (line) => {
|
|
16
|
+
process.stdout.write(`${line}\n`);
|
|
17
|
+
};
|
|
18
|
+
const err = (line) => {
|
|
19
|
+
process.stderr.write(`${line}\n`);
|
|
20
|
+
};
|
|
21
|
+
const withApi = async () => {
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
const config = await loadConfig(cwd);
|
|
24
|
+
return { cwd, config, api: new Api(config.apiUrl, await resolveKey()) };
|
|
25
|
+
};
|
|
26
|
+
const isTty = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
27
|
+
const DEFAULT_API_URL = 'https://translify.tommasofeltrin.work/api';
|
|
28
|
+
// Prompts render on stderr so stdout stays clean; Ctrl+C is an ordinary exit, not a bug.
|
|
29
|
+
const guarded = async (run, fallback) => {
|
|
30
|
+
try {
|
|
31
|
+
return await run();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof Error && error.name === 'ExitPromptError')
|
|
35
|
+
return fallback;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const program = new Command('translify')
|
|
40
|
+
.version(VERSION)
|
|
41
|
+
.description('Sync translation files with Translify')
|
|
42
|
+
.option('--debug', 'print error details and stack traces');
|
|
43
|
+
program
|
|
44
|
+
.command('init')
|
|
45
|
+
.description('Create translify.json for this repository')
|
|
46
|
+
.action(async () => {
|
|
47
|
+
process.exitCode = await runInit({
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
isTty,
|
|
50
|
+
out,
|
|
51
|
+
apiUrlDefault: DEFAULT_API_URL,
|
|
52
|
+
prompt: {
|
|
53
|
+
input: async (message, defaultValue) => guarded(async () => input({ message, default: defaultValue }, { output: process.stderr }), ''),
|
|
54
|
+
confirm: async (message, defaultValue) => guarded(async () => confirm({ message, default: defaultValue }, { output: process.stderr }), false),
|
|
55
|
+
choose: async (message, options) => guarded(async () => select({ message, choices: options.map((value) => ({ value })) }, { output: process.stderr }), options[0] ?? ''),
|
|
56
|
+
},
|
|
57
|
+
fetchWhoami: async (apiUrl) => {
|
|
58
|
+
try {
|
|
59
|
+
return await new Api(apiUrl, await resolveKey()).get('/cli/v1/whoami');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
program
|
|
68
|
+
.command('login')
|
|
69
|
+
.description('Store a secret key for this machine')
|
|
70
|
+
.action(async () => {
|
|
71
|
+
let apiUrl = DEFAULT_API_URL;
|
|
72
|
+
try {
|
|
73
|
+
apiUrl = (await loadConfig(process.cwd())).apiUrl;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// No (valid) config: log in against the default API.
|
|
77
|
+
}
|
|
78
|
+
process.exitCode = await runLogin({
|
|
79
|
+
isTty,
|
|
80
|
+
apiUrl,
|
|
81
|
+
out,
|
|
82
|
+
promptSecret: async () => guarded(async () => password({ message: 'Secret key (sk_…)', mask: '*' }, { output: process.stderr }), ''),
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
program
|
|
86
|
+
.command('push')
|
|
87
|
+
.description('Upload source and target files')
|
|
88
|
+
.option('--dry-run', 'show what would change, write nothing')
|
|
89
|
+
.option('--overwrite-targets', 'overwrite existing target-locale translations')
|
|
90
|
+
.option('--prune', 'delete keys missing from your source files; published keys are kept; deletes exactly the keys you confirmed, and refuses if anything changed meanwhile (re-run to review)')
|
|
91
|
+
.option('-y, --yes', 'confirm destructive flags without prompting')
|
|
92
|
+
.option('--namespace <ns>', 'only this namespace')
|
|
93
|
+
.option('--json', 'machine-readable output')
|
|
94
|
+
.action(async (opts) => {
|
|
95
|
+
process.exitCode = await runPush(opts, {
|
|
96
|
+
...(await withApi()),
|
|
97
|
+
isTty: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
98
|
+
confirm: askConfirm,
|
|
99
|
+
out,
|
|
100
|
+
err,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
program
|
|
104
|
+
.command('publish <environment>')
|
|
105
|
+
.description('Publish an environment')
|
|
106
|
+
.option('--json', 'machine-readable output')
|
|
107
|
+
.action(async (environment, opts) => {
|
|
108
|
+
process.exitCode = await runPublish(environment, { api: (await withApi()).api, out, json: opts.json });
|
|
109
|
+
});
|
|
110
|
+
program
|
|
111
|
+
.command('pull')
|
|
112
|
+
.description('Download translation files')
|
|
113
|
+
.option('--from <source>', 'working (default) or env:<slug>')
|
|
114
|
+
.option('--locale <locale>', 'only this locale (as named on disk)')
|
|
115
|
+
.option('--namespace <ns>', 'only this namespace')
|
|
116
|
+
.option('--json', 'machine-readable output')
|
|
117
|
+
.action(async (opts) => {
|
|
118
|
+
process.exitCode = await runPull(opts, { ...(await withApi()), out });
|
|
119
|
+
});
|
|
120
|
+
program
|
|
121
|
+
.command('status')
|
|
122
|
+
.description('Exit 1 if local files differ from Translify (CI check)')
|
|
123
|
+
.option('--from <source>', 'working (default) or env:<slug>')
|
|
124
|
+
.option('--locale <locale>', 'only this locale (as named on disk)')
|
|
125
|
+
.option('--namespace <ns>', 'only this namespace')
|
|
126
|
+
.option('--json', 'machine-readable output')
|
|
127
|
+
.action(async (opts) => {
|
|
128
|
+
process.exitCode = await runPull({ ...opts, check: true }, { ...(await withApi()), out });
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await program.parseAsync(process.argv);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// Read from argv too: a failure can come before commander has parsed the global flags.
|
|
135
|
+
const debug = program.opts().debug === true || process.argv.includes('--debug');
|
|
136
|
+
if (error instanceof CliError) {
|
|
137
|
+
err(error.message);
|
|
138
|
+
// `details` is the parsed response body only; Api never puts the key or request headers in it.
|
|
139
|
+
if (debug && error.details !== undefined)
|
|
140
|
+
err(JSON.stringify(error.details, null, 2));
|
|
141
|
+
process.exitCode = error.exitCode;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
err(`Unexpected error (this is a bug in translify-cli): ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
if (debug && error instanceof Error && error.stack !== undefined)
|
|
146
|
+
err(error.stack);
|
|
147
|
+
else
|
|
148
|
+
err('Re-run with --debug for details.');
|
|
149
|
+
process.exitCode = EXIT.network;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/patterns.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { CliError, EXIT } from './errors.js';
|
|
3
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4
|
+
/** Patterns are written with `/`; tolerate `\` so a Windows-edited config still matches. */
|
|
5
|
+
const posix = (value) => value.replaceAll('\\', '/');
|
|
6
|
+
/** `locales/{locale}/{namespace}.json` → a glob and a regex with named groups. */
|
|
7
|
+
const compile = (pattern) => {
|
|
8
|
+
const parts = posix(pattern).split(/(\{locale\}|\{namespace\})/);
|
|
9
|
+
const glob = parts.map((part) => (part === '{locale}' || part === '{namespace}' ? '*' : part)).join('');
|
|
10
|
+
const regex = new RegExp(`^${parts
|
|
11
|
+
.map((part) => part === '{locale}'
|
|
12
|
+
? '(?<locale>[A-Za-z0-9_-]+)'
|
|
13
|
+
: part === '{namespace}'
|
|
14
|
+
? '(?<namespace>[a-z0-9-]{1,64})'
|
|
15
|
+
: escape(part))
|
|
16
|
+
.join('')}$`);
|
|
17
|
+
return { glob, regex };
|
|
18
|
+
};
|
|
19
|
+
/** Unmapped locales pass through; the server rejects an unknown one rather than creating it. */
|
|
20
|
+
export const toTranslifyLocale = (fsLocale, config) => config.locales[fsLocale] ?? fsLocale;
|
|
21
|
+
export const toFsLocale = (code, config) => Object.entries(config.locales).find(([, mapped]) => mapped === code)?.[0] ?? code;
|
|
22
|
+
export async function findFiles(config, cwd) {
|
|
23
|
+
const matches = [];
|
|
24
|
+
for (const rule of config.files) {
|
|
25
|
+
const { glob, regex } = compile(rule.pattern);
|
|
26
|
+
const paths = await fg(glob, { cwd, onlyFiles: true, dot: false });
|
|
27
|
+
for (const path of paths.map(posix).sort()) {
|
|
28
|
+
const groups = regex.exec(path)?.groups;
|
|
29
|
+
if (groups?.locale === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
const namespace = groups.namespace ?? rule.namespace;
|
|
32
|
+
if (namespace === undefined)
|
|
33
|
+
continue;
|
|
34
|
+
matches.push({ path, locale: toTranslifyLocale(groups.locale, config), namespace, rule });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// One file per (locale, namespace) cell, one rule per file; otherwise push sends a cell twice and pull skips one.
|
|
38
|
+
const byPath = new Map();
|
|
39
|
+
const byCell = new Map();
|
|
40
|
+
for (const match of matches) {
|
|
41
|
+
const samePath = byPath.get(match.path);
|
|
42
|
+
if (samePath !== undefined) {
|
|
43
|
+
throw new CliError(EXIT.failed, `${match.path} is matched by more than one files rule.`);
|
|
44
|
+
}
|
|
45
|
+
const cell = `${match.locale}\u0000${match.namespace}`;
|
|
46
|
+
const sameCell = byCell.get(cell);
|
|
47
|
+
if (sameCell !== undefined) {
|
|
48
|
+
throw new CliError(EXIT.failed, `${sameCell.path} and ${match.path} both map to locale ${match.locale}, namespace ${match.namespace}. Fix the "locales" map or remove one file.`);
|
|
49
|
+
}
|
|
50
|
+
byPath.set(match.path, match);
|
|
51
|
+
byCell.set(cell, match);
|
|
52
|
+
}
|
|
53
|
+
return matches;
|
|
54
|
+
}
|
|
55
|
+
export function pathFor(rule, translifyLocale, namespace, config) {
|
|
56
|
+
return posix(rule.pattern)
|
|
57
|
+
.replaceAll('{locale}', toFsLocale(translifyLocale, config))
|
|
58
|
+
.replaceAll('{namespace}', namespace);
|
|
59
|
+
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
const MAX_ORPHANS = 20;
|
|
3
|
+
const MAX_UNKNOWN = 5;
|
|
4
|
+
/** Totals across locales, for `--json`. */
|
|
5
|
+
export function summarizePush(res) {
|
|
6
|
+
const summary = {
|
|
7
|
+
dryRun: res.dryRun,
|
|
8
|
+
created: 0,
|
|
9
|
+
updated: 0,
|
|
10
|
+
unchanged: 0,
|
|
11
|
+
skippedFilled: 0,
|
|
12
|
+
unknownKeys: 0,
|
|
13
|
+
orphans: res.orphans.length,
|
|
14
|
+
publishedOrphans: res.orphans.filter((orphan) => orphan.published).length,
|
|
15
|
+
pruned: res.pruned,
|
|
16
|
+
};
|
|
17
|
+
for (const counts of Object.values(res.locales)) {
|
|
18
|
+
summary.created += counts.created;
|
|
19
|
+
summary.updated += counts.updated;
|
|
20
|
+
summary.unchanged += counts.unchanged;
|
|
21
|
+
summary.skippedFilled += counts.skippedFilled;
|
|
22
|
+
summary.unknownKeys += counts.unknownKeys.length;
|
|
23
|
+
}
|
|
24
|
+
return summary;
|
|
25
|
+
}
|
|
26
|
+
export function renderPush(res, view = {}) {
|
|
27
|
+
const lines = [res.dryRun ? pc.yellow('Dry run: nothing was written.') : pc.green('Pushed.')];
|
|
28
|
+
for (const [locale, c] of Object.entries(res.locales).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
29
|
+
let line = ` ${pc.bold(locale.padEnd(8))} ${pc.green(`${c.created} created`)}, ${pc.cyan(`${c.updated} updated`)}, ` +
|
|
30
|
+
pc.dim(`${c.unchanged} unchanged`);
|
|
31
|
+
if (c.skippedFilled > 0)
|
|
32
|
+
line += pc.dim(`, ${c.skippedFilled} kept (already translated; --overwrite-targets replaces them)`);
|
|
33
|
+
lines.push(line);
|
|
34
|
+
if (c.unknownKeys.length > 0) {
|
|
35
|
+
const more = c.unknownKeys.length > MAX_UNKNOWN ? ', …' : '';
|
|
36
|
+
lines.push(pc.yellow(` ${c.unknownKeys.length} unknown key(s) skipped: ${c.unknownKeys.slice(0, MAX_UNKNOWN).join(', ')}${more}`));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// `pruned` is what a dry run would delete or a real push did delete: unpublished orphans only.
|
|
40
|
+
const unpublished = res.orphans.filter((orphan) => !orphan.published);
|
|
41
|
+
const published = res.orphans.filter((orphan) => orphan.published);
|
|
42
|
+
const listed = unpublished;
|
|
43
|
+
const names = (list) => {
|
|
44
|
+
for (const orphan of list.slice(0, MAX_ORPHANS))
|
|
45
|
+
lines.push(` ${orphan.namespace}:${orphan.key}`);
|
|
46
|
+
if (list.length > MAX_ORPHANS)
|
|
47
|
+
lines.push(` …and ${list.length - MAX_ORPHANS} more`);
|
|
48
|
+
};
|
|
49
|
+
if (listed.length > 0) {
|
|
50
|
+
const header = res.pruned === 0
|
|
51
|
+
? `${listed.length} key(s) in Translify are not in your source files:`
|
|
52
|
+
: res.dryRun
|
|
53
|
+
? `${listed.length} key(s) not in your source files would be deleted:`
|
|
54
|
+
: `Deleted ${res.pruned} key(s) that were not in your source files${listed.length > res.pruned ? ` (${listed.length - res.pruned} kept: published during the push)` : ''}:`;
|
|
55
|
+
lines.push(pc.yellow(` ${header}`));
|
|
56
|
+
names(listed);
|
|
57
|
+
if (res.pruned === 0 && view.prune !== true)
|
|
58
|
+
lines.push(pc.dim(' Use --prune to delete them.'));
|
|
59
|
+
}
|
|
60
|
+
if (published.length > 0) {
|
|
61
|
+
lines.push(pc.yellow(` ${published.length} key(s) kept — published; unpublish it in Translify to delete it:`));
|
|
62
|
+
names(published);
|
|
63
|
+
}
|
|
64
|
+
if (res.pruned > 0 && res.dryRun)
|
|
65
|
+
lines.push(pc.yellow(` Would delete ${res.pruned} key(s).`));
|
|
66
|
+
// A real prune's orphan header already says it; repeat it only when there was no list.
|
|
67
|
+
if (res.pruned > 0 && !res.dryRun && listed.length === 0)
|
|
68
|
+
lines.push(pc.red(` Deleted ${res.pruned} key(s).`));
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
package/dist/version.js
ADDED