localingos 0.1.32 → 0.1.33
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.md +69 -1
- package/bin/localingos.js +6 -0
- package/examples/github-actions-check.yml +35 -0
- package/examples/github-actions-sync.yml +58 -0
- package/package.json +6 -3
- package/pom.xml +53 -0
- package/src/commands/extract.js +1 -1
- package/src/commands/init.js +3 -2
- package/src/commands/mcp-serve.js +222 -0
- package/src/config.js +91 -30
package/README.md
CHANGED
|
@@ -202,4 +202,72 @@ git push && git push --tags
|
|
|
202
202
|
|---------|------------|---------|
|
|
203
203
|
| `npm version patch` | Bug fix, typo, minor tweak | 0.1.0 → 0.1.1 |
|
|
204
204
|
| `npm version minor` | New feature, new format support | 0.1.0 → 0.2.0 |
|
|
205
|
-
| `npm version major` | Breaking config/API change | 0.1.0 → 1.0.0 |
|
|
205
|
+
| `npm version major` | Breaking config/API change | 0.1.0 → 1.0.0 |
|
|
206
|
+
|
|
207
|
+
## CI/CD Integration
|
|
208
|
+
|
|
209
|
+
### GitHub Actions — Auto-sync on push
|
|
210
|
+
|
|
211
|
+
Automatically sync translations when `.messages.ts` files change on `main`:
|
|
212
|
+
|
|
213
|
+
1. Add your API key as a repository secret: `LOCALINGOS_API_KEY`
|
|
214
|
+
(Settings → Secrets and variables → Actions → New repository secret)
|
|
215
|
+
|
|
216
|
+
2. Make sure `localingos.config.json` is committed (project config, no secrets).
|
|
217
|
+
`.localingos.json` stays in `.gitignore` (contains API key for local dev only).
|
|
218
|
+
|
|
219
|
+
3. Copy [`examples/github-actions-sync.yml`](examples/github-actions-sync.yml) to `.github/workflows/localingos-sync.yml`
|
|
220
|
+
|
|
221
|
+
This workflow:
|
|
222
|
+
- Triggers when `.messages.ts` or `src/i18n/` files change on `main`
|
|
223
|
+
- Runs `localingos sync --prune` to push source strings and pull translations
|
|
224
|
+
- Commits updated translation files back to the repo
|
|
225
|
+
|
|
226
|
+
### GitHub Actions — PR check
|
|
227
|
+
|
|
228
|
+
Fail PRs if i18n source files are out of date or orphan messages exist:
|
|
229
|
+
|
|
230
|
+
Copy [`examples/github-actions-check.yml`](examples/github-actions-check.yml) to `.github/workflows/localingos-check.yml`
|
|
231
|
+
|
|
232
|
+
This runs `npm run i18n:check` which verifies:
|
|
233
|
+
- `en-US.json` and `en-US.descriptions.json` match the `.messages.ts` files
|
|
234
|
+
- No orphan messages (defined but never referenced in code)
|
|
235
|
+
|
|
236
|
+
### Environment variable
|
|
237
|
+
|
|
238
|
+
The CLI supports `LOCALINGOS_API_KEY` as an environment variable, which takes precedence over the `apiKey` field in `.localingos.json`. This lets you keep the API key out of your repo:
|
|
239
|
+
|
|
240
|
+
When you run `localingos init`, it creates two files:
|
|
241
|
+
|
|
242
|
+
- **`localingos.config.json`** — commit this. Contains project settings (familyId, locale, paths). No secrets.
|
|
243
|
+
- **`.localingos.json`** — gitignored. Contains only your `apiKey` for local development.
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Local dev — the CLI reads apiKey from .localingos.json automatically
|
|
247
|
+
localingos sync
|
|
248
|
+
|
|
249
|
+
# CI — set as a GitHub secret, the CLI reads it from the env var
|
|
250
|
+
LOCALINGOS_API_KEY=your-key localingos sync
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Multiple projects (monorepo)
|
|
254
|
+
|
|
255
|
+
If you have multiple projects in one repo, each with its own `localingos.config.json` and API key, add an `apiKeyEnv` field to each config pointing to a different env var:
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
// apps/web/localingos.config.json
|
|
259
|
+
{ "apiKeyEnv": "LOCALINGOS_KEY_WEB", "familyId": "...", ... }
|
|
260
|
+
|
|
261
|
+
// apps/docs/localingos.config.json
|
|
262
|
+
{ "apiKeyEnv": "LOCALINGOS_KEY_DOCS", "familyId": "...", ... }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Then in your workflow, pass each secret:
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
env:
|
|
269
|
+
LOCALINGOS_KEY_WEB: ${{ secrets.LOCALINGOS_KEY_WEB }}
|
|
270
|
+
LOCALINGOS_KEY_DOCS: ${{ secrets.LOCALINGOS_KEY_DOCS }}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The CLI checks `apiKeyEnv` first, then falls back to `LOCALINGOS_API_KEY`, then `apiKey` in the config file.
|
package/bin/localingos.js
CHANGED
|
@@ -6,6 +6,7 @@ import { syncCommand } from '../src/commands/sync.js';
|
|
|
6
6
|
import { pullCommand } from '../src/commands/pull.js';
|
|
7
7
|
import { pushCommand } from '../src/commands/push.js';
|
|
8
8
|
import { extractCommand } from '../src/commands/extract.js';
|
|
9
|
+
import { mcpServeCommand } from '../src/commands/mcp-serve.js';
|
|
9
10
|
import pkg from '../package.json' with { type: 'json' };
|
|
10
11
|
|
|
11
12
|
program
|
|
@@ -48,4 +49,9 @@ program
|
|
|
48
49
|
.option('-c, --config <path>', 'Path to config file', '.localingos.json')
|
|
49
50
|
.action(extractCommand);
|
|
50
51
|
|
|
52
|
+
program
|
|
53
|
+
.command('mcp-serve')
|
|
54
|
+
.description('Start an MCP server over stdio for AI coding agents (Cursor, Claude Desktop, Kiro)')
|
|
55
|
+
.action(mcpServeCommand);
|
|
56
|
+
|
|
51
57
|
program.parse();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# .github/workflows/localingos-check.yml
|
|
2
|
+
#
|
|
3
|
+
# Checks that i18n source files are up to date on pull requests.
|
|
4
|
+
# Fails if .messages.ts files have changed but extract hasn't been run,
|
|
5
|
+
# or if orphan messages exist.
|
|
6
|
+
#
|
|
7
|
+
# Setup:
|
|
8
|
+
# 1. Copy this file to .github/workflows/localingos-check.yml
|
|
9
|
+
# 2. Make sure "i18n:check" script exists in package.json
|
|
10
|
+
|
|
11
|
+
name: i18n Check
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
pull_request:
|
|
15
|
+
paths:
|
|
16
|
+
- 'src/**/*.messages.ts'
|
|
17
|
+
- 'src/**/*.tsx'
|
|
18
|
+
- 'src/**/*.ts'
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
check:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-node@v4
|
|
28
|
+
with:
|
|
29
|
+
node-version: '20'
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: npm ci
|
|
33
|
+
|
|
34
|
+
- name: Check i18n is up to date
|
|
35
|
+
run: npm run i18n:check
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# .github/workflows/localingos-sync.yml
|
|
2
|
+
#
|
|
3
|
+
# Syncs translations with Localingos on every push to main.
|
|
4
|
+
# Translations are committed back to the repo automatically.
|
|
5
|
+
#
|
|
6
|
+
# Setup:
|
|
7
|
+
# 1. Add your API key as a repository secret: LOCALINGOS_API_KEY
|
|
8
|
+
# (Settings → Secrets and variables → Actions → New repository secret)
|
|
9
|
+
# 2. Make sure localingos.config.json is committed (project config, no secrets)
|
|
10
|
+
# .localingos.json stays in .gitignore (contains API key for local dev only)
|
|
11
|
+
# 3. Copy this file to .github/workflows/localingos-sync.yml
|
|
12
|
+
#
|
|
13
|
+
# Multiple projects (monorepo):
|
|
14
|
+
# Each localingos.config.json can set "apiKeyEnv" to a different env var name:
|
|
15
|
+
# { "apiKeyEnv": "LOCALINGOS_KEY_DOCS", "familyId": "..." }
|
|
16
|
+
# { "apiKeyEnv": "LOCALINGOS_KEY_APP", "familyId": "..." }
|
|
17
|
+
# Then add each as a separate secret and pass them all in the env block.
|
|
18
|
+
|
|
19
|
+
name: Localingos Sync
|
|
20
|
+
|
|
21
|
+
on:
|
|
22
|
+
push:
|
|
23
|
+
branches: [main]
|
|
24
|
+
paths:
|
|
25
|
+
- 'src/**/*.messages.ts'
|
|
26
|
+
- 'src/i18n/**'
|
|
27
|
+
- 'localingos.config.json'
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
sync:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
permissions:
|
|
33
|
+
contents: write
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v4
|
|
37
|
+
|
|
38
|
+
- uses: actions/setup-node@v4
|
|
39
|
+
with:
|
|
40
|
+
node-version: '20'
|
|
41
|
+
|
|
42
|
+
- name: Install Localingos CLI
|
|
43
|
+
run: npm install -g localingos
|
|
44
|
+
|
|
45
|
+
- name: Sync translations
|
|
46
|
+
env:
|
|
47
|
+
LOCALINGOS_API_KEY: ${{ secrets.LOCALINGOS_API_KEY }}
|
|
48
|
+
# For monorepos with multiple projects, add per-project secrets:
|
|
49
|
+
# LOCALINGOS_KEY_APP: ${{ secrets.LOCALINGOS_KEY_APP }}
|
|
50
|
+
# LOCALINGOS_KEY_DOCS: ${{ secrets.LOCALINGOS_KEY_DOCS }}
|
|
51
|
+
run: localingos sync --prune
|
|
52
|
+
|
|
53
|
+
- name: Commit updated translations
|
|
54
|
+
run: |
|
|
55
|
+
git config user.name "github-actions[bot]"
|
|
56
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
57
|
+
git add src/i18n/
|
|
58
|
+
git diff --staged --quiet || git commit -m "chore: sync translations [skip ci]" && git push
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localingos",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"description": "CLI tool to sync translations with Localingos",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
"i18n",
|
|
15
15
|
"translation",
|
|
16
16
|
"localization",
|
|
17
|
-
"cli"
|
|
17
|
+
"cli",
|
|
18
|
+
"mcp"
|
|
18
19
|
],
|
|
19
20
|
"license": "MIT",
|
|
20
21
|
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
21
23
|
"chalk": "^5.3.0",
|
|
22
24
|
"commander": "^12.0.0",
|
|
23
25
|
"inquirer": "^9.2.0",
|
|
24
26
|
"localingos": "^0.1.11",
|
|
25
|
-
"yaml": "^2.4.0"
|
|
27
|
+
"yaml": "^2.4.0",
|
|
28
|
+
"zod": "^3.25.0"
|
|
26
29
|
}
|
|
27
30
|
}
|
package/pom.xml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
3
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
4
|
+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
|
5
|
+
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
6
|
+
<modelVersion>4.0.0</modelVersion>
|
|
7
|
+
|
|
8
|
+
<parent>
|
|
9
|
+
<groupId>com.localingos</groupId>
|
|
10
|
+
<artifactId>localingos-parent</artifactId>
|
|
11
|
+
<version>1.0.0</version>
|
|
12
|
+
</parent>
|
|
13
|
+
|
|
14
|
+
<artifactId>localingos-cli</artifactId>
|
|
15
|
+
<packaging>pom</packaging>
|
|
16
|
+
<name>Localingos CLI</name>
|
|
17
|
+
<description>CLI tool to sync translations with Localingos</description>
|
|
18
|
+
|
|
19
|
+
<build>
|
|
20
|
+
<plugins>
|
|
21
|
+
<plugin>
|
|
22
|
+
<groupId>com.github.eirslett</groupId>
|
|
23
|
+
<artifactId>frontend-maven-plugin</artifactId>
|
|
24
|
+
<executions>
|
|
25
|
+
<execution>
|
|
26
|
+
<id>install node and npm</id>
|
|
27
|
+
<goals><goal>install-node-and-npm</goal></goals>
|
|
28
|
+
<configuration>
|
|
29
|
+
<nodeVersion>v22.12.0</nodeVersion>
|
|
30
|
+
<npmVersion>10.9.0</npmVersion>
|
|
31
|
+
</configuration>
|
|
32
|
+
</execution>
|
|
33
|
+
<execution>
|
|
34
|
+
<id>npm install</id>
|
|
35
|
+
<goals><goal>npm</goal></goals>
|
|
36
|
+
<configuration>
|
|
37
|
+
<arguments>ci</arguments>
|
|
38
|
+
</configuration>
|
|
39
|
+
</execution>
|
|
40
|
+
</executions>
|
|
41
|
+
</plugin>
|
|
42
|
+
<plugin>
|
|
43
|
+
<artifactId>maven-clean-plugin</artifactId>
|
|
44
|
+
<configuration>
|
|
45
|
+
<filesets>
|
|
46
|
+
<fileset><directory>node_modules</directory></fileset>
|
|
47
|
+
<fileset><directory>node</directory></fileset>
|
|
48
|
+
</filesets>
|
|
49
|
+
</configuration>
|
|
50
|
+
</plugin>
|
|
51
|
+
</plugins>
|
|
52
|
+
</build>
|
|
53
|
+
</project>
|
package/src/commands/extract.js
CHANGED
|
@@ -351,7 +351,7 @@ Create \`scripts/extract-messages.js\` — a deterministic Node.js script that:
|
|
|
351
351
|
3. Parses each file to extract \`id\`, \`text\`, and \`description\` from every \`MessageDefinition\`
|
|
352
352
|
4. Builds nested JSON from all dot-path ids, using \`text\` as the ${languageName} value, and writes it to the configured \`sourceFile\` path
|
|
353
353
|
5. Builds a **separate descriptions file** at the same path but with a \`.descriptions.json\` suffix (e.g. \`${descriptionsFile}\`) using the same nested structure, but with \`description\` as the value. Only include keys that have a non-empty \`description\`. If no descriptions exist at all, still write an empty \`{}\` file.
|
|
354
|
-
6.
|
|
354
|
+
6. The \`*${msgExt}\` files are the **source of truth** — always overwrite the source JSON values with the text from message definitions. If a text is changed in a \`*${msgExt}\` file, the JSON must be updated to match.
|
|
355
355
|
7. Reports: new keys added, existing keys preserved, duplicate ids (which should error and abort)
|
|
356
356
|
|
|
357
357
|
**IMPORTANT**: Do NOT hardcode the output file name (e.g. \`en-US.json\`). The script must derive it from the \`sourceFile\` field in \`.localingos.json\` so it works for any configured locale.
|
package/src/commands/init.js
CHANGED
|
@@ -104,7 +104,8 @@ export async function initCommand() {
|
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
const savedPath = saveConfig(config);
|
|
107
|
-
console.log(chalk.green(`\n✅
|
|
107
|
+
console.log(chalk.green(`\n✅ Project config saved to localingos.config.json (commit this)`));
|
|
108
|
+
console.log(chalk.green(`✅ API key saved to .localingos.json (keep this gitignored)`));
|
|
108
109
|
|
|
109
110
|
// Check if .gitignore exists and suggest adding .localingos.json
|
|
110
111
|
const gitignorePath = path.resolve('.gitignore');
|
|
@@ -118,7 +119,7 @@ export async function initCommand() {
|
|
|
118
119
|
default: true
|
|
119
120
|
}]);
|
|
120
121
|
if (addGitignore) {
|
|
121
|
-
fs.appendFileSync(gitignorePath, '\n# Localingos
|
|
122
|
+
fs.appendFileSync(gitignorePath, '\n# Localingos local config (contains API key — do not commit)\n.localingos.json\n');
|
|
122
123
|
console.log(chalk.green('✅ Added .localingos.json to .gitignore'));
|
|
123
124
|
}
|
|
124
125
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localingos mcp-serve
|
|
3
|
+
*
|
|
4
|
+
* Starts an MCP server over stdio for AI coding agents.
|
|
5
|
+
* Reads the same config as the CLI (localingos.config.json + .localingos.json).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { execSync } from 'child_process';
|
|
14
|
+
|
|
15
|
+
function loadConfig(cwd) {
|
|
16
|
+
let config = {};
|
|
17
|
+
try { config = JSON.parse(fs.readFileSync(path.join(cwd, 'localingos.config.json'), 'utf-8')); } catch {}
|
|
18
|
+
try { config = { ...config, ...JSON.parse(fs.readFileSync(path.join(cwd, '.localingos.json'), 'utf-8')) }; } catch {}
|
|
19
|
+
const envVarName = config.apiKeyEnv || 'LOCALINGOS_API_KEY';
|
|
20
|
+
if (process.env[envVarName]) config.apiKey = process.env[envVarName];
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function api(method, urlPath, apiUrl, apiKey, body) {
|
|
25
|
+
const url = `${apiUrl.replace(/\/+$/, '')}${urlPath}`;
|
|
26
|
+
const opts = { method, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } };
|
|
27
|
+
if (body) opts.body = JSON.stringify(body);
|
|
28
|
+
const res = await fetch(url, opts);
|
|
29
|
+
if (!res.ok) throw new Error(`API ${method} ${urlPath} failed (${res.status}): ${await res.text().catch(() => '')}`);
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
return text ? JSON.parse(text) : {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function flatten(obj, prefix = '') {
|
|
35
|
+
const entries = [];
|
|
36
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
37
|
+
const full = prefix ? `${prefix}.${k}` : k;
|
|
38
|
+
if (typeof v === 'object' && v !== null && !Array.isArray(v)) entries.push(...flatten(v, full));
|
|
39
|
+
else entries.push({ id: full, text: String(v) });
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readSourceEntries(config, cwd) {
|
|
45
|
+
const sf = path.resolve(cwd, config.sourceFile);
|
|
46
|
+
if (!fs.existsSync(sf)) return [];
|
|
47
|
+
const entries = flatten(JSON.parse(fs.readFileSync(sf, 'utf-8')));
|
|
48
|
+
let descs = {};
|
|
49
|
+
try { for (const d of flatten(JSON.parse(fs.readFileSync(sf.replace(/\.json$/, '.descriptions.json'), 'utf-8')))) descs[d.id] = d.text; } catch {}
|
|
50
|
+
return entries.map(e => ({ id: e.id, locale: config.sourceLocale, text: e.text, description: descs[e.id] || null, forceReTranslate: false }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeTranslations(translations, config, cwd) {
|
|
54
|
+
const byLocale = {};
|
|
55
|
+
for (const t of translations.filter(t => t.locale !== config.sourceLocale)) (byLocale[t.locale] ||= []).push(t);
|
|
56
|
+
const outputDir = path.resolve(cwd, config.outputDir);
|
|
57
|
+
const written = [];
|
|
58
|
+
for (const [locale, items] of Object.entries(byLocale)) {
|
|
59
|
+
const nested = {};
|
|
60
|
+
for (const { foreignId, text } of items) {
|
|
61
|
+
const keys = foreignId.split('.');
|
|
62
|
+
let cur = nested;
|
|
63
|
+
for (let i = 0; i < keys.length - 1; i++) { cur[keys[i]] = cur[keys[i]] || {}; cur = cur[keys[i]]; }
|
|
64
|
+
cur[keys[keys.length - 1]] = text;
|
|
65
|
+
}
|
|
66
|
+
const fileName = (config.outputPattern || '{locale}.json').replace('{locale}', locale);
|
|
67
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(path.join(outputDir, fileName), JSON.stringify(nested, null, 2) + '\n');
|
|
69
|
+
written.push(fileName);
|
|
70
|
+
}
|
|
71
|
+
return written;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function mcpServeCommand() {
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
const config = loadConfig(cwd);
|
|
77
|
+
const apiUrl = process.env.LOCALINGOS_API_URL || 'https://api.localingos.com';
|
|
78
|
+
|
|
79
|
+
const server = new McpServer({ name: 'localingos', version: '0.1.0' });
|
|
80
|
+
|
|
81
|
+
server.tool('localingos_sync',
|
|
82
|
+
'Push source strings to Localingos and pull back translations. Runs extract first if available.',
|
|
83
|
+
{ prune: z.boolean().optional().describe('Remove stale keys from JSON files') },
|
|
84
|
+
async ({ prune }) => {
|
|
85
|
+
let extractOut = '';
|
|
86
|
+
const script = path.join(cwd, 'scripts/extract-messages.js');
|
|
87
|
+
if (fs.existsSync(script)) {
|
|
88
|
+
try { extractOut = execSync(`node scripts/extract-messages.js${prune ? ' --prune' : ''}`, { cwd, encoding: 'utf-8', timeout: 30000 }); } catch (e) { extractOut = `Extract warning: ${e.message}`; }
|
|
89
|
+
}
|
|
90
|
+
const entries = readSourceEntries(config, cwd);
|
|
91
|
+
if (!entries.length) return { content: [{ type: 'text', text: 'No source strings found.' }] };
|
|
92
|
+
const result = await api('POST', '/sync', apiUrl, config.apiKey, { familyId: config.familyId, translatables: entries });
|
|
93
|
+
const written = writeTranslations(result.translations || [], config, cwd);
|
|
94
|
+
const pending = result.pending || [];
|
|
95
|
+
const lines = [`Pushed ${entries.length} source strings`, `${(result.translations || []).length} translations available`,
|
|
96
|
+
pending.length ? `${pending.length} keys pending` : null, written.length ? `Written: ${written.join(', ')}` : null,
|
|
97
|
+
extractOut ? `\n${extractOut}` : null].filter(Boolean);
|
|
98
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
server.tool('localingos_push', 'Push source strings only.', {}, async () => {
|
|
103
|
+
const entries = readSourceEntries(config, cwd);
|
|
104
|
+
if (!entries.length) return { content: [{ type: 'text', text: 'No source strings found.' }] };
|
|
105
|
+
await api('POST', '/translatable', apiUrl, config.apiKey, { familyId: config.familyId, translatableList: entries });
|
|
106
|
+
return { content: [{ type: 'text', text: `Pushed ${entries.length} source strings.` }] };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
server.tool('localingos_pull', 'Pull latest translations and write locale files.', {}, async () => {
|
|
110
|
+
const translations = await api('GET', `/translation/${config.familyId}`, apiUrl, config.apiKey);
|
|
111
|
+
if (!translations?.length) return { content: [{ type: 'text', text: 'No translations available yet.' }] };
|
|
112
|
+
const written = writeTranslations(translations, config, cwd);
|
|
113
|
+
return { content: [{ type: 'text', text: `Pulled:\n${written.join('\n')}` }] };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
server.tool('localingos_status', 'Show project status: family, locales, key counts.', {}, async () => {
|
|
117
|
+
const entries = readSourceEntries(config, cwd);
|
|
118
|
+
const outputDir = path.resolve(cwd, config.outputDir);
|
|
119
|
+
let locales = [];
|
|
120
|
+
if (fs.existsSync(outputDir)) {
|
|
121
|
+
const re = new RegExp('^' + (config.outputPattern || '{locale}.json').replace('{locale}', '(.+)').replace('.', '\\.') + '$');
|
|
122
|
+
locales = fs.readdirSync(outputDir).map(f => f.match(re)?.[1]).filter(Boolean);
|
|
123
|
+
}
|
|
124
|
+
return { content: [{ type: 'text', text: `Family: ${config.familyId}\nSource: ${config.sourceLocale}\nKeys: ${entries.length}\nTranslations: ${locales.join(', ') || 'none'}` }] };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
server.tool('localingos_search', 'Search translation keys or text.',
|
|
128
|
+
{ query: z.string().describe('Search term — matches against key IDs and text values') },
|
|
129
|
+
async ({ query }) => {
|
|
130
|
+
const q = query.toLowerCase();
|
|
131
|
+
const matches = readSourceEntries(config, cwd).filter(e => e.id.toLowerCase().includes(q) || e.text.toLowerCase().includes(q));
|
|
132
|
+
if (!matches.length) return { content: [{ type: 'text', text: `No matches for "${query}"` }] };
|
|
133
|
+
const lines = matches.slice(0, 20).map(e => `${e.id}: "${e.text}"`);
|
|
134
|
+
if (matches.length > 20) lines.push(`... and ${matches.length - 20} more`);
|
|
135
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
server.tool('localingos_extract', 'Run i18n extraction script.',
|
|
140
|
+
{ prune: z.boolean().optional().describe('Remove stale/orphan keys'), check: z.boolean().optional().describe('Check mode — fail if files would change') },
|
|
141
|
+
async ({ prune, check }) => {
|
|
142
|
+
const script = path.join(cwd, 'scripts/extract-messages.js');
|
|
143
|
+
if (!fs.existsSync(script)) return { content: [{ type: 'text', text: 'scripts/extract-messages.js not found.' }] };
|
|
144
|
+
const flags = [prune && '--prune', check && '--check'].filter(Boolean).join(' ');
|
|
145
|
+
try {
|
|
146
|
+
return { content: [{ type: 'text', text: execSync(`node scripts/extract-messages.js ${flags}`, { cwd, encoding: 'utf-8', timeout: 30000 }) }] };
|
|
147
|
+
} catch (e) { return { content: [{ type: 'text', text: e.stdout || e.message }] }; }
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// ── Family management tools ───────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
server.tool('localingos_family_members', 'List members of a family.',
|
|
154
|
+
{ familyId: z.string().describe('Family ID') },
|
|
155
|
+
async ({ familyId }) => {
|
|
156
|
+
const members = await api('GET', `/family/${familyId}/members`, apiUrl, config.apiKey);
|
|
157
|
+
if (!members?.length) return { content: [{ type: 'text', text: 'No members found.' }] };
|
|
158
|
+
const lines = members.map(m => `${m.userId} — ${m.authorization}`);
|
|
159
|
+
return { content: [{ type: 'text', text: `${members.length} member(s):\n${lines.join('\n')}` }] };
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.tool('localingos_add_member', 'Add a member to a family (admin only).',
|
|
164
|
+
{ familyId: z.string(), userId: z.string().describe('User ID to add'), role: z.enum(['ADMIN', 'WRITE', 'READ']).default('WRITE').describe('Role for the new member') },
|
|
165
|
+
async ({ familyId, userId, role }) => {
|
|
166
|
+
try {
|
|
167
|
+
await api('POST', `/family/${familyId}/members`, apiUrl, config.apiKey, { userId, authorization: role });
|
|
168
|
+
return { content: [{ type: 'text', text: `Added ${userId} as ${role} to family ${familyId}` }] };
|
|
169
|
+
} catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
server.tool('localingos_remove_member', 'Remove a member from a family (admin only).',
|
|
174
|
+
{ familyId: z.string(), userId: z.string().describe('User ID to remove') },
|
|
175
|
+
async ({ familyId, userId }) => {
|
|
176
|
+
try {
|
|
177
|
+
await api('DELETE', `/family/${familyId}/members/${userId}`, apiUrl, config.apiKey);
|
|
178
|
+
return { content: [{ type: 'text', text: `Removed ${userId} from family ${familyId}` }] };
|
|
179
|
+
} catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
server.tool('localingos_billing_status', 'Show billing status for a family — plan, usage, owner.',
|
|
184
|
+
{ familyId: z.string().describe('Family ID') },
|
|
185
|
+
async ({ familyId }) => {
|
|
186
|
+
try {
|
|
187
|
+
const b = await api('GET', `/family/${familyId}/billing-status`, apiUrl, config.apiKey);
|
|
188
|
+
if (!b.attached) return { content: [{ type: 'text', text: 'No billing attached to this family.' }] };
|
|
189
|
+
const lines = [
|
|
190
|
+
`Status: ${b.accessMode}${b.planName ? ' · ' + b.planName : ''}`,
|
|
191
|
+
`Owner: ${b.ownerEmail || b.ownerUserId}`,
|
|
192
|
+
b.yearlyIncludedWords > 0 ? `Usage: ${b.yearlyUsedWords.toLocaleString()} / ${b.yearlyIncludedWords.toLocaleString()} words (${b.yearlyRemainingWords.toLocaleString()} remaining)` : null,
|
|
193
|
+
b.trialInfo || null,
|
|
194
|
+
].filter(Boolean);
|
|
195
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
196
|
+
} catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
server.tool('localingos_attach_billing', 'Attach your billing account to a family (admin only).',
|
|
201
|
+
{ familyId: z.string() },
|
|
202
|
+
async ({ familyId }) => {
|
|
203
|
+
try {
|
|
204
|
+
await api('POST', `/family/${familyId}/billing/attach`, apiUrl, config.apiKey);
|
|
205
|
+
return { content: [{ type: 'text', text: `Billing attached to family ${familyId}` }] };
|
|
206
|
+
} catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
server.tool('localingos_detach_billing', 'Detach billing from a family (admin only). Family becomes read-only.',
|
|
211
|
+
{ familyId: z.string() },
|
|
212
|
+
async ({ familyId }) => {
|
|
213
|
+
try {
|
|
214
|
+
await api('POST', `/family/${familyId}/billing/detach`, apiUrl, config.apiKey);
|
|
215
|
+
return { content: [{ type: 'text', text: `Billing detached from family ${familyId}` }] };
|
|
216
|
+
} catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const transport = new StdioServerTransport();
|
|
221
|
+
await server.connect(transport);
|
|
222
|
+
}
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,23 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Config resolution order:
|
|
7
|
+
*
|
|
8
|
+
* 1. localingos.config.json — committed to repo, shared with team/CI
|
|
9
|
+
* Contains: familyId, sourceLocale, format, sourceFile, outputDir, outputPattern, apiKeyEnv
|
|
10
|
+
*
|
|
11
|
+
* 2. .localingos.json — in .gitignore, local dev only
|
|
12
|
+
* Contains: apiKey (and can override any field from the committed config)
|
|
13
|
+
*
|
|
14
|
+
* 3. Environment variable — for CI/CD
|
|
15
|
+
* LOCALINGOS_API_KEY (default) or custom name via apiKeyEnv field
|
|
16
|
+
*
|
|
17
|
+
* API key resolution: env var (apiKeyEnv || LOCALINGOS_API_KEY) > .localingos.json > localingos.config.json
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PROJECT_CONFIG_FILE = 'localingos.config.json';
|
|
21
|
+
const LOCAL_CONFIG_FILE = '.localingos.json';
|
|
6
22
|
|
|
7
23
|
const ENVIRONMENTS = {
|
|
8
24
|
prod: 'https://api.localingos.com',
|
|
@@ -10,13 +26,6 @@ const ENVIRONMENTS = {
|
|
|
10
26
|
local: 'https://desktop.localingos.com:8080'
|
|
11
27
|
};
|
|
12
28
|
|
|
13
|
-
/**
|
|
14
|
-
* Resolve the API URL based on --env flag or LOCALINGOS_ENV env variable.
|
|
15
|
-
* Defaults to production.
|
|
16
|
-
*
|
|
17
|
-
* For the 'local' environment, TLS certificate verification is disabled
|
|
18
|
-
* since the local dev server uses a self-signed certificate.
|
|
19
|
-
*/
|
|
20
29
|
export function resolveApiUrl(envFlag) {
|
|
21
30
|
const env = envFlag || process.env.LOCALINGOS_ENV || 'prod';
|
|
22
31
|
const url = ENVIRONMENTS[env];
|
|
@@ -25,45 +34,97 @@ export function resolveApiUrl(envFlag) {
|
|
|
25
34
|
console.error(chalk.red(`Unknown environment "${env}". Valid: ${valid}`));
|
|
26
35
|
process.exit(1);
|
|
27
36
|
}
|
|
28
|
-
|
|
29
|
-
// Disable TLS verification for local dev (self-signed certificate)
|
|
30
37
|
if (env === 'local') {
|
|
31
38
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
32
39
|
}
|
|
33
|
-
|
|
34
40
|
return url;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
function readJsonSafe(filePath) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
export function loadConfig(configPath) {
|
|
38
|
-
|
|
52
|
+
// If explicit --config flag was passed, use only that file (backward compat)
|
|
53
|
+
if (configPath && configPath !== LOCAL_CONFIG_FILE) {
|
|
54
|
+
const resolved = path.resolve(configPath);
|
|
55
|
+
if (!fs.existsSync(resolved)) {
|
|
56
|
+
console.error(chalk.red(`Config file not found: ${resolved}`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const config = readJsonSafe(resolved);
|
|
60
|
+
if (!config) {
|
|
61
|
+
console.error(chalk.red(`Failed to parse config file: ${resolved}`));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
return resolveApiKey(config);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Merge: project config (committed) ← local overrides (.gitignored)
|
|
68
|
+
const projectPath = path.resolve(PROJECT_CONFIG_FILE);
|
|
69
|
+
const localPath = path.resolve(LOCAL_CONFIG_FILE);
|
|
70
|
+
|
|
71
|
+
const projectConfig = readJsonSafe(projectPath) || {};
|
|
72
|
+
const localConfig = readJsonSafe(localPath) || {};
|
|
39
73
|
|
|
40
|
-
if (!
|
|
41
|
-
console.error(chalk.red(`
|
|
74
|
+
if (!Object.keys(projectConfig).length && !Object.keys(localConfig).length) {
|
|
75
|
+
console.error(chalk.red(`No config found. Expected ${PROJECT_CONFIG_FILE} or ${LOCAL_CONFIG_FILE}`));
|
|
42
76
|
console.error(chalk.yellow('Run "localingos init" to create one.'));
|
|
43
77
|
process.exit(1);
|
|
44
78
|
}
|
|
45
79
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
80
|
+
// Local overrides project
|
|
81
|
+
const config = { ...projectConfig, ...localConfig };
|
|
82
|
+
|
|
83
|
+
return resolveApiKey(config);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveApiKey(config) {
|
|
87
|
+
// Env var takes precedence: check project-specific name first, then generic
|
|
88
|
+
const envVarName = config.apiKeyEnv || 'LOCALINGOS_API_KEY';
|
|
89
|
+
if (process.env[envVarName]) {
|
|
90
|
+
config.apiKey = process.env[envVarName];
|
|
91
|
+
}
|
|
49
92
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (missing.length
|
|
93
|
+
const required = ['apiKey', 'familyId', 'sourceLocale', 'format', 'sourceFile', 'outputDir'];
|
|
94
|
+
const missing = required.filter(field => !config[field]);
|
|
95
|
+
if (missing.length > 0) {
|
|
96
|
+
if (missing.length === 1 && missing[0] === 'apiKey') {
|
|
97
|
+
console.error(chalk.red('API key not found. Provide it via one of:'));
|
|
98
|
+
console.error(chalk.yellow(` • Environment variable: ${envVarName}`));
|
|
99
|
+
console.error(chalk.yellow(` • "apiKey" field in ${LOCAL_CONFIG_FILE} (gitignored)`));
|
|
100
|
+
} else {
|
|
54
101
|
console.error(chalk.red(`Missing required config fields: ${missing.join(', ')}`));
|
|
55
|
-
process.exit(1);
|
|
56
102
|
}
|
|
57
|
-
|
|
58
|
-
return config;
|
|
59
|
-
} catch (e) {
|
|
60
|
-
console.error(chalk.red(`Failed to parse config file: ${e.message}`));
|
|
61
103
|
process.exit(1);
|
|
62
104
|
}
|
|
105
|
+
|
|
106
|
+
return config;
|
|
63
107
|
}
|
|
64
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Save the project config (committed) and local config (gitignored) separately.
|
|
111
|
+
*/
|
|
65
112
|
export function saveConfig(config, configPath) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
113
|
+
// If explicit path, write everything there (backward compat)
|
|
114
|
+
if (configPath && configPath !== LOCAL_CONFIG_FILE) {
|
|
115
|
+
const resolved = path.resolve(configPath);
|
|
116
|
+
fs.writeFileSync(resolved, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
117
|
+
return resolved;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Split: apiKey goes to .localingos.json, everything else to localingos.config.json
|
|
121
|
+
const { apiKey, ...projectFields } = config;
|
|
122
|
+
|
|
123
|
+
const projectPath = path.resolve(PROJECT_CONFIG_FILE);
|
|
124
|
+
const localPath = path.resolve(LOCAL_CONFIG_FILE);
|
|
125
|
+
|
|
126
|
+
fs.writeFileSync(projectPath, JSON.stringify(projectFields, null, 2) + '\n', 'utf-8');
|
|
127
|
+
fs.writeFileSync(localPath, JSON.stringify({ apiKey }, null, 2) + '\n', 'utf-8');
|
|
128
|
+
|
|
129
|
+
return projectPath;
|
|
130
|
+
}
|