langctl 0.2.0 → 0.4.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/CHANGELOG.md +53 -0
- package/README.md +158 -1188
- package/dist/commands/auth.js +85 -19
- package/dist/commands/config.js +36 -26
- package/dist/commands/export.js +11 -122
- package/dist/commands/import.js +32 -143
- package/dist/commands/init.js +61 -72
- package/dist/commands/keys.js +145 -279
- package/dist/commands/org.js +45 -111
- package/dist/commands/projects.js +124 -312
- package/dist/commands/pull.js +86 -129
- package/dist/commands/push.js +125 -0
- package/dist/commands/review.js +65 -0
- package/dist/commands/team.js +68 -257
- package/dist/commands/translate.js +99 -0
- package/dist/core/cache.js +48 -0
- package/dist/core/config.js +130 -0
- package/dist/core/errors.js +67 -0
- package/dist/core/files.js +38 -0
- package/dist/core/http.js +156 -0
- package/dist/core/output.js +96 -0
- package/dist/core/project.js +58 -0
- package/dist/core/prompts.js +56 -0
- package/dist/formats/index.js +285 -0
- package/dist/index.js +277 -508
- package/dist/version.js +4 -0
- package/package.json +22 -15
- package/.gitattributes +0 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/CONTRIBUTING.md +0 -33
- package/dist/api.d.ts +0 -20
- package/dist/api.js +0 -75
- package/dist/auth.d.ts +0 -38
- package/dist/auth.js +0 -106
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -61
- package/dist/commands/export.d.ts +0 -13
- package/dist/commands/import.d.ts +0 -12
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/keys.d.ts +0 -25
- package/dist/commands/org.d.ts +0 -13
- package/dist/commands/projects.d.ts +0 -33
- package/dist/commands/pull.d.ts +0 -9
- package/dist/commands/team.d.ts +0 -29
- package/dist/config.d.ts +0 -48
- package/dist/config.js +0 -69
- package/dist/exporters/index.d.ts +0 -36
- package/dist/exporters/index.js +0 -214
- package/dist/index.d.ts +0 -3
- package/dist/utils/banner.d.ts +0 -9
- package/dist/utils/banner.js +0 -24
- package/translations/en.json +0 -5
- package/translations/en.xml +0 -9
- package/translations/fr.json +0 -4
- package/translations/fr.xml +0 -7
- package/translations/hi.json +0 -4
- package/translations/hi.xml +0 -7
- package/translations/ios/en.strings +0 -9
- package/translations/ios/fr.strings +0 -6
- package/translations/ios/hi.strings +0 -6
- package/translations/ios/ja.strings +0 -6
- package/translations/ja.json +0 -4
- package/translations/ja.xml +0 -7
package/dist/commands/team.js
CHANGED
|
@@ -1,270 +1,81 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { CliError, ExitCode, notFoundError, usageError } from '../core/errors.js';
|
|
3
|
+
import { getSession } from '../core/http.js';
|
|
4
|
+
import { log, printJson, runtime, table } from '../core/output.js';
|
|
5
|
+
import { confirm } from '../core/prompts.js';
|
|
6
|
+
const ROLES = ['viewer', 'member', 'admin'];
|
|
7
|
+
async function members(session) {
|
|
8
|
+
return session.api.get(`/orgs/${session.orgId}/members`);
|
|
9
|
+
}
|
|
10
|
+
async function requireMember(session, email) {
|
|
11
|
+
const m = (await members(session)).find(x => x.email.toLowerCase() === email.toLowerCase());
|
|
12
|
+
if (!m)
|
|
13
|
+
throw notFoundError(`No team member with email ${email}.`, 'List members with "langctl team list".');
|
|
14
|
+
return m;
|
|
15
|
+
}
|
|
16
|
+
function checkRole(role) {
|
|
17
|
+
if (!ROLES.includes(role))
|
|
18
|
+
throw usageError(`Invalid role "${role}".`, `Use one of: ${ROLES.join(', ')} (ownership is transferred in the web app).`);
|
|
19
|
+
}
|
|
9
20
|
export async function listTeamCommand() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return;
|
|
14
|
-
|
|
15
|
-
const orgId = config.get('organizationId');
|
|
16
|
-
if (!orgId) {
|
|
17
|
-
console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
|
|
18
|
-
process.exitCode = 1;
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
const spinner = ora('Fetching team members...').start();
|
|
22
|
-
try {
|
|
23
|
-
const api = getApiClient();
|
|
24
|
-
const members = await api.get(`/orgs/${orgId}/members`);
|
|
25
|
-
spinner.stop();
|
|
26
|
-
if (!members || members.length === 0) {
|
|
27
|
-
console.log(chalk.yellow('\nNo team members found\n'));
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
console.log(chalk.blue.bold(`\n👥 Team Members (${members.length})\n`));
|
|
31
|
-
members.forEach((member) => {
|
|
32
|
-
console.log(chalk.white.bold(member.email));
|
|
33
|
-
console.log(chalk.gray(` Role: ${member.role}`));
|
|
34
|
-
if (member.joinedAt) {
|
|
35
|
-
console.log(chalk.gray(` Joined: ${new Date(member.joinedAt).toLocaleDateString()}`));
|
|
36
|
-
}
|
|
37
|
-
console.log('');
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
spinner.fail(chalk.red('Failed to list team members'));
|
|
42
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
43
|
-
process.exitCode = 1;
|
|
44
|
-
}
|
|
21
|
+
const session = await getSession();
|
|
22
|
+
const list = await members(session);
|
|
23
|
+
if (runtime.json)
|
|
24
|
+
return printJson(list);
|
|
25
|
+
table(list.map(m => [m.email, m.fullName ?? '', m.role, m.joinedAt ? new Date(m.joinedAt).toISOString().slice(0, 10) : '']), ['EMAIL', 'NAME', 'ROLE', 'JOINED']);
|
|
45
26
|
}
|
|
46
|
-
/**
|
|
47
|
-
* Get team member details
|
|
48
|
-
*/
|
|
49
27
|
export async function getTeamMemberCommand(email) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const orgId = config.get('organizationId');
|
|
56
|
-
if (!orgId) {
|
|
57
|
-
console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
|
|
58
|
-
process.exitCode = 1;
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const spinner = ora('Fetching member details...').start();
|
|
62
|
-
try {
|
|
63
|
-
const api = getApiClient();
|
|
64
|
-
const members = await api.get(`/orgs/${orgId}/members`);
|
|
65
|
-
const member = members.find((m) => m.email === email);
|
|
66
|
-
if (!member) {
|
|
67
|
-
throw new Error(`Member "${email}" not found`);
|
|
68
|
-
}
|
|
69
|
-
spinner.stop();
|
|
70
|
-
console.log(chalk.blue.bold('\n👤 Team Member Details\n'));
|
|
71
|
-
console.log(chalk.white.bold(`Email: ${member.email}`));
|
|
72
|
-
console.log(chalk.gray(`Role: ${member.role}`));
|
|
73
|
-
console.log(chalk.gray(`User ID: ${member.userId}`));
|
|
74
|
-
if (member.joinedAt) {
|
|
75
|
-
console.log(chalk.gray(`Joined: ${new Date(member.joinedAt).toLocaleString()}`));
|
|
76
|
-
}
|
|
77
|
-
console.log('');
|
|
78
|
-
}
|
|
79
|
-
catch (error) {
|
|
80
|
-
spinner.fail(chalk.red('Failed to get member details'));
|
|
81
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
82
|
-
process.exitCode = 1;
|
|
83
|
-
}
|
|
28
|
+
const session = await getSession();
|
|
29
|
+
const m = await requireMember(session, email);
|
|
30
|
+
if (runtime.json)
|
|
31
|
+
return printJson(m);
|
|
32
|
+
log.out(`${chalk.bold(m.email)} ${m.role}${m.fullName ? ` ${m.fullName}` : ''}`);
|
|
84
33
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
const orgId = config.get('organizationId');
|
|
95
|
-
if (!orgId) {
|
|
96
|
-
console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
|
|
97
|
-
process.exitCode = 1;
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const spinner = ora(`Inviting ${email}...`).start();
|
|
101
|
-
try {
|
|
102
|
-
const api = getApiClient();
|
|
103
|
-
await api.post(`/orgs/${orgId}/invitations`, {
|
|
104
|
-
email,
|
|
105
|
-
role: options.role || 'member'
|
|
106
|
-
});
|
|
107
|
-
spinner.succeed(chalk.green(`Invitation sent to ${email}`));
|
|
108
|
-
console.log('');
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
spinner.fail(chalk.red('Failed to invite member'));
|
|
112
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
113
|
-
process.exitCode = 1;
|
|
114
|
-
}
|
|
34
|
+
export async function inviteTeamMemberCommand(email, opts) {
|
|
35
|
+
checkRole(opts.role);
|
|
36
|
+
const session = await getSession();
|
|
37
|
+
const inv = await session.api.post(`/orgs/${session.orgId}/invitations`, { email, role: opts.role });
|
|
38
|
+
if (runtime.json)
|
|
39
|
+
return printJson(inv);
|
|
40
|
+
log.success(`Invited ${chalk.bold(email)} as ${opts.role}`);
|
|
115
41
|
}
|
|
116
|
-
/**
|
|
117
|
-
* Remove team member
|
|
118
|
-
*/
|
|
119
42
|
export async function removeTeamMemberCommand(email) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
process.exitCode = 1;
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const spinner = ora(`Removing ${email}...`).start();
|
|
132
|
-
try {
|
|
133
|
-
const api = getApiClient();
|
|
134
|
-
const members = await api.get(`/orgs/${orgId}/members`);
|
|
135
|
-
const member = members.find((m) => m.email === email);
|
|
136
|
-
if (!member) {
|
|
137
|
-
throw new Error(`Member "${email}" not found`);
|
|
138
|
-
}
|
|
139
|
-
await api.delete(`/orgs/${orgId}/members/${member.id}`);
|
|
140
|
-
spinner.succeed(chalk.green(`Removed ${email} from organization`));
|
|
141
|
-
console.log('');
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
spinner.fail(chalk.red('Failed to remove member'));
|
|
145
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
146
|
-
process.exitCode = 1;
|
|
147
|
-
}
|
|
43
|
+
const session = await getSession();
|
|
44
|
+
const m = await requireMember(session, email);
|
|
45
|
+
if (!(await confirm(`Remove ${m.email} from the organization?`)))
|
|
46
|
+
throw new CliError('Cancelled.', ExitCode.Error);
|
|
47
|
+
await session.api.delete(`/orgs/${session.orgId}/members/${m.id}`);
|
|
48
|
+
if (runtime.json)
|
|
49
|
+
return printJson({ removed: m.email });
|
|
50
|
+
log.success(`Removed ${chalk.bold(m.email)}`);
|
|
148
51
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Update team member role
|
|
151
|
-
*/
|
|
152
52
|
export async function updateTeamRoleCommand(email, role) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
console.log(chalk.red(`✗ Invalid role. Must be one of: ${validRoles.join(', ')}\n`));
|
|
161
|
-
process.exitCode = 1;
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const orgId = config.get('organizationId');
|
|
165
|
-
if (!orgId) {
|
|
166
|
-
console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
|
|
167
|
-
process.exitCode = 1;
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
const spinner = ora(`Updating role for ${email}...`).start();
|
|
171
|
-
try {
|
|
172
|
-
const api = getApiClient();
|
|
173
|
-
const members = await api.get(`/orgs/${orgId}/members`);
|
|
174
|
-
const member = members.find((m) => m.email === email);
|
|
175
|
-
if (!member) {
|
|
176
|
-
throw new Error(`Member "${email}" not found`);
|
|
177
|
-
}
|
|
178
|
-
await api.patch(`/orgs/${orgId}/members/${member.id}`, { role });
|
|
179
|
-
spinner.succeed(chalk.green(`Updated ${email} role to ${role}`));
|
|
180
|
-
console.log('');
|
|
181
|
-
}
|
|
182
|
-
catch (error) {
|
|
183
|
-
spinner.fail(chalk.red('Failed to update role'));
|
|
184
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
185
|
-
process.exitCode = 1;
|
|
186
|
-
}
|
|
53
|
+
checkRole(role);
|
|
54
|
+
const session = await getSession();
|
|
55
|
+
const m = await requireMember(session, email);
|
|
56
|
+
await session.api.patch(`/orgs/${session.orgId}/members/${m.id}`, { role });
|
|
57
|
+
if (runtime.json)
|
|
58
|
+
return printJson({ email: m.email, role });
|
|
59
|
+
log.success(`${chalk.bold(m.email)} is now ${role}`);
|
|
187
60
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return;
|
|
196
|
-
|
|
197
|
-
const orgId = config.get('organizationId');
|
|
198
|
-
if (!orgId) {
|
|
199
|
-
console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
|
|
200
|
-
process.exitCode = 1;
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const spinner = ora('Fetching invitations...').start();
|
|
204
|
-
try {
|
|
205
|
-
const api = getApiClient();
|
|
206
|
-
const params = {};
|
|
207
|
-
if (options?.pending) {
|
|
208
|
-
params.status = 'pending';
|
|
209
|
-
}
|
|
210
|
-
const result = await api.get(`/orgs/${orgId}/invitations`, params);
|
|
211
|
-
const invitations = result.invitations || [];
|
|
212
|
-
spinner.stop();
|
|
213
|
-
if (invitations.length === 0) {
|
|
214
|
-
console.log(chalk.yellow('\nNo invitations found\n'));
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
console.log(chalk.blue.bold(`\n✉️ Invitations (${invitations.length})\n`));
|
|
218
|
-
invitations.forEach((invitation) => {
|
|
219
|
-
console.log(chalk.white.bold(invitation.email));
|
|
220
|
-
console.log(chalk.gray(` Role: ${invitation.role}`));
|
|
221
|
-
console.log(chalk.gray(` Status: ${invitation.status}`));
|
|
222
|
-
if (invitation.createdAt) {
|
|
223
|
-
console.log(chalk.gray(` Invited: ${new Date(invitation.createdAt).toLocaleDateString()}`));
|
|
224
|
-
}
|
|
225
|
-
if (invitation.expiresAt) {
|
|
226
|
-
console.log(chalk.gray(` Expires: ${new Date(invitation.expiresAt).toLocaleDateString()}`));
|
|
227
|
-
}
|
|
228
|
-
console.log('');
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
spinner.fail(chalk.red('Failed to list invitations'));
|
|
233
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
234
|
-
process.exitCode = 1;
|
|
235
|
-
}
|
|
61
|
+
export async function listInvitationsCommand(opts) {
|
|
62
|
+
const session = await getSession();
|
|
63
|
+
const status = opts.pending ? 'pending' : opts.status;
|
|
64
|
+
const { invitations } = await session.api.get(`/orgs/${session.orgId}/invitations`, { status });
|
|
65
|
+
if (runtime.json)
|
|
66
|
+
return printJson(invitations);
|
|
67
|
+
if (!invitations.length)
|
|
68
|
+
return log.info('No invitations.');
|
|
69
|
+
table(invitations.map(i => [i.email, i.role, i.status, i.expiresAt.slice(0, 10)]), ['EMAIL', 'ROLE', 'STATUS', 'EXPIRES']);
|
|
236
70
|
}
|
|
237
|
-
/**
|
|
238
|
-
* Revoke invitation
|
|
239
|
-
*/
|
|
240
71
|
export async function revokeInvitationCommand(email) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const spinner = ora(`Revoking invitation for ${email}...`).start();
|
|
253
|
-
try {
|
|
254
|
-
const api = getApiClient();
|
|
255
|
-
const result = await api.get(`/orgs/${orgId}/invitations`);
|
|
256
|
-
const invitation = (result.invitations || []).find((inv) => inv.email === email && inv.status === 'pending');
|
|
257
|
-
if (!invitation) {
|
|
258
|
-
throw new Error(`No pending invitation found for "${email}"`);
|
|
259
|
-
}
|
|
260
|
-
await api.post(`/orgs/${orgId}/invitations/${invitation.id}/revoke`);
|
|
261
|
-
spinner.succeed(chalk.green(`Invitation for ${email} revoked`));
|
|
262
|
-
console.log('');
|
|
263
|
-
}
|
|
264
|
-
catch (error) {
|
|
265
|
-
spinner.fail(chalk.red('Failed to revoke invitation'));
|
|
266
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
267
|
-
process.exitCode = 1;
|
|
268
|
-
}
|
|
72
|
+
const session = await getSession();
|
|
73
|
+
const { invitations } = await session.api.get(`/orgs/${session.orgId}/invitations`, { status: 'pending' });
|
|
74
|
+
const inv = invitations.find(i => i.email.toLowerCase() === email.toLowerCase());
|
|
75
|
+
if (!inv)
|
|
76
|
+
throw notFoundError(`No pending invitation for ${email}.`);
|
|
77
|
+
await session.api.post(`/orgs/${session.orgId}/invitations/${inv.id}/revoke`);
|
|
78
|
+
if (runtime.json)
|
|
79
|
+
return printJson({ revoked: inv.email });
|
|
80
|
+
log.success(`Revoked invitation for ${chalk.bold(inv.email)}`);
|
|
269
81
|
}
|
|
270
|
-
//# sourceMappingURL=team.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { usageError } from '../core/errors.js';
|
|
3
|
+
import { getSession } from '../core/http.js';
|
|
4
|
+
import { log, printJson, runtime, spinner, table } from '../core/output.js';
|
|
5
|
+
import { assertLanguages, getProject, listAllKeys, projectSlugFrom } from '../core/project.js';
|
|
6
|
+
/** The API translates at most 50 strings per request. */
|
|
7
|
+
const BATCH = 50;
|
|
8
|
+
const isBlank = (v) => !v || !v.trim();
|
|
9
|
+
/**
|
|
10
|
+
* Fill missing translations with AI (DeepL) from the project's default language.
|
|
11
|
+
* Counts against the plan's monthly AI translations unless the org uses its own DeepL key.
|
|
12
|
+
*/
|
|
13
|
+
export async function translateCommand(projectArg, opts) {
|
|
14
|
+
const session = await getSession();
|
|
15
|
+
const project = await getProject(session, projectSlugFrom(projectArg));
|
|
16
|
+
const source = project.defaultLanguage;
|
|
17
|
+
const targets = opts.to
|
|
18
|
+
? opts.to.split(',').map(s => s.trim()).filter(Boolean)
|
|
19
|
+
: project.languages.filter(l => l !== source);
|
|
20
|
+
if (!targets.length)
|
|
21
|
+
throw usageError(`Project "${project.slug}" has no languages besides ${source}.`, `Add one with "langctl projects add-language ${project.slug} <code>".`);
|
|
22
|
+
assertLanguages(project, targets);
|
|
23
|
+
if (targets.includes(source))
|
|
24
|
+
throw usageError(`${source} is the source language and can't be a target.`);
|
|
25
|
+
const spin = spinner('Fetching keys…');
|
|
26
|
+
let keys;
|
|
27
|
+
try {
|
|
28
|
+
keys = await listAllKeys(session, project, { module: opts.module });
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
spin.stop();
|
|
32
|
+
}
|
|
33
|
+
if (opts.keys) {
|
|
34
|
+
const wanted = new Set(opts.keys.split(',').map(s => s.trim()).filter(Boolean));
|
|
35
|
+
const unknown = [...wanted].filter(k => !keys.some(key => key.key === k));
|
|
36
|
+
if (unknown.length)
|
|
37
|
+
throw usageError(`Key${unknown.length > 1 ? 's' : ''} not found: ${unknown.join(', ')}`);
|
|
38
|
+
keys = keys.filter(k => wanted.has(k.key));
|
|
39
|
+
}
|
|
40
|
+
// Work list: per target language, keys with source text whose target is empty (or all, with --overwrite)
|
|
41
|
+
const plan = targets.map(lang => ({
|
|
42
|
+
lang,
|
|
43
|
+
keys: keys.filter(k => !isBlank(k.translations?.[source]) && (opts.overwrite || isBlank(k.translations?.[lang]))),
|
|
44
|
+
}));
|
|
45
|
+
const total = plan.reduce((n, p) => n + p.keys.length, 0);
|
|
46
|
+
const noSource = keys.filter(k => isBlank(k.translations?.[source])).length;
|
|
47
|
+
if (opts.dryRun || total === 0) {
|
|
48
|
+
if (runtime.json) {
|
|
49
|
+
return printJson({ dryRun: Boolean(opts.dryRun), source, languages: plan.map(p => ({ language: p.lang, keys: p.keys.map(k => k.key) })), total });
|
|
50
|
+
}
|
|
51
|
+
table(plan.map(p => [p.lang, String(p.keys.length)]), ['language', opts.overwrite ? 'keys to retranslate' : 'missing']);
|
|
52
|
+
if (noSource)
|
|
53
|
+
log.info(chalk.dim(`${noSource} key(s) have no ${source} text and are skipped.`));
|
|
54
|
+
if (total === 0)
|
|
55
|
+
log.success('Nothing to translate.');
|
|
56
|
+
else
|
|
57
|
+
log.info(`Dry run: ${total} translation(s) would use ${total} AI translation(s). Run without --dry-run to translate.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const done = [];
|
|
61
|
+
let usage;
|
|
62
|
+
let provider;
|
|
63
|
+
const progress = spinner(`Translating 0/${total}…`);
|
|
64
|
+
try {
|
|
65
|
+
for (const { lang, keys: todo } of plan) {
|
|
66
|
+
for (let i = 0; i < todo.length; i += BATCH) {
|
|
67
|
+
const batch = todo.slice(i, i + BATCH);
|
|
68
|
+
const res = await session.api.post(`/orgs/${session.orgId}/translate/bulk`, {
|
|
69
|
+
items: batch.map(k => ({ text: k.translations[source], keyId: k.id })),
|
|
70
|
+
sourceLang: source,
|
|
71
|
+
targetLang: lang,
|
|
72
|
+
projectId: project.id,
|
|
73
|
+
});
|
|
74
|
+
usage = res.usage;
|
|
75
|
+
provider = res.provider;
|
|
76
|
+
for (const [j, key] of batch.entries()) {
|
|
77
|
+
const text = res.results[j]?.translatedText;
|
|
78
|
+
if (!text)
|
|
79
|
+
continue;
|
|
80
|
+
await session.api.patch(`/orgs/${session.orgId}/projects/${project.id}/keys/${key.id}/translations/${encodeURIComponent(lang)}`, { value: text });
|
|
81
|
+
done.push({ language: lang, key: key.key, text });
|
|
82
|
+
progress.text = `Translating ${done.length}/${total}…`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
progress.stop();
|
|
89
|
+
}
|
|
90
|
+
if (runtime.json)
|
|
91
|
+
return printJson({ source, translated: done, provider, usage });
|
|
92
|
+
for (const d of done)
|
|
93
|
+
log.out(`${chalk.dim(d.language)} ${d.key} ${d.text}`);
|
|
94
|
+
const quota = provider === 'org'
|
|
95
|
+
? 'using your own DeepL key'
|
|
96
|
+
: usage?.limit != null ? `${usage.used}/${usage.limit} AI translations used this month` : `${usage?.used ?? done.length} AI translations used this month`;
|
|
97
|
+
log.success(`Translated ${done.length} string(s) into ${plan.filter(p => p.keys.length).map(p => p.lang).join(', ')} (${quota}).`);
|
|
98
|
+
log.info(chalk.dim(`AI translations are held back from "langctl pull" until reviewed. Check them with "langctl review ${project.slug}", then approve with --approve (or edit them in the web app).`));
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { configDir } from './config.js';
|
|
5
|
+
/**
|
|
6
|
+
* Small on-disk cache for conditional GETs (ETag / If-None-Match). When the server says
|
|
7
|
+
* "304 Not Modified" the CLI reuses the saved body, so repeated pulls cost almost nothing.
|
|
8
|
+
* Lives in the config dir (~/.langctl/cache); disable with LANGCTL_NO_CACHE=1.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_ENTRIES = 40;
|
|
11
|
+
function cacheDir() {
|
|
12
|
+
return join(configDir(), 'cache');
|
|
13
|
+
}
|
|
14
|
+
export function cacheEnabled() {
|
|
15
|
+
return !['1', 'true', 'yes'].includes(String(process.env.LANGCTL_NO_CACHE || '').toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
/** Cache key covers the API, the key (orgs/scopes differ per key) and the full request URL. */
|
|
18
|
+
export function cacheKey(apiUrl, apiKey, url) {
|
|
19
|
+
const keyId = createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
|
|
20
|
+
return createHash('sha256').update(`${apiUrl}\n${keyId}\n${url}`).digest('hex').slice(0, 32);
|
|
21
|
+
}
|
|
22
|
+
export function readCache(key) {
|
|
23
|
+
try {
|
|
24
|
+
const entry = JSON.parse(readFileSync(join(cacheDir(), `${key}.json`), 'utf8'));
|
|
25
|
+
return entry && typeof entry.etag === 'string' ? entry : null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function writeCache(key, etag, data) {
|
|
32
|
+
try {
|
|
33
|
+
const dir = cacheDir();
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
writeFileSync(join(dir, `${key}.json`), JSON.stringify({ etag, data, savedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
36
|
+
prune(dir);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// A cache that can't be written (read-only home, full disk) must never fail a command
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function prune(dir) {
|
|
43
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json'))
|
|
44
|
+
.map(f => ({ f, t: statSync(join(dir, f)).mtimeMs }))
|
|
45
|
+
.sort((a, b) => b.t - a.t);
|
|
46
|
+
for (const { f } of files.slice(MAX_ENTRIES))
|
|
47
|
+
unlinkSync(join(dir, f));
|
|
48
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { dirname, join, resolve } from 'path';
|
|
4
|
+
import { CliError, ExitCode, usageError } from './errors.js';
|
|
5
|
+
export const DEFAULT_API_URL = 'https://api.langctl.com/api/v1';
|
|
6
|
+
export function configDir() {
|
|
7
|
+
return process.env.LANGCTL_CONFIG_DIR ? resolve(process.env.LANGCTL_CONFIG_DIR) : join(homedir(), '.langctl');
|
|
8
|
+
}
|
|
9
|
+
export function configPath() {
|
|
10
|
+
return join(configDir(), 'config.json');
|
|
11
|
+
}
|
|
12
|
+
export function readUserConfig() {
|
|
13
|
+
const path = configPath();
|
|
14
|
+
if (!existsSync(path))
|
|
15
|
+
return {};
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
18
|
+
delete raw.__internal__; // left behind by the 0.2.x `conf` store
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new CliError(`Config file ${path} is not valid JSON.`, ExitCode.Error, 'Fix or delete it, then run "langctl auth --stdin" again.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Write atomically with owner-only permissions — the file contains an API key. */
|
|
26
|
+
export function writeUserConfig(next) {
|
|
27
|
+
const path = configPath();
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
29
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
30
|
+
const clean = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined && v !== ''));
|
|
31
|
+
writeFileSync(tmp, JSON.stringify(clean, null, 2) + '\n', { mode: 0o600 });
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
try {
|
|
34
|
+
chmodSync(path, 0o600);
|
|
35
|
+
}
|
|
36
|
+
catch { /* not supported on Windows */ }
|
|
37
|
+
}
|
|
38
|
+
export function clearCredentials() {
|
|
39
|
+
const current = readUserConfig();
|
|
40
|
+
if (!current.apiKey)
|
|
41
|
+
return false;
|
|
42
|
+
const { apiKey: _k, organizationId: _o, organizationName: _n, ...rest } = current;
|
|
43
|
+
if (Object.keys(rest).length === 0) {
|
|
44
|
+
try {
|
|
45
|
+
unlinkSync(configPath());
|
|
46
|
+
}
|
|
47
|
+
catch { /* already gone */ }
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
writeUserConfig(rest);
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
export const flagOverrides = {};
|
|
55
|
+
export function resolveApiUrl() {
|
|
56
|
+
return (flagOverrides.apiUrl || process.env.LANGCTL_API_URL || readUserConfig().apiBaseUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
57
|
+
}
|
|
58
|
+
export function resolveCredentials() {
|
|
59
|
+
const apiUrl = resolveApiUrl();
|
|
60
|
+
if (flagOverrides.apiKey)
|
|
61
|
+
return { apiKey: normalizeApiKey(flagOverrides.apiKey), source: 'flag', apiUrl };
|
|
62
|
+
if (process.env.LANGCTL_API_KEY)
|
|
63
|
+
return { apiKey: normalizeApiKey(process.env.LANGCTL_API_KEY), source: 'env', apiUrl };
|
|
64
|
+
const cfg = readUserConfig();
|
|
65
|
+
if (cfg.apiKey)
|
|
66
|
+
return { apiKey: cfg.apiKey, source: 'config', apiUrl, organizationId: cfg.organizationId };
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export function requireCredentials() {
|
|
70
|
+
const creds = resolveCredentials();
|
|
71
|
+
if (!creds) {
|
|
72
|
+
throw new CliError('Not authenticated.', ExitCode.Auth, 'Run "langctl auth --stdin" (or "langctl init"), or set LANGCTL_API_KEY in CI.');
|
|
73
|
+
}
|
|
74
|
+
return creds;
|
|
75
|
+
}
|
|
76
|
+
const KEY_RE = /^lc_[a-f0-9]{64}$/;
|
|
77
|
+
/** Trim whitespace/quotes that commonly sneak in from copy-paste or CI secret stores. */
|
|
78
|
+
export function normalizeApiKey(input) {
|
|
79
|
+
const key = input.trim().replace(/^['"]|['"]$/g, '').trim();
|
|
80
|
+
if (!KEY_RE.test(key)) {
|
|
81
|
+
throw new CliError('That does not look like a Langctl API key (expected "lc_" followed by 64 hex characters).', ExitCode.Auth, 'Copy the key from https://app.langctl.com/organization/api-keys — it is shown only once.');
|
|
82
|
+
}
|
|
83
|
+
return key;
|
|
84
|
+
}
|
|
85
|
+
export function maskApiKey(key) {
|
|
86
|
+
return `${key.slice(0, 7)}…${key.slice(-4)}`;
|
|
87
|
+
}
|
|
88
|
+
// ── Project config (langctl.json) ──────────────────────────────
|
|
89
|
+
export const PROJECT_CONFIG_FILE = 'langctl.json';
|
|
90
|
+
const KNOWN_KEYS = new Set(['$schema', 'project', 'format', 'output', 'languages', 'sourceLanguage', 'includeDrafts', 'module']);
|
|
91
|
+
/** Find langctl.json in the current directory or the nearest parent. */
|
|
92
|
+
export function loadProjectConfig(cwd = process.cwd()) {
|
|
93
|
+
let dir = resolve(cwd);
|
|
94
|
+
for (;;) {
|
|
95
|
+
const candidate = join(dir, PROJECT_CONFIG_FILE);
|
|
96
|
+
if (existsSync(candidate)) {
|
|
97
|
+
let config;
|
|
98
|
+
try {
|
|
99
|
+
config = JSON.parse(readFileSync(candidate, 'utf-8'));
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
throw usageError(`${candidate} is not valid JSON: ${e.message}`);
|
|
103
|
+
}
|
|
104
|
+
const unknown = Object.keys(config).filter(k => !KNOWN_KEYS.has(k));
|
|
105
|
+
if (unknown.length) {
|
|
106
|
+
throw usageError(`${candidate} has unknown field(s): ${unknown.join(', ')}`, `Allowed: ${[...KNOWN_KEYS].filter(k => k !== '$schema').join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
if (config.languages !== undefined && !Array.isArray(config.languages)) {
|
|
109
|
+
throw usageError(`${candidate}: "languages" must be an array of language codes`);
|
|
110
|
+
}
|
|
111
|
+
return { config, path: candidate, root: dir };
|
|
112
|
+
}
|
|
113
|
+
const parent = dirname(dir);
|
|
114
|
+
if (parent === dir)
|
|
115
|
+
return null;
|
|
116
|
+
dir = parent;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function writeProjectConfig(path, config) {
|
|
120
|
+
const ordered = {
|
|
121
|
+
project: config.project,
|
|
122
|
+
format: config.format,
|
|
123
|
+
output: config.output,
|
|
124
|
+
...(config.languages ? { languages: config.languages } : {}),
|
|
125
|
+
...(config.sourceLanguage ? { sourceLanguage: config.sourceLanguage } : {}),
|
|
126
|
+
...(config.includeDrafts ? { includeDrafts: true } : {}),
|
|
127
|
+
...(config.module ? { module: config.module } : {}),
|
|
128
|
+
};
|
|
129
|
+
writeFileSync(path, JSON.stringify(ordered, null, 2) + '\n');
|
|
130
|
+
}
|