@contentstorage/core 2.2.1 → 3.1.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
@@ -1,21 +1,37 @@
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';
7
+ import { captureScreenshot } from './screenshot.js';
6
8
  const COMMANDS = {
7
9
  pull: {
8
10
  name: 'pull',
9
11
  description: 'Pull content from Contentstorage CDN',
10
12
  usage: 'contentstorage pull [options]',
11
13
  options: [
12
- ' --content-key <key> Content key for your project',
14
+ ' --content-key <key> Content key (read-only access)',
15
+ ' --api-key <key> API key (read + write access)',
16
+ ' --project-id <id> Project ID (required with --api-key)',
13
17
  ' --content-dir <dir> Directory to save content files',
14
18
  ' --lang <code> Language code (e.g., EN, FR)',
15
19
  ' --pending-changes Fetch pending/draft content',
16
20
  ' --flatten Output flattened key-value pairs',
17
21
  ],
18
22
  },
23
+ push: {
24
+ name: 'push',
25
+ description: 'Push local content changes to Contentstorage',
26
+ usage: 'contentstorage push [options]',
27
+ options: [
28
+ ' --api-key <key> API key for authentication (required)',
29
+ ' --project-id <id> Project ID (required)',
30
+ ' --content-dir <dir> Directory with content files',
31
+ ' --lang <code> Language code to push (e.g., EN)',
32
+ ' --dry-run Preview changes without applying',
33
+ ],
34
+ },
19
35
  'generate-types': {
20
36
  name: 'generate-types',
21
37
  description: 'Generate TypeScript type definitions from content',
@@ -32,11 +48,22 @@ const COMMANDS = {
32
48
  description: 'Show translation completeness statistics',
33
49
  usage: 'contentstorage stats [options]',
34
50
  options: [
35
- ' --content-key <key> Content key for your project',
51
+ ' --content-key <key> Content key (read-only access)',
52
+ ' --api-key <key> API key (read + write access)',
53
+ ' --project-id <id> Project ID (required with --api-key)',
36
54
  ' --content-dir <dir> Directory with content files',
37
55
  ' --pending-changes Analyze pending/draft content',
38
56
  ],
39
57
  },
58
+ screenshot: {
59
+ name: 'screenshot',
60
+ description: 'Open browser in live-editor mode for screenshots',
61
+ usage: 'contentstorage screenshot --url <url> [options]',
62
+ options: [
63
+ ' --url <url> Dev server URL (e.g., http://localhost:3000)',
64
+ ' --content-key <key> Content key for your project',
65
+ ],
66
+ },
40
67
  };
41
68
  function showHelp() {
42
69
  console.log(chalk.bold('\nContentstorage CLI'));
@@ -93,12 +120,18 @@ async function main() {
93
120
  case 'pull':
94
121
  await pullContent();
95
122
  break;
123
+ case 'push':
124
+ await pushContent();
125
+ break;
96
126
  case 'generate-types':
97
127
  await generateTypes();
98
128
  break;
99
129
  case 'stats':
100
130
  await showStats();
101
131
  break;
132
+ case 'screenshot':
133
+ await captureScreenshot();
134
+ break;
102
135
  default:
103
136
  console.error(chalk.red(`Unknown command: ${command}\n`));
104
137
  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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function captureScreenshot(): Promise<void>;
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { exec } from 'child_process';
3
+ import chalk from 'chalk';
4
+ import { loadConfig } from '../core/config-loader.js';
5
+ export async function captureScreenshot() {
6
+ console.log(chalk.blue('Starting screenshot mode...'));
7
+ // Parse CLI arguments
8
+ const config = await parseArguments();
9
+ // Validate configuration
10
+ if (!validateConfig(config)) {
11
+ process.exit(1);
12
+ }
13
+ // Build URL with live-editor params
14
+ const liveEditorUrl = buildLiveEditorUrl(config.url, config.contentKey);
15
+ console.log(chalk.blue(`Opening browser with live-editor mode...`));
16
+ console.log(chalk.dim(`URL: ${liveEditorUrl}`));
17
+ // Open in default browser
18
+ openInBrowser(liveEditorUrl);
19
+ console.log(chalk.green('\nBrowser opened successfully!'));
20
+ console.log(chalk.dim('Use the Contentstorage UI to capture screenshots.'));
21
+ }
22
+ async function parseArguments() {
23
+ const args = process.argv.slice(2);
24
+ const cliConfig = {};
25
+ for (let i = 0; i < args.length; i++) {
26
+ const arg = args[i];
27
+ if (arg.startsWith('--')) {
28
+ const key = arg.substring(2);
29
+ const value = args[i + 1];
30
+ if (value && !value.startsWith('--')) {
31
+ if (key === 'url') {
32
+ cliConfig.url = value;
33
+ }
34
+ else if (key === 'content-key') {
35
+ cliConfig.contentKey = value;
36
+ }
37
+ i++;
38
+ }
39
+ }
40
+ }
41
+ // Load file config for contentKey fallback
42
+ let fileConfig = {};
43
+ try {
44
+ fileConfig = await loadConfig();
45
+ }
46
+ catch {
47
+ console.log(chalk.yellow('Could not load configuration file. Using CLI arguments only.'));
48
+ }
49
+ return {
50
+ url: cliConfig.url || '',
51
+ contentKey: cliConfig.contentKey || fileConfig.contentKey,
52
+ };
53
+ }
54
+ function validateConfig(config) {
55
+ if (!config.url) {
56
+ console.error(chalk.red('Error: --url argument is required.'));
57
+ console.log(chalk.dim('Usage: contentstorage screenshot --url http://localhost:3000'));
58
+ console.log(chalk.dim(' contentstorage screenshot --url http://localhost:5173'));
59
+ return false;
60
+ }
61
+ try {
62
+ new URL(config.url);
63
+ }
64
+ catch {
65
+ console.error(chalk.red(`Error: Invalid URL format: ${config.url}`));
66
+ return false;
67
+ }
68
+ if (!config.contentKey) {
69
+ console.error(chalk.red('Error: content-key is required.'));
70
+ console.log(chalk.dim('Provide via --content-key or in contentstorage.config.js'));
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+ function buildLiveEditorUrl(baseUrl, contentKey) {
76
+ const url = new URL(baseUrl);
77
+ url.searchParams.set('contentstorage_live_editor', 'true');
78
+ url.searchParams.set('screenshot_mode', 'true');
79
+ if (contentKey) {
80
+ url.searchParams.set('contentstorage_key', contentKey);
81
+ }
82
+ return url.toString();
83
+ }
84
+ function openInBrowser(url) {
85
+ const platform = process.platform;
86
+ let command;
87
+ if (platform === 'darwin') {
88
+ command = `open "${url}"`;
89
+ }
90
+ else if (platform === 'win32') {
91
+ command = `start "" "${url}"`;
92
+ }
93
+ else {
94
+ command = `xdg-open "${url}"`;
95
+ }
96
+ exec(command, (error) => {
97
+ if (error) {
98
+ console.error(chalk.red(`Failed to open browser: ${error.message}`));
99
+ console.log(chalk.yellow(`Please open this URL manually: ${url}`));
100
+ }
101
+ });
102
+ }
@@ -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,105 @@
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 PushResponse {
24
+ success: boolean;
25
+ result: PushResult;
26
+ dryRun?: boolean;
27
+ }
28
+ export interface ProjectResponse {
29
+ success: boolean;
30
+ project: {
31
+ id: string;
32
+ name: string;
33
+ languages: string[];
34
+ changeTrackingEnabled: boolean;
35
+ createdAt: string;
36
+ updatedAt: string;
37
+ };
38
+ }
39
+ export interface StatsResponse {
40
+ success: boolean;
41
+ stats: {
42
+ totalKeys: number;
43
+ languages: Array<{
44
+ code: string;
45
+ completed: number;
46
+ percentage: number;
47
+ }>;
48
+ pendingChanges: {
49
+ total: number;
50
+ added: number;
51
+ edited: number;
52
+ removed: number;
53
+ };
54
+ };
55
+ }
56
+ export interface ApiError {
57
+ success: false;
58
+ error: string;
59
+ message: string;
60
+ }
61
+ export declare class ApiClient {
62
+ private client;
63
+ private projectId;
64
+ constructor(apiKey: string, projectId: string);
65
+ /**
66
+ * Handle API errors consistently
67
+ */
68
+ private handleError;
69
+ /**
70
+ * Pull content from the API
71
+ * @param options.languageCode - Optional specific language (returns all if omitted)
72
+ * @param options.format - 'nested' or 'flat' (default: nested)
73
+ * @param options.draft - true for draft content, false for published (default: true)
74
+ */
75
+ getContent(options?: {
76
+ languageCode?: string;
77
+ format?: 'nested' | 'flat';
78
+ draft?: boolean;
79
+ }): Promise<ContentResponse>;
80
+ /**
81
+ * Push content to the API
82
+ * @param languageCode - The language code to push
83
+ * @param content - The content object (nested or flat)
84
+ * @param options.dryRun - If true, validate only without saving
85
+ */
86
+ pushContent(languageCode: string, content: Record<string, any>, options?: {
87
+ dryRun?: boolean;
88
+ }): Promise<PushResponse>;
89
+ /**
90
+ * Get project information
91
+ */
92
+ getProject(): Promise<ProjectResponse>;
93
+ /**
94
+ * Get translation statistics
95
+ */
96
+ getStats(): Promise<StatsResponse>;
97
+ }
98
+ /**
99
+ * Create an API client from config
100
+ * Returns null if API key is not configured
101
+ */
102
+ export declare function createApiClient(config: {
103
+ apiKey?: string;
104
+ projectId?: string;
105
+ }): ApiClient | null;
@@ -0,0 +1,114 @@
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 response = await this.client.post('/content/push', {
67
+ projectId: this.projectId,
68
+ languageCode,
69
+ content,
70
+ options: {
71
+ dryRun: options.dryRun || false,
72
+ },
73
+ });
74
+ return response.data;
75
+ }
76
+ catch (error) {
77
+ this.handleError(error);
78
+ }
79
+ }
80
+ /**
81
+ * Get project information
82
+ */
83
+ async getProject() {
84
+ try {
85
+ const response = await this.client.get(`/projects/${this.projectId}`);
86
+ return response.data;
87
+ }
88
+ catch (error) {
89
+ this.handleError(error);
90
+ }
91
+ }
92
+ /**
93
+ * Get translation statistics
94
+ */
95
+ async getStats() {
96
+ try {
97
+ const response = await this.client.get(`/projects/${this.projectId}/stats`);
98
+ return response.data;
99
+ }
100
+ catch (error) {
101
+ this.handleError(error);
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * Create an API client from config
107
+ * Returns null if API key is not configured
108
+ */
109
+ export function createApiClient(config) {
110
+ if (!config.apiKey || !config.projectId) {
111
+ return null;
112
+ }
113
+ return new ApiClient(config.apiKey, config.projectId);
114
+ }
@@ -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
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Find Chrome executable on the system
3
+ * @returns Path to Chrome executable or null if not found
4
+ */
5
+ export declare function findChrome(): string | null;
6
+ /**
7
+ * Get helpful error message when Chrome is not found
8
+ */
9
+ export declare function getChromeNotFoundMessage(): string;
@@ -0,0 +1,58 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * Common Chrome installation paths by platform
5
+ */
6
+ const CHROME_PATHS = {
7
+ darwin: [
8
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
9
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
10
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
11
+ ],
12
+ win32: [
13
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
14
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
15
+ process.env.LOCALAPPDATA
16
+ ? path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe')
17
+ : '',
18
+ ].filter(Boolean),
19
+ linux: [
20
+ '/usr/bin/google-chrome',
21
+ '/usr/bin/google-chrome-stable',
22
+ '/usr/bin/chromium',
23
+ '/usr/bin/chromium-browser',
24
+ '/snap/bin/chromium',
25
+ ],
26
+ };
27
+ /**
28
+ * Find Chrome executable on the system
29
+ * @returns Path to Chrome executable or null if not found
30
+ */
31
+ export function findChrome() {
32
+ const platform = process.platform;
33
+ const paths = CHROME_PATHS[platform] || [];
34
+ for (const chromePath of paths) {
35
+ if (chromePath && fs.existsSync(chromePath)) {
36
+ return chromePath;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * Get helpful error message when Chrome is not found
43
+ */
44
+ export function getChromeNotFoundMessage() {
45
+ const platform = process.platform;
46
+ const installInstructions = {
47
+ darwin: 'Download from https://www.google.com/chrome/ or install via: brew install --cask google-chrome',
48
+ win32: 'Download from https://www.google.com/chrome/',
49
+ linux: 'Install via: sudo apt install google-chrome-stable (Debian/Ubuntu) or sudo dnf install google-chrome-stable (Fedora)',
50
+ };
51
+ return `Chrome not found on your system.
52
+
53
+ Searched locations:
54
+ ${(CHROME_PATHS[platform] || []).map((p) => ` - ${p}`).join('\n')}
55
+
56
+ To install Chrome:
57
+ ${installInstructions[platform] || 'Download from https://www.google.com/chrome/'}`;
58
+ }
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": "2.2.1",
5
+ "version": "3.1.0",
6
6
  "type": "module",
7
7
  "description": "Contentstorage CLI for managing translations and generating TypeScript types",
8
8
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "release": "npx release-it"
24
24
  },
25
25
  "dependencies": {
26
- "axios": "^1.7.2",
26
+ "axios": "^1.13.2",
27
27
  "chalk": "^4.1.2",
28
28
  "pluralize": "^8.0.0"
29
29
  },