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
package/dist/commands/auth.js
CHANGED
|
@@ -1,25 +1,91 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
import { clearCredentials, configPath, maskApiKey, normalizeApiKey, readUserConfig, resolveApiUrl, resolveCredentials, writeUserConfig, } from '../core/config.js';
|
|
3
|
+
import { CliError, ExitCode, usageError } from '../core/errors.js';
|
|
4
|
+
import { ApiClient, validateKey } from '../core/http.js';
|
|
5
|
+
import { isInteractive, log, printJson, runtime, spinner } from '../core/output.js';
|
|
6
|
+
import { password, readStdin } from '../core/prompts.js';
|
|
7
|
+
/** Validate a key against the API and store it in the user config. Returns the organization. */
|
|
8
|
+
export async function saveApiKey(rawKey) {
|
|
9
|
+
const apiKey = normalizeApiKey(rawKey);
|
|
10
|
+
const apiUrl = resolveApiUrl();
|
|
11
|
+
const api = new ApiClient({ apiKey, apiUrl, source: 'flag' });
|
|
12
|
+
const spin = spinner('Verifying API key…');
|
|
13
|
+
try {
|
|
14
|
+
const info = await validateKey(api, apiKey);
|
|
15
|
+
if (!info) {
|
|
16
|
+
throw new CliError('This API key is invalid or has been revoked.', ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys.');
|
|
17
|
+
}
|
|
18
|
+
const org = await api.get(`/orgs/${info.organizationId}`);
|
|
19
|
+
writeUserConfig({ ...readUserConfig(), apiKey, organizationId: org.id, organizationName: org.name });
|
|
20
|
+
return { ...org, scopes: info.scopes };
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
spin.stop();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function authCommand(apiKeyArg, opts) {
|
|
27
|
+
let key = apiKeyArg;
|
|
28
|
+
if (opts.stdin) {
|
|
29
|
+
key = (await readStdin()).trim();
|
|
30
|
+
if (!key)
|
|
31
|
+
throw usageError('No API key received on stdin.', 'Example: echo "$LANGCTL_API_KEY" | langctl auth --stdin');
|
|
32
|
+
}
|
|
33
|
+
else if (key) {
|
|
34
|
+
log.warn('Passing the key as an argument stores it in your shell history. Prefer "langctl auth --stdin" or the interactive prompt.');
|
|
35
|
+
}
|
|
36
|
+
else if (isInteractive()) {
|
|
37
|
+
key = await password('Paste your API key');
|
|
11
38
|
}
|
|
12
39
|
else {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
40
|
+
throw usageError('No API key given.', 'Use "langctl auth --stdin" (e.g. echo "$KEY" | langctl auth --stdin), or set LANGCTL_API_KEY instead of storing a key in CI.');
|
|
41
|
+
}
|
|
42
|
+
const org = await saveApiKey(key);
|
|
43
|
+
if (runtime.json) {
|
|
44
|
+
printJson({ authenticated: true, organization: { id: org.id, name: org.name, plan: org.plan }, scopes: org.scopes, configPath: configPath() });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
log.success(`Authenticated to ${chalk.bold(org.name)} (${org.plan} plan)`);
|
|
48
|
+
log.info(chalk.dim(`Key saved to ${configPath()} (readable only by you). Scopes: ${org.scopes.join(', ')}`));
|
|
49
|
+
}
|
|
50
|
+
export function logoutCommand() {
|
|
51
|
+
const removed = clearCredentials();
|
|
52
|
+
if (runtime.json)
|
|
53
|
+
return printJson({ loggedOut: removed });
|
|
54
|
+
log.success(removed ? 'Logged out — stored API key removed.' : 'No stored API key — nothing to do.');
|
|
55
|
+
if (process.env.LANGCTL_API_KEY)
|
|
56
|
+
log.warn('LANGCTL_API_KEY is still set in your environment and will keep being used.');
|
|
57
|
+
}
|
|
58
|
+
export async function whoamiCommand() {
|
|
59
|
+
const creds = resolveCredentials();
|
|
60
|
+
if (!creds) {
|
|
61
|
+
if (runtime.json)
|
|
62
|
+
printJson({ authenticated: false, apiUrl: resolveApiUrl() });
|
|
63
|
+
throw new CliError('Not authenticated.', ExitCode.Auth, 'Run "langctl auth --stdin", or set LANGCTL_API_KEY.');
|
|
64
|
+
}
|
|
65
|
+
const api = new ApiClient(creds);
|
|
66
|
+
const started = Date.now();
|
|
67
|
+
const info = await validateKey(api, creds.apiKey);
|
|
68
|
+
const latency = Date.now() - started;
|
|
69
|
+
if (!info) {
|
|
70
|
+
throw new CliError(`The API key from ${sourceLabel(creds.source)} is invalid or revoked.`, ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys.');
|
|
19
71
|
}
|
|
72
|
+
const org = await api.get(`/orgs/${info.organizationId}`);
|
|
73
|
+
const data = {
|
|
74
|
+
authenticated: true,
|
|
75
|
+
organization: { id: org.id, name: org.name, slug: org.slug, plan: org.plan },
|
|
76
|
+
key: maskApiKey(creds.apiKey),
|
|
77
|
+
keySource: sourceLabel(creds.source),
|
|
78
|
+
scopes: info.scopes,
|
|
79
|
+
apiUrl: creds.apiUrl,
|
|
80
|
+
latencyMs: latency,
|
|
81
|
+
};
|
|
82
|
+
if (runtime.json)
|
|
83
|
+
return printJson(data);
|
|
84
|
+
log.out(`${chalk.bold(org.name)} ${chalk.dim(`(${org.slug}, ${org.plan} plan)`)}`);
|
|
85
|
+
log.out(` key ${data.key} ${chalk.dim(`from ${data.keySource}`)}`);
|
|
86
|
+
log.out(` scopes ${info.scopes.join(', ')}`);
|
|
87
|
+
log.out(` api ${creds.apiUrl} ${chalk.dim(`${latency}ms`)}`);
|
|
20
88
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
console.log(chalk.green('✓ Logged out successfully\n'));
|
|
89
|
+
function sourceLabel(source) {
|
|
90
|
+
return source === 'env' ? 'LANGCTL_API_KEY' : source === 'flag' ? '--api-key' : configPath();
|
|
24
91
|
}
|
|
25
|
-
//# sourceMappingURL=auth.js.map
|
package/dist/commands/config.js
CHANGED
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { configPath, loadProjectConfig, maskApiKey, readUserConfig, resolveApiUrl, resolveCredentials } from '../core/config.js';
|
|
3
|
+
import { log, printJson, runtime } from '../core/output.js';
|
|
4
|
+
import { FORMATS } from '../formats/index.js';
|
|
5
|
+
/** Show effective configuration and where each value comes from (no network). */
|
|
3
6
|
export function configCommand() {
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
organizationId: allConfig.organizationId,
|
|
15
|
-
organizationName: allConfig.organizationName,
|
|
16
|
-
defaultProject: allConfig.defaultProject,
|
|
17
|
-
defaultLanguage: allConfig.defaultLanguage
|
|
7
|
+
const user = readUserConfig();
|
|
8
|
+
const creds = resolveCredentials();
|
|
9
|
+
const project = loadProjectConfig();
|
|
10
|
+
const data = {
|
|
11
|
+
apiUrl: resolveApiUrl(),
|
|
12
|
+
apiKey: creds ? maskApiKey(creds.apiKey) : null,
|
|
13
|
+
apiKeySource: creds ? (creds.source === 'env' ? 'LANGCTL_API_KEY' : creds.source === 'flag' ? '--api-key' : configPath()) : null,
|
|
14
|
+
organization: user.organizationName ?? null,
|
|
15
|
+
userConfig: configPath(),
|
|
16
|
+
projectConfig: project ? { path: project.path, ...project.config } : null,
|
|
18
17
|
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
if (runtime.json)
|
|
19
|
+
return printJson(data);
|
|
20
|
+
log.out(`api url ${data.apiUrl}`);
|
|
21
|
+
log.out(`api key ${data.apiKey ?? chalk.yellow('not set')}${data.apiKeySource ? chalk.dim(` (${data.apiKeySource})`) : ''}`);
|
|
22
|
+
if (data.organization)
|
|
23
|
+
log.out(`organization ${data.organization}`);
|
|
24
|
+
log.out(`user config ${data.userConfig}`);
|
|
25
|
+
if (project) {
|
|
26
|
+
log.out(`project file ${project.path}`);
|
|
27
|
+
for (const [k, v] of Object.entries(project.config))
|
|
28
|
+
log.out(chalk.dim(` ${k}: ${JSON.stringify(v)}`));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
log.out(`project file ${chalk.dim('none — run "langctl init" to create langctl.json')}`);
|
|
32
|
+
}
|
|
25
33
|
}
|
|
26
|
-
function
|
|
27
|
-
if (
|
|
28
|
-
return
|
|
29
|
-
|
|
34
|
+
export function formatsCommand() {
|
|
35
|
+
if (runtime.json)
|
|
36
|
+
return printJson(FORMATS.map(f => ({ id: f.id, aliases: f.aliases, description: f.label, defaultOutput: f.defaultOutput })));
|
|
37
|
+
for (const f of FORMATS) {
|
|
38
|
+
log.out(`${chalk.bold(f.id.padEnd(12))} ${f.label}`);
|
|
39
|
+
log.out(chalk.dim(`${''.padEnd(12)} default path ${f.defaultOutput}${f.aliases.length ? ` · aliases ${f.aliases.join(', ')}` : ''}`));
|
|
40
|
+
}
|
|
30
41
|
}
|
|
31
|
-
//# sourceMappingURL=config.js.map
|
package/dist/commands/export.js
CHANGED
|
@@ -1,125 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import ora from 'ora';
|
|
3
|
-
import { writeFileSync, mkdirSync } from 'fs';
|
|
4
|
-
import { join, dirname } from 'path';
|
|
5
|
-
import { isAuthenticated } from '../auth.js';
|
|
6
|
-
import { getApiClient } from '../api.js';
|
|
7
|
-
import { config } from '../config.js';
|
|
8
|
-
import { exportTranslations } from '../exporters/index.js';
|
|
1
|
+
import { pullCommand } from './pull.js';
|
|
9
2
|
/**
|
|
10
|
-
*
|
|
3
|
+
* `langctl export` — 0.2.x-compatible entry point. Same engine as `pull`, but
|
|
4
|
+
* aimed at one-off exports (`-l es -o es.json`); `pull` is the CI/repo workflow.
|
|
11
5
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'json': 'json',
|
|
21
|
-
'json-nested': 'json-nested',
|
|
22
|
-
'android': 'android',
|
|
23
|
-
'ios': 'ios',
|
|
24
|
-
'flutter': 'flutter',
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* Get file extension for a given export format
|
|
28
|
-
*/
|
|
29
|
-
function getFileExtension(format, language) {
|
|
30
|
-
switch (format) {
|
|
31
|
-
case 'json':
|
|
32
|
-
case 'json-nested':
|
|
33
|
-
return `${language}.json`;
|
|
34
|
-
case 'android':
|
|
35
|
-
return `strings.xml`;
|
|
36
|
-
case 'ios':
|
|
37
|
-
return `Localizable.strings`;
|
|
38
|
-
case 'flutter':
|
|
39
|
-
return `intl_${language}.arb`;
|
|
40
|
-
default:
|
|
41
|
-
return `${language}.json`;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Export translations command
|
|
46
|
-
*/
|
|
47
|
-
export async function exportCommand(projectSlug, options) {
|
|
48
|
-
if (!isAuthenticated()) {
|
|
49
|
-
console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
|
|
50
|
-
process.exitCode = 1;
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
const orgId = config.get('organizationId');
|
|
54
|
-
if (!orgId) {
|
|
55
|
-
console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
|
|
56
|
-
process.exitCode = 1;
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const spinner = ora('Fetching project...').start();
|
|
60
|
-
try {
|
|
61
|
-
const api = getApiClient();
|
|
62
|
-
// Resolve project by slug
|
|
63
|
-
const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectSlug}`);
|
|
64
|
-
spinner.text = 'Exporting translations...';
|
|
65
|
-
// Determine languages to export
|
|
66
|
-
const languages = options.language
|
|
67
|
-
? [options.language]
|
|
68
|
-
: project.languages;
|
|
69
|
-
// Validate language if specified
|
|
70
|
-
if (options.language && !project.languages.includes(options.language)) {
|
|
71
|
-
spinner.fail(chalk.red(`Language "${options.language}" not found in project`));
|
|
72
|
-
console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
// Resolve format
|
|
76
|
-
const cliFormat = options.format || 'flat-json';
|
|
77
|
-
const exportFormat = formatMap[cliFormat];
|
|
78
|
-
if (!exportFormat) {
|
|
79
|
-
spinner.fail(chalk.red(`Unsupported format: ${cliFormat}`));
|
|
80
|
-
console.log(chalk.yellow(`Valid formats: ${Object.keys(formatMap).join(', ')}\n`));
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
const publishedOnly = !options.includeUnpublished;
|
|
84
|
-
// Export each language
|
|
85
|
-
for (const language of languages) {
|
|
86
|
-
spinner.text = `Exporting ${language}...`;
|
|
87
|
-
// Fetch flat translations from the API
|
|
88
|
-
const params = {
|
|
89
|
-
language,
|
|
90
|
-
publishedOnly: publishedOnly ? 'true' : 'false'
|
|
91
|
-
};
|
|
92
|
-
if (options.module)
|
|
93
|
-
params.module = options.module;
|
|
94
|
-
const result = await api.get(`/orgs/${orgId}/projects/${project.id}/export`, params);
|
|
95
|
-
// Convert flat translations to TranslationKey array for the exporter
|
|
96
|
-
const flatTranslations = result.translations || {};
|
|
97
|
-
const translationKeys = Object.entries(flatTranslations).map(([key, value]) => ({
|
|
98
|
-
key,
|
|
99
|
-
translations: { [language]: value }
|
|
100
|
-
}));
|
|
101
|
-
// Use client-side exporter for format conversion
|
|
102
|
-
const exportResult = exportTranslations(translationKeys, language, exportFormat);
|
|
103
|
-
// Determine output path
|
|
104
|
-
const outputPath = options.output
|
|
105
|
-
? options.output
|
|
106
|
-
: join(process.cwd(), 'translations', getFileExtension(exportFormat, language));
|
|
107
|
-
// Ensure directory exists
|
|
108
|
-
mkdirSync(dirname(outputPath), { recursive: true });
|
|
109
|
-
// Write file
|
|
110
|
-
writeFileSync(outputPath, exportResult.content, 'utf-8');
|
|
111
|
-
spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
|
|
112
|
-
// Show spinner again if there are more languages
|
|
113
|
-
if (languages.indexOf(language) < languages.length - 1) {
|
|
114
|
-
spinner.start();
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
console.log(chalk.blue(`\n✓ Exported ${languages.length} language(s) successfully\n`));
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
spinner.fail(chalk.red('Failed to export translations'));
|
|
121
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
122
|
-
process.exitCode = 1;
|
|
123
|
-
}
|
|
6
|
+
export async function exportCommand(projectArg, opts) {
|
|
7
|
+
await pullCommand(projectArg, {
|
|
8
|
+
languages: opts.languages ?? opts.language,
|
|
9
|
+
format: opts.format,
|
|
10
|
+
output: opts.output,
|
|
11
|
+
module: opts.module,
|
|
12
|
+
includeDrafts: opts.includeDrafts || opts.includeUnpublished,
|
|
13
|
+
});
|
|
124
14
|
}
|
|
125
|
-
//# sourceMappingURL=export.js.map
|
package/dist/commands/import.js
CHANGED
|
@@ -1,150 +1,39 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { relative, resolve } from 'path';
|
|
3
|
+
import { loadProjectConfig } from '../core/config.js';
|
|
4
|
+
import { usageError } from '../core/errors.js';
|
|
5
|
+
import { getSession } from '../core/http.js';
|
|
6
|
+
import { log, printJson, runtime, spinner } from '../core/output.js';
|
|
7
|
+
import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
|
|
8
|
+
import { getFormat } from '../formats/index.js';
|
|
9
|
+
import { readTranslationFile, uploadTranslations } from './push.js';
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
11
|
+
* `langctl import [project] <file> --language <code>` — upload one file.
|
|
12
|
+
* The project may be omitted when langctl.json names it.
|
|
10
13
|
*/
|
|
11
|
-
function
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
* Check if object has nested objects
|
|
26
|
-
*/
|
|
27
|
-
function isNestedObject(obj) {
|
|
28
|
-
return Object.values(obj).some(value => typeof value === 'object' && value !== null && !Array.isArray(value));
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Flatten nested object to dot-notation keys
|
|
32
|
-
*/
|
|
33
|
-
function flattenObject(obj, prefix = '') {
|
|
34
|
-
const result = {};
|
|
35
|
-
Object.entries(obj).forEach(([key, value]) => {
|
|
36
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
37
|
-
if (typeof value === 'string') {
|
|
38
|
-
result[fullKey] = value;
|
|
39
|
-
}
|
|
40
|
-
else if (typeof value === 'object' && value !== null) {
|
|
41
|
-
Object.assign(result, flattenObject(value, fullKey));
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
return result;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Import translations command
|
|
48
|
-
*/
|
|
49
|
-
export async function importCommand(projectSlug, filePath, options) {
|
|
50
|
-
if (!isAuthenticated()) {
|
|
51
|
-
console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
|
|
52
|
-
process.exitCode = 1;
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const orgId = config.get('organizationId');
|
|
56
|
-
if (!orgId) {
|
|
57
|
-
console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
|
|
58
|
-
process.exitCode = 1;
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const spinner = ora('Loading file...').start();
|
|
14
|
+
export async function importCommand(first, second, opts) {
|
|
15
|
+
const [projectArg, file] = second === undefined ? [undefined, first] : [first, second];
|
|
16
|
+
const session = await getSession();
|
|
17
|
+
const project = await getProject(session, projectSlugFrom(projectArg));
|
|
18
|
+
const language = opts.language ?? loadProjectConfig()?.config.sourceLanguage ?? project.defaultLanguage;
|
|
19
|
+
if (!opts.language)
|
|
20
|
+
log.info(chalk.dim(`No --language given; importing as ${language} (the project's default).`));
|
|
21
|
+
assertLanguages(project, [language]);
|
|
22
|
+
const path = resolve(file);
|
|
23
|
+
const translations = readTranslationFile(path, opts.format ? getFormat(opts.format) : undefined, language);
|
|
24
|
+
if (Object.keys(translations).length === 0)
|
|
25
|
+
throw usageError(`${relative(process.cwd(), path)} contains no translations.`);
|
|
26
|
+
const spin = spinner(`${opts.dryRun ? 'Checking' : 'Importing'} ${Object.keys(translations).length} keys…`);
|
|
27
|
+
let result;
|
|
62
28
|
try {
|
|
63
|
-
|
|
64
|
-
const resolvedPath = resolve(filePath);
|
|
65
|
-
// Parse translation file
|
|
66
|
-
spinner.text = 'Parsing translation file...';
|
|
67
|
-
const translations = parseTranslationFile(resolvedPath);
|
|
68
|
-
const keyCount = Object.keys(translations).length;
|
|
69
|
-
if (keyCount === 0) {
|
|
70
|
-
spinner.warn(chalk.yellow('No translations found in file'));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
spinner.text = 'Fetching project...';
|
|
74
|
-
// Get project by slug
|
|
75
|
-
const api = getApiClient();
|
|
76
|
-
const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectSlug}`);
|
|
77
|
-
// Validate language
|
|
78
|
-
if (!project.languages.includes(options.language)) {
|
|
79
|
-
spinner.fail(chalk.red(`Language "${options.language}" not found in project`));
|
|
80
|
-
console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
spinner.text = `Importing ${keyCount} translations...`;
|
|
84
|
-
// Push translations via REST API
|
|
85
|
-
const body = {
|
|
86
|
-
language: options.language,
|
|
87
|
-
translations,
|
|
88
|
-
// Keep import safe by default; only overwrite when explicitly requested.
|
|
89
|
-
overwriteExisting: options.overwrite === true
|
|
90
|
-
};
|
|
91
|
-
if (options.module)
|
|
92
|
-
body.module = options.module;
|
|
93
|
-
const data = await api.post(`/orgs/${orgId}/projects/${project.id}/import`, body);
|
|
94
|
-
let publishedCount = 0;
|
|
95
|
-
if (options.publish) {
|
|
96
|
-
spinner.text = 'Publishing imported keys...';
|
|
97
|
-
const keyNames = new Set(Object.keys(translations));
|
|
98
|
-
const keyIds = [];
|
|
99
|
-
let page = 1;
|
|
100
|
-
const pageSize = 100;
|
|
101
|
-
while (true) {
|
|
102
|
-
const keysPage = await api.get(`/orgs/${orgId}/projects/${project.id}/keys`, {
|
|
103
|
-
page: String(page),
|
|
104
|
-
pageSize: String(pageSize)
|
|
105
|
-
});
|
|
106
|
-
for (const key of (keysPage.data || [])) {
|
|
107
|
-
if (keyNames.has(key.key)) {
|
|
108
|
-
keyIds.push(key.id);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const totalPages = keysPage.pagination?.totalPages || 1;
|
|
112
|
-
if (page >= totalPages)
|
|
113
|
-
break;
|
|
114
|
-
page += 1;
|
|
115
|
-
}
|
|
116
|
-
if (keyIds.length > 0) {
|
|
117
|
-
await api.post(`/orgs/${orgId}/projects/${project.id}/keys/bulk-publish`, {
|
|
118
|
-
keyIds,
|
|
119
|
-
published: true
|
|
120
|
-
});
|
|
121
|
-
publishedCount = keyIds.length;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
spinner.succeed(chalk.green('Import completed successfully'));
|
|
125
|
-
console.log(chalk.green(`\n✓ Created: ${data.created}`));
|
|
126
|
-
console.log(chalk.blue(`✓ Updated: ${data.updated}`));
|
|
127
|
-
console.log(chalk.yellow(`⊘ Skipped: ${data.skipped}`));
|
|
128
|
-
if (options.publish) {
|
|
129
|
-
console.log(chalk.magenta(`✓ Published: ${publishedCount}`));
|
|
130
|
-
}
|
|
131
|
-
console.log(chalk.white(` Total: ${data.total}`));
|
|
132
|
-
console.log('');
|
|
29
|
+
result = await uploadTranslations(session, project, language, translations, opts);
|
|
133
30
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (error.code === 'ENOENT') {
|
|
137
|
-
console.error(chalk.red(`Error: File not found: ${filePath}\n`));
|
|
138
|
-
process.exitCode = 1;
|
|
139
|
-
}
|
|
140
|
-
else if (error instanceof SyntaxError) {
|
|
141
|
-
console.error(chalk.red(`Error: Invalid JSON file\n`));
|
|
142
|
-
process.exitCode = 1;
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
146
|
-
process.exitCode = 1;
|
|
147
|
-
}
|
|
31
|
+
finally {
|
|
32
|
+
spin.stop();
|
|
148
33
|
}
|
|
34
|
+
if (runtime.json)
|
|
35
|
+
return printJson({ project: project.slug, file: relative(process.cwd(), path), dryRun: Boolean(opts.dryRun), ...result });
|
|
36
|
+
log.out(`${language} ${chalk.green(`${result.created} new`)}, ${chalk.yellow(`${result.updated} updated`)}, ${chalk.dim(`${result.unchanged} unchanged`)}${result.published !== undefined ? `, ${chalk.cyan(`${result.published} published`)}` : ''}${opts.dryRun ? chalk.dim(' (dry run)') : ''}`);
|
|
37
|
+
if (!opts.overwrite && result.unchanged > 0)
|
|
38
|
+
log.info(chalk.dim('Existing translations are kept unless you pass --overwrite.'));
|
|
149
39
|
}
|
|
150
|
-
//# sourceMappingURL=import.js.map
|
package/dist/commands/init.js
CHANGED
|
@@ -1,77 +1,66 @@
|
|
|
1
|
-
import inquirer from 'inquirer';
|
|
2
1
|
import chalk from 'chalk';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
]);
|
|
22
|
-
if (!confirm) {
|
|
23
|
-
console.log(chalk.yellow('\nSetup cancelled.'));
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { PROJECT_CONFIG_FILE, resolveCredentials, writeProjectConfig } from '../core/config.js';
|
|
5
|
+
import { usageError } from '../core/errors.js';
|
|
6
|
+
import { getSession } from '../core/http.js';
|
|
7
|
+
import { isInteractive, log, printJson, runtime } from '../core/output.js';
|
|
8
|
+
import { input, password } from '../core/prompts.js';
|
|
9
|
+
import { FORMATS, getFormat } from '../formats/index.js';
|
|
10
|
+
import { saveApiKey } from './auth.js';
|
|
11
|
+
/**
|
|
12
|
+
* Set up a repository: authenticate if needed, then write langctl.json so that
|
|
13
|
+
* `langctl pull` / `langctl push` need no arguments (locally and in CI).
|
|
14
|
+
*/
|
|
15
|
+
export async function initCommand(opts) {
|
|
16
|
+
const interactive = isInteractive();
|
|
17
|
+
const target = join(process.cwd(), PROJECT_CONFIG_FILE);
|
|
18
|
+
if (existsSync(target) && !opts.force) {
|
|
19
|
+
throw usageError(`${PROJECT_CONFIG_FILE} already exists here.`, 'Edit it directly, or re-run with --force to replace it.');
|
|
26
20
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
mask: '*',
|
|
34
|
-
filter: (input) => input?.trim(),
|
|
35
|
-
validate: (input) => {
|
|
36
|
-
const value = (input || '').trim();
|
|
37
|
-
if (!value || !value.startsWith('lc_')) {
|
|
38
|
-
return 'Please enter a valid API key (starts with lc_)';
|
|
39
|
-
}
|
|
40
|
-
if (value.length !== 67) {
|
|
41
|
-
return 'Invalid API key format (should be 67 characters)';
|
|
42
|
-
}
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
]);
|
|
47
|
-
// Authenticate
|
|
48
|
-
const spinner = ora('Verifying API key...').start();
|
|
49
|
-
const result = await authenticate(apiKey);
|
|
50
|
-
if (result.success) {
|
|
51
|
-
spinner.succeed(chalk.green(`Authenticated as ${result.data?.organization.name}`));
|
|
21
|
+
if (!resolveCredentials()) {
|
|
22
|
+
if (!interactive)
|
|
23
|
+
throw usageError('Not authenticated.', 'Set LANGCTL_API_KEY, or run "langctl auth --stdin" first.');
|
|
24
|
+
log.info(`Create an API key at ${chalk.cyan('https://app.langctl.com/organization/api-keys')}`);
|
|
25
|
+
const org = await saveApiKey(await password('Paste your API key'));
|
|
26
|
+
log.success(`Authenticated to ${chalk.bold(org.name)}`);
|
|
52
27
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
28
|
+
const session = await getSession();
|
|
29
|
+
const projects = await session.api.get(`/orgs/${session.orgId}/projects`);
|
|
30
|
+
if (projects.length === 0) {
|
|
31
|
+
throw usageError('This organization has no projects yet.', 'Create one with "langctl projects create <name>" or at https://app.langctl.com.');
|
|
32
|
+
}
|
|
33
|
+
let project = opts.project ? projects.find(p => p.slug === opts.project) : undefined;
|
|
34
|
+
if (opts.project && !project) {
|
|
35
|
+
throw usageError(`Project "${opts.project}" not found.`, `Available: ${projects.map(p => p.slug).join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
if (!project) {
|
|
38
|
+
if (projects.length === 1)
|
|
39
|
+
project = projects[0];
|
|
40
|
+
else if (!interactive)
|
|
41
|
+
throw usageError('Several projects exist — choose one with --project <slug>.', `Available: ${projects.map(p => p.slug).join(', ')}`);
|
|
42
|
+
else
|
|
43
|
+
project = await choose('Project', projects.map(p => ({ label: `${p.name} ${chalk.dim(`(${p.slug})`)}`, value: p })));
|
|
44
|
+
}
|
|
45
|
+
let format = opts.format ? getFormat(opts.format) : undefined;
|
|
46
|
+
if (!format) {
|
|
47
|
+
format = interactive ? await choose('Format', FORMATS.map(f => ({ label: f.label, value: f }))) : getFormat('json');
|
|
48
|
+
}
|
|
49
|
+
const output = opts.output ?? (interactive ? await input('Where should translation files go', format.defaultOutput) : format.defaultOutput);
|
|
50
|
+
const config = { project: project.slug, format: format.id, output };
|
|
51
|
+
writeProjectConfig(target, config);
|
|
52
|
+
if (runtime.json)
|
|
53
|
+
return printJson({ created: target, config });
|
|
54
|
+
log.success(`Wrote ${PROJECT_CONFIG_FILE} for ${chalk.bold(project.name)} (${project.languages.join(', ')})`);
|
|
55
|
+
log.info(`\nNext:\n ${chalk.cyan('langctl pull')} download translations into ${output}\n ${chalk.cyan('langctl push')} upload new ${project.defaultLanguage} strings\n ${chalk.cyan('langctl pull --check')} fail CI when committed files are out of date`);
|
|
56
|
+
}
|
|
57
|
+
async function choose(label, options) {
|
|
58
|
+
options.forEach((o, i) => log.info(` ${chalk.cyan(String(i + 1).padStart(2))} ${o.label}`));
|
|
59
|
+
for (;;) {
|
|
60
|
+
const answer = await input(`${label} [1-${options.length}]`, '1');
|
|
61
|
+
const n = Number(answer);
|
|
62
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length)
|
|
63
|
+
return options[n - 1].value;
|
|
64
|
+
log.warn(`Enter a number between 1 and ${options.length}.`);
|
|
60
65
|
}
|
|
61
|
-
// Ask for default settings
|
|
62
|
-
const { defaultLanguage } = await inquirer.prompt([
|
|
63
|
-
{
|
|
64
|
-
type: 'input',
|
|
65
|
-
name: 'defaultLanguage',
|
|
66
|
-
message: 'Default language code:',
|
|
67
|
-
default: 'en'
|
|
68
|
-
}
|
|
69
|
-
]);
|
|
70
|
-
config.set('defaultLanguage', defaultLanguage);
|
|
71
|
-
console.log(chalk.green.bold('\n✓ Setup complete!\n'));
|
|
72
|
-
console.log(chalk.gray(`Configuration saved to: ${config.getConfigPath()}\n`));
|
|
73
|
-
console.log(chalk.blue('Next steps:'));
|
|
74
|
-
console.log(chalk.white(' 1. Run "langctl projects list" to see your projects'));
|
|
75
|
-
console.log(chalk.white(' 2. Run "langctl pull <project-slug>" to download translations\n'));
|
|
76
66
|
}
|
|
77
|
-
//# sourceMappingURL=init.js.map
|