@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.
- package/README +98 -0
- package/package.json +63 -58
- package/src/api/auth.js +20 -11
- package/src/api/client.js +70 -28
- package/src/api/imports.js +15 -13
- package/src/api/projects.js +17 -17
- package/src/api/translations.js +50 -29
- package/src/cli.js +49 -42
- package/src/commands/init.js +436 -236
- package/src/commands/login.js +59 -48
- package/src/commands/sync.js +28 -0
- package/src/commands/translate.js +232 -247
- package/src/utils/auth.js +15 -15
- package/src/utils/config.js +115 -86
- package/src/utils/files.js +358 -116
- package/src/utils/git.js +64 -8
- package/src/utils/github.js +83 -47
- package/src/utils/import-service.js +111 -129
- package/src/utils/prompt-service.js +66 -50
- package/src/utils/sync-service.js +147 -0
- package/src/utils/translation-updater/common.js +44 -0
- package/src/utils/translation-updater/index.js +36 -0
- package/src/utils/translation-updater/json-handler.js +111 -0
- package/src/utils/translation-updater/yaml-handler.js +207 -0
- package/src/utils/translation-utils.js +278 -0
- package/src/utils/defaults.js +0 -7
- package/src/utils/helpers.js +0 -3
- package/src/utils/project-service.js +0 -11
- package/src/utils/translation-updater.js +0 -154
package/src/utils/github.js
CHANGED
|
@@ -2,22 +2,45 @@ import { execSync } from 'child_process';
|
|
|
2
2
|
import { promises as fs } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
30
|
+
await fs.mkdir(workflowDir, { recursive: true });
|
|
14
31
|
|
|
15
|
-
|
|
32
|
+
const actionContent = `name: Localhero.ai - I18n translation
|
|
16
33
|
|
|
17
34
|
on:
|
|
18
35
|
pull_request:
|
|
19
36
|
paths:
|
|
20
|
-
${translationPaths.map(p =>
|
|
37
|
+
${translationPaths.map(p => {
|
|
38
|
+
// Check if path already contains a file pattern (*, ?, or {})
|
|
39
|
+
const hasPattern = /[*?{}]/.test(p);
|
|
40
|
+
// If it has a pattern, use it as is; otherwise, append /**
|
|
41
|
+
const formattedPath = hasPattern ? p : `${p}${p.endsWith('/') ? '' : '/'}**`;
|
|
42
|
+
return `- "${formattedPath}"`;
|
|
43
|
+
}).join('\n ')}
|
|
21
44
|
|
|
22
45
|
jobs:
|
|
23
46
|
translate:
|
|
@@ -36,57 +59,70 @@ jobs:
|
|
|
36
59
|
- name: Set up Node.js
|
|
37
60
|
uses: actions/setup-node@v4
|
|
38
61
|
with:
|
|
39
|
-
node-version:
|
|
62
|
+
node-version: 22
|
|
40
63
|
|
|
41
64
|
- name: Run LocalHero CLI
|
|
42
65
|
env:
|
|
43
66
|
LOCALHERO_API_KEY: \${{ secrets.LOCALHERO_API_KEY }}
|
|
44
|
-
|
|
67
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
68
|
+
run: npx -y @localheroai/cli translate`;
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
70
|
+
await fs.writeFile(workflowFile, actionContent);
|
|
71
|
+
return workflowFile;
|
|
72
|
+
},
|
|
49
73
|
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
autoCommitChanges(filesPath) {
|
|
75
|
+
const { exec, env } = this.deps;
|
|
52
76
|
|
|
53
|
-
|
|
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" });
|
|
77
|
+
if (!this.isGitHubAction()) return;
|
|
57
78
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
console.log("Running in GitHub Actions. Committing changes...");
|
|
80
|
+
try {
|
|
81
|
+
exec('git config --global user.name "LocalHero Bot"', { stdio: "inherit" });
|
|
82
|
+
exec('git config --global user.email "hi@localhero.ai"', { stdio: "inherit" });
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
const branchName = env.GITHUB_HEAD_REF;
|
|
85
|
+
if (!branchName) {
|
|
86
|
+
throw new Error('Could not determine branch name from GITHUB_HEAD_REF');
|
|
87
|
+
}
|
|
64
88
|
|
|
65
|
-
|
|
66
|
-
if (!status) {
|
|
67
|
-
console.log("No changes to commit.");
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
89
|
+
exec(`git add ${filesPath}`, { stdio: "inherit" });
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
const status = exec('git status --porcelain').toString();
|
|
92
|
+
if (!status) {
|
|
93
|
+
console.log("No changes to commit.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
72
96
|
|
|
73
|
-
|
|
74
|
-
if (!token) {
|
|
75
|
-
throw new Error('GITHUB_TOKEN is not set');
|
|
76
|
-
}
|
|
97
|
+
exec('git commit -m "Update translations"', { stdio: "inherit" });
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
const token = env.GITHUB_TOKEN;
|
|
100
|
+
if (!token) {
|
|
101
|
+
throw new Error('GITHUB_TOKEN is not set');
|
|
102
|
+
}
|
|
82
103
|
|
|
83
|
-
|
|
104
|
+
const repository = env.GITHUB_REPOSITORY;
|
|
105
|
+
if (!repository) {
|
|
106
|
+
throw new Error('GITHUB_REPOSITORY is not set');
|
|
107
|
+
}
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
const remoteUrl = `https://x-access-token:${token}@github.com/${repository}.git`;
|
|
110
|
+
|
|
111
|
+
exec(`git remote set-url origin ${remoteUrl}`, { stdio: "inherit" });
|
|
112
|
+
exec(`git push origin HEAD:${branchName}`, { stdio: "inherit" });
|
|
113
|
+
console.log("Changes committed and pushed successfully.");
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error("Auto-commit failed:", error.message);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
91
118
|
}
|
|
92
|
-
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Only export the functions needed externally
|
|
122
|
+
export function createGitHubActionFile(basePath, translationPaths) {
|
|
123
|
+
return githubService.createGitHubActionFile(basePath, translationPaths);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function autoCommitChanges(filesPath) {
|
|
127
|
+
return githubService.autoCommitChanges(filesPath);
|
|
128
|
+
}
|
|
@@ -1,146 +1,128 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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.import?.status === 'failed') {
|
|
99
|
+
return {
|
|
100
|
+
...importResult.import,
|
|
101
|
+
files: importedFiles
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let finalImportResult = importResult;
|
|
106
|
+
while (finalImportResult.import?.status === 'processing') {
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, finalImportResult.import.poll_interval * 1000));
|
|
108
|
+
finalImportResult = await checkImportStatus(config.projectId, finalImportResult.import.id);
|
|
109
|
+
|
|
110
|
+
if (finalImportResult.import?.status === 'failed') {
|
|
140
111
|
return {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
files: importedFiles
|
|
112
|
+
...finalImportResult.import,
|
|
113
|
+
files: importedFiles
|
|
144
114
|
};
|
|
115
|
+
}
|
|
145
116
|
}
|
|
146
|
-
|
|
117
|
+
|
|
118
|
+
const { import: { status, statistics, warnings, translations_url, sourceImport } = {} } = finalImportResult;
|
|
119
|
+
return {
|
|
120
|
+
status,
|
|
121
|
+
statistics,
|
|
122
|
+
warnings,
|
|
123
|
+
translations_url,
|
|
124
|
+
sourceImport,
|
|
125
|
+
files: importedFiles
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
};
|
|
@@ -1,51 +1,67 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
|
|
3
1
|
export function createPromptService(deps = {}) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|