langctl 0.0.1 → 0.1.1

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 (44) hide show
  1. package/NPM_PUBLISH_GUIDE.md +293 -0
  2. package/QUICK_PUBLISH.md +64 -0
  3. package/README.md +300 -30
  4. package/SECURITY_FIX.md +167 -0
  5. package/dist/auth.d.ts +33 -0
  6. package/dist/auth.js +106 -0
  7. package/dist/commands/auth.d.ts +3 -0
  8. package/dist/commands/auth.js +33 -0
  9. package/dist/commands/config.d.ts +2 -0
  10. package/dist/commands/config.js +30 -0
  11. package/dist/commands/debug.d.ts +3 -0
  12. package/dist/commands/debug.js +57 -0
  13. package/dist/commands/init.d.ts +2 -0
  14. package/dist/commands/init.js +87 -0
  15. package/dist/commands/projects.d.ts +2 -0
  16. package/dist/commands/projects.js +54 -0
  17. package/dist/commands/pull.d.ts +9 -0
  18. package/dist/commands/pull.js +194 -0
  19. package/dist/config.d.ts +47 -0
  20. package/dist/config.js +69 -0
  21. package/dist/exporters/index.d.ts +36 -0
  22. package/dist/exporters/index.js +214 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.js +166 -0
  25. package/dist/supabase.d.ts +14 -0
  26. package/dist/supabase.js +30 -0
  27. package/dist/utils/banner.d.ts +9 -0
  28. package/dist/utils/banner.js +24 -0
  29. package/package.json +22 -3
  30. package/translations/en.json +5 -0
  31. package/translations/en.xml +9 -0
  32. package/translations/fr.json +4 -0
  33. package/translations/fr.xml +7 -0
  34. package/translations/hi.json +4 -0
  35. package/translations/hi.xml +7 -0
  36. package/translations/ios/en.strings +9 -0
  37. package/translations/ios/fr.strings +6 -0
  38. package/translations/ios/hi.strings +6 -0
  39. package/translations/ios/ja.strings +6 -0
  40. package/translations/ja.json +4 -0
  41. package/translations/ja.xml +7 -0
  42. package/PUBLISH_GUIDE.md +0 -209
  43. package/bin/langctl.js +0 -19
  44. package/index.js +0 -26
@@ -0,0 +1,194 @@
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;
49
+ }
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;
60
+ }
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;
68
+ }
69
+ const organizationId = getOrganizationId();
70
+ if (!organizationId) {
71
+ console.log(chalk.red('✗ Organization ID not found. Please run "langctl init" again.\n'));
72
+ return;
73
+ }
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;
85
+ }
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];
119
+ }
120
+ else {
121
+ // Pull all languages
122
+ languagesToPull = project.languages;
123
+ }
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
+ }
189
+ catch (error) {
190
+ spinner.fail(chalk.red('Failed to pull translations'));
191
+ console.error(chalk.red(`Error: ${error.message}\n`));
192
+ }
193
+ }
194
+ //# sourceMappingURL=pull.js.map
@@ -0,0 +1,47 @@
1
+ export interface LangctlConfig {
2
+ apiKey?: string;
3
+ organizationId?: string;
4
+ organizationName?: string;
5
+ defaultProject?: string;
6
+ defaultLanguage?: string;
7
+ }
8
+ declare class ConfigManager {
9
+ private config;
10
+ private configDir;
11
+ constructor();
12
+ /**
13
+ * Get a configuration value
14
+ */
15
+ get<K extends keyof LangctlConfig>(key: K): LangctlConfig[K] | undefined;
16
+ /**
17
+ * Set a configuration value
18
+ */
19
+ set<K extends keyof LangctlConfig>(key: K, value: LangctlConfig[K]): void;
20
+ /**
21
+ * Get all configuration
22
+ */
23
+ getAll(): LangctlConfig;
24
+ /**
25
+ * Set multiple configuration values
26
+ */
27
+ setAll(config: Partial<LangctlConfig>): void;
28
+ /**
29
+ * Delete a configuration value
30
+ */
31
+ delete(key: keyof LangctlConfig): void;
32
+ /**
33
+ * Clear all configuration
34
+ */
35
+ clear(): void;
36
+ /**
37
+ * Check if user is authenticated
38
+ */
39
+ isConfigured(): boolean;
40
+ /**
41
+ * Get the configuration file path
42
+ */
43
+ getConfigPath(): string;
44
+ }
45
+ export declare const config: ConfigManager;
46
+ export {};
47
+ //# sourceMappingURL=config.d.ts.map
package/dist/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import Conf from 'conf';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ class ConfigManager {
5
+ constructor() {
6
+ this.configDir = join(homedir(), '.langctl');
7
+ this.config = new Conf({
8
+ projectName: 'langctl',
9
+ cwd: this.configDir,
10
+ configName: 'config'
11
+ });
12
+ }
13
+ /**
14
+ * Get a configuration value
15
+ */
16
+ get(key) {
17
+ return this.config.get(key);
18
+ }
19
+ /**
20
+ * Set a configuration value
21
+ */
22
+ set(key, value) {
23
+ this.config.set(key, value);
24
+ }
25
+ /**
26
+ * Get all configuration
27
+ */
28
+ getAll() {
29
+ return this.config.store;
30
+ }
31
+ /**
32
+ * Set multiple configuration values
33
+ */
34
+ setAll(config) {
35
+ Object.entries(config).forEach(([key, value]) => {
36
+ if (value !== undefined) {
37
+ this.config.set(key, value);
38
+ }
39
+ });
40
+ }
41
+ /**
42
+ * Delete a configuration value
43
+ */
44
+ delete(key) {
45
+ this.config.delete(key);
46
+ }
47
+ /**
48
+ * Clear all configuration
49
+ */
50
+ clear() {
51
+ this.config.clear();
52
+ }
53
+ /**
54
+ * Check if user is authenticated
55
+ */
56
+ isConfigured() {
57
+ return !!(this.config.get('apiKey') &&
58
+ this.config.get('organizationId'));
59
+ }
60
+ /**
61
+ * Get the configuration file path
62
+ */
63
+ getConfigPath() {
64
+ return this.config.path;
65
+ }
66
+ }
67
+ // Singleton instance
68
+ export const config = new ConfigManager();
69
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,36 @@
1
+ export interface TranslationKey {
2
+ key: string;
3
+ translations: Record<string, string>;
4
+ description?: string;
5
+ module?: string;
6
+ }
7
+ export type ExportFormat = 'json' | 'json-nested' | 'ios' | 'android' | 'flutter';
8
+ export interface ExportResult {
9
+ content: string;
10
+ filename: string;
11
+ }
12
+ /**
13
+ * Export as flat JSON (simple key-value pairs)
14
+ */
15
+ export declare function exportFlatJson(keys: TranslationKey[], languageCode: string): ExportResult;
16
+ /**
17
+ * Export as nested JSON (organized by key structure)
18
+ */
19
+ export declare function exportNestedJson(keys: TranslationKey[], languageCode: string): ExportResult;
20
+ /**
21
+ * Export as iOS .strings format
22
+ */
23
+ export declare function exportIosStrings(keys: TranslationKey[], languageCode: string): ExportResult;
24
+ /**
25
+ * Export as Android XML format
26
+ */
27
+ export declare function exportAndroidXml(keys: TranslationKey[], languageCode: string): ExportResult;
28
+ /**
29
+ * Export as Flutter ARB format
30
+ */
31
+ export declare function exportFlutterArb(keys: TranslationKey[], languageCode: string): ExportResult;
32
+ /**
33
+ * Export translations in the specified format
34
+ */
35
+ export declare function exportTranslations(keys: TranslationKey[], languageCode: string, format: ExportFormat): ExportResult;
36
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Helper: Set nested value in object based on dot-notation key
3
+ */
4
+ function setNestedValue(obj, path, value) {
5
+ const keys = path.split('.');
6
+ let current = obj;
7
+ for (let i = 0; i < keys.length - 1; i++) {
8
+ const key = keys[i];
9
+ if (!current[key] || typeof current[key] !== 'object') {
10
+ current[key] = {};
11
+ }
12
+ current = current[key];
13
+ }
14
+ current[keys[keys.length - 1]] = value;
15
+ }
16
+ /**
17
+ * Helper: Convert i18n format {{param}} to Android format %1$s, %2$s, etc.
18
+ */
19
+ function convertToAndroidFormat(text) {
20
+ const matches = text.match(/\{\{([^}]+)\}\}/g);
21
+ if (!matches)
22
+ return text;
23
+ let result = text;
24
+ matches.forEach((match, index) => {
25
+ const paramNum = index + 1;
26
+ result = result.replace(match, `%${paramNum}$s`);
27
+ });
28
+ return result;
29
+ }
30
+ /**
31
+ * Helper: Convert i18n format {{param}} to iOS format %1$@, %2$@, etc.
32
+ */
33
+ function convertToIosFormat(text) {
34
+ const matches = text.match(/\{\{([^}]+)\}\}/g);
35
+ if (!matches)
36
+ return text;
37
+ let result = text;
38
+ matches.forEach((match, index) => {
39
+ const paramNum = index + 1;
40
+ result = result.replace(match, `%${paramNum}$@`);
41
+ });
42
+ return result;
43
+ }
44
+ /**
45
+ * Helper: Convert i18n format {{param}} to Flutter format {param}
46
+ */
47
+ function convertToFlutterFormat(text) {
48
+ return text.replace(/\{\{([^}]+)\}\}/g, '{$1}');
49
+ }
50
+ /**
51
+ * Helper: Extract placeholder names from Flutter format {param}
52
+ */
53
+ function extractPlaceholders(text) {
54
+ const matches = text.match(/\{([^}]+)\}/g);
55
+ if (!matches)
56
+ return [];
57
+ return matches.map(match => match.slice(1, -1));
58
+ }
59
+ /**
60
+ * Helper: Escape XML special characters
61
+ */
62
+ function escapeXml(text) {
63
+ return text
64
+ .replace(/&/g, '&amp;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;')
67
+ .replace(/"/g, '&quot;')
68
+ .replace(/'/g, '&apos;');
69
+ }
70
+ /**
71
+ * Helper: Escape .strings special characters
72
+ */
73
+ function escapeStrings(text) {
74
+ return text
75
+ .replace(/\\/g, '\\\\')
76
+ .replace(/"/g, '\\"')
77
+ .replace(/\n/g, '\\n')
78
+ .replace(/\r/g, '\\r')
79
+ .replace(/\t/g, '\\t');
80
+ }
81
+ /**
82
+ * Export as flat JSON (simple key-value pairs)
83
+ */
84
+ export function exportFlatJson(keys, languageCode) {
85
+ const translations = {};
86
+ keys.forEach(key => {
87
+ const translation = key.translations[languageCode];
88
+ if (translation) {
89
+ translations[key.key] = translation;
90
+ }
91
+ });
92
+ return {
93
+ content: JSON.stringify(translations, null, 2),
94
+ filename: `${languageCode}.json`
95
+ };
96
+ }
97
+ /**
98
+ * Export as nested JSON (organized by key structure)
99
+ */
100
+ export function exportNestedJson(keys, languageCode) {
101
+ const translations = {};
102
+ keys.forEach(key => {
103
+ const translation = key.translations[languageCode];
104
+ if (translation) {
105
+ setNestedValue(translations, key.key, translation);
106
+ }
107
+ });
108
+ return {
109
+ content: JSON.stringify(translations, null, 2),
110
+ filename: `${languageCode}.json`
111
+ };
112
+ }
113
+ /**
114
+ * Export as iOS .strings format
115
+ */
116
+ export function exportIosStrings(keys, languageCode) {
117
+ let content = '';
118
+ keys.forEach(key => {
119
+ const translation = key.translations[languageCode];
120
+ if (translation) {
121
+ const iosTranslation = convertToIosFormat(translation);
122
+ const escapedTranslation = escapeStrings(iosTranslation);
123
+ if (key.description) {
124
+ content += `/* ${key.description} */\n`;
125
+ }
126
+ content += `"${key.key}" = "${escapedTranslation}";\n\n`;
127
+ }
128
+ });
129
+ return {
130
+ content,
131
+ filename: `Localizable.strings`
132
+ };
133
+ }
134
+ /**
135
+ * Export as Android XML format
136
+ */
137
+ export function exportAndroidXml(keys, languageCode) {
138
+ let xml = '<?xml version="1.0" encoding="utf-8"?>\n';
139
+ xml += '<resources>\n';
140
+ keys.forEach(key => {
141
+ const translation = key.translations[languageCode];
142
+ if (translation) {
143
+ const androidTranslation = convertToAndroidFormat(translation);
144
+ const escapedTranslation = escapeXml(androidTranslation);
145
+ if (key.description) {
146
+ xml += ` <!-- ${escapeXml(key.description)} -->\n`;
147
+ }
148
+ xml += ` <string name="${key.key}">${escapedTranslation}</string>\n`;
149
+ }
150
+ });
151
+ xml += '</resources>\n';
152
+ return {
153
+ content: xml,
154
+ filename: `strings.xml`
155
+ };
156
+ }
157
+ /**
158
+ * Export as Flutter ARB format
159
+ */
160
+ export function exportFlutterArb(keys, languageCode) {
161
+ const arb = {
162
+ '@@locale': languageCode,
163
+ '@@last_modified': new Date().toISOString()
164
+ };
165
+ keys.forEach(key => {
166
+ const translation = key.translations[languageCode];
167
+ if (translation) {
168
+ const flutterTranslation = convertToFlutterFormat(translation);
169
+ arb[key.key] = flutterTranslation;
170
+ // Add metadata
171
+ const metadata = {};
172
+ if (key.description) {
173
+ metadata.description = key.description;
174
+ }
175
+ // Extract placeholders from translation
176
+ const placeholders = extractPlaceholders(flutterTranslation);
177
+ if (placeholders.length > 0) {
178
+ metadata.placeholders = {};
179
+ placeholders.forEach(placeholder => {
180
+ metadata.placeholders[placeholder] = {
181
+ type: 'String'
182
+ };
183
+ });
184
+ }
185
+ if (Object.keys(metadata).length > 0) {
186
+ arb[`@${key.key}`] = metadata;
187
+ }
188
+ }
189
+ });
190
+ return {
191
+ content: JSON.stringify(arb, null, 2),
192
+ filename: `intl_${languageCode}.arb`
193
+ };
194
+ }
195
+ /**
196
+ * Export translations in the specified format
197
+ */
198
+ export function exportTranslations(keys, languageCode, format) {
199
+ switch (format) {
200
+ case 'json':
201
+ return exportFlatJson(keys, languageCode);
202
+ case 'json-nested':
203
+ return exportNestedJson(keys, languageCode);
204
+ case 'ios':
205
+ return exportIosStrings(keys, languageCode);
206
+ case 'android':
207
+ return exportAndroidXml(keys, languageCode);
208
+ case 'flutter':
209
+ return exportFlutterArb(keys, languageCode);
210
+ default:
211
+ throw new Error(`Unsupported export format: ${format}`);
212
+ }
213
+ }
214
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map