langctl 0.2.0 → 0.3.0

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +155 -1188
  3. package/dist/commands/auth.js +85 -19
  4. package/dist/commands/config.js +36 -26
  5. package/dist/commands/export.js +11 -122
  6. package/dist/commands/import.js +32 -143
  7. package/dist/commands/init.js +61 -72
  8. package/dist/commands/keys.js +145 -279
  9. package/dist/commands/org.js +45 -111
  10. package/dist/commands/projects.js +124 -312
  11. package/dist/commands/pull.js +80 -130
  12. package/dist/commands/push.js +125 -0
  13. package/dist/commands/team.js +68 -257
  14. package/dist/core/config.js +130 -0
  15. package/dist/core/errors.js +67 -0
  16. package/dist/core/files.js +38 -0
  17. package/dist/core/http.js +121 -0
  18. package/dist/core/output.js +96 -0
  19. package/dist/core/project.js +58 -0
  20. package/dist/core/prompts.js +56 -0
  21. package/dist/formats/index.js +285 -0
  22. package/dist/index.js +261 -508
  23. package/dist/version.js +4 -0
  24. package/package.json +22 -15
  25. package/.gitattributes +0 -2
  26. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
  27. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  28. package/CONTRIBUTING.md +0 -33
  29. package/dist/api.d.ts +0 -20
  30. package/dist/api.js +0 -75
  31. package/dist/auth.d.ts +0 -38
  32. package/dist/auth.js +0 -106
  33. package/dist/commands/auth.d.ts +0 -3
  34. package/dist/commands/config.d.ts +0 -2
  35. package/dist/commands/debug.d.ts +0 -3
  36. package/dist/commands/debug.js +0 -61
  37. package/dist/commands/export.d.ts +0 -13
  38. package/dist/commands/import.d.ts +0 -12
  39. package/dist/commands/init.d.ts +0 -2
  40. package/dist/commands/keys.d.ts +0 -25
  41. package/dist/commands/org.d.ts +0 -13
  42. package/dist/commands/projects.d.ts +0 -33
  43. package/dist/commands/pull.d.ts +0 -9
  44. package/dist/commands/team.d.ts +0 -29
  45. package/dist/config.d.ts +0 -48
  46. package/dist/config.js +0 -69
  47. package/dist/exporters/index.d.ts +0 -36
  48. package/dist/exporters/index.js +0 -214
  49. package/dist/index.d.ts +0 -3
  50. package/dist/utils/banner.d.ts +0 -9
  51. package/dist/utils/banner.js +0 -24
  52. package/translations/en.json +0 -5
  53. package/translations/en.xml +0 -9
  54. package/translations/fr.json +0 -4
  55. package/translations/fr.xml +0 -7
  56. package/translations/hi.json +0 -4
  57. package/translations/hi.xml +0 -7
  58. package/translations/ios/en.strings +0 -9
  59. package/translations/ios/fr.strings +0 -6
  60. package/translations/ios/hi.strings +0 -6
  61. package/translations/ios/ja.strings +0 -6
  62. package/translations/ja.json +0 -4
  63. package/translations/ja.xml +0 -7
@@ -1,139 +1,89 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
3
- import { writeFileSync, mkdirSync } from 'fs';
4
- import { dirname, join } from 'path';
5
- import { isAuthenticated } from '../auth.js';
6
- import { getApiClient } from '../api.js';
7
- import { config } from '../config.js';
8
- import { exportTranslations } from '../exporters/index.js';
9
- /**
10
- * Get platform-specific output directory
11
- */
12
- function getPlatformDirectory(format) {
13
- const platformDirs = {
14
- 'json': 'i18n',
15
- 'json-nested': 'i18n',
16
- 'ios': 'ios',
17
- 'android': 'android',
18
- 'flutter': 'flutter'
19
- };
20
- return platformDirs[format] || 'i18n';
21
- }
22
- export async function pullCommand(projectIdentifier, options) {
23
- // Check authentication
24
- if (!isAuthenticated()) {
25
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
26
- process.exitCode = 1;
27
- return;
2
+ import { relative, resolve } from 'path';
3
+ import { loadProjectConfig } from '../core/config.js';
4
+ import { CliError, ExitCode, usageError } from '../core/errors.js';
5
+ import { syncFile } from '../core/files.js';
6
+ import { getSession } from '../core/http.js';
7
+ import { log, printJson, runtime, spinner } from '../core/output.js';
8
+ import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
9
+ import { expandTemplate, getFormat, templateHasLanguage } from '../formats/index.js';
10
+ export async function pullCommand(projectArg, opts) {
11
+ const loaded = loadProjectConfig();
12
+ const cfg = loaded?.config ?? {};
13
+ const slug = projectSlugFrom(projectArg);
14
+ const format = getFormat(opts.format ?? cfg.format);
15
+ // Paths from flags are relative to the cwd; paths from langctl.json are relative to that file
16
+ let template;
17
+ if (opts.output)
18
+ template = resolve(opts.output);
19
+ else if (opts.dir)
20
+ template = resolve(opts.dir, format.defaultOutput);
21
+ else if (cfg.output && loaded)
22
+ template = resolve(loaded.root, cfg.output);
23
+ else
24
+ template = resolve(format.defaultOutput);
25
+ const includeDrafts = Boolean(opts.includeDrafts || opts.publishedOnly === false || cfg.includeDrafts);
26
+ const module = opts.module ?? cfg.module;
27
+ const dryRun = Boolean(opts.check || opts.dryRun);
28
+ const session = await getSession();
29
+ const spin = spinner(`Fetching ${slug}…`);
30
+ let project, snapshot;
31
+ try {
32
+ project = await getProject(session, slug);
33
+ snapshot = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, {
34
+ publishedOnly: includeDrafts ? 'false' : 'true',
35
+ module,
36
+ });
28
37
  }
29
- const orgId = config.get('organizationId');
30
- if (!orgId) {
31
- console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
32
- process.exitCode = 1;
33
- return;
38
+ finally {
39
+ spin.stop();
34
40
  }
35
- // Set defaults
36
- const format = (options.format || 'json');
37
- const publishedOnly = options.publishedOnly !== false; // Default to true
38
- const specificLanguage = options.language;
39
- const baseDir = options.dir || './translations';
40
- // Validate format
41
- const validFormats = ['json', 'json-nested', 'ios', 'android', 'flutter'];
42
- if (!validFormats.includes(format)) {
43
- console.log(chalk.red(`✗ Invalid format: ${format}`));
44
- console.log(chalk.yellow(`Valid formats: ${validFormats.join(', ')}\n`));
45
- return;
41
+ const languages = opts.languages ? splitList(opts.languages) : (cfg.languages ?? project.languages);
42
+ assertLanguages(project, languages);
43
+ if (languages.length > 1 && !templateHasLanguage(template)) {
44
+ throw usageError(`Output "${relative(process.cwd(), template)}" has no {lang} placeholder, so ${languages.length} languages would overwrite each other.`, 'Use a template like "locales/{lang}.json", or pick one language with --languages.');
46
45
  }
47
- const spinner = ora('Resolving project...').start();
48
- try {
49
- const api = getApiClient();
50
- // Resolve project by slug
51
- const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectIdentifier}`);
52
- if (!project) {
53
- spinner.fail(chalk.red(`Project "${projectIdentifier}" not found`));
54
- console.log(chalk.yellow('\nRun "langctl projects list" to see available projects.\n'));
55
- return;
56
- }
57
- // Determine which languages to pull
58
- let languagesToPull;
59
- if (specificLanguage) {
60
- if (!project.languages.includes(specificLanguage)) {
61
- spinner.fail(chalk.red(`Language "${specificLanguage}" not found in project`));
62
- console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
63
- return;
64
- }
65
- languagesToPull = [specificLanguage];
66
- }
67
- else {
68
- languagesToPull = project.languages;
46
+ const files = [];
47
+ for (const lang of languages) {
48
+ const entries = snapshot.keys
49
+ .filter(k => typeof k.translations[lang] === 'string' && k.translations[lang] !== '')
50
+ .map(k => ({ key: k.key, value: k.translations[lang], description: k.description }));
51
+ const path = expandTemplate(template, lang, project.defaultLanguage);
52
+ const status = syncFile(path, format.serialize(entries, lang), dryRun, text => format.parse(text, lang));
53
+ files.push({ path, language: lang, status, keys: entries.length, translated: entries.length, total: snapshot.keys.length });
54
+ }
55
+ const changed = files.filter(f => f.status !== 'unchanged');
56
+ const incomplete = files.filter(f => f.translated < f.total);
57
+ if (runtime.json) {
58
+ printJson({
59
+ project: project.slug,
60
+ format: format.id,
61
+ publishedOnly: !includeDrafts,
62
+ keys: snapshot.keys.length,
63
+ dryRun,
64
+ files: files.map(f => ({ ...f, path: relative(process.cwd(), f.path) })),
65
+ changed: changed.length,
66
+ });
67
+ }
68
+ else {
69
+ for (const f of files) {
70
+ const icon = f.status === 'unchanged' ? chalk.dim('=') : f.status === 'created' ? chalk.green('+') : chalk.yellow('~');
71
+ const verb = dryRun && f.status !== 'unchanged' ? `would be ${f.status}` : f.status;
72
+ const coverage = f.translated < f.total ? chalk.yellow(` ${f.translated}/${f.total} translated`) : '';
73
+ log.out(`${icon} ${relative(process.cwd(), f.path)} ${chalk.dim(verb)}${coverage}`);
69
74
  }
70
- spinner.text = `Fetching translations for "${project.name}"...`;
71
- // Get platform directory
72
- const platformDir = getPlatformDirectory(format);
73
- // Export translations for each language
74
- const exportedFiles = [];
75
- let totalKeyCount = 0;
76
- for (const language of languagesToPull) {
77
- spinner.text = `Exporting ${language}...`;
78
- // Fetch flat translations from the API
79
- const result = await api.get(`/orgs/${orgId}/projects/${project.id}/export`, {
80
- language,
81
- publishedOnly: publishedOnly ? 'true' : 'false'
82
- });
83
- // Convert flat translations to TranslationKey array for the exporter
84
- const flatTranslations = result.translations || {};
85
- const translationKeys = Object.entries(flatTranslations).map(([key, value]) => ({
86
- key,
87
- translations: { [language]: value }
88
- }));
89
- if (totalKeyCount === 0) {
90
- totalKeyCount = translationKeys.length;
91
- }
92
- const exportResult = exportTranslations(translationKeys, language, format);
93
- // Determine output path
94
- let outputPath;
95
- if (options.output) {
96
- // If custom output is specified and multiple languages, append language code
97
- if (languagesToPull.length > 1) {
98
- const ext = exportResult.filename.split('.').pop();
99
- const base = options.output.replace(/\.[^.]+$/, '');
100
- outputPath = `${base}-${language}.${ext}`;
101
- }
102
- else {
103
- outputPath = options.output;
104
- }
105
- }
106
- else {
107
- // Default output path: <baseDir>/<platform>/<language>.<ext>
108
- const ext = exportResult.filename.split('.').pop();
109
- outputPath = join(baseDir, platformDir, `${language}.${ext}`);
110
- }
111
- // Create directory if it doesn't exist
112
- const dir = dirname(outputPath);
113
- mkdirSync(dir, { recursive: true });
114
- writeFileSync(outputPath, exportResult.content, 'utf-8');
115
- exportedFiles.push(outputPath);
116
- spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
117
- // Restart spinner if more languages remain
118
- if (languagesToPull.indexOf(language) < languagesToPull.length - 1) {
119
- spinner.start();
120
- }
75
+ log.info(chalk.dim(`${project.slug}: ${snapshot.keys.length} ${includeDrafts ? '' : 'published '}keys, ${languages.length} language(s), format ${format.id}`));
76
+ if (snapshot.keys.length === 0 && !includeDrafts) {
77
+ log.warn('No published keys — drafts are excluded by default. Publish keys, or pass --include-drafts.');
121
78
  }
122
- // Summary
123
- console.log(chalk.hex('#10b981').bold('\n📦 Export Summary:'));
124
- console.log(chalk.white(` Project: ${project.name}`));
125
- console.log(chalk.white(` Format: ${format}`));
126
- console.log(chalk.white(` Platform: ${platformDir}`));
127
- console.log(chalk.white(` Output: ${baseDir}`));
128
- console.log(chalk.white(` Keys: ${totalKeyCount}`));
129
- console.log(chalk.white(` Languages: ${languagesToPull.join(', ')}`));
130
- console.log(chalk.white(` Files: ${exportedFiles.length}`));
131
- console.log('');
132
79
  }
133
- catch (error) {
134
- spinner.fail(chalk.red('Failed to pull translations'));
135
- console.error(chalk.red(`Error: ${error.message}\n`));
136
- process.exitCode = 1;
80
+ if (opts.requireComplete && incomplete.length) {
81
+ throw new CliError(`Missing translations: ${incomplete.map(f => `${f.language} (${f.total - f.translated} missing)`).join(', ')}`, ExitCode.Error);
137
82
  }
83
+ if (opts.check && changed.length) {
84
+ throw new CliError(`${changed.length} translation file(s) are out of date.`, ExitCode.Drift, 'Run "langctl pull" and commit the result.');
85
+ }
86
+ }
87
+ export function splitList(value) {
88
+ return value.split(',').map(s => s.trim()).filter(Boolean);
138
89
  }
139
- //# sourceMappingURL=pull.js.map
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { relative, resolve } from 'path';
4
+ import { loadProjectConfig } from '../core/config.js';
5
+ import { notFoundError, usageError } from '../core/errors.js';
6
+ import { getSession } from '../core/http.js';
7
+ import { log, printJson, runtime, spinner } from '../core/output.js';
8
+ import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
9
+ import { expandTemplate, formatFromPath, getFormat, templateHasLanguage } from '../formats/index.js';
10
+ import { splitList } from './pull.js';
11
+ const CHUNK = 1000; // stay well under the API's 1 MB request body limit
12
+ /** Upload one language's translations. Shared by `push` and the single-file `import`. */
13
+ export async function uploadTranslations(session, project, language, translations, opts) {
14
+ const entries = Object.entries(translations);
15
+ const result = { language, keys: entries.length, created: 0, updated: 0, unchanged: 0, published: undefined };
16
+ if (entries.length === 0)
17
+ return result;
18
+ if (opts.dryRun) {
19
+ // Compare against the server, including drafts, without writing anything
20
+ const snap = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { language, publishedOnly: 'false' });
21
+ const all = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { publishedOnly: 'false' });
22
+ const existingKeys = new Set(all.keys.map(k => k.key));
23
+ for (const [key, value] of entries) {
24
+ if (!existingKeys.has(key))
25
+ result.created++;
26
+ else if (snap.translations[key] === value)
27
+ result.unchanged++;
28
+ else if (opts.overwrite || snap.translations[key] === undefined)
29
+ result.updated++;
30
+ else
31
+ result.unchanged++;
32
+ }
33
+ return result;
34
+ }
35
+ for (let i = 0; i < entries.length; i += CHUNK) {
36
+ const chunk = Object.fromEntries(entries.slice(i, i + CHUNK));
37
+ // Import is an upsert, so retrying after a dropped connection is safe
38
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/import`, { language, translations: chunk, overwriteExisting: Boolean(opts.overwrite), module: opts.module }, true);
39
+ result.created += res.created;
40
+ result.updated += res.updated;
41
+ result.unchanged += res.skipped;
42
+ }
43
+ if (opts.publish) {
44
+ let published = 0;
45
+ const names = entries.map(([k]) => k);
46
+ for (let i = 0; i < names.length; i += 5000) {
47
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/keys/bulk-publish`, { keys: names.slice(i, i + 5000), published: true }, true);
48
+ published += res.count;
49
+ }
50
+ result.published = published;
51
+ }
52
+ return result;
53
+ }
54
+ export function readTranslationFile(path, format, lang) {
55
+ if (!existsSync(path))
56
+ throw notFoundError(`File not found: ${relative(process.cwd(), path) || path}`);
57
+ const fmt = format ?? formatFromPath(path);
58
+ const translations = fmt.parse(readFileSync(path, 'utf-8'), lang);
59
+ if ((fmt.id === 'android' || fmt.id === 'ios') && Object.values(translations).some(v => /\{\{\d+\}\}/.test(v))) {
60
+ log.warn(`${relative(process.cwd(), path)}: positional placeholders (%1$s / %1$@) were imported as {{1}}, {{2}}… — named placeholders can't be recovered from ${fmt.id} files.`);
61
+ }
62
+ return translations;
63
+ }
64
+ export async function pushCommand(projectArg, opts) {
65
+ const loaded = loadProjectConfig();
66
+ const cfg = loaded?.config ?? {};
67
+ const slug = projectSlugFrom(projectArg);
68
+ const format = opts.format || cfg.format ? getFormat(opts.format ?? cfg.format) : undefined;
69
+ let template;
70
+ if (opts.input)
71
+ template = resolve(opts.input);
72
+ else if (cfg.output && loaded)
73
+ template = resolve(loaded.root, cfg.output);
74
+ else
75
+ template = resolve((format ?? getFormat('json')).defaultOutput);
76
+ const session = await getSession();
77
+ const spin = spinner(`Fetching ${slug}…`);
78
+ let project;
79
+ try {
80
+ project = await getProject(session, slug);
81
+ }
82
+ finally {
83
+ spin.stop();
84
+ }
85
+ // Default: push only the source language, so translators' work in other languages isn't overwritten
86
+ const explicit = Boolean(opts.languages);
87
+ const languages = opts.languages === 'all' ? project.languages
88
+ : opts.languages ? splitList(opts.languages)
89
+ : [cfg.sourceLanguage ?? project.defaultLanguage];
90
+ assertLanguages(project, languages);
91
+ if (languages.length > 1 && !templateHasLanguage(template)) {
92
+ throw usageError(`Input "${relative(process.cwd(), template)}" has no {lang} placeholder but ${languages.length} languages were requested.`);
93
+ }
94
+ const results = [];
95
+ for (const lang of languages) {
96
+ const path = expandTemplate(template, lang, project.defaultLanguage);
97
+ if (!existsSync(path)) {
98
+ if (explicit)
99
+ throw notFoundError(`File not found for ${lang}: ${relative(process.cwd(), path)}`);
100
+ throw notFoundError(`Source file not found: ${relative(process.cwd(), path)}`, 'Run "langctl pull" first, or set "output" in langctl.json / pass --input.');
101
+ }
102
+ const translations = readTranslationFile(path, format, lang);
103
+ const s = spinner(`${opts.dryRun ? 'Checking' : 'Uploading'} ${lang} (${Object.keys(translations).length} keys)…`);
104
+ try {
105
+ const r = await uploadTranslations(session, project, lang, translations, { ...opts, module: opts.module ?? cfg.module });
106
+ results.push({ ...r, file: relative(process.cwd(), path) });
107
+ }
108
+ finally {
109
+ s.stop();
110
+ }
111
+ }
112
+ if (runtime.json) {
113
+ printJson({ project: project.slug, dryRun: Boolean(opts.dryRun), overwrite: Boolean(opts.overwrite), results });
114
+ return;
115
+ }
116
+ for (const r of results) {
117
+ const parts = [`${chalk.green(`${r.created} new`)}`, `${chalk.yellow(`${r.updated} updated`)}`, chalk.dim(`${r.unchanged} unchanged`)];
118
+ if (r.published !== undefined)
119
+ parts.push(chalk.cyan(`${r.published} published`));
120
+ log.out(`${r.language} ${r.file} ${parts.join(', ')}${opts.dryRun ? chalk.dim(' (dry run)') : ''}`);
121
+ }
122
+ if (!opts.overwrite && results.some(r => r.unchanged > 0)) {
123
+ log.info(chalk.dim('Existing translations are never overwritten unless you pass --overwrite.'));
124
+ }
125
+ }