@localheroai/cli 0.0.2 → 0.0.3

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.
@@ -2,17 +2,34 @@ import { execSync } from 'child_process';
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
 
5
- export function isGitHubAction() {
6
- return process.env.GITHUB_ACTIONS === 'true';
7
- }
5
+ const defaultDependencies = {
6
+ exec: (cmd, options) => execSync(cmd, options),
7
+ fs,
8
+ path,
9
+ env: process.env
10
+ };
11
+
12
+ export const githubService = {
13
+ deps: { ...defaultDependencies },
14
+
15
+ // For testing - reset or inject custom dependencies
16
+ setDependencies(customDeps = {}) {
17
+ this.deps = { ...defaultDependencies, ...customDeps };
18
+ return this;
19
+ },
20
+
21
+ isGitHubAction() {
22
+ return this.deps.env.GITHUB_ACTIONS === 'true';
23
+ },
8
24
 
9
- export async function createGitHubActionFile(basePath, translationPaths) {
10
- const workflowDir = path.join(basePath, '.github', 'workflows');
11
- const workflowFile = path.join(workflowDir, 'localhero-translate.yml');
25
+ async createGitHubActionFile(basePath, translationPaths) {
26
+ const { fs, path } = this.deps;
27
+ const workflowDir = path.join(basePath, '.github', 'workflows');
28
+ const workflowFile = path.join(workflowDir, 'localhero-translate.yml');
12
29
 
13
- await fs.mkdir(workflowDir, { recursive: true });
30
+ await fs.mkdir(workflowDir, { recursive: true });
14
31
 
15
- const actionContent = `name: Localhero.ai - I18n translation
32
+ const actionContent = `name: Localhero.ai - I18n translation
16
33
 
17
34
  on:
18
35
  pull_request:
@@ -41,52 +58,65 @@ jobs:
41
58
  - name: Run LocalHero CLI
42
59
  env:
43
60
  LOCALHERO_API_KEY: \${{ secrets.LOCALHERO_API_KEY }}
44
- run: npx @localheroai/cli translate`;
61
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
62
+ run: npx -y @localheroai/cli translate`;
45
63
 
46
- await fs.writeFile(workflowFile, actionContent);
47
- return workflowFile;
48
- }
64
+ await fs.writeFile(workflowFile, actionContent);
65
+ return workflowFile;
66
+ },
49
67
 
50
- export function autoCommitChanges(filesPath) {
51
- if (!isGitHubAction()) return;
68
+ autoCommitChanges(filesPath) {
69
+ const { exec, env } = this.deps;
52
70
 
53
- console.log("Running in GitHub Actions. Committing changes...");
54
- try {
55
- execSync('git config --global user.name "LocalHero Bot"', { stdio: "inherit" });
56
- execSync('git config --global user.email "hi@localhero.ai"', { stdio: "inherit" });
71
+ if (!this.isGitHubAction()) return;
57
72
 
58
- const branchName = process.env.GITHUB_HEAD_REF;
59
- if (!branchName) {
60
- throw new Error('Could not determine branch name from GITHUB_HEAD_REF');
61
- }
73
+ console.log("Running in GitHub Actions. Committing changes...");
74
+ try {
75
+ exec('git config --global user.name "LocalHero Bot"', { stdio: "inherit" });
76
+ exec('git config --global user.email "hi@localhero.ai"', { stdio: "inherit" });
62
77
 
63
- execSync(`git add ${filesPath}`, { stdio: "inherit" });
78
+ const branchName = env.GITHUB_HEAD_REF;
79
+ if (!branchName) {
80
+ throw new Error('Could not determine branch name from GITHUB_HEAD_REF');
81
+ }
64
82
 
65
- const status = execSync('git status --porcelain').toString();
66
- if (!status) {
67
- console.log("No changes to commit.");
68
- return;
69
- }
83
+ exec(`git add ${filesPath}`, { stdio: "inherit" });
70
84
 
71
- execSync('git commit -m "Update translations"', { stdio: "inherit" });
85
+ const status = exec('git status --porcelain').toString();
86
+ if (!status) {
87
+ console.log("No changes to commit.");
88
+ return;
89
+ }
72
90
 
73
- const token = process.env.GITHUB_TOKEN;
74
- if (!token) {
75
- throw new Error('GITHUB_TOKEN is not set');
76
- }
91
+ exec('git commit -m "Update translations"', { stdio: "inherit" });
77
92
 
78
- const repository = process.env.GITHUB_REPOSITORY;
79
- if (!repository) {
80
- throw new Error('GITHUB_REPOSITORY is not set');
81
- }
93
+ const token = env.GITHUB_TOKEN;
94
+ if (!token) {
95
+ throw new Error('GITHUB_TOKEN is not set');
96
+ }
82
97
 
83
- const remoteUrl = `https://x-access-token:${token}@github.com/${repository}.git`;
98
+ const repository = env.GITHUB_REPOSITORY;
99
+ if (!repository) {
100
+ throw new Error('GITHUB_REPOSITORY is not set');
101
+ }
84
102
 
85
- execSync(`git remote set-url origin ${remoteUrl}`, { stdio: "inherit" });
86
- execSync(`git push origin HEAD:${branchName}`, { stdio: "inherit" });
87
- console.log("Changes committed and pushed successfully.");
88
- } catch (error) {
89
- console.error("Auto-commit failed:", error.message);
90
- throw error;
103
+ const remoteUrl = `https://x-access-token:${token}@github.com/${repository}.git`;
104
+
105
+ exec(`git remote set-url origin ${remoteUrl}`, { stdio: "inherit" });
106
+ exec(`git push origin HEAD:${branchName}`, { stdio: "inherit" });
107
+ console.log("Changes committed and pushed successfully.");
108
+ } catch (error) {
109
+ console.error("Auto-commit failed:", error.message);
110
+ throw error;
111
+ }
91
112
  }
92
- }
113
+ };
114
+
115
+ // Only export the functions needed externally
116
+ export function createGitHubActionFile(basePath, translationPaths) {
117
+ return githubService.createGitHubActionFile(basePath, translationPaths);
118
+ }
119
+
120
+ export function autoCommitChanges(filesPath) {
121
+ return githubService.autoCommitChanges(filesPath);
122
+ }
@@ -1,146 +1,129 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
- import { glob } from 'glob';
4
3
  import { createImport, checkImportStatus } from '../api/imports.js';
4
+ import { findTranslationFiles, flattenTranslations } from './files.js';
5
5
 
6
6
  function getFileFormat(filePath) {
7
- const ext = path.extname(filePath).toLowerCase();
8
- if (ext === '.json') return 'json';
9
- if (ext === '.yml' || ext === '.yaml') return 'yaml';
10
- return null;
7
+ const ext = path.extname(filePath).toLowerCase();
8
+ if (ext === '.json') return 'json';
9
+ if (ext === '.yml' || ext === '.yaml') return 'yaml';
10
+ return null;
11
11
  }
12
12
 
13
13
  async function readFileContent(filePath) {
14
- const content = await fs.readFile(filePath, 'utf8');
15
- return Buffer.from(content).toString('base64');
16
- }
14
+ const content = await fs.readFile(filePath, 'utf8');
15
+ const format = getFileFormat(filePath);
16
+
17
+ if (format === 'json') {
18
+ try {
19
+ const jsonContent = JSON.parse(content);
20
+ const flattened = flattenTranslations(jsonContent);
17
21
 
18
- function getLanguageFromPath(filePath, sourceLocale) {
19
- const fileName = path.basename(filePath, path.extname(filePath));
20
- return fileName === sourceLocale ? sourceLocale : fileName;
22
+ return Buffer.from(JSON.stringify(flattened)).toString('base64');
23
+ } catch {
24
+ return Buffer.from(content).toString('base64');
25
+ }
26
+ }
27
+
28
+ return Buffer.from(content).toString('base64');
21
29
  }
22
30
 
23
31
  export const importService = {
24
- async findTranslationFiles(config, basePath = process.cwd()) {
25
- const { translationFiles, sourceLocale } = config;
26
- const { paths, ignore = [] } = translationFiles;
27
-
28
- const allFiles = [];
29
- for (const translationPath of paths) {
30
- const fullPath = path.join(basePath, translationPath);
31
- const pattern = path.join(fullPath, '**/*.{json,yml,yaml}');
32
-
33
- const files = await glob(pattern, {
34
- ignore: ignore.map(p => path.join(basePath, p)),
35
- nodir: true
36
- });
37
-
38
- allFiles.push(...files);
39
- }
40
-
41
- return allFiles.map(file => ({
42
- path: path.relative(basePath, file),
43
- language: getLanguageFromPath(file, sourceLocale),
44
- format: getFileFormat(file)
45
- }));
46
- },
47
-
48
- async importTranslations(config, basePath = process.cwd()) {
49
- const files = await this.findTranslationFiles(config, basePath);
50
-
51
- if (!files.length) {
52
- return { status: 'no_files' };
53
- }
54
-
55
- const sourceFiles = files.filter(file => file.language === config.sourceLocale);
56
- const targetFiles = files.filter(file => file.language !== config.sourceLocale);
57
- const importedFiles = {
58
- source: sourceFiles,
59
- target: targetFiles
60
- };
32
+ async findTranslationFiles(config, basePath = process.cwd()) {
33
+ const files = await findTranslationFiles(config, {
34
+ basePath,
35
+ parseContent: false,
36
+ includeContent: false,
37
+ extractKeys: false,
38
+ includeNamespace: true
39
+ });
40
+
41
+ return files.map(file => ({
42
+ path: path.isAbsolute(file.path) ? path.relative(basePath, file.path) : file.path,
43
+ language: file.locale,
44
+ format: file.format === 'yml' ? 'yaml' : file.format,
45
+ namespace: file.namespace || ''
46
+ }));
47
+ },
48
+
49
+ async importTranslations(config, basePath = process.cwd()) {
50
+ const files = await this.findTranslationFiles(config, basePath);
51
+
52
+ if (!files.length) {
53
+ return { status: 'no_files' };
54
+ }
55
+
56
+ const sourceFiles = files.filter(file => file.language === config.sourceLocale);
57
+ const targetFiles = files.filter(file => file.language !== config.sourceLocale);
58
+ const importedFiles = {
59
+ source: sourceFiles,
60
+ target: targetFiles
61
+ };
62
+
63
+ if (!sourceFiles.length) {
64
+ return {
65
+ status: 'failed',
66
+ error: 'No source language files found. Source language files must be included in the first import.',
67
+ files: importedFiles
68
+ };
69
+ }
61
70
 
62
- if (!sourceFiles.length) {
63
- return {
64
- status: 'failed',
65
- error: 'No source language files found. Source language files must be included in the first import.',
66
- files: importedFiles
67
- };
68
- }
69
-
70
- // First import: source language files
71
- const sourceTranslations = await Promise.all(
72
- sourceFiles.map(async file => ({
73
- language: file.language,
74
- format: file.format,
75
- filename: file.path,
76
- content: await readFileContent(path.join(basePath, file.path))
77
- }))
78
- );
79
-
80
- const sourceImport = await createImport({
81
- projectId: config.projectId,
82
- translations: sourceTranslations
83
- });
84
-
85
- if (sourceImport.status === 'failed') {
86
- return sourceImport;
87
- }
88
-
89
- let finalSourceImport = sourceImport;
90
- while (finalSourceImport.status === 'processing') {
91
- await new Promise(resolve => setTimeout(resolve, finalSourceImport.poll_interval * 1000));
92
- finalSourceImport = await checkImportStatus(config.projectId, finalSourceImport.id);
93
-
94
- if (finalSourceImport.status === 'failed') {
95
- return finalSourceImport;
96
- }
97
- }
98
-
99
- if (!targetFiles.length) {
100
- return { ...finalSourceImport, files: importedFiles };
101
- }
102
-
103
- // Second import: target language files
104
- const targetTranslations = await Promise.all(
105
- targetFiles.map(async file => ({
106
- language: file.language,
107
- format: file.format,
108
- filename: file.path,
109
- content: await readFileContent(path.join(basePath, file.path))
110
- }))
111
- );
112
-
113
- const targetImport = await createImport({
114
- projectId: config.projectId,
115
- translations: targetTranslations
116
- });
117
-
118
- if (targetImport.status === 'completed') {
119
- return {
120
- ...targetImport,
121
- sourceImport: finalSourceImport,
122
- files: importedFiles
123
- };
124
- }
125
-
126
- let finalTargetImport = targetImport;
127
- while (finalTargetImport.status === 'processing') {
128
- await new Promise(resolve => setTimeout(resolve, finalTargetImport.poll_interval * 1000));
129
- finalTargetImport = await checkImportStatus(config.projectId, finalTargetImport.id);
130
-
131
- if (finalTargetImport.status !== 'processing') {
132
- return {
133
- ...finalTargetImport,
134
- sourceImport: finalSourceImport,
135
- files: importedFiles
136
- };
137
- }
138
- }
71
+ const allTranslations = [];
139
72
 
73
+ for (const file of sourceFiles) {
74
+ const fullPath = path.join(basePath, file.path);
75
+ allTranslations.push({
76
+ language: file.language,
77
+ format: file.format === 'yml' ? 'yaml' : file.format,
78
+ filename: file.path,
79
+ content: await readFileContent(fullPath)
80
+ });
81
+ }
82
+
83
+ for (const file of targetFiles) {
84
+ const fullPath = path.join(basePath, file.path);
85
+ allTranslations.push({
86
+ language: file.language,
87
+ format: file.format === 'yml' ? 'yaml' : file.format,
88
+ filename: file.path,
89
+ content: await readFileContent(fullPath)
90
+ });
91
+ }
92
+
93
+ const importResult = await createImport({
94
+ projectId: config.projectId,
95
+ translations: allTranslations
96
+ });
97
+
98
+ if (importResult.status === 'failed') {
99
+ return {
100
+ ...importResult,
101
+ files: importedFiles
102
+ };
103
+ }
104
+
105
+ let finalImportResult = importResult;
106
+ while (finalImportResult.status === 'processing') {
107
+ await new Promise(resolve => setTimeout(resolve, finalImportResult.poll_interval * 1000));
108
+ finalImportResult = await checkImportStatus(config.projectId, finalImportResult.id);
109
+
110
+ if (finalImportResult.status === 'failed') {
140
111
  return {
141
- ...finalTargetImport,
142
- sourceImport: finalSourceImport,
143
- files: importedFiles
112
+ ...finalImportResult,
113
+ files: importedFiles
144
114
  };
115
+ }
145
116
  }
146
- };
117
+
118
+ // Ensure we pass through all relevant fields from the API response
119
+ const { status, statistics, warnings, translations_url, sourceImport } = finalImportResult;
120
+ return {
121
+ status,
122
+ statistics,
123
+ warnings,
124
+ translations_url,
125
+ sourceImport,
126
+ files: importedFiles
127
+ };
128
+ }
129
+ };
@@ -1,51 +1,67 @@
1
- import path from 'path';
2
-
3
1
  export function createPromptService(deps = {}) {
4
- const { inquirer = null } = deps;
5
-
6
- return {
7
- async getApiKey() {
8
- if (!inquirer) return '';
9
- return inquirer.password({
10
- message: 'API Key:',
11
- mask: '*'
12
- });
13
- },
14
-
15
- async getProjectSetup() {
16
- if (!inquirer) return {};
17
-
18
- return {
19
- projectName: '',
20
- sourceLocale: '',
21
- outputLocales: [],
22
- translationPath: '',
23
- ignorePaths: []
24
- };
25
- },
26
-
27
- async confirmLogin() {
28
- if (!inquirer) return { shouldLogin: false };
29
- const result = await inquirer.confirm({
30
- message: 'Would you like to login now?',
31
- default: true
32
- });
33
- return { shouldLogin: result };
34
- },
35
-
36
- async select(options) {
37
- if (!inquirer) return 'new';
38
- return inquirer.select(options);
39
- },
40
-
41
- async input(options) {
42
- if (!inquirer) return '';
43
- return inquirer.input(options);
44
- },
45
-
46
- async confirm(options) {
47
- if (!inquirer) return false;
48
- return inquirer.confirm(options);
49
- }
50
- };
51
- }
2
+ const { inquirer = null } = deps;
3
+
4
+ return {
5
+ async getApiKey() {
6
+ if (!inquirer) return '';
7
+ return inquirer.password({
8
+ message: 'API Key:',
9
+ mask: '*'
10
+ });
11
+ },
12
+
13
+ async getProjectSetup() {
14
+ if (!inquirer) return {};
15
+
16
+ return {
17
+ projectName: '',
18
+ sourceLocale: '',
19
+ outputLocales: [],
20
+ translationPath: '',
21
+ ignorePaths: []
22
+ };
23
+ },
24
+
25
+ async select(options) {
26
+ if (!inquirer) return 'new';
27
+ return inquirer.select(options);
28
+ },
29
+
30
+ async input(options) {
31
+ if (!inquirer) return '';
32
+ return inquirer.input(options);
33
+ },
34
+
35
+ async confirm(options) {
36
+ if (!inquirer) return false;
37
+ return inquirer.confirm(options);
38
+ },
39
+
40
+ async selectProject(projectService) {
41
+ const projects = await projectService.listProjects();
42
+
43
+ if (!projects || projects.length === 0) {
44
+ return { choice: 'new' };
45
+ }
46
+
47
+ const choices = [
48
+ { name: '✨ Create new project', value: 'new' },
49
+ { name: '─────────────', value: 'separator', disabled: true },
50
+ ...projects.map(p => ({
51
+ name: p.name,
52
+ value: p.id
53
+ }))
54
+ ];
55
+
56
+ const projectChoice = await this.select({
57
+ message: 'Would you like to use an existing project or create a new one?',
58
+ choices
59
+ });
60
+
61
+ return {
62
+ choice: projectChoice,
63
+ project: projects.find(p => p.id === projectChoice)
64
+ };
65
+ }
66
+ };
67
+ }
@@ -0,0 +1,147 @@
1
+ import chalk from 'chalk';
2
+ import { configService } from './config.js';
3
+ import { getUpdates } from '../api/translations.js';
4
+ import { updateTranslationFile, deleteKeysFromTranslationFile } from './translation-updater/index.js';
5
+ import { findTranslationFiles } from './files.js';
6
+
7
+ const MAX_PAGES = 20;
8
+
9
+ export const syncService = {
10
+ async checkForUpdates({ verbose = false } = {}) {
11
+ const config = await configService.getValidProjectConfig();
12
+
13
+ if (!config.projectId) {
14
+ throw new Error('Project not initialized. Please run `localhero init` first.');
15
+ }
16
+
17
+ const since = config.lastSyncedAt || new Date(0).toISOString();
18
+
19
+ if (verbose) {
20
+ console.log(chalk.blue(`Checking for updates since ${since}`));
21
+ }
22
+
23
+ let allFiles = [];
24
+ let deletedKeys = [];
25
+ let currentPage = 1;
26
+ let hasMorePages = true;
27
+
28
+ while (hasMorePages && currentPage <= MAX_PAGES) {
29
+ const response = await getUpdates(config.projectId, { since, page: currentPage });
30
+
31
+ if (response.updates?.updated_keys?.length) {
32
+ allFiles = allFiles.concat(response.updates.updated_keys);
33
+ }
34
+
35
+ if (response.updates?.deleted_keys?.length) {
36
+ deletedKeys = deletedKeys.concat(response.updates.deleted_keys);
37
+ }
38
+
39
+ if (response.pagination) {
40
+ const { current_page, total_pages } = response.pagination;
41
+ hasMorePages = current_page < total_pages;
42
+ currentPage++;
43
+
44
+ if (verbose && hasMorePages) {
45
+ if (total_pages > MAX_PAGES) {
46
+ console.log(chalk.yellow(` ⚠️ Limiting to ${MAX_PAGES} pages out of ${total_pages} total`));
47
+ } else {
48
+ console.log(chalk.gray(` Fetching page ${currentPage} of ${total_pages}`));
49
+ }
50
+ }
51
+ } else {
52
+ hasMorePages = false;
53
+ }
54
+ }
55
+
56
+ if (!allFiles.length && !deletedKeys.length) {
57
+ if (verbose) {
58
+ console.log(chalk.green('✓ All translations are up to date'));
59
+ }
60
+ return { hasUpdates: false };
61
+ }
62
+
63
+ return {
64
+ hasUpdates: true,
65
+ updates: {
66
+ updates: {
67
+ files: allFiles,
68
+ deleted_keys: deletedKeys
69
+ }
70
+ }
71
+ };
72
+ },
73
+
74
+ async applyUpdates(updates, { verbose = false } = {}) {
75
+ let totalUpdates = 0;
76
+ let totalDeleted = 0;
77
+ for (const file of updates.updates.files || []) {
78
+ for (const lang of file.languages) {
79
+ if (verbose) {
80
+ console.log(chalk.blue(`Updating ${lang.code} translations in ${file.path}`));
81
+ }
82
+
83
+ const translations = {};
84
+ for (const translation of lang.translations) {
85
+ translations[translation.key] = translation.value;
86
+ if (verbose) {
87
+ const displayValue = translation.value.length > 100 ? `${translation.value.slice(0, 100)}…` : translation.value;
88
+ console.log(chalk.gray(` ${translation.key} = ${displayValue}`));
89
+ }
90
+ }
91
+
92
+ try {
93
+ await updateTranslationFile(file.path, translations, lang.code);
94
+ totalUpdates += Object.keys(translations).length;
95
+ } catch (error) {
96
+ console.error(chalk.yellow(`⚠️ Failed to update ${file.path}: ${error.message}`));
97
+ }
98
+ }
99
+ }
100
+ const deletedKeys = updates.updates.deleted_keys || [];
101
+ if (deletedKeys.length > 0) {
102
+ if (verbose) {
103
+ console.log(chalk.blue(`\nProcessing ${deletedKeys.length} deleted keys`));
104
+ }
105
+ const config = await configService.getValidProjectConfig();
106
+ const translationFiles = await findTranslationFiles(config, {
107
+ parseContent: false,
108
+ includeContent: false,
109
+ extractKeys: false,
110
+ verbose
111
+ });
112
+
113
+ if (verbose) {
114
+ console.log(chalk.blue(`Found ${translationFiles.length} translation files to check for deleted keys`));
115
+ }
116
+ const keysToDelete = deletedKeys.map(key => key.name);
117
+ for (const file of translationFiles) {
118
+ try {
119
+ if (verbose) {
120
+ console.log(chalk.blue(`Checking for deleted keys in ${file.path} (${file.locale})`));
121
+ }
122
+
123
+ const deletedFromFile = await deleteKeysFromTranslationFile(file.path, keysToDelete, file.locale);
124
+
125
+ if (deletedFromFile.length > 0) {
126
+ totalDeleted += deletedFromFile.length;
127
+
128
+ if (verbose) {
129
+ console.log(chalk.green(`✓ Deleted ${deletedFromFile.length} keys from ${file.path}`));
130
+ for (const key of deletedFromFile) {
131
+ console.log(chalk.gray(` - ${key}`));
132
+ }
133
+ }
134
+ } else if (verbose) {
135
+ console.log(chalk.gray(` No keys to delete in ${file.path}`));
136
+ }
137
+ } catch (error) {
138
+ console.error(chalk.yellow(`⚠️ Failed to delete keys from ${file.path}: ${error.message}`));
139
+ }
140
+ }
141
+ }
142
+
143
+ await configService.updateLastSyncedAt();
144
+
145
+ return { totalUpdates, totalDeleted };
146
+ }
147
+ };