langctl 0.1.1 → 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.
package/dist/api.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export declare class ApiClient {
2
+ private baseUrl;
3
+ private apiKey;
4
+ constructor(baseUrl: string, apiKey: string);
5
+ private request;
6
+ get<T>(path: string, params?: Record<string, string>): Promise<T>;
7
+ post<T>(path: string, body?: unknown): Promise<T>;
8
+ patch<T>(path: string, body: unknown): Promise<T>;
9
+ delete<T>(path: string): Promise<T>;
10
+ }
11
+ /**
12
+ * Create an API client from the current config.
13
+ * Throws if not authenticated.
14
+ */
15
+ export declare function getApiClient(): ApiClient;
16
+ /**
17
+ * Create an API client with a specific API key (for auth flow before key is saved).
18
+ */
19
+ export declare function createApiClient(apiKey: string): ApiClient;
20
+ //# sourceMappingURL=api.d.ts.map
package/dist/api.js ADDED
@@ -0,0 +1,75 @@
1
+ import { config } from './config.js';
2
+ const DEFAULT_API_URL = 'https://api.langctl.com/api/v1';
3
+ export class ApiClient {
4
+ constructor(baseUrl, apiKey) {
5
+ this.baseUrl = baseUrl.replace(/\/$/, '');
6
+ this.apiKey = apiKey;
7
+ }
8
+ async request(method, path, body, params) {
9
+ let url = `${this.baseUrl}${path}`;
10
+ if (params && Object.keys(params).length > 0) {
11
+ const searchParams = new URLSearchParams();
12
+ for (const [key, value] of Object.entries(params)) {
13
+ if (value !== undefined && value !== null && value !== '') {
14
+ searchParams.append(key, value);
15
+ }
16
+ }
17
+ const qs = searchParams.toString();
18
+ if (qs)
19
+ url += `?${qs}`;
20
+ }
21
+ const headers = {
22
+ 'X-API-Key': this.apiKey,
23
+ };
24
+ if (body !== undefined) {
25
+ headers['Content-Type'] = 'application/json';
26
+ }
27
+ const response = await fetch(url, {
28
+ method,
29
+ headers,
30
+ body: body !== undefined ? JSON.stringify(body) : undefined,
31
+ signal: AbortSignal.timeout(30000),
32
+ });
33
+ if (!response.ok) {
34
+ const errorData = await response.json().catch(() => ({}));
35
+ throw new Error(errorData.error || errorData.message || `HTTP ${response.status}: ${response.statusText}`);
36
+ }
37
+ // Handle 204 No Content
38
+ if (response.status === 204) {
39
+ return undefined;
40
+ }
41
+ return response.json();
42
+ }
43
+ async get(path, params) {
44
+ return this.request('GET', path, undefined, params);
45
+ }
46
+ async post(path, body) {
47
+ return this.request('POST', path, body);
48
+ }
49
+ async patch(path, body) {
50
+ return this.request('PATCH', path, body);
51
+ }
52
+ async delete(path) {
53
+ return this.request('DELETE', path);
54
+ }
55
+ }
56
+ /**
57
+ * Create an API client from the current config.
58
+ * Throws if not authenticated.
59
+ */
60
+ export function getApiClient() {
61
+ const apiKey = config.get('apiKey');
62
+ const baseUrl = config.get('apiBaseUrl') || DEFAULT_API_URL;
63
+ if (!apiKey) {
64
+ throw new Error('Not authenticated. Please run "langctl auth <api-key>" first.');
65
+ }
66
+ return new ApiClient(baseUrl, apiKey);
67
+ }
68
+ /**
69
+ * Create an API client with a specific API key (for auth flow before key is saved).
70
+ */
71
+ export function createApiClient(apiKey) {
72
+ const baseUrl = config.get('apiBaseUrl') || DEFAULT_API_URL;
73
+ return new ApiClient(baseUrl, apiKey);
74
+ }
75
+ //# sourceMappingURL=api.js.map
package/dist/auth.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  export interface ApiKeyData {
2
- organization_id: string;
2
+ organizationId: string;
3
3
  organization: {
4
4
  id: string;
5
5
  name: string;
6
+ slug: string;
6
7
  plan: string;
7
8
  };
8
9
  }
9
10
  /**
10
- * Verify API key via Edge Function
11
+ * Verify API key via Fastify API
11
12
  */
12
13
  export declare function verifyApiKey(plainTextKey: string): Promise<ApiKeyData | null>;
13
14
  /**
@@ -26,6 +27,10 @@ export declare function isAuthenticated(): boolean;
26
27
  * Get current organization ID
27
28
  */
28
29
  export declare function getOrganizationId(): string | undefined;
30
+ /**
31
+ * Get current API key
32
+ */
33
+ export declare function getApiKey(): string | undefined;
29
34
  /**
30
35
  * Logout (clear authentication)
31
36
  */
package/dist/auth.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { config } from './config.js';
2
- // Edge Function URL
3
- const EDGE_FUNCTION_URL = 'https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/verify-api-key';
2
+ import { createApiClient } from './api.js';
4
3
  // Normalize and validate API key input
5
4
  function sanitizeApiKey(input) {
6
5
  const raw = (input || '').trim().toLowerCase();
@@ -15,7 +14,7 @@ function sanitizeApiKey(input) {
15
14
  return { ok: true, key: `lc_${hexOnly}` };
16
15
  }
17
16
  /**
18
- * Verify API key via Edge Function
17
+ * Verify API key via Fastify API
19
18
  */
20
19
  export async function verifyApiKey(plainTextKey) {
21
20
  try {
@@ -23,33 +22,28 @@ export async function verifyApiKey(plainTextKey) {
23
22
  if (!sanitized.ok || !sanitized.key) {
24
23
  return null;
25
24
  }
26
- // Call Edge Function
27
- const response = await fetch(EDGE_FUNCTION_URL, {
28
- method: 'POST',
29
- headers: {
30
- 'Content-Type': 'application/json',
31
- },
32
- body: JSON.stringify({ apiKey: sanitized.key })
25
+ // Create a temporary API client with the key to validate
26
+ const client = createApiClient(sanitized.key);
27
+ // Validate the API key
28
+ const validateResult = await client.post('/api-keys/validate', {
29
+ apiKey: sanitized.key,
33
30
  });
34
- if (!response.ok) {
35
- return null;
36
- }
37
- const data = await response.json();
38
- if (!data.success) {
31
+ if (!validateResult.valid) {
39
32
  return null;
40
33
  }
41
- // Map Edge Function response to ApiKeyData format
34
+ // Fetch organization details
35
+ const org = await client.get(`/orgs/${validateResult.organizationId}`);
42
36
  return {
43
- organization_id: data.organizationId,
37
+ organizationId: validateResult.organizationId,
44
38
  organization: {
45
- id: data.organizationId,
46
- name: data.organizationName,
47
- plan: data.plan
48
- }
39
+ id: org.id,
40
+ name: org.name,
41
+ slug: org.slug,
42
+ plan: org.plan,
43
+ },
49
44
  };
50
45
  }
51
46
  catch (error) {
52
- console.error('Error verifying API key:', error);
53
47
  return null;
54
48
  }
55
49
  }
@@ -61,25 +55,25 @@ export async function authenticate(apiKey) {
61
55
  if (!sanitized.ok || !sanitized.key) {
62
56
  return {
63
57
  success: false,
64
- message: sanitized.message || 'Invalid API key format. Expected format: lc_[64 hex characters]'
58
+ message: sanitized.message || 'Invalid API key format. Expected format: lc_[64 hex characters]',
65
59
  };
66
60
  }
67
- // Verify via Edge Function
61
+ // Verify via Fastify API
68
62
  const keyData = await verifyApiKey(sanitized.key);
69
63
  if (!keyData) {
70
64
  return {
71
65
  success: false,
72
- message: 'Invalid or revoked API key. Please check your key and try again.'
66
+ message: 'Invalid or revoked API key. Please check your key and try again.',
73
67
  };
74
68
  }
75
69
  // Save to config
76
70
  config.set('apiKey', sanitized.key);
77
- config.set('organizationId', keyData.organization_id);
71
+ config.set('organizationId', keyData.organizationId);
78
72
  config.set('organizationName', keyData.organization.name);
79
73
  return {
80
74
  success: true,
81
75
  message: `Successfully authenticated as ${keyData.organization.name}`,
82
- data: keyData
76
+ data: keyData,
83
77
  };
84
78
  }
85
79
  /**
@@ -94,6 +88,12 @@ export function isAuthenticated() {
94
88
  export function getOrganizationId() {
95
89
  return config.get('organizationId');
96
90
  }
91
+ /**
92
+ * Get current API key
93
+ */
94
+ export function getApiKey() {
95
+ return config.get('apiKey');
96
+ }
97
97
  /**
98
98
  * Logout (clear authentication)
99
99
  */
@@ -2,16 +2,7 @@ import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { authenticate, logout } from '../auth.js';
4
4
  import { config } from '../config.js';
5
- import { initializeSupabase } from '../supabase.js';
6
5
  export async function authCommand(apiKey) {
7
- // Initialize Supabase (with hardcoded credentials)
8
- try {
9
- initializeSupabase();
10
- }
11
- catch (error) {
12
- console.log(chalk.red('✗ Failed to connect to Langctl servers.\n'));
13
- return;
14
- }
15
6
  const spinner = ora('Authenticating...').start();
16
7
  const result = await authenticate(apiKey);
17
8
  if (result.success) {
@@ -20,10 +11,11 @@ export async function authCommand(apiKey) {
20
11
  }
21
12
  else {
22
13
  spinner.fail(chalk.red(result.message));
14
+ process.exitCode = 1;
23
15
  console.log(chalk.yellow('\nTroubleshooting:'));
24
16
  console.log(chalk.white(' - Check that your API key is correct'));
25
17
  console.log(chalk.white(' - Verify the key is not revoked in the dashboard'));
26
- console.log(chalk.white(' - Generate a new key: https://langctl.com/dashboard/settings/api-keys\n'));
18
+ console.log(chalk.white(' - Generate a new key: https://app.langctl.com/dashboard/settings/api-keys\n'));
27
19
  }
28
20
  }
29
21
  export async function logoutCommand() {
@@ -9,6 +9,7 @@ export function configCommand() {
9
9
  }
10
10
  // Display config with masked sensitive values
11
11
  const displayConfig = {
12
+ apiBaseUrl: allConfig.apiBaseUrl || 'https://api.langctl.com/api/v1 (default)',
12
13
  apiKey: allConfig.apiKey ? maskApiKey(allConfig.apiKey) : undefined,
13
14
  organizationId: allConfig.organizationId,
14
15
  organizationName: allConfig.organizationName,
@@ -1,57 +1,61 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
- import { getSupabase, initializeSupabase } from '../supabase.js';
3
+ import { isAuthenticated } from '../auth.js';
4
+ import { getApiClient } from '../api.js';
5
+ import { config } from '../config.js';
4
6
  export async function debugListApiKeys() {
5
- // Initialize supabase (with hardcoded credentials)
6
- try {
7
- initializeSupabase();
8
- }
9
- catch (e) {
10
- console.error(chalk.red('✗ Failed to connect to Langctl servers.'));
7
+ if (!isAuthenticated()) {
8
+ console.log(chalk.red('✗ Not authenticated.\n'));
9
+ process.exitCode = 1;
11
10
  return;
12
11
  }
13
- const supabase = getSupabase();
14
- const spinner = ora('Fetching API keys (debug)...').start();
15
- const { data, error } = await supabase
16
- .from('api_keys')
17
- .select('id, name, key_prefix, organization_id, revoked, created_at, last_used_at')
18
- .limit(100);
19
- if (error) {
20
- spinner.fail(chalk.red(`Error fetching API keys: ${error.message}`));
21
- console.log(chalk.yellow('Hint: This is a debug command and may require elevated permissions.'));
12
+ const orgId = config.get('organizationId');
13
+ if (!orgId) {
14
+ console.log(chalk.red('✗ No organization configured.\n'));
15
+ process.exitCode = 1;
22
16
  return;
23
17
  }
24
- spinner.succeed(chalk.green(`Found ${data?.length || 0} API keys`));
25
- if (!data || data.length === 0) {
26
- console.log(chalk.yellow('No API keys visible with current permissions.'));
27
- return;
28
- }
29
- for (const row of data) {
30
- console.log(`${chalk.cyan(row.id)} ${chalk.white(row.name)} prefix=${chalk.magenta(row.key_prefix)} org=${chalk.yellow(row.organization_id)} revoked=${row.revoked ? chalk.red('true') : chalk.green('false')} last_used=${chalk.gray(row.last_used_at || 'never')}`);
18
+ const spinner = ora('Testing API connection...').start();
19
+ try {
20
+ const api = getApiClient();
21
+ const org = await api.get(`/orgs/${orgId}`);
22
+ spinner.succeed(chalk.green(`API connection successful`));
23
+ console.log(chalk.blue.bold('\n🔧 Debug Info\n'));
24
+ console.log(chalk.white(`Organization: ${org.name}`));
25
+ console.log(chalk.white(`Plan: ${org.plan}`));
26
+ console.log(chalk.gray(`API URL: ${config.get('apiBaseUrl') || 'https://api.langctl.com/api/v1'}`));
27
+ console.log(chalk.gray(`Config: ${config.getConfigPath()}`));
28
+ console.log('');
29
+ }
30
+ catch (error) {
31
+ spinner.fail(chalk.red('API connection failed'));
32
+ console.error(chalk.red(`Error: ${error.message}\n`));
33
+ process.exitCode = 1;
31
34
  }
32
35
  }
33
36
  export async function debugListOrganizations() {
34
- // Initialize supabase (with hardcoded credentials)
35
- try {
36
- initializeSupabase();
37
- }
38
- catch (e) {
39
- console.error(chalk.red('✗ Failed to connect to Langctl servers.'));
37
+ if (!isAuthenticated()) {
38
+ console.log(chalk.red('✗ Not authenticated.\n'));
39
+ process.exitCode = 1;
40
40
  return;
41
41
  }
42
- const supabase = getSupabase();
43
- const spinner = ora('Fetching organizations (debug)...').start();
44
- const { data, error } = await supabase
45
- .from('organizations')
46
- .select('id, name, plan, created_at')
47
- .limit(100);
48
- if (error) {
49
- spinner.fail(chalk.red(`Error fetching organizations: ${error.message}`));
42
+ const orgId = config.get('organizationId');
43
+ if (!orgId) {
44
+ console.log(chalk.red('✗ No organization configured.\n'));
45
+ process.exitCode = 1;
50
46
  return;
51
47
  }
52
- spinner.succeed(chalk.green(`Found ${data?.length || 0} organizations`));
53
- (data || []).forEach(o => {
54
- console.log(`${chalk.cyan(o.id)} ${chalk.white(o.name)} plan=${chalk.magenta(o.plan)} created=${chalk.gray(o.created_at)}`);
55
- });
48
+ const spinner = ora('Fetching organization...').start();
49
+ try {
50
+ const api = getApiClient();
51
+ const org = await api.get(`/orgs/${orgId}`);
52
+ spinner.succeed(chalk.green('Found organization'));
53
+ console.log(`${chalk.cyan(org.id)} ${chalk.white(org.name)} plan=${chalk.magenta(org.plan)} created=${chalk.gray(org.createdAt)}`);
54
+ }
55
+ catch (error) {
56
+ spinner.fail(chalk.red('Failed to fetch organization'));
57
+ console.error(chalk.red(`Error: ${error.message}\n`));
58
+ process.exitCode = 1;
59
+ }
56
60
  }
57
61
  //# sourceMappingURL=debug.js.map
@@ -0,0 +1,13 @@
1
+ interface ExportOptions {
2
+ language?: string;
3
+ format?: string;
4
+ output?: string;
5
+ module?: string;
6
+ includeUnpublished?: boolean;
7
+ }
8
+ /**
9
+ * Export translations command
10
+ */
11
+ export declare function exportCommand(projectSlug: string, options: ExportOptions): Promise<void>;
12
+ export {};
13
+ //# sourceMappingURL=export.d.ts.map
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { writeFileSync, mkdirSync } from 'fs';
4
+ import { join, dirname } 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
+ * Map CLI format names to exporter format names
11
+ */
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`;
42
+ }
43
+ }
44
+ /**
45
+ * Export translations command
46
+ */
47
+ export async function exportCommand(projectSlug, options) {
48
+ if (!isAuthenticated()) {
49
+ console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
50
+ process.exitCode = 1;
51
+ return;
52
+ }
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;
57
+ return;
58
+ }
59
+ const spinner = ora('Fetching project...').start();
60
+ try {
61
+ const api = getApiClient();
62
+ // Resolve project by slug
63
+ const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectSlug}`);
64
+ spinner.text = 'Exporting translations...';
65
+ // Determine languages to export
66
+ const languages = options.language
67
+ ? [options.language]
68
+ : project.languages;
69
+ // Validate language if specified
70
+ if (options.language && !project.languages.includes(options.language)) {
71
+ spinner.fail(chalk.red(`Language "${options.language}" not found in project`));
72
+ console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
73
+ return;
74
+ }
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
+ }
83
+ const publishedOnly = !options.includeUnpublished;
84
+ // Export each language
85
+ for (const language of languages) {
86
+ spinner.text = `Exporting ${language}...`;
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);
103
+ // Determine output path
104
+ const outputPath = options.output
105
+ ? options.output
106
+ : join(process.cwd(), 'translations', getFileExtension(exportFormat, language));
107
+ // Ensure directory exists
108
+ mkdirSync(dirname(outputPath), { recursive: true });
109
+ // Write file
110
+ writeFileSync(outputPath, exportResult.content, 'utf-8');
111
+ spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
112
+ // Show spinner again if there are more languages
113
+ if (languages.indexOf(language) < languages.length - 1) {
114
+ spinner.start();
115
+ }
116
+ }
117
+ console.log(chalk.blue(`\n✓ Exported ${languages.length} language(s) successfully\n`));
118
+ }
119
+ catch (error) {
120
+ spinner.fail(chalk.red('Failed to export translations'));
121
+ console.error(chalk.red(`Error: ${error.message}\n`));
122
+ process.exitCode = 1;
123
+ }
124
+ }
125
+ //# sourceMappingURL=export.js.map
@@ -0,0 +1,12 @@
1
+ interface ImportOptions {
2
+ language: string;
3
+ overwrite?: boolean;
4
+ module?: string;
5
+ publish?: boolean;
6
+ }
7
+ /**
8
+ * Import translations command
9
+ */
10
+ export declare function importCommand(projectSlug: string, filePath: string, options: ImportOptions): Promise<void>;
11
+ export {};
12
+ //# sourceMappingURL=import.d.ts.map