@contentstorage/core 3.0.1 → 3.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/README.md CHANGED
@@ -11,11 +11,12 @@ Contentstorage Core is a powerful CLI tool for managing translations and content
11
11
 
12
12
  ## Features
13
13
 
14
- - **Translation Management** - Pull content from Contentstorage CDN
14
+ - **Translation Management** - Pull and push content to/from Contentstorage
15
15
  - **TypeScript Generation** - Automatic type generation from your content
16
16
  - **Translation Statistics** - Analyze translation completeness across languages
17
17
  - **Multi-Language Support** - Built-in support for 40+ languages
18
18
  - **CLI Tools** - Professional command-line interface
19
+ - **API Key Authentication** - Secure project-level API keys for CI/CD integration
19
20
  - **Plugin Ecosystem** - Integrate with i18next, react-intl, vue-i18n, and more
20
21
 
21
22
  ## Installation
@@ -38,13 +39,21 @@ Create `contentstorage.config.js` in your project root:
38
39
 
39
40
  ```javascript
40
41
  export default {
41
- contentKey: 'your-content-key',
42
+ // Option A: Content Key (read-only access)
43
+ contentKey: 'your-team-id/your-project-id',
44
+
45
+ // Option B: API Key (read + write access) - recommended for CI/CD
46
+ // apiKey: 'csk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxx',
47
+ // projectId: 'your-project-uuid',
48
+
42
49
  languageCodes: ['EN', 'FR', 'DE'],
43
50
  contentDir: 'src/content/json',
44
51
  typesOutputFile: 'src/content/content-types.d.ts'
45
52
  };
46
53
  ```
47
54
 
55
+ > **Note:** Use `contentKey` for read-only access (pull, stats). Use `apiKey` + `projectId` for full access including push operations. API keys can be created in Project Settings → API Keys.
56
+
48
57
  ### 2. Pull Content & Generate Types
49
58
 
50
59
  ```bash
@@ -85,18 +94,59 @@ npx contentstorage pull [options]
85
94
  ```
86
95
 
87
96
  **Options:**
88
- - `--content-key <key>` - Content key for your project
97
+ - `--content-key <key>` - Content key (read-only access)
98
+ - `--api-key <key>` - API key (read + write access)
99
+ - `--project-id <id>` - Project ID (required with --api-key)
89
100
  - `--content-dir <dir>` - Directory to save content files
90
101
  - `--lang <code>` - Language code (e.g., EN, FR)
91
102
  - `--pending-changes` - Fetch pending/draft content
103
+ - `--flatten` - Output flattened key-value pairs
92
104
 
93
105
  **Examples:**
94
106
  ```bash
95
- npx contentstorage pull --content-key abc123
107
+ # Using content key (read-only)
108
+ npx contentstorage pull --content-key teamId/projectId
109
+
110
+ # Using API key (recommended for CI/CD)
111
+ npx contentstorage pull --api-key csk_xxx --project-id uuid
112
+
113
+ # Pull specific language with draft content
96
114
  npx contentstorage pull --lang EN --pending-changes
97
- npx contentstorage pull --content-dir src/content
98
115
  ```
99
116
 
117
+ ### `contentstorage push`
118
+
119
+ Push local content changes to Contentstorage (requires API key)
120
+
121
+ ```bash
122
+ npx contentstorage push [options]
123
+ ```
124
+
125
+ **Options:**
126
+ - `--api-key <key>` - API key (required)
127
+ - `--project-id <id>` - Project ID (required)
128
+ - `--content-dir <dir>` - Directory with content files
129
+ - `--lang <code>` - Language code to push (e.g., EN)
130
+ - `--dry-run` - Preview changes without applying
131
+
132
+ **Examples:**
133
+ ```bash
134
+ # Push all configured languages
135
+ npx contentstorage push --api-key csk_xxx --project-id uuid
136
+
137
+ # Push specific language
138
+ npx contentstorage push --api-key csk_xxx --project-id uuid --lang EN
139
+
140
+ # Preview changes without applying
141
+ npx contentstorage push --api-key csk_xxx --project-id uuid --dry-run
142
+ ```
143
+
144
+ **Behavior:**
145
+ - Adds new keys that don't exist in Contentstorage
146
+ - Updates existing keys with different values
147
+ - Never removes keys (safe by design)
148
+ - If change tracking is enabled, creates pending changes for review
149
+
100
150
  ### `contentstorage generate-types`
101
151
 
102
152
  Generate TypeScript type definitions from content
@@ -126,15 +176,20 @@ npx contentstorage stats [options]
126
176
  ```
127
177
 
128
178
  **Options:**
129
- - `--content-key <key>` - Content key for your project
179
+ - `--content-key <key>` - Content key (read-only access)
180
+ - `--api-key <key>` - API key (fetches stats directly from API)
181
+ - `--project-id <id>` - Project ID (required with --api-key)
130
182
  - `--content-dir <dir>` - Directory with content files
131
183
  - `--pending-changes` - Analyze pending/draft content
132
184
 
133
185
  **Examples:**
134
186
  ```bash
187
+ # Using local files + content key
135
188
  npx contentstorage stats
136
- npx contentstorage stats --content-key abc123
137
- npx contentstorage stats --pending-changes
189
+ npx contentstorage stats --content-key teamId/projectId
190
+
191
+ # Using API key (fetches directly from server)
192
+ npx contentstorage stats --api-key csk_xxx --project-id uuid
138
193
  ```
139
194
 
140
195
  **What it shows:**
@@ -167,8 +222,14 @@ npx contentstorage stats --help
167
222
 
168
223
  ```javascript
169
224
  export default {
170
- // Required: Unique content identifier
171
- contentKey: 'your-content-key',
225
+ // Authentication Option A: Content Key (read-only)
226
+ // Format: teamId/projectId
227
+ contentKey: 'your-team-id/your-project-id',
228
+
229
+ // Authentication Option B: API Key (read + write)
230
+ // Create in Project Settings → API Keys
231
+ // apiKey: 'csk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxx',
232
+ // projectId: 'your-project-uuid',
172
233
 
173
234
  // Required: Array of language codes
174
235
  languageCodes: ['EN', 'FR', 'DE', 'ES'],
@@ -184,6 +245,17 @@ export default {
184
245
  };
185
246
  ```
186
247
 
248
+ ### Authentication Methods
249
+
250
+ | Method | Access Level | Use Case |
251
+ |--------|-------------|----------|
252
+ | `contentKey` | Read-only | Pull content, view stats |
253
+ | `apiKey` + `projectId` | Read + Write | Pull, push, CI/CD pipelines |
254
+
255
+ **Getting your credentials:**
256
+ - **Content Key**: Found in Project Settings → General (format: `teamId/projectId`)
257
+ - **API Key**: Create in Project Settings → API Keys
258
+
187
259
  ### Supported Languages
188
260
 
189
261
  The CLI supports 40+ languages including:
@@ -1,22 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  import chalk from 'chalk';
3
3
  import { pullContent } from './pull.js';
4
+ import { pushContent } from './push.js';
4
5
  import { generateTypes } from './generate-types.js';
5
6
  import { showStats } from './stats.js';
6
7
  import { captureScreenshot } from './screenshot.js';
8
+ import { translateContent } from './translate.js';
7
9
  const COMMANDS = {
8
10
  pull: {
9
11
  name: 'pull',
10
12
  description: 'Pull content from Contentstorage CDN',
11
13
  usage: 'contentstorage pull [options]',
12
14
  options: [
13
- ' --content-key <key> Content key for your project',
15
+ ' --content-key <key> Content key (read-only access)',
16
+ ' --api-key <key> API key (read + write access)',
17
+ ' --project-id <id> Project ID (required with --api-key)',
14
18
  ' --content-dir <dir> Directory to save content files',
15
19
  ' --lang <code> Language code (e.g., EN, FR)',
16
20
  ' --pending-changes Fetch pending/draft content',
17
21
  ' --flatten Output flattened key-value pairs',
18
22
  ],
19
23
  },
24
+ push: {
25
+ name: 'push',
26
+ description: 'Push local content changes to Contentstorage',
27
+ usage: 'contentstorage push [options]',
28
+ options: [
29
+ ' --api-key <key> API key for authentication (required)',
30
+ ' --project-id <id> Project ID (required)',
31
+ ' --content-dir <dir> Directory with content files',
32
+ ' --lang <code> Language code to push (e.g., EN)',
33
+ ' --dry-run Preview changes without applying',
34
+ ],
35
+ },
20
36
  'generate-types': {
21
37
  name: 'generate-types',
22
38
  description: 'Generate TypeScript type definitions from content',
@@ -33,7 +49,9 @@ const COMMANDS = {
33
49
  description: 'Show translation completeness statistics',
34
50
  usage: 'contentstorage stats [options]',
35
51
  options: [
36
- ' --content-key <key> Content key for your project',
52
+ ' --content-key <key> Content key (read-only access)',
53
+ ' --api-key <key> API key (read + write access)',
54
+ ' --project-id <id> Project ID (required with --api-key)',
37
55
  ' --content-dir <dir> Directory with content files',
38
56
  ' --pending-changes Analyze pending/draft content',
39
57
  ],
@@ -47,6 +65,19 @@ const COMMANDS = {
47
65
  ' --content-key <key> Content key for your project',
48
66
  ],
49
67
  },
68
+ translate: {
69
+ name: 'translate',
70
+ description: 'Push new/changed keys and create a translate session',
71
+ usage: 'contentstorage translate [options]',
72
+ options: [
73
+ ' --api-key <key> API key for authentication',
74
+ ' --project-id <id> Project ID',
75
+ ' --content-dir <dir> Directory with content files',
76
+ ' --lang <code> Source language code (e.g., EN)',
77
+ ' --dry-run Preview changes without pushing',
78
+ ' -y Skip confirmation prompt',
79
+ ],
80
+ },
50
81
  };
51
82
  function showHelp() {
52
83
  console.log(chalk.bold('\nContentstorage CLI'));
@@ -103,6 +134,9 @@ async function main() {
103
134
  case 'pull':
104
135
  await pullContent();
105
136
  break;
137
+ case 'push':
138
+ await pushContent();
139
+ break;
106
140
  case 'generate-types':
107
141
  await generateTypes();
108
142
  break;
@@ -112,6 +146,9 @@ async function main() {
112
146
  case 'screenshot':
113
147
  await captureScreenshot();
114
148
  break;
149
+ case 'translate':
150
+ await translateContent();
151
+ break;
115
152
  default:
116
153
  console.error(chalk.red(`Unknown command: ${command}\n`));
117
154
  console.log(chalk.dim('Run "contentstorage --help" for usage'));
@@ -4,6 +4,7 @@ import fs from 'fs/promises';
4
4
  import path from 'path';
5
5
  import chalk from 'chalk';
6
6
  import { loadConfig } from '../core/config-loader.js';
7
+ import { createApiClient } from '../core/api-client.js';
7
8
  import { CONTENTSTORAGE_CONFIG } from '../utils/constants.js';
8
9
  import { flattenJson } from '../utils/flatten-json.js';
9
10
  export async function pullContent() {
@@ -31,6 +32,12 @@ export async function pullContent() {
31
32
  else if (key === 'content-dir') {
32
33
  cliConfig.contentDir = value;
33
34
  }
35
+ else if (key === 'api-key') {
36
+ cliConfig.apiKey = value;
37
+ }
38
+ else if (key === 'project-id') {
39
+ cliConfig.projectId = value;
40
+ }
34
41
  // Skip the value in the next iteration
35
42
  i++;
36
43
  }
@@ -44,18 +51,42 @@ export async function pullContent() {
44
51
  console.log(chalk.yellow('Could not load a configuration file. Proceeding with CLI arguments.'));
45
52
  }
46
53
  const config = { ...fileConfig, ...cliConfig };
47
- // Validate required fields
48
- if (!config.contentKey) {
49
- console.error(chalk.red('Error: Configuration is missing the required "contentKey" property.'));
54
+ // Check if using API key authentication or contentKey (read-only)
55
+ const useApiClient = !!(config.apiKey && config.projectId);
56
+ // Validate required fields based on auth method
57
+ if (!useApiClient && !config.contentKey) {
58
+ console.error(chalk.red('Error: Either "contentKey" or "apiKey" + "projectId" is required.\n' +
59
+ ' Use --content-key for read-only access, or\n' +
60
+ ' Use --api-key and --project-id for full API access (read + write).'));
50
61
  process.exit(1);
51
62
  }
52
63
  if (!config.contentDir) {
53
64
  console.error(chalk.red('Error: Configuration is missing the required "contentDir" property.'));
54
65
  process.exit(1);
55
66
  }
56
- console.log(chalk.blue(`Content key: ${config.contentKey}`));
67
+ if (useApiClient) {
68
+ console.log(chalk.blue(`Using API key authentication`));
69
+ console.log(chalk.blue(`Project ID: ${config.projectId}`));
70
+ }
71
+ else {
72
+ console.log(chalk.blue(`Content key: ${config.contentKey}`));
73
+ }
57
74
  console.log(chalk.blue(`Saving content to: ${config.contentDir}`));
58
75
  try {
76
+ // Create API client if using new authentication
77
+ const apiClient = useApiClient
78
+ ? createApiClient({
79
+ apiKey: config.apiKey,
80
+ projectId: config.projectId,
81
+ })
82
+ : null;
83
+ // If using API client and no languages specified, fetch from project info
84
+ if (apiClient && (!config.languageCodes || config.languageCodes.length === 0)) {
85
+ console.log(chalk.dim('Fetching available languages from project...'));
86
+ const projectInfo = await apiClient.getProject();
87
+ config.languageCodes = projectInfo.project.languages;
88
+ console.log(chalk.blue(`Found languages: ${config.languageCodes.join(', ')}`));
89
+ }
59
90
  // Validate languageCodes array
60
91
  if (!Array.isArray(config.languageCodes)) {
61
92
  console.log(chalk.red(`Expected array from config.languageCodes, but received type ${typeof config.languageCodes}. Cannot pull files.`));
@@ -67,6 +98,37 @@ export async function pullContent() {
67
98
  }
68
99
  // Ensure the output directory exists (create it once before the loop)
69
100
  await fs.mkdir(config.contentDir, { recursive: true });
101
+ // Use API client if available
102
+ if (apiClient) {
103
+ // Fetch all content at once using new API
104
+ const format = config.flatten ? 'flat' : 'nested';
105
+ const draft = config.pendingChanges !== false; // Default to draft
106
+ console.log(chalk.dim(`\nFetching ${draft ? 'draft' : 'published'} content...`));
107
+ const response = await apiClient.getContent({
108
+ format,
109
+ draft,
110
+ });
111
+ // Save each language to a file
112
+ for (const languageCode of config.languageCodes) {
113
+ const upperLang = languageCode.toUpperCase();
114
+ const jsonData = response.data[upperLang];
115
+ if (!jsonData) {
116
+ console.log(chalk.yellow(`\nNo content found for language: ${upperLang}`));
117
+ continue;
118
+ }
119
+ const filename = `${upperLang}.json`;
120
+ const outputPath = path.join(config.contentDir, filename);
121
+ console.log(chalk.blue(`\nProcessing language: ${upperLang}`));
122
+ await fs.writeFile(outputPath, JSON.stringify(jsonData, null, 2));
123
+ console.log(chalk.green(`Successfully saved ${outputPath}`));
124
+ }
125
+ if (response.metadata.hasPendingChanges) {
126
+ console.log(chalk.cyan('\n📝 Note: This project has pending changes awaiting publish.'));
127
+ }
128
+ console.log(chalk.green('\nAll content successfully pulled and saved.'));
129
+ return;
130
+ }
131
+ // contentKey path: read-only access via CDN/API
70
132
  // Process each language code
71
133
  for (const languageCode of config.languageCodes) {
72
134
  let fileUrl;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function pushContent(): Promise<void>;
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import chalk from 'chalk';
5
+ import { loadConfig } from '../core/config-loader.js';
6
+ import { createApiClient } from '../core/api-client.js';
7
+ export async function pushContent() {
8
+ console.log(chalk.blue('Starting content push...'));
9
+ // Parse CLI arguments
10
+ const args = process.argv.slice(2);
11
+ const cliConfig = {};
12
+ for (let i = 0; i < args.length; i++) {
13
+ const arg = args[i];
14
+ if (arg.startsWith('--')) {
15
+ const key = arg.substring(2);
16
+ const value = args[i + 1];
17
+ if (key === 'dry-run') {
18
+ cliConfig.dryRun = true;
19
+ }
20
+ else if (value && !value.startsWith('--')) {
21
+ if (key === 'lang') {
22
+ cliConfig.languageCodes = [value.toUpperCase()];
23
+ }
24
+ else if (key === 'api-key') {
25
+ cliConfig.apiKey = value;
26
+ }
27
+ else if (key === 'project-id') {
28
+ cliConfig.projectId = value;
29
+ }
30
+ else if (key === 'content-dir') {
31
+ cliConfig.contentDir = value;
32
+ }
33
+ i++; // Skip the value in next iteration
34
+ }
35
+ }
36
+ }
37
+ // Load config from file
38
+ let fileConfig = {};
39
+ try {
40
+ fileConfig = await loadConfig();
41
+ }
42
+ catch {
43
+ console.log(chalk.yellow('Could not load a configuration file. Proceeding with CLI arguments.'));
44
+ }
45
+ const config = { ...fileConfig, ...cliConfig };
46
+ // Validate required fields
47
+ if (!config.apiKey) {
48
+ console.error(chalk.red('\n❌ Error: API key is required for push.\n' +
49
+ ' Provide it via config file (apiKey) or --api-key argument.\n' +
50
+ ' You can create an API key in Project Settings → API Keys.'));
51
+ process.exit(1);
52
+ }
53
+ if (!config.projectId) {
54
+ console.error(chalk.red('\n❌ Error: Project ID is required for push.\n' +
55
+ ' Provide it via config file (projectId) or --project-id argument.'));
56
+ process.exit(1);
57
+ }
58
+ if (!config.contentDir) {
59
+ console.error(chalk.red('\n❌ Error: Content directory is required.\n' +
60
+ ' Provide it via config file (contentDir) or --content-dir argument.'));
61
+ process.exit(1);
62
+ }
63
+ if (!config.languageCodes || config.languageCodes.length === 0) {
64
+ console.error(chalk.red('\n❌ Error: At least one language code is required.\n' +
65
+ ' Provide it via config file (languageCodes) or --lang argument.'));
66
+ process.exit(1);
67
+ }
68
+ // Create API client
69
+ const apiClient = createApiClient({
70
+ apiKey: config.apiKey,
71
+ projectId: config.projectId,
72
+ });
73
+ if (!apiClient) {
74
+ console.error(chalk.red('Failed to create API client'));
75
+ process.exit(1);
76
+ }
77
+ console.log(chalk.blue(`Project ID: ${config.projectId}`));
78
+ console.log(chalk.blue(`Content directory: ${config.contentDir}`));
79
+ console.log(chalk.blue(`Languages to push: ${config.languageCodes.join(', ')}`));
80
+ if (config.dryRun) {
81
+ console.log(chalk.yellow('\n🔍 Dry run mode - no changes will be made\n'));
82
+ }
83
+ try {
84
+ let totalAdded = 0;
85
+ let totalUpdated = 0;
86
+ let totalSkipped = 0;
87
+ for (const languageCode of config.languageCodes) {
88
+ const filePath = path.join(config.contentDir, `${languageCode}.json`);
89
+ console.log(chalk.blue(`\nProcessing ${languageCode}...`));
90
+ console.log(chalk.dim(` Reading from: ${filePath}`));
91
+ // Read the local content file
92
+ let content;
93
+ try {
94
+ const fileContent = await fs.readFile(filePath, 'utf-8');
95
+ content = JSON.parse(fileContent);
96
+ }
97
+ catch (error) {
98
+ if (error.code === 'ENOENT') {
99
+ console.error(chalk.red(` ❌ File not found: ${filePath}`));
100
+ continue;
101
+ }
102
+ console.error(chalk.red(` ❌ Error reading file: ${error.message}`));
103
+ continue;
104
+ }
105
+ // Push to API
106
+ try {
107
+ const response = await apiClient.pushContent(languageCode, content, {
108
+ dryRun: config.dryRun,
109
+ });
110
+ const { result } = response;
111
+ if (config.dryRun) {
112
+ console.log(chalk.cyan(` 📊 Dry run results for ${languageCode}:`));
113
+ console.log(chalk.green(` Keys to add: ${result.keysAdded}`));
114
+ console.log(chalk.yellow(` Keys to update: ${result.keysUpdated}`));
115
+ console.log(chalk.dim(` Keys unchanged: ${result.keysSkipped}`));
116
+ }
117
+ else {
118
+ console.log(chalk.green(` ✅ Successfully pushed ${languageCode}`));
119
+ console.log(chalk.green(` Keys added: ${result.keysAdded}`));
120
+ console.log(chalk.yellow(` Keys updated: ${result.keysUpdated}`));
121
+ console.log(chalk.dim(` Keys skipped: ${result.keysSkipped}`));
122
+ if (result.pendingChangesCreated) {
123
+ console.log(chalk.cyan(' 📝 Changes added to pending (publish required)'));
124
+ }
125
+ }
126
+ totalAdded += result.keysAdded;
127
+ totalUpdated += result.keysUpdated;
128
+ totalSkipped += result.keysSkipped;
129
+ }
130
+ catch (error) {
131
+ console.error(chalk.red(` ❌ Failed to push ${languageCode}: ${error.message}`));
132
+ throw error;
133
+ }
134
+ }
135
+ // Summary
136
+ console.log(chalk.bold('\n📊 Summary:'));
137
+ console.log(chalk.dim('─'.repeat(40)));
138
+ if (config.dryRun) {
139
+ console.log(chalk.cyan(' Dry run completed'));
140
+ console.log(chalk.green(` Total keys to add: ${totalAdded}`));
141
+ console.log(chalk.yellow(` Total keys to update: ${totalUpdated}`));
142
+ console.log(chalk.dim(` Total keys unchanged: ${totalSkipped}`));
143
+ console.log(chalk.dim('\n Run without --dry-run to apply changes.'));
144
+ }
145
+ else {
146
+ console.log(chalk.green(` ✅ Push completed successfully`));
147
+ console.log(chalk.green(` Total keys added: ${totalAdded}`));
148
+ console.log(chalk.yellow(` Total keys updated: ${totalUpdated}`));
149
+ console.log(chalk.dim(` Total keys skipped: ${totalSkipped}`));
150
+ }
151
+ }
152
+ catch {
153
+ console.error(chalk.red('\n❌ Push failed. See errors above.'));
154
+ process.exit(1);
155
+ }
156
+ }
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { promises as fs } from 'fs';
4
4
  import path from 'path';
5
5
  import { loadConfig } from '../core/config-loader.js';
6
+ import { createApiClient } from '../core/api-client.js';
6
7
  import { flattenJson } from '../utils/flatten-json.js';
7
8
  import { CONTENTSTORAGE_CONFIG } from '../utils/constants.js';
8
9
  /**
@@ -171,6 +172,70 @@ function displayStats(result) {
171
172
  console.log(overallColor(`Overall Completion: ${chalk.bold(result.overallCompletion.toFixed(1) + '%')}`));
172
173
  console.log('');
173
174
  }
175
+ /**
176
+ * Display statistics from API response
177
+ */
178
+ function displayApiStats(response) {
179
+ const { stats } = response;
180
+ console.log(chalk.bold('\n📊 Translation Statistics (from API)'));
181
+ console.log(chalk.dim('═'.repeat(70)));
182
+ console.log(chalk.cyan(`\nTotal unique content items: ${chalk.bold(stats.totalKeys)}`));
183
+ // Language statistics table header
184
+ console.log(chalk.bold('\n📋 Language Statistics:'));
185
+ console.log(chalk.dim('─'.repeat(70)));
186
+ // Table header
187
+ const headerFormat = (str, width) => str.padEnd(width, ' ');
188
+ console.log(chalk.bold(headerFormat('Language', 12) +
189
+ headerFormat('Completed', 12) +
190
+ headerFormat('Total', 10) +
191
+ 'Complete'));
192
+ console.log(chalk.dim('─'.repeat(70)));
193
+ // Calculate overall completion
194
+ let totalCompleted = 0;
195
+ let totalPossible = 0;
196
+ // Language rows
197
+ for (const lang of stats.languages) {
198
+ const percentageColor = lang.percentage === 100
199
+ ? chalk.green
200
+ : lang.percentage >= 80
201
+ ? chalk.yellow
202
+ : chalk.red;
203
+ const percentage = percentageColor(lang.percentage + '%');
204
+ console.log(chalk.white(headerFormat(lang.code, 12)) +
205
+ chalk.white(headerFormat(lang.completed.toString(), 12)) +
206
+ chalk.white(headerFormat(stats.totalKeys.toString(), 10)) +
207
+ percentage);
208
+ totalCompleted += lang.completed;
209
+ totalPossible += stats.totalKeys;
210
+ }
211
+ console.log(chalk.dim('─'.repeat(70)));
212
+ // Pending changes info
213
+ if (stats.pendingChanges.total > 0) {
214
+ console.log(chalk.bold('\n📝 Pending Changes:'));
215
+ console.log(chalk.dim('─'.repeat(70)));
216
+ console.log(chalk.cyan(` Total: ${stats.pendingChanges.total}`));
217
+ if (stats.pendingChanges.added > 0) {
218
+ console.log(chalk.green(` Added: ${stats.pendingChanges.added}`));
219
+ }
220
+ if (stats.pendingChanges.edited > 0) {
221
+ console.log(chalk.yellow(` Edited: ${stats.pendingChanges.edited}`));
222
+ }
223
+ if (stats.pendingChanges.removed > 0) {
224
+ console.log(chalk.red(` Removed: ${stats.pendingChanges.removed}`));
225
+ }
226
+ }
227
+ // Overall completion
228
+ console.log(chalk.bold('\n📈 Overall Summary:'));
229
+ console.log(chalk.dim('─'.repeat(70)));
230
+ const overallCompletion = totalPossible > 0 ? (totalCompleted / totalPossible) * 100 : 100;
231
+ const overallColor = overallCompletion === 100
232
+ ? chalk.green
233
+ : overallCompletion >= 80
234
+ ? chalk.yellow
235
+ : chalk.red;
236
+ console.log(overallColor(`Overall Completion: ${chalk.bold(overallCompletion.toFixed(1) + '%')}`));
237
+ console.log('');
238
+ }
174
239
  /**
175
240
  * Main stats command function
176
241
  */
@@ -196,6 +261,12 @@ export async function showStats() {
196
261
  else if (key === 'content-dir') {
197
262
  cliConfig.contentDir = value;
198
263
  }
264
+ else if (key === 'api-key') {
265
+ cliConfig.apiKey = value;
266
+ }
267
+ else if (key === 'project-id') {
268
+ cliConfig.projectId = value;
269
+ }
199
270
  i++; // Skip the value in next iteration
200
271
  }
201
272
  }
@@ -211,11 +282,31 @@ export async function showStats() {
211
282
  }
212
283
  // Merge configurations (CLI args override file config)
213
284
  const config = { ...fileConfig, ...cliConfig };
214
- // Validate required fields
215
- if (!config.contentKey) {
216
- console.error(chalk.red('\n❌ Error: Content key is required. Provide it via config file or --content-key argument.'));
285
+ // Check if using new API key authentication
286
+ const useApiClient = !!(config.apiKey && config.projectId);
287
+ // Validate required fields based on auth method
288
+ if (!useApiClient && !config.contentKey) {
289
+ console.error(chalk.red('\n❌ Error: Either "contentKey" or "apiKey" + "projectId" is required.\n' +
290
+ ' Use --content-key for read-only access, or\n' +
291
+ ' Use --api-key and --project-id for full API access.'));
217
292
  process.exit(1);
218
293
  }
294
+ // If using API client, fetch stats directly from API
295
+ if (useApiClient) {
296
+ const apiClient = createApiClient({
297
+ apiKey: config.apiKey,
298
+ projectId: config.projectId,
299
+ });
300
+ if (!apiClient) {
301
+ console.error(chalk.red('Failed to create API client'));
302
+ process.exit(1);
303
+ }
304
+ console.log(chalk.blue('Fetching statistics from API...\n'));
305
+ const apiStats = await apiClient.getStats();
306
+ displayApiStats(apiStats);
307
+ return;
308
+ }
309
+ // contentKey path: local file analysis or fetch from CDN/API
219
310
  if (!config.languageCodes || config.languageCodes.length === 0) {
220
311
  console.error(chalk.red('\n❌ Error: At least one language code is required in configuration.'));
221
312
  process.exit(1);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function translateContent(): Promise<void>;
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import { exec, execSync } from 'child_process';
6
+ import chalk from 'chalk';
7
+ import { loadConfig } from '../core/config-loader.js';
8
+ import { createApiClient } from '../core/api-client.js';
9
+ import { flattenJson } from '../utils/flatten-json.js';
10
+ function prompt(question) {
11
+ const rl = readline.createInterface({
12
+ input: process.stdin,
13
+ output: process.stdout,
14
+ });
15
+ return new Promise((resolve) => {
16
+ rl.question(question, (answer) => {
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ });
20
+ });
21
+ }
22
+ function getGitBranch() {
23
+ try {
24
+ return execSync('git rev-parse --abbrev-ref HEAD', {
25
+ encoding: 'utf-8',
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ }).trim();
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function openInBrowser(url) {
34
+ const platform = process.platform;
35
+ let command;
36
+ if (platform === 'darwin') {
37
+ command = `open "${url}"`;
38
+ }
39
+ else if (platform === 'win32') {
40
+ command = `start "" "${url}"`;
41
+ }
42
+ else {
43
+ command = `xdg-open "${url}"`;
44
+ }
45
+ exec(command, (error) => {
46
+ if (error) {
47
+ console.error(chalk.red(`Failed to open browser: ${error.message}`));
48
+ console.log(chalk.yellow(`Please open this URL manually:\n ${url}`));
49
+ }
50
+ });
51
+ }
52
+ export async function translateContent() {
53
+ // Parse CLI arguments
54
+ const args = process.argv.slice(2);
55
+ const cliConfig = {};
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i];
58
+ if (arg === '-y') {
59
+ cliConfig.skipConfirm = true;
60
+ }
61
+ else if (arg.startsWith('--')) {
62
+ const key = arg.substring(2);
63
+ const value = args[i + 1];
64
+ if (key === 'dry-run') {
65
+ cliConfig.dryRun = true;
66
+ }
67
+ else if (value && !value.startsWith('--') && value !== '-y') {
68
+ if (key === 'lang') {
69
+ cliConfig.languageCodes = [value.toUpperCase()];
70
+ }
71
+ else if (key === 'api-key') {
72
+ cliConfig.apiKey = value;
73
+ }
74
+ else if (key === 'project-id') {
75
+ cliConfig.projectId = value;
76
+ }
77
+ else if (key === 'content-dir') {
78
+ cliConfig.contentDir = value;
79
+ }
80
+ i++;
81
+ }
82
+ }
83
+ }
84
+ // Load config
85
+ let fileConfig = {};
86
+ try {
87
+ fileConfig = await loadConfig();
88
+ }
89
+ catch {
90
+ console.log(chalk.yellow('Could not load configuration file. Using CLI arguments only.'));
91
+ }
92
+ const config = { ...fileConfig, ...cliConfig };
93
+ // Validate required fields
94
+ if (!config.apiKey) {
95
+ console.error(chalk.red('\n❌ Error: API key is required.\n' +
96
+ ' Provide it via config file (apiKey) or --api-key argument.\n' +
97
+ ' You can create an API key in Project Settings → API Keys.'));
98
+ process.exit(1);
99
+ }
100
+ if (!config.projectId) {
101
+ console.error(chalk.red('\n❌ Error: Project ID is required.\n' +
102
+ ' Provide it via config file (projectId) or --project-id argument.'));
103
+ process.exit(1);
104
+ }
105
+ if (!config.contentDir) {
106
+ console.error(chalk.red('\n❌ Error: Content directory is required.\n' +
107
+ ' Provide it via config file (contentDir) or --content-dir argument.'));
108
+ process.exit(1);
109
+ }
110
+ if (!config.languageCodes || config.languageCodes.length === 0) {
111
+ console.error(chalk.red('\n❌ Error: At least one language code is required.\n' +
112
+ ' Provide it via config file (languageCodes) or --lang argument.'));
113
+ process.exit(1);
114
+ }
115
+ const sourceLanguage = config.languageCodes[0];
116
+ // Create API client
117
+ const apiClient = createApiClient({
118
+ apiKey: config.apiKey,
119
+ projectId: config.projectId,
120
+ });
121
+ if (!apiClient) {
122
+ console.error(chalk.red('Failed to create API client'));
123
+ process.exit(1);
124
+ }
125
+ // Step 1: Read local source file
126
+ const filePath = path.join(config.contentDir, `${sourceLanguage}.json`);
127
+ let localContent;
128
+ try {
129
+ const fileContent = await fs.readFile(filePath, 'utf-8');
130
+ localContent = JSON.parse(fileContent);
131
+ }
132
+ catch (error) {
133
+ if (error.code === 'ENOENT') {
134
+ console.error(chalk.red(`\n❌ Source file not found: ${filePath}\n` +
135
+ ` Check your contentDir setting and ensure the file exists.`));
136
+ }
137
+ else {
138
+ console.error(chalk.red(`\n❌ Error reading source file: ${error.message}`));
139
+ }
140
+ process.exit(1);
141
+ }
142
+ const localFlat = flattenJson(localContent);
143
+ // Step 2: Fetch server content
144
+ console.log(chalk.blue('Comparing with server...'));
145
+ let serverFlat;
146
+ try {
147
+ const serverResponse = await apiClient.getContent({
148
+ languageCode: sourceLanguage,
149
+ format: 'flat',
150
+ draft: true,
151
+ });
152
+ serverFlat = (serverResponse.data[sourceLanguage] || {});
153
+ }
154
+ catch (error) {
155
+ console.error(chalk.red(`\n❌ Could not fetch server content: ${error.message}`));
156
+ process.exit(1);
157
+ }
158
+ // Step 3: Compute diff
159
+ const diff = { newKeys: [], modifiedKeys: [] };
160
+ for (const [key, value] of Object.entries(localFlat)) {
161
+ const serverValue = serverFlat[key];
162
+ if (serverValue === undefined) {
163
+ diff.newKeys.push({ key, value });
164
+ }
165
+ else if (serverValue !== value) {
166
+ diff.modifiedKeys.push({ key, value, oldValue: serverValue });
167
+ }
168
+ }
169
+ const totalChanges = diff.newKeys.length + diff.modifiedKeys.length;
170
+ if (totalChanges === 0) {
171
+ console.log(chalk.green('\n✓ All keys are up to date. Nothing to translate.'));
172
+ process.exit(0);
173
+ }
174
+ // Step 4: Display diff
175
+ const parts = [];
176
+ if (diff.newKeys.length > 0)
177
+ parts.push(`${diff.newKeys.length} new`);
178
+ if (diff.modifiedKeys.length > 0)
179
+ parts.push(`${diff.modifiedKeys.length} modified`);
180
+ console.log(chalk.bold(`\nFound ${parts.join(', ')}:\n`));
181
+ for (const { key, value } of diff.newKeys) {
182
+ const displayValue = value.length > 50 ? value.substring(0, 47) + '...' : value;
183
+ console.log(chalk.green(` + ${key.padEnd(35)} ${chalk.dim(`"${displayValue}"`)}`));
184
+ }
185
+ for (const { key, value, oldValue } of diff.modifiedKeys) {
186
+ const displayOld = oldValue.length > 25 ? oldValue.substring(0, 22) + '...' : oldValue;
187
+ const displayNew = value.length > 25 ? value.substring(0, 22) + '...' : value;
188
+ console.log(chalk.yellow(` ~ ${key.padEnd(35)} ${chalk.dim(`"${displayOld}" → "${displayNew}"`)}`));
189
+ }
190
+ // Dry run stops here
191
+ if (config.dryRun) {
192
+ console.log(chalk.dim('\n(dry run — nothing was pushed)'));
193
+ process.exit(0);
194
+ }
195
+ // Step 5: Confirm
196
+ if (!config.skipConfirm) {
197
+ // Get project name for display
198
+ let projectName = config.projectId;
199
+ try {
200
+ const projectInfo = await apiClient.getProject();
201
+ projectName = projectInfo.project.name;
202
+ }
203
+ catch {
204
+ // Use projectId as fallback
205
+ }
206
+ console.log(chalk.dim(`\nThese will be pushed to Contentstorage (project: ${projectName}).`));
207
+ const answer = await prompt('Proceed? (Y/n) ');
208
+ if (answer.toLowerCase() === 'n') {
209
+ console.log(chalk.dim('Cancelled.'));
210
+ process.exit(0);
211
+ }
212
+ }
213
+ // Step 6: Push with session
214
+ const sessionKeys = [
215
+ ...diff.newKeys.map(({ key, value }) => ({ key, value, status: 'new' })),
216
+ ...diff.modifiedKeys.map(({ key, value }) => ({ key, value, status: 'modified' })),
217
+ ];
218
+ const gitBranch = getGitBranch();
219
+ try {
220
+ const response = await apiClient.pushContent(sourceLanguage, localContent, {
221
+ session: {
222
+ keys: sessionKeys,
223
+ metadata: {
224
+ ...(gitBranch ? { gitBranch } : {}),
225
+ pushedAt: new Date().toISOString(),
226
+ },
227
+ },
228
+ });
229
+ const { result } = response;
230
+ const pushed = result.keysAdded + result.keysUpdated;
231
+ console.log(chalk.green(`\n✓ ${pushed} keys pushed (${result.keysAdded} new, ${result.keysUpdated} modified)`) +
232
+ (response.session ? chalk.dim(` [session: ${response.session.id}]`) : ''));
233
+ // Step 7: Open browser
234
+ if (response.session) {
235
+ console.log(chalk.bold('\n→ Create task and add visual context:'));
236
+ console.log(chalk.cyan(` ${response.session.url}`));
237
+ openInBrowser(response.session.url);
238
+ }
239
+ }
240
+ catch (error) {
241
+ console.error(chalk.red(`\n❌ Failed to push keys: ${error.message}`));
242
+ process.exit(1);
243
+ }
244
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * API Client for Contentstorage CLI API
3
+ * Uses API key authentication for project-level access
4
+ */
5
+ export interface ContentResponse {
6
+ success: boolean;
7
+ data: Record<string, Record<string, any>>;
8
+ metadata: {
9
+ projectId: string;
10
+ languages: string[];
11
+ hasPendingChanges: boolean;
12
+ format: string;
13
+ source: 'draft' | 'published';
14
+ };
15
+ }
16
+ export interface PushResult {
17
+ keysAdded: number;
18
+ keysUpdated: number;
19
+ keysSkipped: number;
20
+ pendingChangesCreated: boolean;
21
+ uploadId?: string;
22
+ }
23
+ export interface TranslateSessionData {
24
+ keys: Array<{
25
+ key: string;
26
+ value: string;
27
+ status: 'new' | 'modified';
28
+ }>;
29
+ metadata?: {
30
+ gitBranch?: string;
31
+ pushedAt?: string;
32
+ };
33
+ }
34
+ export interface TranslateSessionResult {
35
+ id: string;
36
+ url: string;
37
+ }
38
+ export interface PushResponse {
39
+ success: boolean;
40
+ result: PushResult;
41
+ dryRun?: boolean;
42
+ session?: TranslateSessionResult;
43
+ }
44
+ export interface ProjectResponse {
45
+ success: boolean;
46
+ project: {
47
+ id: string;
48
+ name: string;
49
+ languages: string[];
50
+ changeTrackingEnabled: boolean;
51
+ createdAt: string;
52
+ updatedAt: string;
53
+ };
54
+ }
55
+ export interface StatsResponse {
56
+ success: boolean;
57
+ stats: {
58
+ totalKeys: number;
59
+ languages: Array<{
60
+ code: string;
61
+ completed: number;
62
+ percentage: number;
63
+ }>;
64
+ pendingChanges: {
65
+ total: number;
66
+ added: number;
67
+ edited: number;
68
+ removed: number;
69
+ };
70
+ };
71
+ }
72
+ export interface ApiError {
73
+ success: false;
74
+ error: string;
75
+ message: string;
76
+ }
77
+ export declare class ApiClient {
78
+ private client;
79
+ private projectId;
80
+ constructor(apiKey: string, projectId: string);
81
+ /**
82
+ * Handle API errors consistently
83
+ */
84
+ private handleError;
85
+ /**
86
+ * Pull content from the API
87
+ * @param options.languageCode - Optional specific language (returns all if omitted)
88
+ * @param options.format - 'nested' or 'flat' (default: nested)
89
+ * @param options.draft - true for draft content, false for published (default: true)
90
+ */
91
+ getContent(options?: {
92
+ languageCode?: string;
93
+ format?: 'nested' | 'flat';
94
+ draft?: boolean;
95
+ }): Promise<ContentResponse>;
96
+ /**
97
+ * Push content to the API
98
+ * @param languageCode - The language code to push
99
+ * @param content - The content object (nested or flat)
100
+ * @param options.dryRun - If true, validate only without saving
101
+ */
102
+ pushContent(languageCode: string, content: Record<string, any>, options?: {
103
+ dryRun?: boolean;
104
+ session?: TranslateSessionData;
105
+ }): Promise<PushResponse>;
106
+ /**
107
+ * Get project information
108
+ */
109
+ getProject(): Promise<ProjectResponse>;
110
+ /**
111
+ * Get translation statistics
112
+ */
113
+ getStats(): Promise<StatsResponse>;
114
+ }
115
+ /**
116
+ * Create an API client from config
117
+ * Returns null if API key is not configured
118
+ */
119
+ export declare function createApiClient(config: {
120
+ apiKey?: string;
121
+ projectId?: string;
122
+ }): ApiClient | null;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * API Client for Contentstorage CLI API
3
+ * Uses API key authentication for project-level access
4
+ */
5
+ import axios from 'axios';
6
+ import { CONTENTSTORAGE_CONFIG } from '../utils/constants.js';
7
+ export class ApiClient {
8
+ constructor(apiKey, projectId) {
9
+ this.projectId = projectId;
10
+ this.client = axios.create({
11
+ baseURL: `${CONTENTSTORAGE_CONFIG.API_URL}/api/v1/cli`,
12
+ headers: {
13
+ Authorization: `Bearer ${apiKey}`,
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ timeout: 30000,
17
+ });
18
+ }
19
+ /**
20
+ * Handle API errors consistently
21
+ */
22
+ handleError(error) {
23
+ if (error.response?.data) {
24
+ const { error: errorCode, message } = error.response.data;
25
+ throw new Error(`${errorCode}: ${message}`);
26
+ }
27
+ if (error.response) {
28
+ throw new Error(`API error: ${error.response.status} ${error.response.statusText}`);
29
+ }
30
+ throw new Error(`Network error: ${error.message}`);
31
+ }
32
+ /**
33
+ * Pull content from the API
34
+ * @param options.languageCode - Optional specific language (returns all if omitted)
35
+ * @param options.format - 'nested' or 'flat' (default: nested)
36
+ * @param options.draft - true for draft content, false for published (default: true)
37
+ */
38
+ async getContent(options = {}) {
39
+ try {
40
+ const params = new URLSearchParams();
41
+ params.append('projectId', this.projectId);
42
+ if (options.languageCode) {
43
+ params.append('languageCode', options.languageCode);
44
+ }
45
+ if (options.format) {
46
+ params.append('format', options.format);
47
+ }
48
+ if (options.draft !== undefined) {
49
+ params.append('draft', String(options.draft));
50
+ }
51
+ const response = await this.client.get(`/content?${params}`);
52
+ return response.data;
53
+ }
54
+ catch (error) {
55
+ this.handleError(error);
56
+ }
57
+ }
58
+ /**
59
+ * Push content to the API
60
+ * @param languageCode - The language code to push
61
+ * @param content - The content object (nested or flat)
62
+ * @param options.dryRun - If true, validate only without saving
63
+ */
64
+ async pushContent(languageCode, content, options = {}) {
65
+ try {
66
+ const body = {
67
+ projectId: this.projectId,
68
+ languageCode,
69
+ content,
70
+ options: {
71
+ dryRun: options.dryRun || false,
72
+ },
73
+ };
74
+ if (options.session) {
75
+ body.session = options.session;
76
+ }
77
+ const response = await this.client.post('/content/push', body);
78
+ return response.data;
79
+ }
80
+ catch (error) {
81
+ this.handleError(error);
82
+ }
83
+ }
84
+ /**
85
+ * Get project information
86
+ */
87
+ async getProject() {
88
+ try {
89
+ const response = await this.client.get(`/projects/${this.projectId}`);
90
+ return response.data;
91
+ }
92
+ catch (error) {
93
+ this.handleError(error);
94
+ }
95
+ }
96
+ /**
97
+ * Get translation statistics
98
+ */
99
+ async getStats() {
100
+ try {
101
+ const response = await this.client.get(`/projects/${this.projectId}/stats`);
102
+ return response.data;
103
+ }
104
+ catch (error) {
105
+ this.handleError(error);
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Create an API client from config
111
+ * Returns null if API key is not configured
112
+ */
113
+ export function createApiClient(config) {
114
+ if (!config.apiKey || !config.projectId) {
115
+ return null;
116
+ }
117
+ return new ApiClient(config.apiKey, config.projectId);
118
+ }
@@ -37,6 +37,9 @@ export async function loadConfig() {
37
37
  contentKey: mergedConfig.contentKey || '',
38
38
  contentDir: path.resolve(process.cwd(), mergedConfig.contentDir),
39
39
  typesOutputFile: path.resolve(process.cwd(), mergedConfig.typesOutputFile),
40
+ // New API key authentication fields
41
+ apiKey: mergedConfig.apiKey,
42
+ projectId: mergedConfig.projectId,
40
43
  };
41
44
  return finalConfig;
42
45
  }
package/dist/types.d.ts CHANGED
@@ -5,6 +5,8 @@ export type AppConfig = {
5
5
  typesOutputFile: string;
6
6
  pendingChanges?: boolean;
7
7
  flatten?: boolean;
8
+ apiKey?: string;
9
+ projectId?: string;
8
10
  };
9
11
  export type LanguageCode = 'SQ' | 'BE' | 'BS' | 'BG' | 'HR' | 'CS' | 'DA' | 'NL' | 'EN' | 'ET' | 'FI' | 'FR' | 'DE' | 'EL' | 'HU' | 'GA' | 'IT' | 'LV' | 'LT' | 'MK' | 'NO' | 'PL' | 'PT' | 'RO' | 'RU' | 'SR' | 'SK' | 'SL' | 'ES' | 'SV' | 'TR' | 'UK';
10
12
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@contentstorage/core",
3
3
  "author": "Kaido Hussar <kaido@contentstorage.app>",
4
4
  "homepage": "https://contentstorage.app",
5
- "version": "3.0.1",
5
+ "version": "3.2.0",
6
6
  "type": "module",
7
7
  "description": "Contentstorage CLI for managing translations and generating TypeScript types",
8
8
  "license": "MIT",