langctl 0.1.2 → 0.2.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.
@@ -2,57 +2,65 @@ import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { writeFileSync, mkdirSync } from 'fs';
4
4
  import { join, dirname } from 'path';
5
- import { isAuthenticated, getApiKey } from '../auth.js';
6
- const EDGE_FUNCTION_URL = 'https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/export-translations';
7
- const LIST_PROJECTS_URL = 'https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/list-projects';
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';
8
9
  /**
9
- * Get project by slug
10
+ * Map CLI format names to exporter format names
10
11
  */
11
- async function getProjectBySlug(apiKey, slug) {
12
- try {
13
- const response = await fetch(LIST_PROJECTS_URL, {
14
- method: 'POST',
15
- headers: {
16
- 'Content-Type': 'application/json',
17
- 'X-API-Key': apiKey
18
- }
19
- });
20
- if (!response.ok) {
21
- return null;
22
- }
23
- const data = await response.json();
24
- if (!data.success || !data.projects) {
25
- return null;
26
- }
27
- return data.projects.find((p) => p.slug === slug) || null;
28
- }
29
- catch (error) {
30
- return null;
12
+ const formatMap = {
13
+ 'flat-json': 'json',
14
+ 'nested-json': 'json-nested',
15
+ 'i18n-json': 'json',
16
+ 'android-xml': 'android',
17
+ 'ios-strings': 'ios',
18
+ 'flutter-arb': 'flutter',
19
+ // Also accept exporter format names directly
20
+ 'json': 'json',
21
+ 'json-nested': 'json-nested',
22
+ 'android': 'android',
23
+ 'ios': 'ios',
24
+ 'flutter': 'flutter',
25
+ };
26
+ /**
27
+ * Get file extension for a given export format
28
+ */
29
+ function getFileExtension(format, language) {
30
+ switch (format) {
31
+ case 'json':
32
+ case 'json-nested':
33
+ return `${language}.json`;
34
+ case 'android':
35
+ return `strings.xml`;
36
+ case 'ios':
37
+ return `Localizable.strings`;
38
+ case 'flutter':
39
+ return `intl_${language}.arb`;
40
+ default:
41
+ return `${language}.json`;
31
42
  }
32
43
  }
33
44
  /**
34
45
  * Export translations command
35
46
  */
36
47
  export async function exportCommand(projectSlug, options) {
37
- // Check authentication
38
48
  if (!isAuthenticated()) {
39
49
  console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
50
+ process.exitCode = 1;
40
51
  return;
41
52
  }
42
- const apiKey = getApiKey();
43
- if (!apiKey) {
44
- console.log(chalk.red('✗ API key not found. Please run "langctl auth <api-key>" again.\n'));
53
+ const orgId = config.get('organizationId');
54
+ if (!orgId) {
55
+ console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
56
+ process.exitCode = 1;
45
57
  return;
46
58
  }
47
59
  const spinner = ora('Fetching project...').start();
48
60
  try {
49
- // Get project by slug
50
- const project = await getProjectBySlug(apiKey, projectSlug);
51
- if (!project) {
52
- spinner.fail(chalk.red(`Project "${projectSlug}" not found`));
53
- console.log(chalk.yellow('\nRun "langctl projects list" to see available projects.\n'));
54
- return;
55
- }
61
+ const api = getApiClient();
62
+ // Resolve project by slug
63
+ const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectSlug}`);
56
64
  spinner.text = 'Exporting translations...';
57
65
  // Determine languages to export
58
66
  const languages = options.language
@@ -64,41 +72,42 @@ export async function exportCommand(projectSlug, options) {
64
72
  console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
65
73
  return;
66
74
  }
67
- const format = options.format || 'flat-json';
75
+ // Resolve format
76
+ const cliFormat = options.format || 'flat-json';
77
+ const exportFormat = formatMap[cliFormat];
78
+ if (!exportFormat) {
79
+ spinner.fail(chalk.red(`Unsupported format: ${cliFormat}`));
80
+ console.log(chalk.yellow(`Valid formats: ${Object.keys(formatMap).join(', ')}\n`));
81
+ return;
82
+ }
68
83
  const publishedOnly = !options.includeUnpublished;
69
84
  // Export each language
70
85
  for (const language of languages) {
71
86
  spinner.text = `Exporting ${language}...`;
72
- const response = await fetch(EDGE_FUNCTION_URL, {
73
- method: 'POST',
74
- headers: {
75
- 'Content-Type': 'application/json',
76
- 'X-API-Key': apiKey
77
- },
78
- body: JSON.stringify({
79
- projectId: project.id,
80
- language,
81
- format,
82
- publishedOnly,
83
- module: options.module
84
- })
85
- });
86
- if (!response.ok) {
87
- const errorData = await response.json().catch(() => ({}));
88
- throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
89
- }
90
- const data = await response.json();
91
- if (!data.success || !data.content) {
92
- throw new Error(data.error || 'Failed to export translations');
93
- }
87
+ // Fetch flat translations from the API
88
+ const params = {
89
+ language,
90
+ publishedOnly: publishedOnly ? 'true' : 'false'
91
+ };
92
+ if (options.module)
93
+ params.module = options.module;
94
+ const result = await api.get(`/orgs/${orgId}/projects/${project.id}/export`, params);
95
+ // Convert flat translations to TranslationKey array for the exporter
96
+ const flatTranslations = result.translations || {};
97
+ const translationKeys = Object.entries(flatTranslations).map(([key, value]) => ({
98
+ key,
99
+ translations: { [language]: value }
100
+ }));
101
+ // Use client-side exporter for format conversion
102
+ const exportResult = exportTranslations(translationKeys, language, exportFormat);
94
103
  // Determine output path
95
104
  const outputPath = options.output
96
105
  ? options.output
97
- : join(process.cwd(), 'translations', data.filename || `${language}.json`);
106
+ : join(process.cwd(), 'translations', getFileExtension(exportFormat, language));
98
107
  // Ensure directory exists
99
108
  mkdirSync(dirname(outputPath), { recursive: true });
100
109
  // Write file
101
- writeFileSync(outputPath, data.content, 'utf-8');
110
+ writeFileSync(outputPath, exportResult.content, 'utf-8');
102
111
  spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
103
112
  // Show spinner again if there are more languages
104
113
  if (languages.indexOf(language) < languages.length - 1) {
@@ -110,6 +119,7 @@ export async function exportCommand(projectSlug, options) {
110
119
  catch (error) {
111
120
  spinner.fail(chalk.red('Failed to export translations'));
112
121
  console.error(chalk.red(`Error: ${error.message}\n`));
122
+ process.exitCode = 1;
113
123
  }
114
124
  }
115
125
  //# sourceMappingURL=export.js.map
@@ -1,6 +1,7 @@
1
1
  interface ImportOptions {
2
2
  language: string;
3
3
  overwrite?: boolean;
4
+ module?: string;
4
5
  publish?: boolean;
5
6
  }
6
7
  /**
@@ -2,34 +2,9 @@ import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { readFileSync } from 'fs';
4
4
  import { resolve } from 'path';
5
- import { isAuthenticated, getApiKey } from '../auth.js';
6
- const PUSH_TRANSLATIONS_URL = 'https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/push-translations';
7
- const LIST_PROJECTS_URL = 'https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/list-projects';
8
- /**
9
- * Get project by slug
10
- */
11
- async function getProjectBySlug(apiKey, slug) {
12
- try {
13
- const response = await fetch(LIST_PROJECTS_URL, {
14
- method: 'POST',
15
- headers: {
16
- 'Content-Type': 'application/json',
17
- 'X-API-Key': apiKey
18
- }
19
- });
20
- if (!response.ok) {
21
- return null;
22
- }
23
- const data = await response.json();
24
- if (!data.success || !data.projects) {
25
- return null;
26
- }
27
- return data.projects.find((p) => p.slug === slug) || null;
28
- }
29
- catch (error) {
30
- return null;
31
- }
32
- }
5
+ import { isAuthenticated } from '../auth.js';
6
+ import { getApiClient } from '../api.js';
7
+ import { config } from '../config.js';
33
8
  /**
34
9
  * Parse translation file based on extension
35
10
  */
@@ -72,14 +47,15 @@ function flattenObject(obj, prefix = '') {
72
47
  * Import translations command
73
48
  */
74
49
  export async function importCommand(projectSlug, filePath, options) {
75
- // Check authentication
76
50
  if (!isAuthenticated()) {
77
51
  console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
52
+ process.exitCode = 1;
78
53
  return;
79
54
  }
80
- const apiKey = getApiKey();
81
- if (!apiKey) {
82
- console.log(chalk.red('✗ API key not found. Please run "langctl auth <api-key>" again.\n'));
55
+ const orgId = config.get('organizationId');
56
+ if (!orgId) {
57
+ console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
58
+ process.exitCode = 1;
83
59
  return;
84
60
  }
85
61
  const spinner = ora('Loading file...').start();
@@ -96,12 +72,8 @@ export async function importCommand(projectSlug, filePath, options) {
96
72
  }
97
73
  spinner.text = 'Fetching project...';
98
74
  // Get project by slug
99
- const project = await getProjectBySlug(apiKey, projectSlug);
100
- if (!project) {
101
- spinner.fail(chalk.red(`Project "${projectSlug}" not found`));
102
- console.log(chalk.yellow('\nRun "langctl projects list" to see available projects.\n'));
103
- return;
104
- }
75
+ const api = getApiClient();
76
+ const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectSlug}`);
105
77
  // Validate language
106
78
  if (!project.languages.includes(options.language)) {
107
79
  spinner.fail(chalk.red(`Language "${options.language}" not found in project`));
@@ -109,62 +81,69 @@ export async function importCommand(projectSlug, filePath, options) {
109
81
  return;
110
82
  }
111
83
  spinner.text = `Importing ${keyCount} translations...`;
112
- // Push translations
113
- const response = await fetch(PUSH_TRANSLATIONS_URL, {
114
- method: 'POST',
115
- headers: {
116
- 'Content-Type': 'application/json',
117
- 'X-API-Key': apiKey
118
- },
119
- body: JSON.stringify({
120
- projectId: project.id,
121
- language: options.language,
122
- translations,
123
- publish: options.publish || false,
124
- createMissing: true
125
- })
126
- });
127
- if (!response.ok) {
128
- const errorData = await response.json().catch(() => ({}));
129
- throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
130
- }
131
- const data = await response.json();
132
- if (!data.success && data.errors.length > 0) {
133
- spinner.fail(chalk.red('Import completed with errors'));
134
- console.log(chalk.green(`\n✓ Created: ${data.created}`));
135
- console.log(chalk.blue(`✓ Updated: ${data.updated}`));
136
- console.log(chalk.yellow(`⊘ Skipped: ${data.skipped}`));
137
- console.log(chalk.red(`✗ Errors: ${data.errors.length}\n`));
138
- if (data.errors.length > 0) {
139
- console.log(chalk.red('Errors:'));
140
- data.errors.slice(0, 5).forEach(err => {
141
- console.log(chalk.red(` - ${err.key}: ${err.error}`));
84
+ // Push translations via REST API
85
+ const body = {
86
+ language: options.language,
87
+ translations,
88
+ // Keep import safe by default; only overwrite when explicitly requested.
89
+ overwriteExisting: options.overwrite === true
90
+ };
91
+ if (options.module)
92
+ body.module = options.module;
93
+ const data = await api.post(`/orgs/${orgId}/projects/${project.id}/import`, body);
94
+ let publishedCount = 0;
95
+ if (options.publish) {
96
+ spinner.text = 'Publishing imported keys...';
97
+ const keyNames = new Set(Object.keys(translations));
98
+ const keyIds = [];
99
+ let page = 1;
100
+ const pageSize = 100;
101
+ while (true) {
102
+ const keysPage = await api.get(`/orgs/${orgId}/projects/${project.id}/keys`, {
103
+ page: String(page),
104
+ pageSize: String(pageSize)
142
105
  });
143
- if (data.errors.length > 5) {
144
- console.log(chalk.gray(` ... and ${data.errors.length - 5} more errors\n`));
106
+ for (const key of (keysPage.data || [])) {
107
+ if (keyNames.has(key.key)) {
108
+ keyIds.push(key.id);
109
+ }
145
110
  }
111
+ const totalPages = keysPage.pagination?.totalPages || 1;
112
+ if (page >= totalPages)
113
+ break;
114
+ page += 1;
115
+ }
116
+ if (keyIds.length > 0) {
117
+ await api.post(`/orgs/${orgId}/projects/${project.id}/keys/bulk-publish`, {
118
+ keyIds,
119
+ published: true
120
+ });
121
+ publishedCount = keyIds.length;
146
122
  }
147
- return;
148
123
  }
149
124
  spinner.succeed(chalk.green('Import completed successfully'));
150
125
  console.log(chalk.green(`\n✓ Created: ${data.created}`));
151
126
  console.log(chalk.blue(`✓ Updated: ${data.updated}`));
152
127
  console.log(chalk.yellow(`⊘ Skipped: ${data.skipped}`));
153
128
  if (options.publish) {
154
- console.log(chalk.green('✓ Keys published'));
129
+ console.log(chalk.magenta(`✓ Published: ${publishedCount}`));
155
130
  }
131
+ console.log(chalk.white(` Total: ${data.total}`));
156
132
  console.log('');
157
133
  }
158
134
  catch (error) {
159
135
  spinner.fail(chalk.red('Failed to import translations'));
160
136
  if (error.code === 'ENOENT') {
161
137
  console.error(chalk.red(`Error: File not found: ${filePath}\n`));
138
+ process.exitCode = 1;
162
139
  }
163
140
  else if (error instanceof SyntaxError) {
164
141
  console.error(chalk.red(`Error: Invalid JSON file\n`));
142
+ process.exitCode = 1;
165
143
  }
166
144
  else {
167
145
  console.error(chalk.red(`Error: ${error.message}\n`));
146
+ process.exitCode = 1;
168
147
  }
169
148
  }
170
149
  }
@@ -3,7 +3,6 @@ import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import { config } from '../config.js';
5
5
  import { authenticate } from '../auth.js';
6
- import { initializeSupabase } from '../supabase.js';
7
6
  import { showLogo } from '../utils/banner.js';
8
7
  export async function initCommand() {
9
8
  // Show logo
@@ -25,15 +24,6 @@ export async function initCommand() {
25
24
  return;
26
25
  }
27
26
  }
28
- // Initialize Supabase client (with hardcoded credentials - user doesn't need to know)
29
- try {
30
- initializeSupabase();
31
- }
32
- catch (error) {
33
- console.error(chalk.red('✗ Failed to connect to Langctl servers'));
34
- console.error(chalk.red('Please check your internet connection and try again.\n'));
35
- return;
36
- }
37
27
  // Ask for API key
38
28
  const { apiKey } = await inquirer.prompt([
39
29
  {