langctl 0.2.0 → 0.3.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +155 -1188
  3. package/dist/commands/auth.js +85 -19
  4. package/dist/commands/config.js +36 -26
  5. package/dist/commands/export.js +11 -122
  6. package/dist/commands/import.js +32 -143
  7. package/dist/commands/init.js +61 -72
  8. package/dist/commands/keys.js +145 -279
  9. package/dist/commands/org.js +45 -111
  10. package/dist/commands/projects.js +124 -312
  11. package/dist/commands/pull.js +80 -130
  12. package/dist/commands/push.js +125 -0
  13. package/dist/commands/team.js +68 -257
  14. package/dist/core/config.js +130 -0
  15. package/dist/core/errors.js +67 -0
  16. package/dist/core/files.js +38 -0
  17. package/dist/core/http.js +121 -0
  18. package/dist/core/output.js +96 -0
  19. package/dist/core/project.js +58 -0
  20. package/dist/core/prompts.js +56 -0
  21. package/dist/formats/index.js +285 -0
  22. package/dist/index.js +261 -508
  23. package/dist/version.js +4 -0
  24. package/package.json +22 -15
  25. package/.gitattributes +0 -2
  26. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
  27. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  28. package/CONTRIBUTING.md +0 -33
  29. package/dist/api.d.ts +0 -20
  30. package/dist/api.js +0 -75
  31. package/dist/auth.d.ts +0 -38
  32. package/dist/auth.js +0 -106
  33. package/dist/commands/auth.d.ts +0 -3
  34. package/dist/commands/config.d.ts +0 -2
  35. package/dist/commands/debug.d.ts +0 -3
  36. package/dist/commands/debug.js +0 -61
  37. package/dist/commands/export.d.ts +0 -13
  38. package/dist/commands/import.d.ts +0 -12
  39. package/dist/commands/init.d.ts +0 -2
  40. package/dist/commands/keys.d.ts +0 -25
  41. package/dist/commands/org.d.ts +0 -13
  42. package/dist/commands/projects.d.ts +0 -33
  43. package/dist/commands/pull.d.ts +0 -9
  44. package/dist/commands/team.d.ts +0 -29
  45. package/dist/config.d.ts +0 -48
  46. package/dist/config.js +0 -69
  47. package/dist/exporters/index.d.ts +0 -36
  48. package/dist/exporters/index.js +0 -214
  49. package/dist/index.d.ts +0 -3
  50. package/dist/utils/banner.d.ts +0 -9
  51. package/dist/utils/banner.js +0 -24
  52. package/translations/en.json +0 -5
  53. package/translations/en.xml +0 -9
  54. package/translations/fr.json +0 -4
  55. package/translations/fr.xml +0 -7
  56. package/translations/hi.json +0 -4
  57. package/translations/hi.xml +0 -7
  58. package/translations/ios/en.strings +0 -9
  59. package/translations/ios/fr.strings +0 -6
  60. package/translations/ios/hi.strings +0 -6
  61. package/translations/ios/ja.strings +0 -6
  62. package/translations/ja.json +0 -4
  63. package/translations/ja.xml +0 -7
@@ -1,25 +1,91 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
3
- import { authenticate, logout } from '../auth.js';
4
- import { config } from '../config.js';
5
- export async function authCommand(apiKey) {
6
- const spinner = ora('Authenticating...').start();
7
- const result = await authenticate(apiKey);
8
- if (result.success) {
9
- spinner.succeed(chalk.green(`Authenticated as ${result.data?.organization.name}`));
10
- console.log(chalk.gray(`\nAPI Key saved to: ${config.getConfigPath()}\n`));
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
- spinner.fail(chalk.red(result.message));
14
- process.exitCode = 1;
15
- console.log(chalk.yellow('\nTroubleshooting:'));
16
- console.log(chalk.white(' - Check that your API key is correct'));
17
- console.log(chalk.white(' - Verify the key is not revoked in the dashboard'));
18
- console.log(chalk.white(' - Generate a new key: https://app.langctl.com/dashboard/settings/api-keys\n'));
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
- export async function logoutCommand() {
22
- logout();
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
@@ -1,31 +1,41 @@
1
1
  import chalk from 'chalk';
2
- import { config } from '../config.js';
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
- console.log(chalk.blue.bold('\n📋 Langctl Configuration\n'));
5
- const allConfig = config.getAll();
6
- if (Object.keys(allConfig).length === 0) {
7
- console.log(chalk.yellow('No configuration found. Run "langctl init" to set up.\n'));
8
- return;
9
- }
10
- // Display config with masked sensitive values
11
- const displayConfig = {
12
- apiBaseUrl: allConfig.apiBaseUrl || 'https://api.langctl.com/api/v1 (default)',
13
- apiKey: allConfig.apiKey ? maskApiKey(allConfig.apiKey) : undefined,
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
- Object.entries(displayConfig).forEach(([key, value]) => {
20
- if (value !== undefined) {
21
- console.log(chalk.white(`${key}: ${chalk.cyan(value)}`));
22
- }
23
- });
24
- console.log(chalk.gray(`\nConfig file: ${config.getConfigPath()}\n`));
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 maskApiKey(key) {
27
- if (key.length < 10)
28
- return '***';
29
- return `${key.substring(0, 10)}...${key.substring(key.length - 4)}`;
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
@@ -1,125 +1,14 @@
1
- import chalk from 'chalk';
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
- * Map CLI format names to exporter format names
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
- const formatMap = {
13
- 'flat-json': 'json',
14
- 'nested-json': 'json-nested',
15
- 'i18n-json': 'json',
16
- 'android-xml': 'android',
17
- 'ios-strings': 'ios',
18
- 'flutter-arb': 'flutter',
19
- // Also accept exporter format names directly
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
@@ -1,150 +1,39 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
3
- import { readFileSync } from 'fs';
4
- import { resolve } from 'path';
5
- import { isAuthenticated } from '../auth.js';
6
- import { getApiClient } from '../api.js';
7
- import { config } from '../config.js';
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
- * Parse translation file based on extension
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 parseTranslationFile(filePath) {
12
- const content = readFileSync(filePath, 'utf-8');
13
- const ext = filePath.split('.').pop()?.toLowerCase();
14
- if (ext === 'json') {
15
- const parsed = JSON.parse(content);
16
- // Check if it's nested JSON and flatten it
17
- if (isNestedObject(parsed)) {
18
- return flattenObject(parsed);
19
- }
20
- return parsed;
21
- }
22
- throw new Error(`Unsupported file format: .${ext}. Only JSON files are currently supported.`);
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
- // Resolve file path
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
- catch (error) {
135
- spinner.fail(chalk.red('Failed to import translations'));
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
@@ -1,77 +1,66 @@
1
- import inquirer from 'inquirer';
2
1
  import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { config } from '../config.js';
5
- import { authenticate } from '../auth.js';
6
- import { showLogo } from '../utils/banner.js';
7
- export async function initCommand() {
8
- // Show logo
9
- showLogo();
10
- console.log(chalk.blue.bold('🚀 CLI Setup\n'));
11
- console.log(chalk.gray('Get your API key from: https://app.langctl.com/dashboard/settings/api-keys\n'));
12
- // Check if already configured
13
- if (config.isConfigured()) {
14
- const { confirm } = await inquirer.prompt([
15
- {
16
- type: 'confirm',
17
- name: 'confirm',
18
- message: 'Configuration already exists. Do you want to reconfigure?',
19
- default: false
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
- // Ask for API key
28
- const { apiKey } = await inquirer.prompt([
29
- {
30
- type: 'password',
31
- name: 'apiKey',
32
- message: 'Enter your API key:',
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
- else {
54
- spinner.fail(chalk.red(result.message));
55
- console.log(chalk.yellow('\nTroubleshooting:'));
56
- console.log(chalk.white(' - Check that your API key is correct'));
57
- console.log(chalk.white(' - Verify the key is not revoked in the dashboard'));
58
- console.log(chalk.white(' - Generate a new key if needed: https://app.langctl.com/dashboard/settings/api-keys\n'));
59
- return;
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