@localheroai/cli 0.0.2 → 0.0.5

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.
@@ -1,282 +1,267 @@
1
1
  import chalk from 'chalk';
2
2
  import { configService } from '../utils/config.js';
3
- import { findTranslationFiles } from '../utils/files.js';
3
+ import { findTranslationFiles, parseFile, flattenTranslations } from '../utils/files.js';
4
4
  import { createTranslationJob, checkJobStatus } from '../api/translations.js';
5
- import { updateTranslationFile } from '../utils/translation-updater.js';
6
- import path from 'path';
5
+ import { updateTranslationFile } from '../utils/translation-updater/index.js';
7
6
  import { checkAuth } from '../utils/auth.js';
7
+ import { findMissingTranslations, batchKeysWithMissing, processLocaleTranslations } from '../utils/translation-utils.js';
8
+ import { syncService } from '../utils/sync-service.js';
8
9
  import { autoCommitChanges } from '../utils/github.js';
9
10
 
10
- const DEFAULT_LOCALE_REGEX = '.*?([a-z]{2}(?:-[A-Z]{2})?)\\.(?:yml|yaml|json)$';
11
- const MAX_RETRY_ATTEMPTS = 3;
12
- const BATCH_SIZE = 100;
13
-
14
- function validateConfig(config) {
15
- const required = ['projectId', 'sourceLocale', 'outputLocales', 'translationFiles'];
16
- const missing = required.filter(key => !config[key]);
11
+ const BATCH_SIZE = 50;
12
+
13
+ const defaultDeps = {
14
+ console,
15
+ configUtils: configService,
16
+ authUtils: { checkAuth },
17
+ fileUtils: { findTranslationFiles },
18
+ translationUtils: {
19
+ createTranslationJob,
20
+ checkJobStatus,
21
+ updateTranslationFile,
22
+ findMissingTranslations,
23
+ batchKeysWithMissing
24
+ },
25
+ syncService,
26
+ gitUtils: { autoCommitChanges }
27
+ };
28
+
29
+ export async function translate(options = {}, deps = defaultDeps) {
30
+ const { console, configUtils, authUtils, fileUtils, translationUtils, syncService, gitUtils } = deps;
31
+ const { verbose } = options;
32
+
33
+ const isAuthenticated = await authUtils.checkAuth();
34
+ if (!isAuthenticated) {
35
+ console.error(chalk.red('\n✖ Your API key is invalid. Please run `npx @localheroai/cli login` to authenticate.\n'));
36
+ process.exit(1);
37
+ }
38
+
39
+ const config = await configUtils.getProjectConfig();
40
+ if (!config) {
41
+ console.error(chalk.red('\n✖ No configuration found. Please run `npx @localheroai/cli init` first.\n'));
42
+ process.exit(1);
43
+ return;
44
+ }
45
+
46
+ if (!config.translationFiles?.paths) {
47
+ console.error(chalk.red('\n✖ Invalid configuration: missing translationFiles.paths. Please run `npx @localheroai/cli init` to set up your configuration.\n'));
48
+ process.exit(1);
49
+ return;
50
+ }
51
+
52
+ const { hasUpdates, updates } = await syncService.checkForUpdates({ verbose });
53
+ if (hasUpdates) {
54
+ await syncService.applyUpdates(updates, { verbose });
55
+ }
56
+
57
+ if (verbose) {
58
+ console.log(chalk.blue('\nℹ Using configuration:'));
59
+ console.log(chalk.gray(` Project ID: ${config.projectId}`));
60
+ console.log(chalk.gray(` Source locale: ${config.sourceLocale}`));
61
+ console.log(chalk.gray(` Output locales: ${config.outputLocales.join(', ')}`));
62
+ console.log(chalk.gray(` Translation files: ${config.translationFiles.paths.join(', ')}`));
63
+ }
64
+
65
+ const result = await fileUtils.findTranslationFiles(config, { verbose, returnFullResult: true });
66
+ const { sourceFiles, targetFilesByLocale, allFiles } = result;
67
+
68
+ if (!allFiles || allFiles.length === 0) {
69
+ console.error(chalk.red('\n✖ No translation files found in the specified paths.\n'));
70
+ process.exit(1);
71
+ }
72
+
73
+ if (verbose) {
74
+ console.log(chalk.blue(`\nℹ Found ${allFiles.length} translation files`));
75
+ }
76
+
77
+ if (sourceFiles.length === 0) {
78
+ console.error(chalk.red(`\n✖ No source files found for locale ${config.sourceLocale}\n`));
79
+ console.error(chalk.yellow(`This could be due to one of the following issues:`));
80
+ console.error(chalk.yellow(` 1. No translation files with the source locale "${config.sourceLocale}" exist in the configured paths`));
81
+ console.error(chalk.yellow(` 2. The locale identifiers in your filenames don't match the expected pattern`));
82
+ console.error(chalk.yellow(` 3. There was an error parsing one or more files (check for syntax errors in YAML or JSON)\n`));
83
+ console.error(chalk.yellow(`Try running with the --verbose flag for more detailed information.\n`));
84
+ process.exit(1);
85
+ }
86
+
87
+ if (verbose) {
88
+ console.log(chalk.blue(`ℹ Found ${sourceFiles.length} source files for locale ${config.sourceLocale}`));
89
+ }
90
+
91
+ const missingByLocale = {};
92
+
93
+ for (const sourceFile of sourceFiles) {
94
+ const sourceContentRaw = Buffer.from(sourceFile.content, 'base64').toString();
95
+ const sourceContent = parseFile(sourceContentRaw, sourceFile.format);
96
+ const sourceKeys = flattenTranslations(sourceContent[config.sourceLocale] || sourceContent);
97
+
98
+ for (const targetLocale of config.outputLocales) {
99
+ const targetFiles = targetFilesByLocale[targetLocale] || [];
100
+ const result = processLocaleTranslations(sourceKeys, targetLocale, targetFiles, sourceFile, config.sourceLocale);
101
+
102
+ if (Object.keys(result.missingKeys).length > 0) {
103
+ if (!missingByLocale[targetLocale]) {
104
+ missingByLocale[targetLocale] = {
105
+ path: sourceFile.path,
106
+ targetPath: result.targetPath,
107
+ keys: {},
108
+ keyCount: 0
109
+ };
110
+ }
17
111
 
18
- if (missing.length) {
19
- throw new Error(`Missing required config: ${missing.join(', ')}. Run 'npx localhero init' to set up your project.`);
20
- }
112
+ missingByLocale[targetLocale].keys = {
113
+ ...missingByLocale[targetLocale].keys,
114
+ ...result.missingKeys
115
+ };
116
+ missingByLocale[targetLocale].keyCount += Object.keys(result.missingKeys).length;
117
+ }
21
118
 
22
- if (!Array.isArray(config.outputLocales) || config.outputLocales.length === 0) {
23
- throw new Error('outputLocales must be an array with at least one locale');
119
+ if (verbose && Object.keys(result.skippedKeys).length > 0) {
120
+ console.log(chalk.yellow(`\nℹ Skipped ${Object.keys(result.skippedKeys).length} keys marked as WIP in ${sourceFile.path}`));
121
+ }
24
122
  }
25
-
26
- if (config.outputLocales.length > 10) {
27
- throw new Error('Maximum 10 target languages allowed per request');
123
+ }
124
+
125
+ const missingLocales = Object.keys(missingByLocale);
126
+ if (missingLocales.length === 0) {
127
+ console.log(chalk.green('✓ All translations are up to date!'));
128
+ return;
129
+ }
130
+
131
+ if (verbose) {
132
+ console.log(chalk.blue('\nℹ Missing translations:'));
133
+ for (const [locale, data] of Object.entries(missingByLocale)) {
134
+ console.log(chalk.gray(` ${locale}: ${data.keyCount} keys`));
28
135
  }
136
+ }
29
137
 
30
- if (!config.translationFiles.paths || !Array.isArray(config.translationFiles.paths)) {
31
- throw new Error('translationFiles.paths must be an array of paths');
32
- }
33
- }
138
+ const { batches, errors } = translationUtils.batchKeysWithMissing(sourceFiles, missingByLocale, BATCH_SIZE);
34
139
 
35
- function findMissingTranslations(sourceKeys, targetKeys) {
36
- const missing = {};
37
- for (const [key, value] of Object.entries(sourceKeys)) {
38
- if (!targetKeys[key]) {
39
- missing[key] = value;
40
- }
140
+ if (errors.length > 0) {
141
+ console.error(chalk.red('\n✖ Errors occurred while preparing translation jobs:'));
142
+ for (const error of errors) {
143
+ console.error(chalk.red(` ${error.message}`));
41
144
  }
42
- return missing;
43
- }
145
+ process.exit(1);
146
+ }
147
+
148
+ let totalTranslated = 0;
149
+ let totalLanguages = 0;
150
+ const processedLocales = new Set();
151
+ const allJobIds = [];
152
+ let resultsBaseUrl = null;
153
+
154
+ for (const batch of batches) {
155
+ const targetPaths = Object.entries(missingByLocale).reduce((acc, [locale, data]) => {
156
+ acc[locale] = data.targetPath;
157
+ return acc;
158
+ }, {});
159
+
160
+ const jobRequest = {
161
+ projectId: config.projectId,
162
+ sourceFiles: batch.files,
163
+ targetLocales: batch.locales,
164
+ targetPaths
165
+ };
44
166
 
45
- async function retryWithBackoff(operation, attempt = 1) {
46
167
  try {
47
- return await operation();
48
- } catch (error) {
49
- if (attempt >= MAX_RETRY_ATTEMPTS) throw error;
50
-
51
- const backoffTime = Math.min(1000 * Math.pow(2, attempt), 30000);
52
- const jitter = Math.random() * 1000;
53
- const waitTime = backoffTime + jitter;
54
-
55
- console.log(chalk.yellow(`⚠️ API error, retrying in ${Math.round(waitTime / 1000)}s (attempt ${attempt}/${MAX_RETRY_ATTEMPTS})`));
56
- console.log(error);
57
- await new Promise(resolve => setTimeout(resolve, waitTime));
58
-
59
- return retryWithBackoff(operation, attempt + 1);
60
- }
61
- }
62
-
63
- function batchKeysWithMissing(sourceFiles, missingByLocale, batchSize = BATCH_SIZE) {
64
- const batches = [];
65
- const sourceFileEntries = new Map();
66
-
67
- for (const [locale, localeData] of Object.entries(missingByLocale)) {
68
- const sourceFile = sourceFiles.find(f => f.path === localeData.path);
69
- if (!sourceFile) continue;
70
-
71
- if (!sourceFileEntries.has(sourceFile.path)) {
72
- sourceFileEntries.set(sourceFile.path, {
73
- path: path.relative(process.cwd(), sourceFile.path),
74
- keys: {},
75
- locales: new Set()
76
- });
77
- }
168
+ const response = await translationUtils.createTranslationJob(jobRequest);
169
+ const { jobs } = response;
170
+ const batchJobIds = jobs.map(job => job.id);
78
171
 
79
- const entry = sourceFileEntries.get(sourceFile.path);
80
- entry.keys = { ...entry.keys, ...localeData.keys };
81
- entry.locales.add(locale);
82
- }
172
+ allJobIds.push(...batchJobIds);
83
173
 
84
- for (const entry of sourceFileEntries.values()) {
85
- const keys = entry.keys;
86
- const keyEntries = Object.entries(keys);
87
- for (let i = 0; i < keyEntries.length; i += batchSize) {
88
- const batchKeys = Object.fromEntries(keyEntries.slice(i, i + batchSize));
89
- batches.push([{
90
- path: entry.path,
91
- content: Buffer.from(JSON.stringify({ keys: batchKeys })).toString('base64')
92
- }]);
93
- }
94
- }
174
+ const pendingJobs = new Set(batchJobIds);
95
175
 
96
- return batches;
97
- }
176
+ while (pendingJobs.size > 0) {
177
+ const jobPromises = Array.from(pendingJobs).map(async jobId => {
178
+ if (verbose) {
179
+ console.log(chalk.blue(`\nℹ Checking job ${jobId}`));
180
+ }
98
181
 
99
- export async function translate(options = {}) {
100
- const { verbose = false, commit = false } = options;
101
- const log = verbose ? console.log : () => { };
182
+ let status;
183
+ let retries = 0;
184
+ const MAX_WAIT_MINUTES = 10;
185
+ const startTime = Date.now();
102
186
 
103
- try {
104
- const config = await configService.getValidProjectConfig();
105
- const isAuthenticated = await checkAuth();
187
+ do {
188
+ status = await translationUtils.checkJobStatus(jobId, true);
106
189
 
107
- if (!isAuthenticated) {
108
- throw new Error('No API key found. Run `npx localhero login` or set LOCALHERO_API_KEY');
109
- }
110
-
111
- console.log(chalk.blue('ℹ️ Loading configuration from localhero.json'));
112
-
113
- const { translationFiles } = config;
114
- const fileLocaleRegex = DEFAULT_LOCALE_REGEX;
115
- let allFiles = [];
116
-
117
- for (const translationPath of translationFiles.paths) {
118
- const filesInPath = await findTranslationFiles(translationPath, fileLocaleRegex);
119
- allFiles = allFiles.concat(filesInPath);
120
- }
121
-
122
- if (!allFiles.length) {
123
- console.error(chalk.red('❌ No translation files found'));
124
- process.exit(1);
125
- }
190
+ if (status.status === 'failed') {
191
+ throw new Error(`Translation job failed: ${status.error_details || 'Unknown error'}`);
192
+ }
126
193
 
127
- const sourceFiles = allFiles.filter(f => f.locale === config.sourceLocale);
128
- const targetFiles = allFiles.filter(f => config.outputLocales.includes(f.locale));
194
+ if (status.status === 'pending' || status.status === 'processing') {
195
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
196
+ if (elapsed > MAX_WAIT_MINUTES * 60) {
197
+ throw new Error(`Translation timed out after ${MAX_WAIT_MINUTES} minutes`);
198
+ }
199
+
200
+ const waitSeconds = Math.min(2 ** retries, 30);
201
+ if (verbose) {
202
+ console.log(chalk.blue(` Job ${jobId} is ${status.status}, checking again in ${waitSeconds}s...`));
203
+ }
204
+ await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
205
+ retries = Math.min(retries + 1, 5);
206
+ return { jobId, status: 'pending' };
207
+ }
129
208
 
130
- if (!sourceFiles.length) {
131
- console.error(chalk.red(`❌ No source files found for locale ${config.sourceLocale}`));
132
- process.exit(1);
133
- }
209
+ if (status.status === 'completed' && status.translations?.data && status.language?.code) {
210
+ if (status.results_url && !resultsBaseUrl) {
211
+ resultsBaseUrl = status.results_url.split('?')[0];
212
+ }
134
213
 
135
- log(chalk.blue(`✓ Found ${allFiles.length} translation files`));
136
- log(chalk.blue(`ℹ️ Analyzing target translations...`));
137
-
138
- const missingByLocale = {};
139
- for (const sourceFile of sourceFiles) {
140
- const sourceContent = JSON.parse(Buffer.from(sourceFile.content, 'base64').toString());
141
-
142
- for (const targetLocale of config.outputLocales) {
143
- const targetFile = targetFiles.find(f => f.locale === targetLocale);
144
- const targetContent = targetFile ?
145
- JSON.parse(Buffer.from(targetFile.content, 'base64').toString()) :
146
- { keys: {} };
147
-
148
- const missing = findMissingTranslations(sourceContent.keys, targetContent.keys);
149
- const missingCount = Object.keys(missing).length;
150
-
151
- if (missingCount > 0) {
152
- if (!missingByLocale[targetLocale]) {
153
- missingByLocale[targetLocale] = {
154
- keys: {},
155
- path: sourceFile.path
156
- };
157
- }
158
- missingByLocale[targetLocale].keys = {
159
- ...missingByLocale[targetLocale].keys,
160
- ...missing
161
- };
162
- console.log(chalk.blue(` ${targetLocale} (${path.basename(sourceFile.path)}): ${missingCount} missing keys`));
214
+ const languageCode = status.language.code;
215
+ if (!processedLocales.has(languageCode)) {
216
+ const targetPath = missingByLocale[languageCode].targetPath;
217
+ if (verbose) {
218
+ console.log(chalk.blue(` Updating translations for ${languageCode} in ${targetPath}`));
163
219
  }
220
+ await translationUtils.updateTranslationFile(targetPath, status.translations.data, languageCode);
221
+ totalTranslated += missingByLocale[languageCode].keyCount;
222
+ totalLanguages++;
223
+ processedLocales.add(languageCode);
224
+ }
225
+ return { jobId, status: 'completed' };
164
226
  }
165
- }
166
227
 
167
- if (Object.keys(missingByLocale).length === 0) {
168
- console.log(chalk.green('✓ All translations are up to date!'));
169
- return;
170
- }
228
+ return { jobId, status: status.status };
229
+ } while (status.status === 'pending' || status.status === 'processing');
230
+ });
171
231
 
172
- const updatedFiles = new Set();
173
- const batches = batchKeysWithMissing(sourceFiles, missingByLocale);
174
- let totalKeysProcessed = 0;
175
- let totalErrors = 0;
176
- const processedKeys = new Set();
177
- let hasShownUpdateMessage = false;
178
-
179
- for (const [batchIndex, batch] of batches.entries()) {
180
- log(chalk.blue(`\nProcessing batch ${batchIndex + 1}/${batches.length}...`));
181
-
182
- try {
183
- const { jobs } = await retryWithBackoff(() =>
184
- createTranslationJob({
185
- sourceFiles: batch,
186
- targetLocales: config.outputLocales,
187
- projectId: config.projectId
188
- })
189
- );
190
- const jobStatuses = new Map();
191
- let allCompleted = false;
192
-
193
- while (!allCompleted) {
194
- allCompleted = true;
195
- let totalProgress = 0;
196
-
197
- for (const job of jobs) {
198
- const status = await retryWithBackoff(() => checkJobStatus(job.id, true));
199
- jobStatuses.set(job.id, status);
200
-
201
- if (status.status === 'processing') {
202
- allCompleted = false;
203
- }
204
- totalProgress += status.progress.percentage;
205
- }
206
-
207
- const averageProgress = Math.round(totalProgress / jobs.length);
208
- const bar = '='.repeat(averageProgress / 2) + ' '.repeat(50 - averageProgress / 2);
209
-
210
- process.stdout.write(`\r⧗ Translating... [${bar}] ${averageProgress}%`);
211
-
212
- await new Promise(resolve => setTimeout(resolve, 2000));
213
- }
232
+ const results = await Promise.all(jobPromises);
233
+ results.forEach(result => {
234
+ if (result.status === 'completed') {
235
+ pendingJobs.delete(result.jobId);
236
+ }
237
+ });
214
238
 
215
- for (const job of jobs) {
216
- const status = jobStatuses.get(job.id);
217
- if (status.status === 'completed' && status.translations) {
218
- if (!hasShownUpdateMessage) {
219
- console.log(chalk.blue('\nℹ️ Updating translation files...'));
220
- hasShownUpdateMessage = true;
221
- }
222
-
223
- const translations = status.translations.translations;
224
- const targetLocale = status.language.code;
225
- const uniqueKey = `${targetLocale}:${Object.keys(translations).sort().join(',')}`;
226
-
227
- if (processedKeys.has(uniqueKey)) {
228
- continue;
229
- }
230
- processedKeys.add(uniqueKey);
231
-
232
- const targetFile = targetFiles.find(f => f.locale === targetLocale)?.path ||
233
- path.join(config.translationPath, `${targetLocale}.yml`);
234
-
235
- try {
236
- const updatedKeys = await updateTranslationFile(
237
- targetFile,
238
- translations,
239
- targetLocale
240
- );
241
-
242
- updatedFiles.add(targetFile);
243
- totalKeysProcessed += updatedKeys.length;
244
-
245
- if (verbose) {
246
- console.log(chalk.blue(` Updated ${targetFile}`));
247
- updatedKeys.forEach(key => console.log(chalk.gray(` - Added: ${key}`)));
248
- } else {
249
- console.log(chalk.blue(` Updated ${path.basename(targetFile)}`));
250
- }
251
- } catch (error) {
252
- console.error(chalk.yellow(`⚠️ Failed to update ${targetFile}: ${error.message}`));
253
- totalErrors++;
254
- }
255
- }
256
- }
257
- } catch (error) {
258
- totalErrors++;
259
- console.error(chalk.red(`❌ Batch ${batchIndex + 1} failed: ${error.message}`));
260
- console.log(error);
261
-
262
- }
239
+ if (pendingJobs.size > 0) {
240
+ await new Promise(resolve => setTimeout(resolve, 2000));
263
241
  }
242
+ }
243
+ } catch (error) {
244
+ console.error(chalk.red(`\n✖ Error processing translation jobs: ${error.message}\n`));
245
+ process.exit(1);
246
+ }
247
+ }
264
248
 
265
- if (totalErrors > 0) {
266
- console.error(chalk.red(`\n❌ Translation completed with ${totalErrors} failed batches`));
267
- process.exit(1);
268
- }
249
+ await configUtils.updateLastSyncedAt();
269
250
 
270
- console.log(chalk.green('\n✓ Translations complete!') + ` Updated ${totalKeysProcessed} keys in ${config.outputLocales.length} languages`);
251
+ console.log(chalk.green('✓ Translations complete!'));
252
+ console.log(`Updated ${totalTranslated} keys in ${totalLanguages} languages`);
271
253
 
272
- if (commit || process.env.GITHUB_ACTIONS === 'true') {
273
- const translationPaths = Array.from(updatedFiles).join(' ');
274
- if (translationPaths) {
275
- autoCommitChanges(translationPaths);
276
- }
277
- }
254
+ // Auto-commit changes if we're running in GitHub Actions
255
+ if (totalTranslated > 0) {
256
+ try {
257
+ gitUtils.autoCommitChanges(config.translationFiles.paths.join(' '));
278
258
  } catch (error) {
279
- console.error(chalk.red(`❌ ${error.message}`));
280
- process.exit(1);
259
+ console.warn(chalk.yellow(`\nℹ Could not auto-commit changes: ${error.message}`));
281
260
  }
282
- }
261
+ }
262
+
263
+ if (resultsBaseUrl && allJobIds.length > 0) {
264
+ const jobIdsParam = allJobIds.join(',');
265
+ console.log(`View job results at: ${resultsBaseUrl}?job_ids=${jobIdsParam}`);
266
+ }
267
+ }
package/src/utils/auth.js CHANGED
@@ -1,23 +1,23 @@
1
1
  import { configService } from './config.js';
2
2
 
3
3
  export async function getApiKey() {
4
- const envKey = process.env.LOCALHERO_API_KEY;
5
- if (envKey) {
6
- return envKey;
7
- }
4
+ const envKey = process.env.LOCALHERO_API_KEY;
5
+ if (typeof envKey === 'string' && envKey.trim() !== '') {
6
+ return envKey;
7
+ }
8
8
 
9
- const config = await configService.getAuthConfig();
10
- return config?.api_key;
9
+ const config = await configService.getAuthConfig();
10
+ return config?.api_key;
11
11
  }
12
12
 
13
13
  export async function checkAuth() {
14
- try {
15
- const apiKey = await getApiKey();
16
- const isValidFormat = typeof apiKey === 'string' &&
17
- /^tk_[a-f0-9]+$/.test(apiKey);
14
+ try {
15
+ const apiKey = await getApiKey();
16
+ const isValidFormat = typeof apiKey === 'string' &&
17
+ /^tk_[a-f0-9]+$/.test(apiKey);
18
18
 
19
- return isValidFormat;
20
- } catch (error) {
21
- return false;
22
- }
23
- }
19
+ return isValidFormat;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }