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.
Files changed (66) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +158 -1188
  3. package/dist/commands/auth.js +85 -19
  4. package/dist/commands/config.js +36 -26
  5. package/dist/commands/export.js +11 -122
  6. package/dist/commands/import.js +32 -143
  7. package/dist/commands/init.js +61 -72
  8. package/dist/commands/keys.js +145 -279
  9. package/dist/commands/org.js +45 -111
  10. package/dist/commands/projects.js +124 -312
  11. package/dist/commands/pull.js +86 -129
  12. package/dist/commands/push.js +125 -0
  13. package/dist/commands/review.js +65 -0
  14. package/dist/commands/team.js +68 -257
  15. package/dist/commands/translate.js +99 -0
  16. package/dist/core/cache.js +48 -0
  17. package/dist/core/config.js +130 -0
  18. package/dist/core/errors.js +67 -0
  19. package/dist/core/files.js +38 -0
  20. package/dist/core/http.js +156 -0
  21. package/dist/core/output.js +96 -0
  22. package/dist/core/project.js +58 -0
  23. package/dist/core/prompts.js +56 -0
  24. package/dist/formats/index.js +285 -0
  25. package/dist/index.js +277 -508
  26. package/dist/version.js +4 -0
  27. package/package.json +22 -15
  28. package/.gitattributes +0 -2
  29. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
  30. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  31. package/CONTRIBUTING.md +0 -33
  32. package/dist/api.d.ts +0 -20
  33. package/dist/api.js +0 -75
  34. package/dist/auth.d.ts +0 -38
  35. package/dist/auth.js +0 -106
  36. package/dist/commands/auth.d.ts +0 -3
  37. package/dist/commands/config.d.ts +0 -2
  38. package/dist/commands/debug.d.ts +0 -3
  39. package/dist/commands/debug.js +0 -61
  40. package/dist/commands/export.d.ts +0 -13
  41. package/dist/commands/import.d.ts +0 -12
  42. package/dist/commands/init.d.ts +0 -2
  43. package/dist/commands/keys.d.ts +0 -25
  44. package/dist/commands/org.d.ts +0 -13
  45. package/dist/commands/projects.d.ts +0 -33
  46. package/dist/commands/pull.d.ts +0 -9
  47. package/dist/commands/team.d.ts +0 -29
  48. package/dist/config.d.ts +0 -48
  49. package/dist/config.js +0 -69
  50. package/dist/exporters/index.d.ts +0 -36
  51. package/dist/exporters/index.js +0 -214
  52. package/dist/index.d.ts +0 -3
  53. package/dist/utils/banner.d.ts +0 -9
  54. package/dist/utils/banner.js +0 -24
  55. package/translations/en.json +0 -5
  56. package/translations/en.xml +0 -9
  57. package/translations/fr.json +0 -4
  58. package/translations/fr.xml +0 -7
  59. package/translations/hi.json +0 -4
  60. package/translations/hi.xml +0 -7
  61. package/translations/ios/en.strings +0 -9
  62. package/translations/ios/fr.strings +0 -6
  63. package/translations/ios/hi.strings +0 -6
  64. package/translations/ios/ja.strings +0 -6
  65. package/translations/ja.json +0 -4
  66. package/translations/ja.xml +0 -7
@@ -1,270 +1,81 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
3
- import { isAuthenticated } from '../auth.js';
4
- import { getApiClient } from '../api.js';
5
- import { config } from '../config.js';
6
- /**
7
- * List all team members
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
- if (!isAuthenticated()) {
11
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
12
- process.exitCode = 1;
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
- if (!isAuthenticated()) {
51
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
52
- process.exitCode = 1;
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
- * Invite team member
87
- */
88
- export async function inviteTeamMemberCommand(email, options) {
89
- if (!isAuthenticated()) {
90
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
91
- process.exitCode = 1;
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
- if (!isAuthenticated()) {
121
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
122
- process.exitCode = 1;
123
- return;
124
- }
125
- const orgId = config.get('organizationId');
126
- if (!orgId) {
127
- console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
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
- if (!isAuthenticated()) {
154
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
155
- process.exitCode = 1;
156
- return;
157
- }
158
- const validRoles = ['viewer', 'member', 'admin', 'owner'];
159
- if (!validRoles.includes(role)) {
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
- * List invitations
190
- */
191
- export async function listInvitationsCommand(options) {
192
- if (!isAuthenticated()) {
193
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
194
- process.exitCode = 1;
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
- if (!isAuthenticated()) {
242
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
243
- process.exitCode = 1;
244
- return;
245
- }
246
- const orgId = config.get('organizationId');
247
- if (!orgId) {
248
- console.log(chalk.red('✗ No organization configured. Please run "langctl init" first.\n'));
249
- process.exitCode = 1;
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
+ }