langctl 0.1.2 → 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/LICENSE +1 -1
  3. package/README.md +159 -1151
  4. package/dist/commands/auth.js +82 -24
  5. package/dist/commands/config.js +36 -25
  6. package/dist/commands/export.js +11 -112
  7. package/dist/commands/import.js +32 -164
  8. package/dist/commands/init.js +59 -80
  9. package/dist/commands/keys.js +145 -381
  10. package/dist/commands/org.js +45 -140
  11. package/dist/commands/projects.js +125 -390
  12. package/dist/commands/pull.js +78 -183
  13. package/dist/commands/push.js +125 -0
  14. package/dist/commands/team.js +68 -272
  15. package/dist/core/config.js +130 -0
  16. package/dist/core/errors.js +67 -0
  17. package/dist/core/files.js +38 -0
  18. package/dist/core/http.js +121 -0
  19. package/dist/core/output.js +96 -0
  20. package/dist/core/project.js +58 -0
  21. package/dist/core/prompts.js +56 -0
  22. package/dist/formats/index.js +285 -0
  23. package/dist/index.js +261 -508
  24. package/dist/version.js +4 -0
  25. package/package.json +22 -16
  26. package/.gitattributes +0 -2
  27. package/NEXT_PHASE.md +0 -618
  28. package/WORKING_INSTRUCTIONS.md +0 -1389
  29. package/dist/auth.d.ts +0 -37
  30. package/dist/auth.js +0 -112
  31. package/dist/commands/auth.d.ts +0 -3
  32. package/dist/commands/config.d.ts +0 -2
  33. package/dist/commands/debug.d.ts +0 -3
  34. package/dist/commands/debug.js +0 -57
  35. package/dist/commands/export.d.ts +0 -14
  36. package/dist/commands/import.d.ts +0 -11
  37. package/dist/commands/init.d.ts +0 -2
  38. package/dist/commands/keys.d.ts +0 -25
  39. package/dist/commands/org.d.ts +0 -13
  40. package/dist/commands/projects.d.ts +0 -30
  41. package/dist/commands/pull.d.ts +0 -9
  42. package/dist/commands/team.d.ts +0 -29
  43. package/dist/config.d.ts +0 -47
  44. package/dist/config.js +0 -69
  45. package/dist/exporters/index.d.ts +0 -36
  46. package/dist/exporters/index.js +0 -214
  47. package/dist/index.d.ts +0 -3
  48. package/dist/supabase.d.ts +0 -14
  49. package/dist/supabase.js +0 -30
  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,194 +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 { getSupabase } from '../supabase.js';
6
- import { isAuthenticated, getOrganizationId } from '../auth.js';
7
- import { exportTranslations } from '../exporters/index.js';
8
- /**
9
- * Get platform-specific output directory
10
- */
11
- function getPlatformDirectory(format) {
12
- const platformDirs = {
13
- 'json': 'i18n',
14
- 'json-nested': 'i18n',
15
- 'ios': 'ios',
16
- 'android': 'android',
17
- 'flutter': 'flutter'
18
- };
19
- return platformDirs[format] || 'i18n';
20
- }
21
- /**
22
- * Resolve project identifier (name, slug, or UUID) to project UUID
23
- */
24
- async function resolveProjectId(identifier, organizationId) {
25
- const supabase = getSupabase();
26
- // Check if it's already a UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
27
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
- if (uuidRegex.test(identifier)) {
29
- // Verify it exists
30
- const { data } = await supabase
31
- .from('projects')
32
- .select('id')
33
- .eq('id', identifier)
34
- .eq('organization_id', organizationId)
35
- .is('deleted_at', null)
36
- .single();
37
- return data ? identifier : null;
38
- }
39
- // Try to match by slug (case-insensitive)
40
- const { data: bySlug } = await supabase
41
- .from('projects')
42
- .select('id')
43
- .eq('organization_id', organizationId)
44
- .ilike('slug', identifier)
45
- .is('deleted_at', null)
46
- .single();
47
- if (bySlug) {
48
- return bySlug.id;
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
+ });
49
37
  }
50
- // Try to match by name (case-insensitive)
51
- const { data: byName } = await supabase
52
- .from('projects')
53
- .select('id')
54
- .eq('organization_id', organizationId)
55
- .ilike('name', identifier)
56
- .is('deleted_at', null)
57
- .single();
58
- if (byName) {
59
- return byName.id;
38
+ finally {
39
+ spin.stop();
60
40
  }
61
- return null;
62
- }
63
- export async function pullCommand(projectIdentifier, options) {
64
- // Check authentication
65
- if (!isAuthenticated()) {
66
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
67
- 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.');
68
45
  }
69
- const organizationId = getOrganizationId();
70
- if (!organizationId) {
71
- console.log(chalk.red('✗ Organization ID not found. Please run "langctl init" again.\n'));
72
- return;
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 });
73
54
  }
74
- // Set defaults
75
- const format = (options.format || 'json');
76
- const publishedOnly = options.publishedOnly !== false; // Default to true
77
- const specificLanguage = options.language; // If not specified, pull all languages
78
- const baseDir = options.dir || './translations'; // Default to ./translations
79
- // Validate format
80
- const validFormats = ['json', 'json-nested', 'ios', 'android', 'flutter'];
81
- if (!validFormats.includes(format)) {
82
- console.log(chalk.red(`✗ Invalid format: ${format}`));
83
- console.log(chalk.yellow(`Valid formats: ${validFormats.join(', ')}\n`));
84
- return;
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
+ });
85
67
  }
86
- const spinner = ora('Resolving project...').start();
87
- try {
88
- const supabase = getSupabase();
89
- // Resolve project identifier to UUID
90
- const projectId = await resolveProjectId(projectIdentifier, organizationId);
91
- if (!projectId) {
92
- spinner.fail(chalk.red(`Project "${projectIdentifier}" not found`));
93
- console.log(chalk.yellow('\nRun "langctl projects list" to see available projects.\n'));
94
- return;
95
- }
96
- // Fetch project details
97
- spinner.text = 'Fetching project...';
98
- const { data: project, error: projectError } = await supabase
99
- .from('projects')
100
- .select('id, name, slug, languages, default_language')
101
- .eq('id', projectId)
102
- .eq('organization_id', organizationId)
103
- .is('deleted_at', null)
104
- .single();
105
- if (projectError || !project) {
106
- spinner.fail(chalk.red('Project not found or access denied'));
107
- return;
108
- }
109
- // Determine which languages to pull
110
- let languagesToPull;
111
- if (specificLanguage) {
112
- // Check if specified language exists in project
113
- if (!project.languages.includes(specificLanguage)) {
114
- spinner.fail(chalk.red(`Language "${specificLanguage}" not found in project`));
115
- console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
116
- return;
117
- }
118
- languagesToPull = [specificLanguage];
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}`);
119
74
  }
120
- else {
121
- // Pull all languages
122
- languagesToPull = project.languages;
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.');
123
78
  }
124
- spinner.text = `Fetching translations for "${project.name}"...`;
125
- // Fetch translation keys
126
- let query = supabase
127
- .from('translation_keys')
128
- .select('key, translations, description, module')
129
- .eq('project_id', projectId)
130
- .is('deleted_at', null);
131
- if (publishedOnly) {
132
- query = query.eq('published', true);
133
- }
134
- const { data: keys, error: keysError } = await query;
135
- if (keysError) {
136
- spinner.fail(chalk.red('Failed to fetch translations'));
137
- console.error(chalk.red(`Error: ${keysError.message}\n`));
138
- return;
139
- }
140
- if (!keys || keys.length === 0) {
141
- spinner.fail(chalk.yellow('No translations found'));
142
- console.log(chalk.yellow(`\nNo ${publishedOnly ? 'published ' : ''}translations found for this project.\n`));
143
- return;
144
- }
145
- spinner.succeed(chalk.green(`Found ${keys.length} translation keys`));
146
- // Get platform directory
147
- const platformDir = getPlatformDirectory(format);
148
- // Export translations for each language
149
- const exportedFiles = [];
150
- for (const language of languagesToPull) {
151
- spinner.start(`Exporting ${language}...`);
152
- const result = exportTranslations(keys, language, format);
153
- // Determine output path
154
- let outputPath;
155
- if (options.output) {
156
- // If custom output is specified and multiple languages, append language code
157
- if (languagesToPull.length > 1) {
158
- const ext = result.filename.split('.').pop();
159
- const base = options.output.replace(/\.[^.]+$/, '');
160
- outputPath = `${base}-${language}.${ext}`;
161
- }
162
- else {
163
- outputPath = options.output;
164
- }
165
- }
166
- else {
167
- // Default output path: <baseDir>/<platform>/<language>.<ext>
168
- const ext = result.filename.split('.').pop();
169
- outputPath = join(baseDir, platformDir, `${language}.${ext}`);
170
- }
171
- // Create directory if it doesn't exist
172
- const dir = dirname(outputPath);
173
- mkdirSync(dir, { recursive: true });
174
- writeFileSync(outputPath, result.content, 'utf-8');
175
- exportedFiles.push(outputPath);
176
- spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
177
- }
178
- // Summary
179
- console.log(chalk.hex('#10b981').bold('\n📦 Export Summary:'));
180
- console.log(chalk.white(` Project: ${project.name}`));
181
- console.log(chalk.white(` Format: ${format}`));
182
- console.log(chalk.white(` Platform: ${platformDir}`));
183
- console.log(chalk.white(` Output: ${baseDir}`));
184
- console.log(chalk.white(` Keys: ${keys.length}`));
185
- console.log(chalk.white(` Languages: ${languagesToPull.join(', ')}`));
186
- console.log(chalk.white(` Files: ${exportedFiles.length}`));
187
- console.log('');
188
79
  }
189
- catch (error) {
190
- spinner.fail(chalk.red('Failed to pull translations'));
191
- console.error(chalk.red(`Error: ${error.message}\n`));
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);
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.');
192
85
  }
193
86
  }
194
- //# sourceMappingURL=pull.js.map
87
+ export function splitList(value) {
88
+ return value.split(',').map(s => s.trim()).filter(Boolean);
89
+ }
@@ -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
+ }