langctl 0.1.2 → 0.3.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 +42 -0
- package/LICENSE +1 -1
- package/README.md +159 -1151
- package/dist/commands/auth.js +82 -24
- package/dist/commands/config.js +36 -25
- package/dist/commands/export.js +11 -112
- package/dist/commands/import.js +32 -164
- package/dist/commands/init.js +59 -80
- package/dist/commands/keys.js +145 -381
- package/dist/commands/org.js +45 -140
- package/dist/commands/projects.js +125 -390
- package/dist/commands/pull.js +78 -183
- package/dist/commands/push.js +125 -0
- package/dist/commands/team.js +68 -272
- 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 +121 -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 +261 -508
- package/dist/version.js +4 -0
- package/package.json +22 -16
- package/.gitattributes +0 -2
- package/NEXT_PHASE.md +0 -618
- package/WORKING_INSTRUCTIONS.md +0 -1389
- package/dist/auth.d.ts +0 -37
- package/dist/auth.js +0 -112
- 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 -57
- package/dist/commands/export.d.ts +0 -14
- package/dist/commands/import.d.ts +0 -11
- 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 -30
- package/dist/commands/pull.d.ts +0 -9
- package/dist/commands/team.d.ts +0 -29
- package/dist/config.d.ts +0 -47
- 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/supabase.d.ts +0 -14
- package/dist/supabase.js +0 -30
- 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,285 +1,81 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
}
|
|
8
20
|
export async function listTeamCommand() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const spinner = ora('Fetching team members...').start();
|
|
15
|
-
try {
|
|
16
|
-
const response = await fetch(TEAM_MANAGEMENT_URL, {
|
|
17
|
-
method: 'POST',
|
|
18
|
-
headers: {
|
|
19
|
-
'Content-Type': 'application/json',
|
|
20
|
-
'X-API-Key': apiKey
|
|
21
|
-
},
|
|
22
|
-
body: JSON.stringify({
|
|
23
|
-
action: 'list'
|
|
24
|
-
})
|
|
25
|
-
});
|
|
26
|
-
const data = await response.json();
|
|
27
|
-
if (!data.success) {
|
|
28
|
-
throw new Error(data.error);
|
|
29
|
-
}
|
|
30
|
-
spinner.stop();
|
|
31
|
-
if (!data.members || data.members.length === 0) {
|
|
32
|
-
console.log(chalk.yellow('\nNo team members found\n'));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
console.log(chalk.blue.bold(`\n👥 Team Members (${data.members.length})\n`));
|
|
36
|
-
data.members.forEach((member) => {
|
|
37
|
-
console.log(chalk.white.bold(member.email || member.user_id));
|
|
38
|
-
console.log(chalk.gray(` Role: ${member.role}`));
|
|
39
|
-
if (member.joined_at) {
|
|
40
|
-
console.log(chalk.gray(` Joined: ${new Date(member.joined_at).toLocaleDateString()}`));
|
|
41
|
-
}
|
|
42
|
-
console.log('');
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
spinner.fail(chalk.red('Failed to list team members'));
|
|
47
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
48
|
-
}
|
|
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']);
|
|
49
26
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Get team member details
|
|
52
|
-
*/
|
|
53
27
|
export async function getTeamMemberCommand(email) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const spinner = ora('Fetching member details...').start();
|
|
60
|
-
try {
|
|
61
|
-
const response = await fetch(TEAM_MANAGEMENT_URL, {
|
|
62
|
-
method: 'POST',
|
|
63
|
-
headers: {
|
|
64
|
-
'Content-Type': 'application/json',
|
|
65
|
-
'X-API-Key': apiKey
|
|
66
|
-
},
|
|
67
|
-
body: JSON.stringify({
|
|
68
|
-
action: 'get',
|
|
69
|
-
email
|
|
70
|
-
})
|
|
71
|
-
});
|
|
72
|
-
const data = await response.json();
|
|
73
|
-
if (!data.success) {
|
|
74
|
-
throw new Error(data.error);
|
|
75
|
-
}
|
|
76
|
-
spinner.stop();
|
|
77
|
-
const member = data.member;
|
|
78
|
-
console.log(chalk.blue.bold('\n👤 Team Member Details\n'));
|
|
79
|
-
console.log(chalk.white.bold(`Email: ${member.email}`));
|
|
80
|
-
console.log(chalk.gray(`Role: ${member.role}`));
|
|
81
|
-
console.log(chalk.gray(`User ID: ${member.user_id}`));
|
|
82
|
-
if (member.joined_at) {
|
|
83
|
-
console.log(chalk.gray(`Joined: ${new Date(member.joined_at).toLocaleString()}`));
|
|
84
|
-
}
|
|
85
|
-
console.log('');
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
spinner.fail(chalk.red('Failed to get member details'));
|
|
89
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
90
|
-
}
|
|
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}` : ''}`);
|
|
91
33
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
const apiKey = getApiKey();
|
|
101
|
-
const spinner = ora(`Inviting ${email}...`).start();
|
|
102
|
-
try {
|
|
103
|
-
const response = await fetch(TEAM_MANAGEMENT_URL, {
|
|
104
|
-
method: 'POST',
|
|
105
|
-
headers: {
|
|
106
|
-
'Content-Type': 'application/json',
|
|
107
|
-
'X-API-Key': apiKey
|
|
108
|
-
},
|
|
109
|
-
body: JSON.stringify({
|
|
110
|
-
action: 'invite',
|
|
111
|
-
email,
|
|
112
|
-
role: options.role || 'member'
|
|
113
|
-
})
|
|
114
|
-
});
|
|
115
|
-
const data = await response.json();
|
|
116
|
-
if (!data.success) {
|
|
117
|
-
throw new Error(data.error);
|
|
118
|
-
}
|
|
119
|
-
spinner.succeed(chalk.green(data.message));
|
|
120
|
-
console.log('');
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
spinner.fail(chalk.red('Failed to invite member'));
|
|
124
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
125
|
-
}
|
|
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}`);
|
|
126
41
|
}
|
|
127
|
-
/**
|
|
128
|
-
* Remove team member
|
|
129
|
-
*/
|
|
130
42
|
export async function removeTeamMemberCommand(email) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
method: 'POST',
|
|
140
|
-
headers: {
|
|
141
|
-
'Content-Type': 'application/json',
|
|
142
|
-
'X-API-Key': apiKey
|
|
143
|
-
},
|
|
144
|
-
body: JSON.stringify({
|
|
145
|
-
action: 'remove',
|
|
146
|
-
email
|
|
147
|
-
})
|
|
148
|
-
});
|
|
149
|
-
const data = await response.json();
|
|
150
|
-
if (!data.success) {
|
|
151
|
-
throw new Error(data.error);
|
|
152
|
-
}
|
|
153
|
-
spinner.succeed(chalk.green(data.message));
|
|
154
|
-
console.log('');
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
spinner.fail(chalk.red('Failed to remove member'));
|
|
158
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
159
|
-
}
|
|
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)}`);
|
|
160
51
|
}
|
|
161
|
-
/**
|
|
162
|
-
* Update team member role
|
|
163
|
-
*/
|
|
164
52
|
export async function updateTeamRoleCommand(email, role) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
const apiKey = getApiKey();
|
|
175
|
-
const spinner = ora(`Updating role for ${email}...`).start();
|
|
176
|
-
try {
|
|
177
|
-
const response = await fetch(TEAM_MANAGEMENT_URL, {
|
|
178
|
-
method: 'POST',
|
|
179
|
-
headers: {
|
|
180
|
-
'Content-Type': 'application/json',
|
|
181
|
-
'X-API-Key': apiKey
|
|
182
|
-
},
|
|
183
|
-
body: JSON.stringify({
|
|
184
|
-
action: 'update-role',
|
|
185
|
-
email,
|
|
186
|
-
role
|
|
187
|
-
})
|
|
188
|
-
});
|
|
189
|
-
const data = await response.json();
|
|
190
|
-
if (!data.success) {
|
|
191
|
-
throw new Error(data.error);
|
|
192
|
-
}
|
|
193
|
-
spinner.succeed(chalk.green(data.message));
|
|
194
|
-
console.log('');
|
|
195
|
-
}
|
|
196
|
-
catch (error) {
|
|
197
|
-
spinner.fail(chalk.red('Failed to update role'));
|
|
198
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
199
|
-
}
|
|
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}`);
|
|
200
60
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const spinner = ora('Fetching invitations...').start();
|
|
211
|
-
try {
|
|
212
|
-
const response = await fetch(TEAM_MANAGEMENT_URL, {
|
|
213
|
-
method: 'POST',
|
|
214
|
-
headers: {
|
|
215
|
-
'Content-Type': 'application/json',
|
|
216
|
-
'X-API-Key': apiKey
|
|
217
|
-
},
|
|
218
|
-
body: JSON.stringify({
|
|
219
|
-
action: 'list-invitations',
|
|
220
|
-
pending: options.pending || false
|
|
221
|
-
})
|
|
222
|
-
});
|
|
223
|
-
const data = await response.json();
|
|
224
|
-
if (!data.success) {
|
|
225
|
-
throw new Error(data.error);
|
|
226
|
-
}
|
|
227
|
-
spinner.stop();
|
|
228
|
-
if (!data.invitations || data.invitations.length === 0) {
|
|
229
|
-
console.log(chalk.yellow('\nNo invitations found\n'));
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
console.log(chalk.blue.bold(`\n✉️ Invitations (${data.invitations.length})\n`));
|
|
233
|
-
data.invitations.forEach((invitation) => {
|
|
234
|
-
console.log(chalk.white.bold(invitation.email));
|
|
235
|
-
console.log(chalk.gray(` Role: ${invitation.role}`));
|
|
236
|
-
console.log(chalk.gray(` Status: ${invitation.status}`));
|
|
237
|
-
if (invitation.invited_at) {
|
|
238
|
-
console.log(chalk.gray(` Invited: ${new Date(invitation.invited_at).toLocaleDateString()}`));
|
|
239
|
-
}
|
|
240
|
-
if (invitation.expires_at) {
|
|
241
|
-
console.log(chalk.gray(` Expires: ${new Date(invitation.expires_at).toLocaleDateString()}`));
|
|
242
|
-
}
|
|
243
|
-
console.log('');
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
catch (error) {
|
|
247
|
-
spinner.fail(chalk.red('Failed to list invitations'));
|
|
248
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
249
|
-
}
|
|
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']);
|
|
250
70
|
}
|
|
251
|
-
/**
|
|
252
|
-
* Revoke invitation
|
|
253
|
-
*/
|
|
254
71
|
export async function revokeInvitationCommand(email) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
headers: {
|
|
265
|
-
'Content-Type': 'application/json',
|
|
266
|
-
'X-API-Key': apiKey
|
|
267
|
-
},
|
|
268
|
-
body: JSON.stringify({
|
|
269
|
-
action: 'revoke-invitation',
|
|
270
|
-
email
|
|
271
|
-
})
|
|
272
|
-
});
|
|
273
|
-
const data = await response.json();
|
|
274
|
-
if (!data.success) {
|
|
275
|
-
throw new Error(data.error);
|
|
276
|
-
}
|
|
277
|
-
spinner.succeed(chalk.green(data.message));
|
|
278
|
-
console.log('');
|
|
279
|
-
}
|
|
280
|
-
catch (error) {
|
|
281
|
-
spinner.fail(chalk.red('Failed to revoke invitation'));
|
|
282
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
283
|
-
}
|
|
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)}`);
|
|
284
81
|
}
|
|
285
|
-
//# sourceMappingURL=team.js.map
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes are part of the CLI's contract — CI scripts branch on them. Keep in sync with README.
|
|
3
|
+
*/
|
|
4
|
+
export const ExitCode = {
|
|
5
|
+
Ok: 0,
|
|
6
|
+
Error: 1,
|
|
7
|
+
Usage: 2,
|
|
8
|
+
Auth: 3,
|
|
9
|
+
NotFound: 4,
|
|
10
|
+
Network: 5,
|
|
11
|
+
Limit: 6,
|
|
12
|
+
Drift: 7,
|
|
13
|
+
};
|
|
14
|
+
export class CliError extends Error {
|
|
15
|
+
constructor(message, exitCode = ExitCode.Error, hint, status) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.exitCode = exitCode;
|
|
18
|
+
this.hint = hint;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.name = 'CliError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export const usageError = (message, hint) => new CliError(message, ExitCode.Usage, hint);
|
|
24
|
+
export const notFoundError = (message, hint) => new CliError(message, ExitCode.NotFound, hint);
|
|
25
|
+
/** Map an HTTP error response from the API to a CliError with the right exit code and a useful hint. */
|
|
26
|
+
export function httpError(status, serverMessage, method, path) {
|
|
27
|
+
const msg = serverMessage || `HTTP ${status}`;
|
|
28
|
+
if (status === 401) {
|
|
29
|
+
return new CliError('Your API key is invalid or has been revoked.', ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys and run "langctl auth --stdin", or set LANGCTL_API_KEY.', status);
|
|
30
|
+
}
|
|
31
|
+
if (status === 403) {
|
|
32
|
+
if (/limit/i.test(msg))
|
|
33
|
+
return new CliError(msg, ExitCode.Limit, 'See your plan limits with "langctl org plan".', status);
|
|
34
|
+
if (/scope/i.test(msg)) {
|
|
35
|
+
return new CliError(msg, ExitCode.Auth, 'Create an API key with the required permission at https://app.langctl.com/organization/api-keys.', status);
|
|
36
|
+
}
|
|
37
|
+
return new CliError(msg, ExitCode.Auth, undefined, status);
|
|
38
|
+
}
|
|
39
|
+
if (status === 404)
|
|
40
|
+
return new CliError(msg, ExitCode.NotFound, undefined, status);
|
|
41
|
+
if (status === 400 || status === 409 || status === 422)
|
|
42
|
+
return new CliError(msg, ExitCode.Usage, undefined, status);
|
|
43
|
+
if (status === 429)
|
|
44
|
+
return new CliError('Rate limited by the Langctl API.', ExitCode.Network, 'Wait a moment and retry.', status);
|
|
45
|
+
if (status >= 500) {
|
|
46
|
+
return new CliError(`The Langctl API failed (${status}) on ${method} ${path}.`, ExitCode.Network, 'This is usually temporary — retry in a minute. If it persists, contact hello@langctl.com.', status);
|
|
47
|
+
}
|
|
48
|
+
return new CliError(msg, ExitCode.Error, undefined, status);
|
|
49
|
+
}
|
|
50
|
+
/** Map a fetch/network failure (no HTTP response) to a CliError. */
|
|
51
|
+
export function networkError(err, baseUrl, timeoutMs) {
|
|
52
|
+
const e = err;
|
|
53
|
+
const code = e?.cause?.code;
|
|
54
|
+
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
|
|
55
|
+
return new CliError(`Request to ${baseUrl} timed out after ${Math.round(timeoutMs / 1000)}s.`, ExitCode.Network, 'Check your connection, or raise the limit with --timeout <seconds> / LANGCTL_TIMEOUT.');
|
|
56
|
+
}
|
|
57
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
58
|
+
return new CliError(`Cannot resolve ${new URL(baseUrl).host}.`, ExitCode.Network, 'Check your network/DNS, or LANGCTL_API_URL if you set it.');
|
|
59
|
+
}
|
|
60
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'UND_ERR_SOCKET') {
|
|
61
|
+
return new CliError(`Could not connect to ${baseUrl} (${code}).`, ExitCode.Network, 'Check your network, proxy, or LANGCTL_API_URL.');
|
|
62
|
+
}
|
|
63
|
+
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'SELF_SIGNED_CERT_IN_CHAIN') {
|
|
64
|
+
return new CliError(`TLS error talking to ${baseUrl} (${code}).`, ExitCode.Network, 'If you are behind a corporate proxy, set NODE_EXTRA_CA_CERTS to its CA bundle.');
|
|
65
|
+
}
|
|
66
|
+
return new CliError(`Network error: ${e?.cause?.message || e?.message || String(err)}`, ExitCode.Network);
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Write a file only if its content changed (atomic temp-file + rename).
|
|
5
|
+
* With `dryRun`, report what would happen without touching disk (used by --check / --dry-run).
|
|
6
|
+
*/
|
|
7
|
+
export function syncFile(path, content, dryRun,
|
|
8
|
+
/** Parse function of the file's format: if the existing file holds the same translations, leave it alone */
|
|
9
|
+
parse) {
|
|
10
|
+
const exists = existsSync(path);
|
|
11
|
+
if (exists) {
|
|
12
|
+
const current = readFileSync(path, 'utf-8');
|
|
13
|
+
if (current === content)
|
|
14
|
+
return 'unchanged';
|
|
15
|
+
// A hand-edited file (other key order, indentation, no trailing newline) with the same
|
|
16
|
+
// translations is not drift — keep the user's formatting and don't fail `pull --check`
|
|
17
|
+
if (parse && sameTranslations(current, content, parse))
|
|
18
|
+
return 'unchanged';
|
|
19
|
+
}
|
|
20
|
+
if (!dryRun) {
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const tmp = `${path}.langctl-${process.pid}.tmp`;
|
|
23
|
+
writeFileSync(tmp, content, 'utf-8');
|
|
24
|
+
renameSync(tmp, path);
|
|
25
|
+
}
|
|
26
|
+
return exists ? 'updated' : 'created';
|
|
27
|
+
}
|
|
28
|
+
function sameTranslations(a, b, parse) {
|
|
29
|
+
try {
|
|
30
|
+
const x = parse(a);
|
|
31
|
+
const y = parse(b);
|
|
32
|
+
const kx = Object.keys(x);
|
|
33
|
+
return kx.length === Object.keys(y).length && kx.every(k => x[k] === y[k]);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false; // unparseable existing file → rewrite it
|
|
37
|
+
}
|
|
38
|
+
}
|