langctl 0.2.0 → 0.4.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/CHANGELOG.md +53 -0
- package/README.md +158 -1188
- package/dist/commands/auth.js +85 -19
- package/dist/commands/config.js +36 -26
- package/dist/commands/export.js +11 -122
- package/dist/commands/import.js +32 -143
- package/dist/commands/init.js +61 -72
- package/dist/commands/keys.js +145 -279
- package/dist/commands/org.js +45 -111
- package/dist/commands/projects.js +124 -312
- package/dist/commands/pull.js +86 -129
- package/dist/commands/push.js +125 -0
- package/dist/commands/review.js +65 -0
- package/dist/commands/team.js +68 -257
- package/dist/commands/translate.js +99 -0
- package/dist/core/cache.js +48 -0
- package/dist/core/config.js +130 -0
- package/dist/core/errors.js +67 -0
- package/dist/core/files.js +38 -0
- package/dist/core/http.js +156 -0
- package/dist/core/output.js +96 -0
- package/dist/core/project.js +58 -0
- package/dist/core/prompts.js +56 -0
- package/dist/formats/index.js +285 -0
- package/dist/index.js +277 -508
- package/dist/version.js +4 -0
- package/package.json +22 -15
- package/.gitattributes +0 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/CONTRIBUTING.md +0 -33
- package/dist/api.d.ts +0 -20
- package/dist/api.js +0 -75
- package/dist/auth.d.ts +0 -38
- package/dist/auth.js +0 -106
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -61
- package/dist/commands/export.d.ts +0 -13
- package/dist/commands/import.d.ts +0 -12
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/keys.d.ts +0 -25
- package/dist/commands/org.d.ts +0 -13
- package/dist/commands/projects.d.ts +0 -33
- package/dist/commands/pull.d.ts +0 -9
- package/dist/commands/team.d.ts +0 -29
- package/dist/config.d.ts +0 -48
- package/dist/config.js +0 -69
- package/dist/exporters/index.d.ts +0 -36
- package/dist/exporters/index.js +0 -214
- package/dist/index.d.ts +0 -3
- package/dist/utils/banner.d.ts +0 -9
- package/dist/utils/banner.js +0 -24
- package/translations/en.json +0 -5
- package/translations/en.xml +0 -9
- package/translations/fr.json +0 -4
- package/translations/fr.xml +0 -7
- package/translations/hi.json +0 -4
- package/translations/hi.xml +0 -7
- package/translations/ios/en.strings +0 -9
- package/translations/ios/fr.strings +0 -6
- package/translations/ios/hi.strings +0 -6
- package/translations/ios/ja.strings +0 -6
- package/translations/ja.json +0 -4
- package/translations/ja.xml +0 -7
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes are part of the CLI's contract — CI scripts branch on them. Keep in sync with README.
|
|
3
|
+
*/
|
|
4
|
+
export const ExitCode = {
|
|
5
|
+
Ok: 0,
|
|
6
|
+
Error: 1,
|
|
7
|
+
Usage: 2,
|
|
8
|
+
Auth: 3,
|
|
9
|
+
NotFound: 4,
|
|
10
|
+
Network: 5,
|
|
11
|
+
Limit: 6,
|
|
12
|
+
Drift: 7,
|
|
13
|
+
};
|
|
14
|
+
export class CliError extends Error {
|
|
15
|
+
constructor(message, exitCode = ExitCode.Error, hint, status) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.exitCode = exitCode;
|
|
18
|
+
this.hint = hint;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.name = 'CliError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export const usageError = (message, hint) => new CliError(message, ExitCode.Usage, hint);
|
|
24
|
+
export const notFoundError = (message, hint) => new CliError(message, ExitCode.NotFound, hint);
|
|
25
|
+
/** Map an HTTP error response from the API to a CliError with the right exit code and a useful hint. */
|
|
26
|
+
export function httpError(status, serverMessage, method, path) {
|
|
27
|
+
const msg = serverMessage || `HTTP ${status}`;
|
|
28
|
+
if (status === 401) {
|
|
29
|
+
return new CliError('Your API key is invalid or has been revoked.', ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys and run "langctl auth --stdin", or set LANGCTL_API_KEY.', status);
|
|
30
|
+
}
|
|
31
|
+
if (status === 403) {
|
|
32
|
+
if (/limit/i.test(msg))
|
|
33
|
+
return new CliError(msg, ExitCode.Limit, 'See your plan limits with "langctl org plan".', status);
|
|
34
|
+
if (/scope/i.test(msg)) {
|
|
35
|
+
return new CliError(msg, ExitCode.Auth, 'Create an API key with the required permission at https://app.langctl.com/organization/api-keys.', status);
|
|
36
|
+
}
|
|
37
|
+
return new CliError(msg, ExitCode.Auth, undefined, status);
|
|
38
|
+
}
|
|
39
|
+
if (status === 404)
|
|
40
|
+
return new CliError(msg, ExitCode.NotFound, undefined, status);
|
|
41
|
+
if (status === 400 || status === 409 || status === 422)
|
|
42
|
+
return new CliError(msg, ExitCode.Usage, undefined, status);
|
|
43
|
+
if (status === 429)
|
|
44
|
+
return new CliError('Rate limited by the Langctl API.', ExitCode.Network, 'Wait a moment and retry.', status);
|
|
45
|
+
if (status >= 500) {
|
|
46
|
+
return new CliError(`The Langctl API failed (${status}) on ${method} ${path}.`, ExitCode.Network, 'This is usually temporary — retry in a minute. If it persists, contact hello@langctl.com.', status);
|
|
47
|
+
}
|
|
48
|
+
return new CliError(msg, ExitCode.Error, undefined, status);
|
|
49
|
+
}
|
|
50
|
+
/** Map a fetch/network failure (no HTTP response) to a CliError. */
|
|
51
|
+
export function networkError(err, baseUrl, timeoutMs) {
|
|
52
|
+
const e = err;
|
|
53
|
+
const code = e?.cause?.code;
|
|
54
|
+
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
|
|
55
|
+
return new CliError(`Request to ${baseUrl} timed out after ${Math.round(timeoutMs / 1000)}s.`, ExitCode.Network, 'Check your connection, or raise the limit with --timeout <seconds> / LANGCTL_TIMEOUT.');
|
|
56
|
+
}
|
|
57
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
58
|
+
return new CliError(`Cannot resolve ${new URL(baseUrl).host}.`, ExitCode.Network, 'Check your network/DNS, or LANGCTL_API_URL if you set it.');
|
|
59
|
+
}
|
|
60
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'UND_ERR_SOCKET') {
|
|
61
|
+
return new CliError(`Could not connect to ${baseUrl} (${code}).`, ExitCode.Network, 'Check your network, proxy, or LANGCTL_API_URL.');
|
|
62
|
+
}
|
|
63
|
+
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'SELF_SIGNED_CERT_IN_CHAIN') {
|
|
64
|
+
return new CliError(`TLS error talking to ${baseUrl} (${code}).`, ExitCode.Network, 'If you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS to its CA bundle.');
|
|
65
|
+
}
|
|
66
|
+
return new CliError(`Network error: ${e?.cause?.message || e?.message || String(err)}`, ExitCode.Network);
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Write a file only if its content changed (atomic temp-file + rename).
|
|
5
|
+
* With `dryRun`, report what would happen without touching disk (used by --check / --dry-run).
|
|
6
|
+
*/
|
|
7
|
+
export function syncFile(path, content, dryRun,
|
|
8
|
+
/** Parse function of the file's format: if the existing file holds the same translations, leave it alone */
|
|
9
|
+
parse) {
|
|
10
|
+
const exists = existsSync(path);
|
|
11
|
+
if (exists) {
|
|
12
|
+
const current = readFileSync(path, 'utf-8');
|
|
13
|
+
if (current === content)
|
|
14
|
+
return 'unchanged';
|
|
15
|
+
// A hand-edited file (other key order, indentation, no trailing newline) with the same
|
|
16
|
+
// translations is not drift — keep the user's formatting and don't fail `pull --check`
|
|
17
|
+
if (parse && sameTranslations(current, content, parse))
|
|
18
|
+
return 'unchanged';
|
|
19
|
+
}
|
|
20
|
+
if (!dryRun) {
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const tmp = `${path}.langctl-${process.pid}.tmp`;
|
|
23
|
+
writeFileSync(tmp, content, 'utf-8');
|
|
24
|
+
renameSync(tmp, path);
|
|
25
|
+
}
|
|
26
|
+
return exists ? 'updated' : 'created';
|
|
27
|
+
}
|
|
28
|
+
function sameTranslations(a, b, parse) {
|
|
29
|
+
try {
|
|
30
|
+
const x = parse(a);
|
|
31
|
+
const y = parse(b);
|
|
32
|
+
const kx = Object.keys(x);
|
|
33
|
+
return kx.length === Object.keys(y).length && kx.every(k => x[k] === y[k]);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false; // unparseable existing file → rewrite it
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { CliError, ExitCode, httpError, networkError } from './errors.js';
|
|
2
|
+
import { requireCredentials } from './config.js';
|
|
3
|
+
import { detectCi, log } from './output.js';
|
|
4
|
+
import { VERSION } from '../version.js';
|
|
5
|
+
import { cacheEnabled, cacheKey, readCache, writeCache } from './cache.js';
|
|
6
|
+
export const httpSettings = {
|
|
7
|
+
timeoutMs: Number(process.env.LANGCTL_TIMEOUT || 30) * 1000,
|
|
8
|
+
retries: 3,
|
|
9
|
+
};
|
|
10
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
11
|
+
function userAgent() {
|
|
12
|
+
const ci = detectCi();
|
|
13
|
+
return `langctl/${VERSION} (node ${process.versions.node}; ${process.platform}${ci ? `; ci=${ci}` : ''})`;
|
|
14
|
+
}
|
|
15
|
+
export class ApiClient {
|
|
16
|
+
constructor(creds) {
|
|
17
|
+
this.creds = creds;
|
|
18
|
+
}
|
|
19
|
+
get baseUrl() {
|
|
20
|
+
return this.creds.apiUrl;
|
|
21
|
+
}
|
|
22
|
+
async request(method, path, opts = {}) {
|
|
23
|
+
return (await this.send(method, this.buildUrl(path, opts.query), opts)).data;
|
|
24
|
+
}
|
|
25
|
+
buildUrl(path, query) {
|
|
26
|
+
const url = new URL(this.creds.apiUrl + path);
|
|
27
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
28
|
+
if (v !== undefined && v !== '')
|
|
29
|
+
url.searchParams.set(k, String(v));
|
|
30
|
+
}
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* GET with an ETag cache: if the server reports the resource unchanged (304) the saved body is
|
|
35
|
+
* reused. Used for exports, which CI fetches over and over while translations rarely change.
|
|
36
|
+
*/
|
|
37
|
+
async getCached(path, query) {
|
|
38
|
+
const url = this.buildUrl(path, query);
|
|
39
|
+
if (!cacheEnabled())
|
|
40
|
+
return { data: (await this.send('GET', url, {})).data, fromCache: false };
|
|
41
|
+
const key = cacheKey(this.creds.apiUrl, this.creds.apiKey, url.toString());
|
|
42
|
+
const cached = readCache(key);
|
|
43
|
+
const res = await this.send('GET', url, { ifNoneMatch: cached?.etag });
|
|
44
|
+
if (res.notModified && cached) {
|
|
45
|
+
log.debug(`cache hit for ${url.pathname} (${cached.etag})`);
|
|
46
|
+
return { data: cached.data, fromCache: true };
|
|
47
|
+
}
|
|
48
|
+
if (res.notModified) {
|
|
49
|
+
// 304 without a cached body (cache wiped mid-run): fetch unconditionally
|
|
50
|
+
return { data: (await this.send('GET', url, {})).data, fromCache: false };
|
|
51
|
+
}
|
|
52
|
+
if (res.etag)
|
|
53
|
+
writeCache(key, res.etag, res.data);
|
|
54
|
+
return { data: res.data, fromCache: false };
|
|
55
|
+
}
|
|
56
|
+
async send(method, url, opts) {
|
|
57
|
+
const headers = {
|
|
58
|
+
'X-API-Key': this.creds.apiKey,
|
|
59
|
+
'User-Agent': userAgent(),
|
|
60
|
+
Accept: 'application/json',
|
|
61
|
+
};
|
|
62
|
+
// Only declare a JSON body when there is one — Fastify rejects an empty body with this header
|
|
63
|
+
if (opts.body !== undefined)
|
|
64
|
+
headers['Content-Type'] = 'application/json';
|
|
65
|
+
if (opts.ifNoneMatch)
|
|
66
|
+
headers['If-None-Match'] = opts.ifNoneMatch;
|
|
67
|
+
const idempotent = opts.idempotent ?? (method === 'GET');
|
|
68
|
+
const attempts = idempotent ? httpSettings.retries : 1;
|
|
69
|
+
for (let attempt = 1;; attempt++) {
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(url, {
|
|
74
|
+
method,
|
|
75
|
+
headers,
|
|
76
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
77
|
+
signal: AbortSignal.timeout(httpSettings.timeoutMs),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const mapped = networkError(err, this.creds.apiUrl, httpSettings.timeoutMs);
|
|
82
|
+
log.debug(`${method} ${url.pathname} → ${mapped.message} (${Date.now() - started}ms, attempt ${attempt}/${attempts})`);
|
|
83
|
+
if (attempt < attempts) {
|
|
84
|
+
await sleep(backoff(attempt));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
throw mapped;
|
|
88
|
+
}
|
|
89
|
+
log.debug(`${method} ${url.pathname}${url.search} → ${res.status} (${Date.now() - started}ms)`);
|
|
90
|
+
if (res.status === 304) {
|
|
91
|
+
return { status: 304, data: undefined, etag: res.headers.get('etag'), notModified: true };
|
|
92
|
+
}
|
|
93
|
+
const retryable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
|
|
94
|
+
if (retryable && attempt < attempts) {
|
|
95
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
96
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 30) * 1000 : backoff(attempt));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
let data = undefined;
|
|
101
|
+
if (text) {
|
|
102
|
+
try {
|
|
103
|
+
data = JSON.parse(text);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
throw httpError(res.status, undefined, method, url.pathname);
|
|
108
|
+
throw new CliError(`Unexpected non-JSON response from ${url.host} (${res.status}).`, ExitCode.Network, 'Is LANGCTL_API_URL pointing at the Langctl API (…/api/v1)?');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const d = data;
|
|
113
|
+
throw httpError(res.status, d?.error || d?.message, method, url.pathname);
|
|
114
|
+
}
|
|
115
|
+
return { status: res.status, data: data, etag: res.headers.get('etag'), notModified: false };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
get(path, query) {
|
|
119
|
+
return this.request('GET', path, { query });
|
|
120
|
+
}
|
|
121
|
+
post(path, body, idempotent = false) {
|
|
122
|
+
return this.request('POST', path, { body, idempotent });
|
|
123
|
+
}
|
|
124
|
+
patch(path, body) {
|
|
125
|
+
return this.request('PATCH', path, { body, idempotent: true });
|
|
126
|
+
}
|
|
127
|
+
delete(path) {
|
|
128
|
+
return this.request('DELETE', path, { idempotent: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function backoff(attempt) {
|
|
132
|
+
return Math.min(8000, 500 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250);
|
|
133
|
+
}
|
|
134
|
+
/** Look up which org a key belongs to (and its scopes). Returns null if the key is invalid/revoked. */
|
|
135
|
+
export async function validateKey(api, apiKey) {
|
|
136
|
+
const res = await api.request('POST', '/api-keys/validate', { body: { apiKey }, idempotent: true });
|
|
137
|
+
return res.valid && res.organizationId ? { organizationId: res.organizationId, scopes: res.scopes ?? [] } : null;
|
|
138
|
+
}
|
|
139
|
+
let cached = null;
|
|
140
|
+
export async function getSession() {
|
|
141
|
+
if (cached)
|
|
142
|
+
return cached;
|
|
143
|
+
const creds = requireCredentials();
|
|
144
|
+
const api = new ApiClient(creds);
|
|
145
|
+
if (creds.organizationId) {
|
|
146
|
+
cached = { api, orgId: creds.organizationId, creds };
|
|
147
|
+
return cached;
|
|
148
|
+
}
|
|
149
|
+
// Env/flag key: resolve the org from the key itself, so CI needs no config file
|
|
150
|
+
const info = await validateKey(api, creds.apiKey);
|
|
151
|
+
if (!info) {
|
|
152
|
+
throw new CliError(`The API key from ${creds.source === 'env' ? 'LANGCTL_API_KEY' : '--api-key'} is invalid or revoked.`, ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys.');
|
|
153
|
+
}
|
|
154
|
+
cached = { api, orgId: info.organizationId, creds, scopes: info.scopes };
|
|
155
|
+
return cached;
|
|
156
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* Process-wide output settings, set once from global flags in index.ts.
|
|
4
|
+
*
|
|
5
|
+
* Contract: command *results* go to stdout (and only JSON when --json is set);
|
|
6
|
+
* progress, warnings and errors go to stderr. That keeps `langctl ... --json | jq`
|
|
7
|
+
* and `$(langctl ...)` reliable in CI.
|
|
8
|
+
*/
|
|
9
|
+
export const runtime = {
|
|
10
|
+
json: false,
|
|
11
|
+
quiet: false,
|
|
12
|
+
verbose: false,
|
|
13
|
+
yes: false,
|
|
14
|
+
};
|
|
15
|
+
const ciEnv = ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'BUILDKITE', 'CIRCLECI', 'JENKINS_URL', 'TF_BUILD', 'BITBUCKET_BUILD_NUMBER'];
|
|
16
|
+
export function detectCi() {
|
|
17
|
+
for (const name of ciEnv) {
|
|
18
|
+
const value = process.env[name];
|
|
19
|
+
if (value && value !== 'false' && value !== '0')
|
|
20
|
+
return name === 'CI' ? 'ci' : name.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** True only when a human is at an interactive terminal (prompts and spinners allowed). */
|
|
25
|
+
export function isInteractive() {
|
|
26
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY) && !detectCi() && !runtime.json;
|
|
27
|
+
}
|
|
28
|
+
const write = (stream, line) => stream.write(line + '\n');
|
|
29
|
+
export const log = {
|
|
30
|
+
/** Human-readable result line (stdout). Suppressed in --json mode — print JSON with `printJson` instead. */
|
|
31
|
+
out(line = '') {
|
|
32
|
+
if (!runtime.json)
|
|
33
|
+
write(process.stdout, line);
|
|
34
|
+
},
|
|
35
|
+
info(line) {
|
|
36
|
+
if (!runtime.quiet && !runtime.json)
|
|
37
|
+
write(process.stderr, line);
|
|
38
|
+
},
|
|
39
|
+
success(line) {
|
|
40
|
+
if (!runtime.quiet && !runtime.json)
|
|
41
|
+
write(process.stderr, `${chalk.green('✔')} ${line}`);
|
|
42
|
+
},
|
|
43
|
+
warn(line) {
|
|
44
|
+
write(process.stderr, `${chalk.yellow('warning:')} ${line}`);
|
|
45
|
+
},
|
|
46
|
+
error(line, hint) {
|
|
47
|
+
write(process.stderr, `${chalk.red('error:')} ${line}`);
|
|
48
|
+
if (hint)
|
|
49
|
+
write(process.stderr, `${chalk.dim('hint:')} ${hint}`);
|
|
50
|
+
},
|
|
51
|
+
debug(line) {
|
|
52
|
+
if (runtime.verbose)
|
|
53
|
+
write(process.stderr, chalk.dim(`[debug] ${line}`));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export function printJson(data) {
|
|
57
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
58
|
+
}
|
|
59
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
60
|
+
/**
|
|
61
|
+
* Progress indicator that only animates for humans at a terminal; elsewhere (CI, pipes,
|
|
62
|
+
* --json, --quiet) it is a no-op. Deliberately not `ora`: ora touches stdin/cursor state and
|
|
63
|
+
* froze the CLI under emulated terminals (`script`, `docker -t`, some CI runners). This
|
|
64
|
+
* writes to stderr only, and its timer is unref'd so it can never keep the process alive.
|
|
65
|
+
*/
|
|
66
|
+
export function spinner(text) {
|
|
67
|
+
if (!isInteractive() || runtime.quiet || runtime.verbose)
|
|
68
|
+
return { text, stop() { } };
|
|
69
|
+
let i = 0;
|
|
70
|
+
const state = { text };
|
|
71
|
+
const render = () => process.stderr.write(`\r\x1b[2K${chalk.cyan(FRAMES[i++ % FRAMES.length])} ${state.text}`);
|
|
72
|
+
render();
|
|
73
|
+
const timer = setInterval(render, 80);
|
|
74
|
+
timer.unref();
|
|
75
|
+
return {
|
|
76
|
+
get text() { return state.text; },
|
|
77
|
+
set text(t) { state.text = t; },
|
|
78
|
+
stop() {
|
|
79
|
+
clearInterval(timer);
|
|
80
|
+
process.stderr.write('\r\x1b[2K');
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Minimal aligned table for human output. */
|
|
85
|
+
export function table(rows, header) {
|
|
86
|
+
const all = header ? [header, ...rows] : rows;
|
|
87
|
+
const widths = all[0]?.map((_, i) => Math.max(...all.map(r => stripAnsi(r[i] ?? '').length))) ?? [];
|
|
88
|
+
const fmt = (r) => r.map((c, i) => (i === r.length - 1 ? c : c + ' '.repeat(widths[i] - stripAnsi(c).length))).join(' ');
|
|
89
|
+
if (header)
|
|
90
|
+
log.out(chalk.bold(fmt(header)));
|
|
91
|
+
rows.forEach(r => log.out(fmt(r)));
|
|
92
|
+
}
|
|
93
|
+
function stripAnsi(s) {
|
|
94
|
+
// eslint-disable-next-line no-control-regex
|
|
95
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
96
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CliError, ExitCode, notFoundError, usageError } from './errors.js';
|
|
2
|
+
import { loadProjectConfig } from './config.js';
|
|
3
|
+
/** Project slug from the argument, else langctl.json, else a usage error. */
|
|
4
|
+
export function projectSlugFrom(arg) {
|
|
5
|
+
if (arg)
|
|
6
|
+
return arg;
|
|
7
|
+
const loaded = loadProjectConfig();
|
|
8
|
+
if (loaded?.config.project)
|
|
9
|
+
return loaded.config.project;
|
|
10
|
+
throw usageError('No project given.', 'Pass a project slug, or add "project" to langctl.json (see "langctl init"). List projects with "langctl projects list".');
|
|
11
|
+
}
|
|
12
|
+
export async function getProject(session, slug) {
|
|
13
|
+
try {
|
|
14
|
+
return await session.api.get(`/orgs/${session.orgId}/projects/by-slug/${encodeURIComponent(slug)}`);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (err instanceof CliError && err.exitCode === ExitCode.NotFound) {
|
|
18
|
+
const projects = await session.api.get(`/orgs/${session.orgId}/projects`).catch(() => []);
|
|
19
|
+
const available = projects.map(p => p.slug);
|
|
20
|
+
throw notFoundError(`Project "${slug}" not found.`, available.length ? `Available projects: ${available.join(', ')}` : 'This organization has no projects yet — create one with "langctl projects create".');
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function assertLanguages(project, languages) {
|
|
26
|
+
const missing = languages.filter(l => !project.languages.includes(l));
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
throw usageError(`Language${missing.length > 1 ? 's' : ''} ${missing.join(', ')} not in project "${project.slug}".`, `Project languages: ${project.languages.join(', ')}. Add one with "langctl projects add-language ${project.slug} <code>".`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function findKey(session, project, keyName) {
|
|
32
|
+
const res = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/keys`, { key: keyName, pageSize: 1 });
|
|
33
|
+
return res.data.find(k => k.key === keyName) ?? null;
|
|
34
|
+
}
|
|
35
|
+
export async function requireKey(session, project, keyName) {
|
|
36
|
+
const key = await findKey(session, project, keyName);
|
|
37
|
+
if (!key)
|
|
38
|
+
throw notFoundError(`Key "${keyName}" not found in project "${project.slug}".`, `Search with "langctl keys list ${project.slug} --search <text>".`);
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
/** Fetch every key page by page (the API caps pages at 100). */
|
|
42
|
+
export async function listAllKeys(session, project, filters = {}) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (let page = 1;; page++) {
|
|
45
|
+
const res = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/keys`, {
|
|
46
|
+
page,
|
|
47
|
+
pageSize: 100,
|
|
48
|
+
sortBy: 'key',
|
|
49
|
+
sortOrder: 'asc',
|
|
50
|
+
module: filters.module,
|
|
51
|
+
search: filters.search,
|
|
52
|
+
published: filters.published === undefined ? undefined : String(filters.published),
|
|
53
|
+
});
|
|
54
|
+
out.push(...res.data);
|
|
55
|
+
if (page >= res.pagination.totalPages)
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { Writable } from 'stream';
|
|
3
|
+
import { isInteractive, runtime } from './output.js';
|
|
4
|
+
import { usageError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Tiny prompts on node:readline (replaces inquirer). They are only ever shown at an
|
|
7
|
+
* interactive terminal; in CI a missing answer is a usage error, never a hang.
|
|
8
|
+
*/
|
|
9
|
+
function ask(question, { hidden = false } = {}) {
|
|
10
|
+
let muted = false;
|
|
11
|
+
const output = new Writable({
|
|
12
|
+
write(chunk, _enc, cb) {
|
|
13
|
+
if (!muted)
|
|
14
|
+
process.stderr.write(chunk);
|
|
15
|
+
cb();
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
rl.question(question, answer => {
|
|
21
|
+
rl.close();
|
|
22
|
+
if (hidden)
|
|
23
|
+
process.stderr.write('\n');
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
rl.on('SIGINT', () => { rl.close(); process.stderr.write('\n'); reject(usageError('Cancelled.')); });
|
|
27
|
+
muted = hidden;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function input(question, fallback) {
|
|
31
|
+
const answer = await ask(`${question}${fallback ? ` (${fallback})` : ''}: `);
|
|
32
|
+
return answer || fallback || '';
|
|
33
|
+
}
|
|
34
|
+
export async function password(question) {
|
|
35
|
+
return ask(`${question}: `, { hidden: true });
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ask before something destructive. `--yes` skips the question; without a TTY and
|
|
39
|
+
* without `--yes` we refuse instead of guessing.
|
|
40
|
+
*/
|
|
41
|
+
export async function confirm(question, flagName = '--yes') {
|
|
42
|
+
if (runtime.yes)
|
|
43
|
+
return true;
|
|
44
|
+
if (!isInteractive()) {
|
|
45
|
+
throw usageError(`Refusing to ${question.replace(/\?$/, '').toLowerCase()} without confirmation.`, `Re-run with ${flagName} to confirm in non-interactive environments.`);
|
|
46
|
+
}
|
|
47
|
+
const answer = (await ask(`${question} [y/N] `)).toLowerCase();
|
|
48
|
+
return answer === 'y' || answer === 'yes';
|
|
49
|
+
}
|
|
50
|
+
/** Read all of stdin (for `auth --stdin`). */
|
|
51
|
+
export async function readStdin() {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for await (const chunk of process.stdin)
|
|
54
|
+
chunks.push(chunk);
|
|
55
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
56
|
+
}
|