langctl 0.1.1 → 0.1.2
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/.gitattributes +2 -0
- package/NEXT_PHASE.md +618 -0
- package/README.md +971 -107
- package/WORKING_INSTRUCTIONS.md +1389 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +6 -0
- package/dist/commands/export.d.ts +14 -0
- package/dist/commands/export.js +115 -0
- package/dist/commands/import.d.ts +11 -0
- package/dist/commands/import.js +171 -0
- package/dist/commands/keys.d.ts +25 -0
- package/dist/commands/keys.js +396 -0
- package/dist/commands/org.d.ts +13 -0
- package/dist/commands/org.js +147 -0
- package/dist/commands/projects.d.ts +28 -0
- package/dist/commands/projects.js +369 -21
- package/dist/commands/team.d.ts +29 -0
- package/dist/commands/team.js +285 -0
- package/dist/index.js +354 -3
- package/package.json +52 -5
- package/NPM_PUBLISH_GUIDE.md +0 -293
- package/QUICK_PUBLISH.md +0 -64
- package/SECURITY_FIX.md +0 -167
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
# Langctl CLI - Internal Working Instructions
|
|
2
|
+
|
|
3
|
+
**For Developers & AI Agents**
|
|
4
|
+
|
|
5
|
+
This document explains the internal architecture and working of the Langctl CLI. Use this to understand how each component works, how to extend functionality, and troubleshoot issues.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Architecture Overview](#architecture-overview)
|
|
12
|
+
2. [Authentication System](#authentication-system)
|
|
13
|
+
3. [Edge Functions Integration](#edge-functions-integration)
|
|
14
|
+
4. [Command Structure](#command-structure)
|
|
15
|
+
5. [File Organization](#file-organization)
|
|
16
|
+
6. [How Each Command Works](#how-each-command-works)
|
|
17
|
+
7. [Configuration Management](#configuration-management)
|
|
18
|
+
8. [Error Handling](#error-handling)
|
|
19
|
+
9. [Testing & Deployment](#testing--deployment)
|
|
20
|
+
10. [Adding New Commands](#adding-new-commands)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Architecture Overview
|
|
25
|
+
|
|
26
|
+
### Core Principles
|
|
27
|
+
|
|
28
|
+
1. **CLI-First Design**: Never expose Supabase credentials to users
|
|
29
|
+
2. **Edge Function Gateway**: All operations go through Supabase Edge Functions
|
|
30
|
+
3. **API Key Authentication**: Users authenticate with organization-scoped API keys
|
|
31
|
+
4. **Stateless Operations**: Each command is independent and self-contained
|
|
32
|
+
|
|
33
|
+
### Technology Stack
|
|
34
|
+
|
|
35
|
+
- **Commander.js**: CLI framework for command parsing and routing
|
|
36
|
+
- **Chalk**: Terminal string styling and colors
|
|
37
|
+
- **Ora**: Loading spinners for async operations
|
|
38
|
+
- **Node-fetch**: HTTP requests to Edge Functions
|
|
39
|
+
- **TypeScript**: Type safety and better DX
|
|
40
|
+
|
|
41
|
+
### Data Flow
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
User Command
|
|
45
|
+
↓
|
|
46
|
+
Commander.js (index.ts)
|
|
47
|
+
↓
|
|
48
|
+
Command Handler (commands/*.ts)
|
|
49
|
+
↓
|
|
50
|
+
Authentication Check (auth.ts)
|
|
51
|
+
↓
|
|
52
|
+
Edge Function Request (fetch)
|
|
53
|
+
↓
|
|
54
|
+
Edge Function (Supabase)
|
|
55
|
+
↓
|
|
56
|
+
Database Query/Mutation
|
|
57
|
+
↓
|
|
58
|
+
Response to CLI
|
|
59
|
+
↓
|
|
60
|
+
Formatted Output (chalk)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Authentication System
|
|
66
|
+
|
|
67
|
+
### File: `src/auth.ts`
|
|
68
|
+
|
|
69
|
+
#### How API Keys Work
|
|
70
|
+
|
|
71
|
+
1. **Format**: `lc_[64 hex characters]` (67 chars total)
|
|
72
|
+
2. **Storage**: SHA-256 hash stored in database
|
|
73
|
+
3. **Validation**: Keys validated via `verify-api-key` Edge Function
|
|
74
|
+
4. **Local Storage**: Plain text key stored in `~/.langctl/config.json`
|
|
75
|
+
|
|
76
|
+
#### Key Functions
|
|
77
|
+
|
|
78
|
+
##### `sanitizeApiKey(input: string)`
|
|
79
|
+
```typescript
|
|
80
|
+
// Purpose: Validate and normalize API key format
|
|
81
|
+
// Input: User-provided API key (may have extra spaces, wrong case)
|
|
82
|
+
// Output: { ok: boolean, key?: string, message?: string }
|
|
83
|
+
// Logic:
|
|
84
|
+
// 1. Trim and lowercase
|
|
85
|
+
// 2. Check 'lc_' prefix
|
|
86
|
+
// 3. Extract hex part (everything after 'lc_')
|
|
87
|
+
// 4. Remove non-hex characters
|
|
88
|
+
// 5. Verify exactly 64 hex characters
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
##### `verifyApiKey(plainTextKey: string)`
|
|
92
|
+
```typescript
|
|
93
|
+
// Purpose: Verify API key with server
|
|
94
|
+
// Calls: verify-api-key Edge Function
|
|
95
|
+
// Returns: ApiKeyData | null
|
|
96
|
+
// Process:
|
|
97
|
+
// 1. Sanitize input key
|
|
98
|
+
// 2. POST to verify-api-key function
|
|
99
|
+
// 3. Parse response (organizationId, name, plan)
|
|
100
|
+
// 4. Return null if invalid
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
##### `authenticate(apiKey: string)`
|
|
104
|
+
```typescript
|
|
105
|
+
// Purpose: Full authentication flow
|
|
106
|
+
// Process:
|
|
107
|
+
// 1. Sanitize key
|
|
108
|
+
// 2. Verify with server
|
|
109
|
+
// 3. Save to config.json
|
|
110
|
+
// 4. Return success/failure
|
|
111
|
+
// Side Effects: Writes to ~/.langctl/config.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
##### `isAuthenticated()`
|
|
115
|
+
```typescript
|
|
116
|
+
// Purpose: Check if user has valid stored credentials
|
|
117
|
+
// Logic: Checks if apiKey and organizationId exist in config
|
|
118
|
+
// Note: Does NOT verify key validity with server
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
##### `getApiKey()` / `getOrganizationId()`
|
|
122
|
+
```typescript
|
|
123
|
+
// Purpose: Retrieve stored credentials
|
|
124
|
+
// Returns: string | undefined
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
##### `logout()`
|
|
128
|
+
```typescript
|
|
129
|
+
// Purpose: Clear all stored credentials
|
|
130
|
+
// Side Effects: Deletes apiKey, organizationId, organizationName, defaultProject from config
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Edge Functions Integration
|
|
136
|
+
|
|
137
|
+
### Base URL
|
|
138
|
+
All Edge Functions are at: `https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/`
|
|
139
|
+
|
|
140
|
+
### Authentication Pattern
|
|
141
|
+
|
|
142
|
+
Every Edge Function follows this pattern:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
const response = await fetch(EDGE_FUNCTION_URL, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: {
|
|
148
|
+
'Content-Type': 'application/json',
|
|
149
|
+
'X-API-Key': apiKey // User's API key
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
action: 'action-name',
|
|
153
|
+
// ... other parameters
|
|
154
|
+
})
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Edge Functions List
|
|
159
|
+
|
|
160
|
+
#### 1. `verify-api-key`
|
|
161
|
+
- **Purpose**: Validate API key and get organization info
|
|
162
|
+
- **Input**: `{ apiKey: string }`
|
|
163
|
+
- **Output**: `{ success: boolean, organizationId: string, organizationName: string, plan: string }`
|
|
164
|
+
- **Used By**: `auth.ts`
|
|
165
|
+
|
|
166
|
+
#### 2. `list-projects`
|
|
167
|
+
- **Purpose**: List all projects for authenticated organization
|
|
168
|
+
- **Input**: (none - uses X-API-Key header)
|
|
169
|
+
- **Output**: `{ success: boolean, projects: Project[] }`
|
|
170
|
+
- **Used By**: `projects.ts`
|
|
171
|
+
|
|
172
|
+
#### 3. `manage-projects`
|
|
173
|
+
- **Purpose**: CRUD operations on projects
|
|
174
|
+
- **Actions**:
|
|
175
|
+
- `create`: Create new project
|
|
176
|
+
- `get`: Get project details (NOT USED - use list-projects instead)
|
|
177
|
+
- `update`: Update project
|
|
178
|
+
- `delete`: Soft delete project
|
|
179
|
+
- `add-language`: Add language to project
|
|
180
|
+
- `remove-language`: Remove language from project
|
|
181
|
+
- `stats`: Get project statistics
|
|
182
|
+
- **Used By**: `projects.ts`
|
|
183
|
+
|
|
184
|
+
#### 4. `manage-translation-keys`
|
|
185
|
+
- **Purpose**: CRUD operations on translation keys
|
|
186
|
+
- **Actions**:
|
|
187
|
+
- `list`: List keys with filtering
|
|
188
|
+
- `get`: Get single key details (NOT USED directly)
|
|
189
|
+
- `create`: Create new key
|
|
190
|
+
- `update`: Update key metadata (NOT USED)
|
|
191
|
+
- `delete`: Delete key
|
|
192
|
+
- `translate`: Update single language translation
|
|
193
|
+
- `publish`: Bulk publish/unpublish keys
|
|
194
|
+
- **Used By**: `keys.ts`
|
|
195
|
+
|
|
196
|
+
#### 5. `manage-team-members`
|
|
197
|
+
- **Purpose**: Team member management
|
|
198
|
+
- **Actions**:
|
|
199
|
+
- `list`: List team members
|
|
200
|
+
- `get`: Get member details
|
|
201
|
+
- `invite`: Invite new member
|
|
202
|
+
- `remove`: Remove member
|
|
203
|
+
- `update-role`: Update member role
|
|
204
|
+
- `list-invitations`: List invitations
|
|
205
|
+
- `revoke-invitation`: Revoke invitation
|
|
206
|
+
- **Used By**: `team.ts`
|
|
207
|
+
|
|
208
|
+
#### 6. `get-organization-info`
|
|
209
|
+
- **Purpose**: Organization information and statistics
|
|
210
|
+
- **Actions**:
|
|
211
|
+
- `info`: Get org details
|
|
212
|
+
- `stats`: Get org statistics
|
|
213
|
+
- `plan`: Get subscription plan and limits
|
|
214
|
+
- **Used By**: `org.ts`
|
|
215
|
+
|
|
216
|
+
#### 7. `export-translations`
|
|
217
|
+
- **Purpose**: Export translations in various formats
|
|
218
|
+
- **Input**: `{ projectSlug: string, language?: string, format: string, module?: string, includeUnpublished: boolean }`
|
|
219
|
+
- **Output**: `{ success: boolean, translations: object/string, format: string }`
|
|
220
|
+
- **Used By**: `export.ts`
|
|
221
|
+
|
|
222
|
+
#### 8. `push-translations`
|
|
223
|
+
- **Purpose**: Import/push translations to server
|
|
224
|
+
- **Input**: `{ projectSlug: string, language: string, translations: object, overwrite: boolean, publish: boolean }`
|
|
225
|
+
- **Output**: `{ success: boolean, created: number, updated: number, skipped: number }`
|
|
226
|
+
- **Used By**: `import.ts`
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Command Structure
|
|
231
|
+
|
|
232
|
+
### File: `src/index.ts`
|
|
233
|
+
|
|
234
|
+
This is the main entry point that registers all commands using Commander.js.
|
|
235
|
+
|
|
236
|
+
#### Command Registration Pattern
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
program
|
|
240
|
+
.command('command-name <required> [optional]')
|
|
241
|
+
.description('What this command does')
|
|
242
|
+
.option('-f, --flag <value>', 'Flag description', 'default')
|
|
243
|
+
.action(async (requiredArg, options) => {
|
|
244
|
+
try {
|
|
245
|
+
await commandFunction(requiredArg, options);
|
|
246
|
+
} catch (error: any) {
|
|
247
|
+
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Subcommand Groups
|
|
254
|
+
|
|
255
|
+
Commands are organized into groups:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const projects = program.command('projects').description('Manage projects');
|
|
259
|
+
projects.command('list').action(...);
|
|
260
|
+
projects.command('create <name>').action(...);
|
|
261
|
+
|
|
262
|
+
const keys = program.command('keys').description('Manage translation keys');
|
|
263
|
+
keys.command('list <project>').action(...);
|
|
264
|
+
keys.command('create <project> <key>').action(...);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## File Organization
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
langctl-cli/
|
|
273
|
+
├── src/
|
|
274
|
+
│ ├── index.ts # Main entry point, command registration
|
|
275
|
+
│ ├── auth.ts # Authentication utilities
|
|
276
|
+
│ ├── config.ts # Configuration management
|
|
277
|
+
│ ├── commands/
|
|
278
|
+
│ │ ├── auth.ts # Auth commands (init, logout)
|
|
279
|
+
│ │ ├── projects.ts # Project CRUD commands
|
|
280
|
+
│ │ ├── keys.ts # Translation key commands
|
|
281
|
+
│ │ ├── team.ts # Team management commands
|
|
282
|
+
│ │ ├── org.ts # Organization commands
|
|
283
|
+
│ │ ├── export.ts # Export translations
|
|
284
|
+
│ │ ├── import.ts # Import translations
|
|
285
|
+
│ │ ├── pull.ts # Legacy pull command
|
|
286
|
+
│ │ ├── init.ts # Interactive setup
|
|
287
|
+
│ │ ├── config.ts # Config viewer
|
|
288
|
+
│ │ └── debug.ts # Debug utilities
|
|
289
|
+
│ └── utils/
|
|
290
|
+
│ └── banner.ts # CLI banner/logo
|
|
291
|
+
├── dist/ # Compiled JavaScript
|
|
292
|
+
├── bin/
|
|
293
|
+
│ └── langctl.js # Executable entry point
|
|
294
|
+
├── package.json
|
|
295
|
+
├── tsconfig.json
|
|
296
|
+
└── README.md
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## How Each Command Works
|
|
302
|
+
|
|
303
|
+
### Authentication Commands
|
|
304
|
+
|
|
305
|
+
#### `langctl init`
|
|
306
|
+
**File**: `src/commands/init.ts`
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// Process:
|
|
310
|
+
1. Show welcome banner
|
|
311
|
+
2. Prompt for API key
|
|
312
|
+
3. Call authenticate() from auth.ts
|
|
313
|
+
4. Prompt for default language preference
|
|
314
|
+
5. Save to config
|
|
315
|
+
6. Show success message
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### `langctl auth <api-key>`
|
|
319
|
+
**File**: `src/commands/auth.ts`
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// Process:
|
|
323
|
+
1. Get API key from argument
|
|
324
|
+
2. Call authenticate() from auth.ts
|
|
325
|
+
3. authenticate() does:
|
|
326
|
+
- Sanitize key
|
|
327
|
+
- POST to verify-api-key Edge Function
|
|
328
|
+
- Save to config on success
|
|
329
|
+
4. Show success/error message
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
#### `langctl logout`
|
|
333
|
+
**File**: `src/commands/auth.ts`
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// Process:
|
|
337
|
+
1. Call logout() from auth.ts
|
|
338
|
+
2. logout() deletes all keys from config
|
|
339
|
+
3. Show confirmation message
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
#### `langctl config`
|
|
343
|
+
**File**: `src/commands/config.ts`
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
// Process:
|
|
347
|
+
1. Read config from ~/.langctl/config.json
|
|
348
|
+
2. Display masked API key (first 10 chars + ...)
|
|
349
|
+
3. Display organizationId, organizationName
|
|
350
|
+
4. Display config file path
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
### Organization Commands
|
|
356
|
+
|
|
357
|
+
#### `langctl org info`
|
|
358
|
+
**File**: `src/commands/org.ts`
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
// Process:
|
|
362
|
+
1. Check authentication (isAuthenticated())
|
|
363
|
+
2. Get API key (getApiKey())
|
|
364
|
+
3. Start spinner
|
|
365
|
+
4. POST to get-organization-info with action: 'info'
|
|
366
|
+
5. Parse response
|
|
367
|
+
6. Display:
|
|
368
|
+
- Organization name
|
|
369
|
+
- ID and slug
|
|
370
|
+
- Subscription plan
|
|
371
|
+
- Creation date
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### `langctl org stats`
|
|
375
|
+
**File**: `src/commands/org.ts`
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// Process:
|
|
379
|
+
1. Check authentication
|
|
380
|
+
2. POST to get-organization-info with action: 'stats'
|
|
381
|
+
3. Parse response with stats:
|
|
382
|
+
- members: number of team members
|
|
383
|
+
- projects: number of projects
|
|
384
|
+
- total_keys, published_keys, unpublished_keys
|
|
385
|
+
- languages: count and codes
|
|
386
|
+
- api_keys: active API keys
|
|
387
|
+
- webhooks: active webhooks
|
|
388
|
+
4. Display formatted statistics
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
#### `langctl org plan`
|
|
392
|
+
**File**: `src/commands/org.ts`
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// Process:
|
|
396
|
+
1. Check authentication
|
|
397
|
+
2. POST to get-organization-info with action: 'plan'
|
|
398
|
+
3. Parse response with limits:
|
|
399
|
+
- max_members
|
|
400
|
+
- max_projects
|
|
401
|
+
- max_keys_per_project
|
|
402
|
+
- max_api_keys
|
|
403
|
+
4. Format limits (null or -1 = "Unlimited")
|
|
404
|
+
5. Display plan name and limits
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
### Project Commands
|
|
410
|
+
|
|
411
|
+
#### `langctl projects list`
|
|
412
|
+
**File**: `src/commands/projects.ts`
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
// Process:
|
|
416
|
+
1. Check authentication
|
|
417
|
+
2. POST to list-projects Edge Function
|
|
418
|
+
3. Parse response with projects array
|
|
419
|
+
4. For each project, display:
|
|
420
|
+
- Name (bold)
|
|
421
|
+
- Slug
|
|
422
|
+
- Description
|
|
423
|
+
- Languages (comma-separated)
|
|
424
|
+
- Default language
|
|
425
|
+
- Modules (if any)
|
|
426
|
+
5. Show export command example
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
#### `langctl projects create <name>`
|
|
430
|
+
**File**: `src/commands/projects.ts`
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
// Process:
|
|
434
|
+
1. Check authentication
|
|
435
|
+
2. Parse options:
|
|
436
|
+
- languages: split by comma, default 'en'
|
|
437
|
+
- defaultLanguage: default to first language
|
|
438
|
+
- description: optional
|
|
439
|
+
3. POST to manage-projects with action: 'create'
|
|
440
|
+
4. Send: name, description, languages array, defaultLanguage
|
|
441
|
+
5. Display success with project slug
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
#### `langctl projects get <slug>`
|
|
445
|
+
**File**: `src/commands/projects.ts`
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
// Process:
|
|
449
|
+
1. Check authentication
|
|
450
|
+
2. POST to list-projects
|
|
451
|
+
3. Find project with matching slug
|
|
452
|
+
4. Display detailed info:
|
|
453
|
+
- Name, slug, ID
|
|
454
|
+
- Description
|
|
455
|
+
- All languages
|
|
456
|
+
- Default language
|
|
457
|
+
- Modules list
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**Note**: We use list-projects and filter client-side because manage-projects 'get' action requires project ID, but users only know slugs.
|
|
461
|
+
|
|
462
|
+
#### `langctl projects update <slug>`
|
|
463
|
+
**File**: `src/commands/projects.ts`
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
// Process:
|
|
467
|
+
1. Check authentication
|
|
468
|
+
2. POST to list-projects to get project ID from slug
|
|
469
|
+
3. Build updateData object with:
|
|
470
|
+
- name (if provided)
|
|
471
|
+
- description (if provided)
|
|
472
|
+
- languages array (if provided, split by comma)
|
|
473
|
+
- defaultLanguage (if provided)
|
|
474
|
+
4. POST to manage-projects with action: 'update'
|
|
475
|
+
5. Send projectId + updateData
|
|
476
|
+
6. Display success
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
#### `langctl projects delete <slug>`
|
|
480
|
+
**File**: `src/commands/projects.ts`
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
// Process:
|
|
484
|
+
1. Check authentication
|
|
485
|
+
2. Get project ID from slug (via list-projects)
|
|
486
|
+
3. POST to manage-projects with action: 'delete'
|
|
487
|
+
4. Send projectId
|
|
488
|
+
5. Display success
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**Note**: This is a soft delete. Project is marked deleted but not removed from database.
|
|
492
|
+
|
|
493
|
+
#### `langctl projects add-language <slug> <language>`
|
|
494
|
+
**File**: `src/commands/projects.ts`
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
// Process:
|
|
498
|
+
1. Check authentication
|
|
499
|
+
2. Get project ID from slug
|
|
500
|
+
3. POST to manage-projects with action: 'add-language'
|
|
501
|
+
4. Send: projectId, language
|
|
502
|
+
5. Edge Function validates:
|
|
503
|
+
- Project exists
|
|
504
|
+
- Language not already in project
|
|
505
|
+
6. Display success message from server
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
#### `langctl projects remove-language <slug> <language>`
|
|
509
|
+
**File**: `src/commands/projects.ts`
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
// Process:
|
|
513
|
+
1. Check authentication
|
|
514
|
+
2. Get project ID from slug
|
|
515
|
+
3. POST to manage-projects with action: 'remove-language'
|
|
516
|
+
4. Send: projectId, language
|
|
517
|
+
5. Edge Function validates:
|
|
518
|
+
- Project exists
|
|
519
|
+
- Language exists in project
|
|
520
|
+
- Language is not the default language
|
|
521
|
+
6. Display success message from server
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### `langctl projects stats <slug>`
|
|
525
|
+
**File**: `src/commands/projects.ts`
|
|
526
|
+
|
|
527
|
+
```typescript
|
|
528
|
+
// Process:
|
|
529
|
+
1. Check authentication
|
|
530
|
+
2. Get project ID from slug
|
|
531
|
+
3. POST to manage-projects with action: 'stats'
|
|
532
|
+
4. Parse response with:
|
|
533
|
+
- total_keys
|
|
534
|
+
- published_keys
|
|
535
|
+
- unpublished_keys
|
|
536
|
+
- modules (count)
|
|
537
|
+
- module_names (array)
|
|
538
|
+
5. Display formatted statistics
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
### Translation Key Commands
|
|
544
|
+
|
|
545
|
+
#### Helper Function: `getProjectBySlug()`
|
|
546
|
+
**File**: `src/commands/keys.ts`
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
// Purpose: Convert project slug to project ID
|
|
550
|
+
// Process:
|
|
551
|
+
1. POST to list-projects
|
|
552
|
+
2. Find project where project.slug === slug
|
|
553
|
+
3. Return Project object or null
|
|
554
|
+
// Why: Edge Functions need project ID, users only know slugs
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### `langctl keys list <project>`
|
|
558
|
+
**File**: `src/commands/keys.ts`
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
// Process:
|
|
562
|
+
1. Check authentication
|
|
563
|
+
2. Get project object from slug
|
|
564
|
+
3. POST to manage-translation-keys with action: 'list'
|
|
565
|
+
4. Send filters:
|
|
566
|
+
- projectId
|
|
567
|
+
- module (optional)
|
|
568
|
+
- published (optional boolean)
|
|
569
|
+
- search (optional string)
|
|
570
|
+
- limit (default 100)
|
|
571
|
+
- offset (default 0)
|
|
572
|
+
5. Parse response with keys array
|
|
573
|
+
6. For each key, display:
|
|
574
|
+
- Key name (bold)
|
|
575
|
+
- Description
|
|
576
|
+
- Module
|
|
577
|
+
- Published status (colored)
|
|
578
|
+
- Languages available
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
#### `langctl keys get <project> <key>`
|
|
582
|
+
**File**: `src/commands/keys.ts`
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
// Process:
|
|
586
|
+
1. Check authentication
|
|
587
|
+
2. Get project ID from slug
|
|
588
|
+
3. POST to manage-translation-keys with action: 'list'
|
|
589
|
+
4. Send: projectId, search: keyName, limit: 1
|
|
590
|
+
5. Find exact match in results
|
|
591
|
+
6. Display detailed info:
|
|
592
|
+
- Key name and ID
|
|
593
|
+
- Description
|
|
594
|
+
- Module
|
|
595
|
+
- Published status
|
|
596
|
+
- All translations (all languages)
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Note**: We use 'list' action with search instead of 'get' to match by key name (users don't know key IDs).
|
|
600
|
+
|
|
601
|
+
#### `langctl keys create <project> <key>`
|
|
602
|
+
**File**: `src/commands/keys.ts`
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
// Process:
|
|
606
|
+
1. Check authentication
|
|
607
|
+
2. Get project ID from slug
|
|
608
|
+
3. Parse options:
|
|
609
|
+
- Build translations object from --value-* options
|
|
610
|
+
- Example: --value-en → translations.en
|
|
611
|
+
- Example: --value-es → translations.es
|
|
612
|
+
4. POST to manage-translation-keys with action: 'create'
|
|
613
|
+
5. Send:
|
|
614
|
+
- projectId
|
|
615
|
+
- key (key name)
|
|
616
|
+
- translations (object)
|
|
617
|
+
- description (optional)
|
|
618
|
+
- module (optional)
|
|
619
|
+
- tags (array, optional)
|
|
620
|
+
6. Display success
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**Implementation Detail**: Options starting with 'value' are converted to language codes:
|
|
624
|
+
```typescript
|
|
625
|
+
Object.keys(options).forEach(opt => {
|
|
626
|
+
if (opt.startsWith('value')) {
|
|
627
|
+
const lang = opt.replace('value', '').toLowerCase();
|
|
628
|
+
if (lang) translations[lang] = options[opt];
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
#### `langctl keys delete <project> <key>`
|
|
634
|
+
**File**: `src/commands/keys.ts`
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
// Process:
|
|
638
|
+
1. Check authentication
|
|
639
|
+
2. Get project ID from slug
|
|
640
|
+
3. Find key ID (list with search, find exact match)
|
|
641
|
+
4. POST to manage-translation-keys with action: 'delete'
|
|
642
|
+
5. Send: projectId, keyId
|
|
643
|
+
6. Display success
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
**Note**: This is a soft delete.
|
|
647
|
+
|
|
648
|
+
#### `langctl keys translate <project> <key>`
|
|
649
|
+
**File**: `src/commands/keys.ts`
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
// Process:
|
|
653
|
+
1. Check authentication
|
|
654
|
+
2. Get project ID from slug
|
|
655
|
+
3. Find key ID
|
|
656
|
+
4. POST to manage-translation-keys with action: 'translate'
|
|
657
|
+
5. Send:
|
|
658
|
+
- projectId
|
|
659
|
+
- keyId
|
|
660
|
+
- language (from --language option)
|
|
661
|
+
- value (from --value option)
|
|
662
|
+
6. Display success with language and key name
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
#### `langctl keys publish <project> <keys...>`
|
|
666
|
+
**File**: `src/commands/keys.ts`
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
// Process:
|
|
670
|
+
1. Check authentication
|
|
671
|
+
2. Get project ID from slug
|
|
672
|
+
3. List all keys (limit: 1000)
|
|
673
|
+
4. Filter keys by name (match against keys... argument)
|
|
674
|
+
5. Extract key IDs
|
|
675
|
+
6. POST to manage-translation-keys with action: 'publish'
|
|
676
|
+
7. Send:
|
|
677
|
+
- projectId
|
|
678
|
+
- keyIds (array)
|
|
679
|
+
- published (boolean, true unless --unpublish)
|
|
680
|
+
8. Display success message from server
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
**Note**: Can publish/unpublish multiple keys at once.
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
### Team Commands
|
|
688
|
+
|
|
689
|
+
#### `langctl team list`
|
|
690
|
+
**File**: `src/commands/team.ts`
|
|
691
|
+
|
|
692
|
+
```typescript
|
|
693
|
+
// Process:
|
|
694
|
+
1. Check authentication
|
|
695
|
+
2. POST to manage-team-members with action: 'list'
|
|
696
|
+
3. Parse response with members array
|
|
697
|
+
4. For each member, display:
|
|
698
|
+
- Email (or user_id if no email)
|
|
699
|
+
- Role
|
|
700
|
+
- Join date
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**Note**: Edge Function uses RPC function `get_organization_members_with_users` to join with auth.users.
|
|
704
|
+
|
|
705
|
+
#### `langctl team get <email>`
|
|
706
|
+
**File**: `src/commands/team.ts`
|
|
707
|
+
|
|
708
|
+
```typescript
|
|
709
|
+
// Process:
|
|
710
|
+
1. Check authentication
|
|
711
|
+
2. POST to manage-team-members with action: 'get'
|
|
712
|
+
3. Send: email
|
|
713
|
+
4. Display:
|
|
714
|
+
- Email
|
|
715
|
+
- Role
|
|
716
|
+
- User ID
|
|
717
|
+
- Join date
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
#### `langctl team invite <email>`
|
|
721
|
+
**File**: `src/commands/team.ts`
|
|
722
|
+
|
|
723
|
+
```typescript
|
|
724
|
+
// Process:
|
|
725
|
+
1. Check authentication
|
|
726
|
+
2. POST to manage-team-members with action: 'invite'
|
|
727
|
+
3. Send:
|
|
728
|
+
- email
|
|
729
|
+
- role (default: 'member', from --role option)
|
|
730
|
+
4. Edge Function:
|
|
731
|
+
- Calls RPC function invite_user_to_organization
|
|
732
|
+
- Creates invitation record
|
|
733
|
+
- Sends email (via database trigger)
|
|
734
|
+
5. Display success message
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
**Roles**: viewer, member, admin (not owner - that's assigned at org creation).
|
|
738
|
+
|
|
739
|
+
#### `langctl team remove <email>`
|
|
740
|
+
**File**: `src/commands/team.ts`
|
|
741
|
+
|
|
742
|
+
```typescript
|
|
743
|
+
// Process:
|
|
744
|
+
1. Check authentication
|
|
745
|
+
2. POST to manage-team-members with action: 'remove'
|
|
746
|
+
3. Send: email
|
|
747
|
+
4. Edge Function validates:
|
|
748
|
+
- User exists
|
|
749
|
+
- User is member of organization
|
|
750
|
+
- User is not the owner
|
|
751
|
+
- User is not self (cannot remove yourself)
|
|
752
|
+
5. Deletes organization_members record
|
|
753
|
+
6. Display success
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
#### `langctl team update-role <email> <role>`
|
|
757
|
+
**File**: `src/commands/team.ts`
|
|
758
|
+
|
|
759
|
+
```typescript
|
|
760
|
+
// Process:
|
|
761
|
+
1. Check authentication
|
|
762
|
+
2. Validate role (viewer, member, admin)
|
|
763
|
+
3. POST to manage-team-members with action: 'update-role'
|
|
764
|
+
4. Send: email, role
|
|
765
|
+
5. Edge Function validates:
|
|
766
|
+
- User exists and is member
|
|
767
|
+
- Cannot change to/from owner role
|
|
768
|
+
6. Updates organization_members.role
|
|
769
|
+
7. Display success
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
#### `langctl team invitations`
|
|
773
|
+
**File**: `src/commands/team.ts`
|
|
774
|
+
|
|
775
|
+
```typescript
|
|
776
|
+
// Process:
|
|
777
|
+
1. Check authentication
|
|
778
|
+
2. POST to manage-team-members with action: 'list-invitations'
|
|
779
|
+
3. Send: pending (boolean from --pending option)
|
|
780
|
+
4. Parse response with invitations array
|
|
781
|
+
5. For each invitation, display:
|
|
782
|
+
- Email
|
|
783
|
+
- Role
|
|
784
|
+
- Status (pending, accepted, cancelled)
|
|
785
|
+
- Invited date
|
|
786
|
+
- Expires date
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
#### `langctl team revoke-invitation <email>`
|
|
790
|
+
**File**: `src/commands/team.ts`
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
// Process:
|
|
794
|
+
1. Check authentication
|
|
795
|
+
2. POST to manage-team-members with action: 'revoke-invitation'
|
|
796
|
+
3. Send: email
|
|
797
|
+
4. Edge Function:
|
|
798
|
+
- Finds pending invitation for email
|
|
799
|
+
- Updates status to 'cancelled'
|
|
800
|
+
5. Display success
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
### Export/Import Commands
|
|
806
|
+
|
|
807
|
+
#### `langctl export <project>`
|
|
808
|
+
**File**: `src/commands/export.ts`
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
// Process:
|
|
812
|
+
1. Check authentication
|
|
813
|
+
2. Get project object from slug
|
|
814
|
+
3. Validate format (flat-json, nested-json, i18n-json, android-xml, ios-strings, flutter-arb)
|
|
815
|
+
4. POST to export-translations
|
|
816
|
+
5. Send:
|
|
817
|
+
- projectSlug
|
|
818
|
+
- language (optional)
|
|
819
|
+
- format
|
|
820
|
+
- module (optional)
|
|
821
|
+
- includeUnpublished (boolean)
|
|
822
|
+
6. Parse response with translations
|
|
823
|
+
7. If language specified:
|
|
824
|
+
- Write to file (output path or auto-generated)
|
|
825
|
+
8. If no language (export all):
|
|
826
|
+
- Loop through all project languages
|
|
827
|
+
- Export each language
|
|
828
|
+
- Write to separate files
|
|
829
|
+
9. Display success with file paths
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
**Auto-generated paths**:
|
|
833
|
+
- JSON formats: `./translations/<slug>.<language>.json`
|
|
834
|
+
- iOS: `./translations/<slug>.<language>.strings`
|
|
835
|
+
- Android: `./translations/<slug>.<language>.xml`
|
|
836
|
+
- Flutter: `./translations/<slug>.<language>.arb`
|
|
837
|
+
|
|
838
|
+
**Format Conversion**: Done server-side in Edge Function:
|
|
839
|
+
- Converts `{{param}}` to platform-specific format
|
|
840
|
+
- Android: `%1$s` (positional)
|
|
841
|
+
- iOS: `%@` (positional)
|
|
842
|
+
- Flutter: `{param}` (named)
|
|
843
|
+
|
|
844
|
+
#### `langctl import <project> <file>`
|
|
845
|
+
**File**: `src/commands/import.ts`
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
// Process:
|
|
849
|
+
1. Check authentication
|
|
850
|
+
2. Get project object from slug
|
|
851
|
+
3. Validate language option is provided
|
|
852
|
+
4. Read file:
|
|
853
|
+
- Check file exists
|
|
854
|
+
- Parse JSON
|
|
855
|
+
5. Flatten JSON if nested:
|
|
856
|
+
- Converts { "home": { "title": "..." } } to { "home.title": "..." }
|
|
857
|
+
6. POST to push-translations
|
|
858
|
+
7. Send:
|
|
859
|
+
- projectSlug
|
|
860
|
+
- language
|
|
861
|
+
- translations (flattened object)
|
|
862
|
+
- overwrite (boolean from --overwrite)
|
|
863
|
+
- publish (boolean from --publish)
|
|
864
|
+
8. Parse response:
|
|
865
|
+
- created: number of new keys
|
|
866
|
+
- updated: number of updated keys
|
|
867
|
+
- skipped: number of skipped keys
|
|
868
|
+
9. Display summary
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
**Flattening Logic**:
|
|
872
|
+
```typescript
|
|
873
|
+
function flattenObject(obj: any, prefix = ''): Record<string, string> {
|
|
874
|
+
let result: Record<string, string> = {};
|
|
875
|
+
for (const key in obj) {
|
|
876
|
+
const value = obj[key];
|
|
877
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
878
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
879
|
+
Object.assign(result, flattenObject(value, newKey));
|
|
880
|
+
} else {
|
|
881
|
+
result[newKey] = String(value);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
## Configuration Management
|
|
891
|
+
|
|
892
|
+
### File: `src/config.ts`
|
|
893
|
+
|
|
894
|
+
```typescript
|
|
895
|
+
class Config {
|
|
896
|
+
private configPath: string;
|
|
897
|
+
private configDir: string;
|
|
898
|
+
private data: Record<string, any>;
|
|
899
|
+
|
|
900
|
+
constructor() {
|
|
901
|
+
// Config stored at: ~/.langctl/config.json
|
|
902
|
+
this.configDir = path.join(os.homedir(), '.langctl');
|
|
903
|
+
this.configPath = path.join(this.configDir, 'config.json');
|
|
904
|
+
this.load();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private load() {
|
|
908
|
+
// Create directory if doesn't exist
|
|
909
|
+
// Read config.json or initialize empty object
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private save() {
|
|
913
|
+
// Write config.json with pretty formatting
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
get(key: string): any {
|
|
917
|
+
return this.data[key];
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
set(key: string, value: any) {
|
|
921
|
+
this.data[key] = value;
|
|
922
|
+
this.save();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
delete(key: string) {
|
|
926
|
+
delete this.data[key];
|
|
927
|
+
this.save();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
getAll(): Record<string, any> {
|
|
931
|
+
return { ...this.data };
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export const config = new Config();
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
### Configuration Keys
|
|
939
|
+
|
|
940
|
+
- `apiKey`: User's API key (plain text)
|
|
941
|
+
- `organizationId`: UUID of organization
|
|
942
|
+
- `organizationName`: Display name
|
|
943
|
+
- `defaultLanguage`: User preference for language (not currently used)
|
|
944
|
+
- `defaultProject`: User's default project slug (not currently used)
|
|
945
|
+
|
|
946
|
+
---
|
|
947
|
+
|
|
948
|
+
## Error Handling
|
|
949
|
+
|
|
950
|
+
### Common Error Patterns
|
|
951
|
+
|
|
952
|
+
#### Network Errors
|
|
953
|
+
```typescript
|
|
954
|
+
try {
|
|
955
|
+
const response = await fetch(url, options);
|
|
956
|
+
if (!response.ok) {
|
|
957
|
+
const errorData = await response.json().catch(() => ({})) as any;
|
|
958
|
+
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
|
959
|
+
}
|
|
960
|
+
} catch (error: any) {
|
|
961
|
+
spinner.fail(chalk.red('Operation failed'));
|
|
962
|
+
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
#### Authentication Errors
|
|
967
|
+
```typescript
|
|
968
|
+
if (!isAuthenticated()) {
|
|
969
|
+
console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
#### Validation Errors
|
|
975
|
+
```typescript
|
|
976
|
+
if (!validRoles.includes(role)) {
|
|
977
|
+
console.log(chalk.red(`✗ Invalid role. Must be one of: ${validRoles.join(', ')}\n`));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### Error Display
|
|
983
|
+
|
|
984
|
+
- Use `chalk.red()` for errors
|
|
985
|
+
- Use `chalk.yellow()` for warnings
|
|
986
|
+
- Use `chalk.green()` for success
|
|
987
|
+
- Use `spinner.fail()` for operation failures
|
|
988
|
+
- Always include newline at end for readability
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
## Testing & Deployment
|
|
993
|
+
|
|
994
|
+
### Building
|
|
995
|
+
|
|
996
|
+
```bash
|
|
997
|
+
cd langctl-cli
|
|
998
|
+
npm run build
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
This compiles TypeScript to JavaScript in `dist/` directory.
|
|
1002
|
+
|
|
1003
|
+
### Testing Locally
|
|
1004
|
+
|
|
1005
|
+
```bash
|
|
1006
|
+
# Test specific command
|
|
1007
|
+
node dist/index.js org info
|
|
1008
|
+
|
|
1009
|
+
# Or use npm link for global testing
|
|
1010
|
+
npm link
|
|
1011
|
+
langctl org info
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
### Deploying Edge Functions
|
|
1015
|
+
|
|
1016
|
+
```bash
|
|
1017
|
+
# Deploy all functions
|
|
1018
|
+
supabase functions deploy --no-verify-jwt
|
|
1019
|
+
|
|
1020
|
+
# Deploy specific function
|
|
1021
|
+
supabase functions deploy manage-projects --no-verify-jwt
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
**IMPORTANT**: Always use `--no-verify-jwt` flag. Edge Functions authenticate using custom X-API-Key header, not Supabase JWT.
|
|
1025
|
+
|
|
1026
|
+
### Publishing to npm
|
|
1027
|
+
|
|
1028
|
+
```bash
|
|
1029
|
+
npm version patch # or minor, or major
|
|
1030
|
+
npm publish
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
---
|
|
1034
|
+
|
|
1035
|
+
## Adding New Commands
|
|
1036
|
+
|
|
1037
|
+
### Step 1: Create Edge Function
|
|
1038
|
+
|
|
1039
|
+
1. Create function directory:
|
|
1040
|
+
```bash
|
|
1041
|
+
mkdir -p supabase/functions/my-function
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
2. Create `index.ts`:
|
|
1045
|
+
```typescript
|
|
1046
|
+
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
|
1047
|
+
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
1048
|
+
|
|
1049
|
+
const corsHeaders = {
|
|
1050
|
+
'Access-Control-Allow-Origin': '*',
|
|
1051
|
+
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-api-key',
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function hashApiKey(key: string): Promise<string> {
|
|
1055
|
+
const encoder = new TextEncoder()
|
|
1056
|
+
const data = encoder.encode(key)
|
|
1057
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
1058
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
1059
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function verifyApiKey(supabase: any, apiKey: string) {
|
|
1063
|
+
try {
|
|
1064
|
+
const keyHash = await hashApiKey(apiKey.trim().toLowerCase())
|
|
1065
|
+
const { data, error } = await supabase
|
|
1066
|
+
.from('api_keys')
|
|
1067
|
+
.select('organization_id, created_by, scopes, revoked')
|
|
1068
|
+
.eq('key_hash', keyHash)
|
|
1069
|
+
.eq('revoked', false)
|
|
1070
|
+
.single()
|
|
1071
|
+
|
|
1072
|
+
if (error || !data) {
|
|
1073
|
+
return { valid: false, error: 'Invalid or revoked API key' }
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return {
|
|
1077
|
+
valid: true,
|
|
1078
|
+
organizationId: data.organization_id,
|
|
1079
|
+
userId: data.created_by,
|
|
1080
|
+
scopes: data.scopes
|
|
1081
|
+
}
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
return { valid: false, error: 'Authentication failed' }
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
serve(async (req) => {
|
|
1088
|
+
if (req.method === 'OPTIONS') {
|
|
1089
|
+
return new Response('ok', { headers: corsHeaders })
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
const apiKey = req.headers.get('X-API-Key')
|
|
1094
|
+
if (!apiKey) {
|
|
1095
|
+
return new Response(
|
|
1096
|
+
JSON.stringify({ success: false, error: 'X-API-Key header is required' }),
|
|
1097
|
+
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
1098
|
+
)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
|
1102
|
+
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
1103
|
+
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
1104
|
+
|
|
1105
|
+
const auth = await verifyApiKey(supabase, apiKey)
|
|
1106
|
+
if (!auth.valid) {
|
|
1107
|
+
return new Response(
|
|
1108
|
+
JSON.stringify({ success: false, error: auth.error }),
|
|
1109
|
+
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const { action, ...params } = await req.json()
|
|
1114
|
+
|
|
1115
|
+
let result
|
|
1116
|
+
|
|
1117
|
+
switch (action) {
|
|
1118
|
+
case 'my-action':
|
|
1119
|
+
// Your logic here
|
|
1120
|
+
result = { success: true, data: {} }
|
|
1121
|
+
break
|
|
1122
|
+
default:
|
|
1123
|
+
return new Response(
|
|
1124
|
+
JSON.stringify({ success: false, error: 'Invalid action' }),
|
|
1125
|
+
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return new Response(
|
|
1130
|
+
JSON.stringify(result),
|
|
1131
|
+
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
return new Response(
|
|
1136
|
+
JSON.stringify({ success: false, error: error.message }),
|
|
1137
|
+
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
3. Create `deno.json`:
|
|
1144
|
+
```json
|
|
1145
|
+
{
|
|
1146
|
+
"imports": {}
|
|
1147
|
+
}
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
4. Add to `supabase/config.toml`:
|
|
1151
|
+
```toml
|
|
1152
|
+
[functions.my-function]
|
|
1153
|
+
enabled = true
|
|
1154
|
+
verify_jwt = false
|
|
1155
|
+
import_map = "./functions/my-function/deno.json"
|
|
1156
|
+
entrypoint = "./functions/my-function/index.ts"
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
5. Deploy:
|
|
1160
|
+
```bash
|
|
1161
|
+
supabase functions deploy my-function --no-verify-jwt
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
### Step 2: Create CLI Command
|
|
1165
|
+
|
|
1166
|
+
1. Create command file:
|
|
1167
|
+
```typescript
|
|
1168
|
+
// src/commands/mycommand.ts
|
|
1169
|
+
import chalk from 'chalk';
|
|
1170
|
+
import ora from 'ora';
|
|
1171
|
+
import { isAuthenticated, getApiKey } from '../auth.js';
|
|
1172
|
+
|
|
1173
|
+
const MY_FUNCTION_URL = 'https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/my-function';
|
|
1174
|
+
|
|
1175
|
+
export async function myCommand(arg: string, options: any): Promise<void> {
|
|
1176
|
+
if (!isAuthenticated()) {
|
|
1177
|
+
console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const apiKey = getApiKey();
|
|
1182
|
+
const spinner = ora('Processing...').start();
|
|
1183
|
+
|
|
1184
|
+
try {
|
|
1185
|
+
const response = await fetch(MY_FUNCTION_URL, {
|
|
1186
|
+
method: 'POST',
|
|
1187
|
+
headers: {
|
|
1188
|
+
'Content-Type': 'application/json',
|
|
1189
|
+
'X-API-Key': apiKey!
|
|
1190
|
+
},
|
|
1191
|
+
body: JSON.stringify({
|
|
1192
|
+
action: 'my-action',
|
|
1193
|
+
arg,
|
|
1194
|
+
...options
|
|
1195
|
+
})
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
const data = await response.json() as any;
|
|
1199
|
+
|
|
1200
|
+
if (!data.success) {
|
|
1201
|
+
throw new Error(data.error);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
spinner.succeed(chalk.green('Success!'));
|
|
1205
|
+
console.log(data.result);
|
|
1206
|
+
|
|
1207
|
+
} catch (error: any) {
|
|
1208
|
+
spinner.fail(chalk.red('Failed'));
|
|
1209
|
+
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
2. Register in `src/index.ts`:
|
|
1215
|
+
```typescript
|
|
1216
|
+
import { myCommand } from './commands/mycommand.js';
|
|
1217
|
+
|
|
1218
|
+
program
|
|
1219
|
+
.command('mycommand <arg>')
|
|
1220
|
+
.description('Description of my command')
|
|
1221
|
+
.option('-f, --flag <value>', 'Flag description')
|
|
1222
|
+
.action(async (arg: string, options: any) => {
|
|
1223
|
+
try {
|
|
1224
|
+
await myCommand(arg, options);
|
|
1225
|
+
} catch (error: any) {
|
|
1226
|
+
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
1227
|
+
process.exit(1);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
3. Build and test:
|
|
1233
|
+
```bash
|
|
1234
|
+
npm run build
|
|
1235
|
+
node dist/index.js mycommand test-arg --flag value
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
---
|
|
1239
|
+
|
|
1240
|
+
## Security Considerations
|
|
1241
|
+
|
|
1242
|
+
### What Users Can Access
|
|
1243
|
+
|
|
1244
|
+
- ✅ Their API key (plain text, stored locally)
|
|
1245
|
+
- ✅ Their organization data (via API key auth)
|
|
1246
|
+
- ✅ Projects within their organization
|
|
1247
|
+
|
|
1248
|
+
### What Users CANNOT Access
|
|
1249
|
+
|
|
1250
|
+
- ❌ Supabase credentials
|
|
1251
|
+
- ❌ Other organizations' data
|
|
1252
|
+
- ❌ Database connection strings
|
|
1253
|
+
- ❌ Service role keys
|
|
1254
|
+
- ❌ Edge Function URLs (hardcoded, but not secret)
|
|
1255
|
+
|
|
1256
|
+
### API Key Security
|
|
1257
|
+
|
|
1258
|
+
1. **Format Validation**: Strict format enforcement prevents injection
|
|
1259
|
+
2. **SHA-256 Hashing**: Keys hashed before storage
|
|
1260
|
+
3. **Server-Side Validation**: All validation happens on server
|
|
1261
|
+
4. **Organization Scoping**: Keys tied to specific organization
|
|
1262
|
+
5. **Revocable**: Keys can be revoked from dashboard
|
|
1263
|
+
|
|
1264
|
+
### Edge Function Security
|
|
1265
|
+
|
|
1266
|
+
1. **No JWT Verification**: Uses custom X-API-Key header
|
|
1267
|
+
2. **Service Role Key**: Edge Functions use service role for database access
|
|
1268
|
+
3. **Input Validation**: All inputs validated before database queries
|
|
1269
|
+
4. **Row Level Security**: Database RLS policies provide additional security layer
|
|
1270
|
+
5. **CORS Enabled**: Allows CLI to call functions
|
|
1271
|
+
|
|
1272
|
+
---
|
|
1273
|
+
|
|
1274
|
+
## Common Debugging Patterns
|
|
1275
|
+
|
|
1276
|
+
### Check Authentication
|
|
1277
|
+
```bash
|
|
1278
|
+
langctl config # View stored credentials
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
### Test Edge Function Directly
|
|
1282
|
+
```bash
|
|
1283
|
+
curl -X POST https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/my-function \
|
|
1284
|
+
-H "Content-Type: application/json" \
|
|
1285
|
+
-H "X-API-Key: lc_..." \
|
|
1286
|
+
-d '{"action":"test"}'
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
### Check Edge Function Logs
|
|
1290
|
+
```bash
|
|
1291
|
+
supabase functions logs my-function
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
### Verify API Key
|
|
1295
|
+
```bash
|
|
1296
|
+
curl -X POST https://bcgnmvkgkbhbxzzflwdb.supabase.co/functions/v1/verify-api-key \
|
|
1297
|
+
-H "Content-Type: application/json" \
|
|
1298
|
+
-d '{"apiKey":"lc_..."}'
|
|
1299
|
+
```
|
|
1300
|
+
|
|
1301
|
+
### Test Command Locally
|
|
1302
|
+
```bash
|
|
1303
|
+
cd langctl-cli
|
|
1304
|
+
npm run build
|
|
1305
|
+
node dist/index.js <command> <args>
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
---
|
|
1309
|
+
|
|
1310
|
+
## Performance Considerations
|
|
1311
|
+
|
|
1312
|
+
### Avoiding Multiple API Calls
|
|
1313
|
+
|
|
1314
|
+
**Bad**:
|
|
1315
|
+
```typescript
|
|
1316
|
+
// Get project ID
|
|
1317
|
+
const projects = await listProjects();
|
|
1318
|
+
const project = projects.find(p => p.slug === slug);
|
|
1319
|
+
|
|
1320
|
+
// Then use project ID for each key
|
|
1321
|
+
for (const key of keys) {
|
|
1322
|
+
await getKey(project.id, key);
|
|
1323
|
+
}
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
**Good**:
|
|
1327
|
+
```typescript
|
|
1328
|
+
// Get project ID once
|
|
1329
|
+
const projects = await listProjects();
|
|
1330
|
+
const project = projects.find(p => p.slug === slug);
|
|
1331
|
+
|
|
1332
|
+
// Batch operation
|
|
1333
|
+
await publishKeys(project.id, keys);
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
### Caching Project Data
|
|
1337
|
+
|
|
1338
|
+
The `getProjectBySlug()` helper is called frequently. Consider:
|
|
1339
|
+
- Caching project list for session
|
|
1340
|
+
- Storing last-used project slug
|
|
1341
|
+
- Implementing project shortcuts
|
|
1342
|
+
|
|
1343
|
+
**Current**: Each command calls list-projects
|
|
1344
|
+
**Future**: Cache in config with TTL
|
|
1345
|
+
|
|
1346
|
+
---
|
|
1347
|
+
|
|
1348
|
+
## Future Improvements
|
|
1349
|
+
|
|
1350
|
+
See `NEXT_PHASE.md` for Phase 3 features.
|
|
1351
|
+
|
|
1352
|
+
---
|
|
1353
|
+
|
|
1354
|
+
## Summary
|
|
1355
|
+
|
|
1356
|
+
### Request Flow
|
|
1357
|
+
|
|
1358
|
+
1. User runs command: `langctl keys list my-app`
|
|
1359
|
+
2. Commander parses: command='keys list', args=['my-app'], options={}
|
|
1360
|
+
3. Calls: `listKeysCommand('my-app', {})`
|
|
1361
|
+
4. Checks: `isAuthenticated()` → reads ~/.langctl/config.json
|
|
1362
|
+
5. Gets: `getApiKey()` → reads from config
|
|
1363
|
+
6. Fetches: `list-projects` → converts slug to project ID
|
|
1364
|
+
7. Posts: `manage-translation-keys` with action='list'
|
|
1365
|
+
8. Edge Function: Validates API key, queries database
|
|
1366
|
+
9. Response: JSON with keys array
|
|
1367
|
+
10. Format: Chalk colors + console.log
|
|
1368
|
+
11. Exit: Process completes
|
|
1369
|
+
|
|
1370
|
+
### Key Files to Know
|
|
1371
|
+
|
|
1372
|
+
- `src/index.ts` - Command registration
|
|
1373
|
+
- `src/auth.ts` - Authentication logic
|
|
1374
|
+
- `src/config.ts` - Configuration management
|
|
1375
|
+
- `src/commands/*.ts` - Command implementations
|
|
1376
|
+
- `supabase/functions/*/index.ts` - Edge Functions
|
|
1377
|
+
- `supabase/config.toml` - Edge Function configuration
|
|
1378
|
+
|
|
1379
|
+
### Architecture Principles
|
|
1380
|
+
|
|
1381
|
+
1. **CLI Never Touches Database**: All through Edge Functions
|
|
1382
|
+
2. **User-Friendly Arguments**: Use slugs/names, not UUIDs
|
|
1383
|
+
3. **Consistent Error Handling**: Always use try/catch with spinners
|
|
1384
|
+
4. **Colored Output**: Success=green, Error=red, Info=blue/white
|
|
1385
|
+
5. **Helpful Messages**: Guide users to next steps
|
|
1386
|
+
|
|
1387
|
+
---
|
|
1388
|
+
|
|
1389
|
+
**Last Updated**: Phase 1 & 2 Complete (January 2026)
|