@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.
package/README ADDED
@@ -0,0 +1,98 @@
1
+ # LocalHero.ai CLI šŸŒāœØ
2
+
3
+ > Automatic translations for teams that ship
4
+
5
+ LocalHero.ai is an AI-powered I18n translation service that seamlessly integrates with your development workflow. It automatically detects and translates I18n keys with missing translations, then saving any new translations directly to your repository. [Learn more at localhero.ai](https://localhero.ai/)
6
+
7
+ ## Features šŸš€
8
+
9
+ - šŸ¤– AI-powered translations that preserve your brand voice
10
+ - šŸ”Œ Seamless integration with Rails, React and other frameworks coming soon
11
+ - šŸš€ Automated workflow with GitHub Actions support
12
+ - šŸ“¦ Works with YAML and JSON translation files
13
+
14
+ ## Getting Started šŸ
15
+
16
+ 1. Sign up for a free trial at [localhero.ai](https://localhero.ai/) (currently closed beta, get in touch if you are interested in trying it out)
17
+ 2. Get your API key from [localhero.ai/api-keys](https://localhero.ai/api-keys)
18
+ 3. Run the init command in your project:
19
+ ```bash
20
+ npx @localheroai/cli init
21
+ ```
22
+
23
+ ## Commands šŸ‘
24
+
25
+ ### Initialize a Project
26
+
27
+ ```bash
28
+ npx @localheroai/cli init
29
+ ```
30
+
31
+ The init command helps you set up your project with LocalHero.ai. It will:
32
+ - Detect your project type (Rails, React, or generic)
33
+ - Link to an existing LocalHero.ai project
34
+ - Configure translation paths and file patterns
35
+ - Set up GitHub Actions (optional)
36
+ - Import existing translations (optional)
37
+
38
+ This creates a `localhero.json` configuration file in your project root that stores your project settings:
39
+ - Project identifier
40
+ - Source and target languages for translation
41
+ - Translation file paths and patterns
42
+ - Ignore patterns for files to exclude
43
+
44
+ The configuration file is used by the tool to interact with your translations and the API.
45
+
46
+ ### Login
47
+
48
+ ```bash
49
+ npx @localheroai/cli login
50
+ ```
51
+
52
+ Authenticate with your LocalHero.ai account. Use this when:
53
+ - Setting up a new development environment
54
+ - Updating your API key
55
+ - Verifying your authentication status
56
+
57
+ ### Translate
58
+
59
+ ```bash
60
+ npx @localheroai/cli translate
61
+ ```
62
+
63
+ Translating your missing keys:
64
+ - Automatically detects missing translations and sends them to the Localhero.ai translation API for translation
65
+ - Updates translation files with any new or update translations
66
+ - It's run manually or by GitHub Actions
67
+
68
+ ## Environment Variables āš™ļø
69
+
70
+ Configure the CLI behavior with these environment variables:
71
+
72
+ | Variable | Description | Default |
73
+ |----------|-------------|---------|
74
+ | `LOCALHERO_API_KEY` | Your LocalHero API key (get it at [localhero.ai/api-keys](https://localhero.ai/api-keys)) | Required |
75
+ | `LOCALHERO_API_HOST` | API host for LocalHero (you typically don't need to change this) | https://api.localhero.ai |
76
+
77
+ ## GitHub Actions Integration šŸ¤–
78
+
79
+ LocalHero.ai automatically translate your I18n files when you push changes. During the `init` command, you'll be prompted to set up GitHub Actions.
80
+
81
+ 1. Add your API key to your repository secrets:
82
+ - Go to Settings > Secrets > Actions
83
+ - Create a new secret named `LOCALHERO_API_KEY`
84
+ - Add your API key as the value
85
+
86
+ 2. The workflow will:
87
+ - Run on push to your main branch
88
+ - Check for missing translations
89
+ - Create a pull request with new translations
90
+
91
+ ## Support šŸ’¬
92
+
93
+ - Documentation: [localhero.ai/docs](https://docs.localhero.ai/docs)
94
+ - Email: support@localhero.ai
95
+
96
+ ## License šŸ“„
97
+
98
+ MIT License - see LICENSE file for details
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
1
  {
2
- "name": "@localheroai/cli",
3
- "version": "0.0.2",
4
- "description": "CLI tool for managing translations with LocalHero.ai",
5
- "type": "module",
6
- "main": "src/index.js",
7
- "bin": {
8
- "localheroai": "./src/cli.js"
2
+ "name": "@localheroai/cli",
3
+ "version": "0.0.3",
4
+ "description": "CLI tool for managing translations with LocalHero.ai",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "localheroai": "./src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/cli.js",
12
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
13
+ "lint": "eslint .",
14
+ "postinstall": "chmod +x src/cli.js"
15
+ },
16
+ "keywords": [
17
+ "i18n",
18
+ "translation",
19
+ "cli",
20
+ "localization"
21
+ ],
22
+ "author": "LocalHero.ai",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "chalk": "^5.3.0",
26
+ "commander": "^12.0.0",
27
+ "glob": "^10.3.10",
28
+ "inquirer": "^12.0.1",
29
+ "yaml": "^2.3.4"
30
+ },
31
+ "devDependencies": {
32
+ "@babel/preset-env": "^7.24.0",
33
+ "@inquirer/testing": "^2.1.36",
34
+ "@jest/globals": "^29.7.0",
35
+ "eslint": "^9.19.0",
36
+ "globals": "^15.14.0",
37
+ "jest": "^29.7.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.0.0"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "jest": {
48
+ "testEnvironment": "node",
49
+ "transform": {},
50
+ "moduleNameMapper": {
51
+ "^(\\.{1,2}/.*)\\.js$": "$1"
9
52
  },
10
- "scripts": {
11
- "start": "node src/cli.js",
12
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
13
- "lint": "eslint .",
14
- "postinstall": "chmod +x src/cli.js"
15
- },
16
- "keywords": [
17
- "i18n",
18
- "translation",
19
- "cli",
20
- "localization"
21
- ],
22
- "author": "LocalHero.ai",
23
- "license": "MIT",
24
- "dependencies": {
25
- "chalk": "^5.3.0",
26
- "commander": "^12.0.0",
27
- "dotenv": "^16.4.5",
28
- "glob": "^10.3.10",
29
- "inquirer": "^12.0.1",
30
- "yaml": "^2.3.4"
31
- },
32
- "devDependencies": {
33
- "@inquirer/testing": "^2.1.36",
34
- "@jest/globals": "^29.7.0",
35
- "eslint": "^9.14.0",
36
- "jest": "^29.7.0",
37
- "@babel/preset-env": "^7.24.0"
38
- },
39
- "engines": {
40
- "node": ">=22.11.0"
41
- },
42
- "files": [
43
- "src",
44
- "README.md",
45
- "LICENSE"
53
+ "testMatch": [
54
+ "**/tests/**/*.test.js"
46
55
  ],
47
- "jest": {
48
- "testEnvironment": "node",
49
- "transform": {},
50
- "moduleNameMapper": {
51
- "^(\\.{1,2}/.*)\\.js$": "$1"
52
- },
53
- "testMatch": [
54
- "**/tests/**/*.test.js"
55
- ],
56
- "testEnvironmentOptions": {
57
- "extensionsToTreatAsEsm": [
58
- ".js"
59
- ]
60
- }
56
+ "testEnvironmentOptions": {
57
+ "extensionsToTreatAsEsm": [
58
+ ".js"
59
+ ]
61
60
  }
61
+ }
62
62
  }
package/src/api/auth.js CHANGED
@@ -1,15 +1,24 @@
1
1
  import { apiRequest } from './client.js';
2
2
 
3
3
  export async function verifyApiKey(apiKey) {
4
- try {
5
- return await apiRequest('/api/v1/auth/verify', {
6
- apiKey
7
- });
8
- } catch (error) {
9
- return {
10
- error: {
11
- message: error.message || 'Failed to verify API key'
12
- }
13
- };
4
+ try {
5
+ return await apiRequest('/api/v1/auth/verify', {
6
+ apiKey
7
+ });
8
+ } catch (error) {
9
+ if (error.code === 'invalid_api_key') {
10
+ return {
11
+ error: {
12
+ code: 'invalid_api_key',
13
+ message: error.message
14
+ }
15
+ };
14
16
  }
15
- }
17
+ return {
18
+ error: {
19
+ code: 'verification_failed',
20
+ message: error.message || 'Failed to verify API key'
21
+ }
22
+ };
23
+ }
24
+ }
package/src/api/client.js CHANGED
@@ -1,41 +1,83 @@
1
1
  const DEFAULT_API_HOST = 'https://api.localhero.ai';
2
2
 
3
3
  export function getApiHost() {
4
- return process.env.LOCALHERO_API_HOST || DEFAULT_API_HOST;
4
+ return process.env.LOCALHERO_API_HOST || DEFAULT_API_HOST;
5
+ }
6
+
7
+ function getNetworkErrorMessage(error) {
8
+ if (error.code === 'ECONNREFUSED') {
9
+ return `Unable to connect to ${getApiHost()}. Please check your internet connection and try again.`;
10
+ }
11
+ if (error.cause?.code === 'ENOTFOUND') {
12
+ return `Could not resolve host ${getApiHost()}. Please check your internet connection and try again.`;
13
+ }
14
+ if (error.cause?.code === 'ETIMEDOUT') {
15
+ return `Connection to ${getApiHost()} timed out. Please try again later.`;
16
+ }
17
+ return `Network error while connecting to ${getApiHost()}. Please check your internet connection and try again.`;
5
18
  }
6
19
 
7
20
  export async function apiRequest(endpoint, options = {}) {
8
- const apiHost = getApiHost();
9
- const url = `${apiHost}${endpoint}`;
10
- const apiKey = process.env.LOCALHERO_API_KEY || options.apiKey;
21
+ const apiHost = getApiHost();
22
+ const url = `${apiHost}${endpoint}`;
23
+ const apiKey = process.env.LOCALHERO_API_KEY || options.apiKey;
11
24
 
12
- const headers = {
13
- 'Content-Type': 'application/json',
14
- ...options.headers
15
- };
25
+ const headers = {
26
+ 'Content-Type': 'application/json',
27
+ ...options.headers
28
+ };
16
29
 
17
- if (apiKey) {
18
- headers['Authorization'] = `Bearer ${apiKey}`;
19
- }
30
+ if (apiKey) {
31
+ headers['Authorization'] = `Bearer ${apiKey}`;
32
+ }
20
33
 
21
- const response = await fetch(url, {
22
- ...options,
23
- headers,
24
- });
34
+ const fetchOptions = {
35
+ method: options.method || 'GET',
36
+ headers,
37
+ };
25
38
 
26
- let data;
27
- try {
28
- data = await response.json();
29
- } catch (error) {
30
- throw new Error('Failed to parse API response', { cause: error });
31
- }
39
+ if (options.body) {
40
+ fetchOptions.body = options.body;
41
+ }
42
+
43
+ let response;
44
+ try {
45
+ response = await fetch(url, fetchOptions);
46
+ } catch (error) {
47
+ const message = getNetworkErrorMessage(error);
48
+ error.message = message;
49
+ error.cliErrorMessage = message;
50
+ throw error;
51
+ }
52
+
53
+ let data;
54
+ try {
55
+ data = await response.json();
56
+ } catch (error) {
57
+ const message = 'Failed to parse API response';
58
+ error.message = message;
59
+ error.cliErrorMessage = message;
60
+ throw error;
61
+ }
32
62
 
33
- if (!response.ok) {
34
- const errorMessage = Array.isArray(data?.errors)
35
- ? data.errors.map(err => typeof err === 'string' ? err : err.message).join(', ')
36
- : 'API request failed';
37
- throw new Error(errorMessage);
63
+ if (!response.ok) {
64
+ if (response.status === 401 && data?.error?.code === 'invalid_api_key') {
65
+ const message = 'Your API key is invalid or has been revoked. Please run `npx @localheroai/cli login` to update your API key.';
66
+ const error = new Error(message);
67
+ error.cliErrorMessage = message;
68
+ error.code = 'invalid_api_key';
69
+ error.data = data;
70
+ throw error;
38
71
  }
72
+ const message = Array.isArray(data?.errors)
73
+ ? data.errors.map(err => typeof err === 'string' ? err : err.message).join(', ')
74
+ : data?.error?.message || 'API request failed';
75
+ const error = new Error(message);
76
+ error.cliErrorMessage = message;
77
+ error.code = data?.error?.code || 'API_ERROR';
78
+ error.data = data;
79
+ throw error;
80
+ }
39
81
 
40
- return data;
41
- }
82
+ return data;
83
+ }
@@ -2,19 +2,21 @@ import { getApiKey } from '../utils/auth.js';
2
2
  import { apiRequest } from './client.js';
3
3
 
4
4
  export async function createImport({ projectId, translations }) {
5
- const apiKey = await getApiKey();
6
- const response = await apiRequest(`/api/v1/projects/${projectId}/imports`, {
7
- method: 'POST',
8
- body: JSON.stringify({ translations }),
9
- apiKey
10
- });
11
- return response.import;
5
+ const apiKey = await getApiKey();
6
+ const response = await apiRequest(`/api/v1/projects/${projectId}/imports`, {
7
+ method: 'POST',
8
+ body: JSON.stringify({
9
+ translations
10
+ }),
11
+ apiKey
12
+ });
13
+ return response;
12
14
  }
13
15
 
14
16
  export async function checkImportStatus(projectId, importId) {
15
- const apiKey = await getApiKey();
16
- const response = await apiRequest(`/api/v1/projects/${projectId}/imports/${importId}`, {
17
- apiKey
18
- });
19
- return response.import;
20
- }
17
+ const apiKey = await getApiKey();
18
+ const response = await apiRequest(`/api/v1/projects/${projectId}/imports/${importId}`, {
19
+ apiKey
20
+ });
21
+ return response.import;
22
+ }
@@ -2,23 +2,23 @@ import { getApiKey } from '../utils/auth.js';
2
2
  import { apiRequest } from './client.js';
3
3
 
4
4
  export async function listProjects() {
5
- const apiKey = await getApiKey();
6
- const response = await apiRequest('/api/v1/projects', { apiKey });
7
- return response.projects;
5
+ const apiKey = await getApiKey();
6
+ const response = await apiRequest('/api/v1/projects', { apiKey });
7
+ return response.projects;
8
8
  }
9
9
 
10
10
  export async function createProject(data) {
11
- const apiKey = await getApiKey();
12
- const response = await apiRequest('/api/v1/projects', {
13
- method: 'POST',
14
- body: JSON.stringify({
15
- project: {
16
- name: data.name,
17
- source_language: data.sourceLocale,
18
- target_languages: data.targetLocales
19
- }
20
- }),
21
- apiKey
22
- });
23
- return response.project;
24
- }
11
+ const apiKey = await getApiKey();
12
+ const response = await apiRequest('/api/v1/projects', {
13
+ method: 'POST',
14
+ body: JSON.stringify({
15
+ project: {
16
+ name: data.name,
17
+ source_language: data.sourceLocale,
18
+ target_languages: data.targetLocales
19
+ }
20
+ }),
21
+ apiKey
22
+ });
23
+ return response.project;
24
+ }
@@ -1,37 +1,58 @@
1
1
  import { getApiKey } from '../utils/auth.js';
2
2
  import { apiRequest } from './client.js';
3
- export async function createTranslationJob({ sourceFiles, targetLocales, projectId }) {
4
- const apiKey = await getApiKey();
5
- const response = await apiRequest(`/api/v1/projects/${projectId}/translation_jobs`, {
6
- method: 'POST',
7
- body: JSON.stringify({
8
- target_languages: targetLocales,
9
- files: sourceFiles.map(file => ({
10
- path: file.path,
11
- content: file.content,
12
- format: file.format
13
- }))
14
- }),
15
- apiKey
16
- });
17
-
18
- if (!response.jobs || !response.jobs.length) {
19
- throw new Error('No translation jobs were created');
20
- }
21
-
22
- return {
23
- jobs: response.jobs,
24
- totalJobs: response.jobs.length
25
- };
3
+ import { getCurrentBranch } from '../utils/git.js';
4
+
5
+ export async function createTranslationJob({ sourceFiles, targetLocales, projectId, targetPaths }) {
6
+ const apiKey = await getApiKey();
7
+ const branch = await getCurrentBranch();
8
+
9
+ const response = await apiRequest(`/api/v1/projects/${projectId}/translation_jobs`, {
10
+ method: 'POST',
11
+ body: JSON.stringify({
12
+ target_languages: targetLocales,
13
+ files: sourceFiles.map(file => ({
14
+ path: file.path,
15
+ content: file.content,
16
+ format: file.format,
17
+ target_paths: targetPaths
18
+ })),
19
+ ...(branch && { branch })
20
+ }),
21
+ apiKey
22
+ });
23
+
24
+ if (!response.jobs || !response.jobs.length) {
25
+ throw new Error('No translation jobs were created');
26
+ }
27
+
28
+ return {
29
+ jobs: response.jobs,
30
+ totalJobs: response.jobs.length
31
+ };
26
32
  }
27
33
 
28
34
  export async function checkJobStatus(jobId, includeTranslations = false) {
29
- const apiKey = await getApiKey();
30
- const endpoint = `/api/v1/translation_jobs/${jobId}${includeTranslations ? '?include_translations=true' : ''}`;
31
- return apiRequest(endpoint, { apiKey });
35
+ const apiKey = await getApiKey();
36
+ const endpoint = `/api/v1/translation_jobs/${jobId}${includeTranslations ? '?include_translations=true' : ''}`;
37
+ return apiRequest(endpoint, { apiKey });
32
38
  }
33
39
 
34
40
  export async function getTranslations(jobId) {
35
- const apiKey = await getApiKey();
36
- return apiRequest(`/api/v1/translation_jobs/${jobId}/translations`, { apiKey });
37
- }
41
+ const apiKey = await getApiKey();
42
+ return apiRequest(`/api/v1/translation_jobs/${jobId}/translations`, { apiKey });
43
+ }
44
+
45
+ export async function getUpdates(projectId, { since, page = 1 }) {
46
+ const apiKey = await getApiKey();
47
+
48
+ if (!since) {
49
+ throw new Error('Missing required parameter: since (ISO 8601 timestamp)');
50
+ }
51
+
52
+ const queryParams = new URLSearchParams({
53
+ since,
54
+ page: page.toString()
55
+ });
56
+
57
+ return apiRequest(`/api/v1/projects/${projectId}/updates?${queryParams}`, { apiKey });
58
+ }
package/src/cli.js CHANGED
@@ -5,67 +5,74 @@ import chalk from 'chalk';
5
5
  import { readFileSync } from 'fs';
6
6
  import { login } from './commands/login.js';
7
7
  import { init } from './commands/init.js';
8
- import { defaultDependencies } from './utils/defaults.js';
9
8
  import { translate } from './commands/translate.js';
9
+ import { sync } from './commands/sync.js';
10
10
 
11
11
  const program = new Command();
12
12
 
13
- function displayBanner() {
14
- console.log(chalk.blue(`
15
- ===============================================
16
-
17
- LocalHero.ai CLI
18
-
19
- ===============================================
20
- `));
21
- }
22
-
23
13
  function getVersion() {
24
- const packageJson = JSON.parse(
25
- readFileSync(new URL('../package.json', import.meta.url))
26
- );
27
- return packageJson.version;
14
+ const packageJson = JSON.parse(
15
+ readFileSync(new URL('../package.json', import.meta.url))
16
+ );
17
+ return packageJson.version;
28
18
  }
29
19
 
30
20
  function handleApiError(error) {
31
- console.error(chalk.red(`āŒ ${error.message}`));
32
- process.exit(1);
21
+ console.error(chalk.red(`āŒ ${error.cliErrorMessage || error.message}`));
22
+
23
+ if (program.opts().debug) {
24
+ console.error(chalk.dim(error.stack || error));
25
+
26
+ if (error.cause) {
27
+ console.error(chalk.dim(error.cause.stack || error.cause));
28
+ }
29
+ } else {
30
+ console.error(chalk.dim('\nRun with --debug for more information'));
31
+ }
32
+
33
+ process.exit(1);
33
34
  }
34
35
 
35
36
  function wrapCommandAction(action) {
36
- return function (...args) {
37
- return Promise.resolve(action(...args)).catch(handleApiError);
38
- };
37
+ return function (...args) {
38
+ return Promise.resolve(action(...args)).catch(handleApiError);
39
+ };
39
40
  }
40
41
 
41
42
  program
42
- .name('localhero')
43
- .description('CLI tool for automatic I18n translations with LocalHero.ai')
44
- .version(getVersion())
45
- .addHelpText('beforeAll', displayBanner)
46
- .action(() => {
47
- console.log(`Version: ${getVersion()}`);
48
- console.log('\nLocalHero.ai is a powerful i18n translation service');
49
- console.log('that helps you manage your application translations.');
50
- console.log('\nšŸ”— Visit https://localhero.ai for more information');
51
- console.log('šŸ’” Use --help to see available commands');
52
- });
43
+ .name('localhero')
44
+ .description('CLI tool for automatic I18n translations with LocalHero.ai, more info at https://localhero.ai.')
45
+ .version(getVersion())
46
+ .option('--debug', 'Show debug information when errors occur')
47
+ .action(() => {
48
+ console.log('LocalHero.ai is automatic I18n translations service that easily integrates with your dev workflow.');
49
+ console.log(`\nVersion: ${getVersion()}`);
50
+ console.log('\nšŸ”— Visit https://localhero.ai for more information');
51
+ console.log('šŸ‘ Set up your project with `npx @localheroai/cli init`');
52
+ console.log('šŸ’” Use --help to see available commands');
53
+ });
54
+
55
+ program
56
+ .command('login')
57
+ .description('Authenticate with LocalHero.ai using an API key')
58
+ .action(wrapCommandAction(() => login()));
53
59
 
54
60
  program
55
- .command('login')
56
- .description('Authenticate with LocalHero.ai using an API key')
57
- .action(wrapCommandAction(() => login(defaultDependencies)));
61
+ .command('init')
62
+ .description('Initialize a new LocalHero.ai project')
63
+ .action(wrapCommandAction(() => init()));
58
64
 
59
65
  program
60
- .command('init')
61
- .description('Initialize a new LocalHero.ai project')
62
- .action(wrapCommandAction(() => init(defaultDependencies)));
66
+ .command('translate')
67
+ .description('Translate missing keys in your i18n files')
68
+ .option('-v, --verbose', 'Show detailed progress information')
69
+ .option('-c, --commit', 'Automatically commit changes (useful for CI/CD)')
70
+ .action(wrapCommandAction((options) => translate(options)));
63
71
 
64
72
  program
65
- .command('translate')
66
- .description('Translate missing keys in your i18n files')
67
- .option('-v, --verbose', 'Show detailed progress information')
68
- .option('-c, --commit', 'Automatically commit changes (useful for CI/CD)')
69
- .action(wrapCommandAction((options) => translate(options)));
73
+ .command('sync')
74
+ .description('Sync updates from LocalHero.ai to your local files')
75
+ .option('-v, --verbose', 'Show detailed progress information')
76
+ .action(wrapCommandAction((options) => sync(options)));
70
77
 
71
78
  program.parse();