@contentstorage/core 3.1.0 → 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
@@ -39,13 +39,21 @@ Create `contentstorage.config.js` in your project root:
39
39
 
40
40
  ```javascript
41
41
  export default {
42
- 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
+
43
49
  languageCodes: ['EN', 'FR', 'DE'],
44
50
  contentDir: 'src/content/json',
45
51
  typesOutputFile: 'src/content/content-types.d.ts'
46
52
  };
47
53
  ```
48
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
+
49
57
  ### 2. Pull Content & Generate Types
50
58
 
51
59
  ```bash
@@ -86,18 +94,59 @@ npx contentstorage pull [options]
86
94
  ```
87
95
 
88
96
  **Options:**
89
- - `--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)
90
100
  - `--content-dir <dir>` - Directory to save content files
91
101
  - `--lang <code>` - Language code (e.g., EN, FR)
92
102
  - `--pending-changes` - Fetch pending/draft content
103
+ - `--flatten` - Output flattened key-value pairs
93
104
 
94
105
  **Examples:**
95
106
  ```bash
96
- 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
97
114
  npx contentstorage pull --lang EN --pending-changes
98
- npx contentstorage pull --content-dir src/content
99
115
  ```
100
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
+
101
150
  ### `contentstorage generate-types`
102
151
 
103
152
  Generate TypeScript type definitions from content
@@ -127,15 +176,20 @@ npx contentstorage stats [options]
127
176
  ```
128
177
 
129
178
  **Options:**
130
- - `--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)
131
182
  - `--content-dir <dir>` - Directory with content files
132
183
  - `--pending-changes` - Analyze pending/draft content
133
184
 
134
185
  **Examples:**
135
186
  ```bash
187
+ # Using local files + content key
136
188
  npx contentstorage stats
137
- npx contentstorage stats --content-key abc123
138
- 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
139
193
  ```
140
194
 
141
195
  **What it shows:**
@@ -168,8 +222,14 @@ npx contentstorage stats --help
168
222
 
169
223
  ```javascript
170
224
  export default {
171
- // Required: Unique content identifier
172
- 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',
173
233
 
174
234
  // Required: Array of language codes
175
235
  languageCodes: ['EN', 'FR', 'DE', 'ES'],
@@ -185,6 +245,17 @@ export default {
185
245
  };
186
246
  ```
187
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
+
188
259
  ### Supported Languages
189
260
 
190
261
  The CLI supports 40+ languages including:
@@ -5,6 +5,7 @@ import { pushContent } from './push.js';
5
5
  import { generateTypes } from './generate-types.js';
6
6
  import { showStats } from './stats.js';
7
7
  import { captureScreenshot } from './screenshot.js';
8
+ import { translateContent } from './translate.js';
8
9
  const COMMANDS = {
9
10
  pull: {
10
11
  name: 'pull',
@@ -64,6 +65,19 @@ const COMMANDS = {
64
65
  ' --content-key <key> Content key for your project',
65
66
  ],
66
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
+ },
67
81
  };
68
82
  function showHelp() {
69
83
  console.log(chalk.bold('\nContentstorage CLI'));
@@ -132,6 +146,9 @@ async function main() {
132
146
  case 'screenshot':
133
147
  await captureScreenshot();
134
148
  break;
149
+ case 'translate':
150
+ await translateContent();
151
+ break;
135
152
  default:
136
153
  console.error(chalk.red(`Unknown command: ${command}\n`));
137
154
  console.log(chalk.dim('Run "contentstorage --help" for usage'));
@@ -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
+ }
@@ -20,10 +20,26 @@ export interface PushResult {
20
20
  pendingChangesCreated: boolean;
21
21
  uploadId?: string;
22
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
+ }
23
38
  export interface PushResponse {
24
39
  success: boolean;
25
40
  result: PushResult;
26
41
  dryRun?: boolean;
42
+ session?: TranslateSessionResult;
27
43
  }
28
44
  export interface ProjectResponse {
29
45
  success: boolean;
@@ -85,6 +101,7 @@ export declare class ApiClient {
85
101
  */
86
102
  pushContent(languageCode: string, content: Record<string, any>, options?: {
87
103
  dryRun?: boolean;
104
+ session?: TranslateSessionData;
88
105
  }): Promise<PushResponse>;
89
106
  /**
90
107
  * Get project information
@@ -63,14 +63,18 @@ export class ApiClient {
63
63
  */
64
64
  async pushContent(languageCode, content, options = {}) {
65
65
  try {
66
- const response = await this.client.post('/content/push', {
66
+ const body = {
67
67
  projectId: this.projectId,
68
68
  languageCode,
69
69
  content,
70
70
  options: {
71
71
  dryRun: options.dryRun || false,
72
72
  },
73
- });
73
+ };
74
+ if (options.session) {
75
+ body.session = options.session;
76
+ }
77
+ const response = await this.client.post('/content/push', body);
74
78
  return response.data;
75
79
  }
76
80
  catch (error) {
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.1.0",
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",