@vocoder/cli 0.10.0 → 0.12.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 +137 -1
- package/dist/bin.mjs +669 -1049
- package/dist/bin.mjs.map +1 -1
- package/dist/{chunk-73U4VZYP.mjs → chunk-XUCVAFBG.mjs} +800 -37
- package/dist/chunk-XUCVAFBG.mjs.map +1 -0
- package/dist/lib.d.mts +358 -4
- package/dist/lib.mjs +15 -3
- package/dist/lib.mjs.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-73U4VZYP.mjs.map +0 -1
package/dist/bin.mjs
CHANGED
|
@@ -2,759 +2,37 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
StringExtractor,
|
|
5
|
+
VocoderAPI,
|
|
6
|
+
VocoderAPIError,
|
|
5
7
|
buildInstallCommand,
|
|
8
|
+
clearAuthData,
|
|
6
9
|
detectLocalEcosystem,
|
|
7
10
|
getPackagesToInstall,
|
|
8
11
|
getSetupSnippets,
|
|
9
|
-
loadVocoderConfig
|
|
10
|
-
|
|
12
|
+
loadVocoderConfig,
|
|
13
|
+
readAuthData,
|
|
14
|
+
verifyStoredAuth,
|
|
15
|
+
writeAuthData
|
|
16
|
+
} from "./chunk-XUCVAFBG.mjs";
|
|
11
17
|
|
|
12
18
|
// src/bin.ts
|
|
13
19
|
import { Command } from "commander";
|
|
14
20
|
|
|
15
21
|
// src/commands/init.ts
|
|
16
|
-
import { execSync as execSync3, spawn as spawn2 } from "child_process";
|
|
17
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
18
|
-
import { join as join3 } from "path";
|
|
19
22
|
import * as p5 from "@clack/prompts";
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
// src/utils/api.ts
|
|
24
|
-
function isLimitErrorResponse(value) {
|
|
25
|
-
if (!value || typeof value !== "object") {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
const candidate = value;
|
|
29
|
-
return typeof candidate.errorCode === "string" && typeof candidate.limitType === "string" && typeof candidate.planId === "string" && typeof candidate.current === "number" && typeof candidate.required === "number" && typeof candidate.upgradeUrl === "string" && typeof candidate.message === "string";
|
|
30
|
-
}
|
|
31
|
-
function isSyncPolicyErrorResponse(value) {
|
|
32
|
-
if (!value || typeof value !== "object") {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
const candidate = value;
|
|
36
|
-
return (candidate.errorCode === "BRANCH_NOT_ALLOWED" || candidate.errorCode === "PROJECT_REPOSITORY_MISMATCH") && typeof candidate.message === "string";
|
|
37
|
-
}
|
|
38
|
-
function extractErrorMessage(payload, fallback) {
|
|
39
|
-
if (!payload || typeof payload !== "object") {
|
|
40
|
-
return fallback;
|
|
41
|
-
}
|
|
42
|
-
const candidate = payload;
|
|
43
|
-
if (typeof candidate.message === "string") {
|
|
44
|
-
return candidate.message;
|
|
45
|
-
}
|
|
46
|
-
if (typeof candidate.error === "string") {
|
|
47
|
-
return candidate.error;
|
|
48
|
-
}
|
|
49
|
-
return fallback;
|
|
50
|
-
}
|
|
51
|
-
function parsePayload(raw) {
|
|
52
|
-
if (raw.length === 0) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
const trimmed = raw.trimStart();
|
|
56
|
-
if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
|
|
57
|
-
return {
|
|
58
|
-
message: "Unexpected response from server (received HTML). Check your network connection or try again."
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
return JSON.parse(raw);
|
|
63
|
-
} catch {
|
|
64
|
-
return { message: raw };
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
async function readPayload(response) {
|
|
68
|
-
if (typeof response.text === "function") {
|
|
69
|
-
const raw = await response.text();
|
|
70
|
-
return parsePayload(raw);
|
|
71
|
-
}
|
|
72
|
-
if (typeof response.json === "function") {
|
|
73
|
-
return response.json();
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
var VocoderAPIError = class extends Error {
|
|
78
|
-
constructor(params) {
|
|
79
|
-
super(params.message);
|
|
80
|
-
this.name = "VocoderAPIError";
|
|
81
|
-
this.status = params.status;
|
|
82
|
-
this.payload = params.payload;
|
|
83
|
-
this.limitError = params.limitError ?? null;
|
|
84
|
-
this.syncPolicyError = params.syncPolicyError ?? null;
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
var VocoderAPI = class {
|
|
88
|
-
constructor(config) {
|
|
89
|
-
this.apiUrl = config.apiUrl;
|
|
90
|
-
this.apiKey = config.apiKey;
|
|
91
|
-
}
|
|
92
|
-
async request(path, init2 = {}, errorPrefix) {
|
|
93
|
-
const response = await fetch(`${this.apiUrl}${path}`, {
|
|
94
|
-
...init2,
|
|
95
|
-
headers: {
|
|
96
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
97
|
-
...init2.headers ?? {}
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
const payload = await readPayload(response);
|
|
101
|
-
if (!response.ok) {
|
|
102
|
-
const limitError = isLimitErrorResponse(payload) ? payload : null;
|
|
103
|
-
const syncPolicyError = isSyncPolicyErrorResponse(payload) ? payload : null;
|
|
104
|
-
const baseMessage = extractErrorMessage(
|
|
105
|
-
payload,
|
|
106
|
-
`Request failed with status ${response.status}`
|
|
107
|
-
);
|
|
108
|
-
throw new VocoderAPIError({
|
|
109
|
-
message: errorPrefix ? `${errorPrefix}: ${baseMessage}` : baseMessage,
|
|
110
|
-
status: response.status,
|
|
111
|
-
payload,
|
|
112
|
-
limitError,
|
|
113
|
-
syncPolicyError
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
return payload;
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Fetch project configuration from API
|
|
120
|
-
* Project is determined from the API key
|
|
121
|
-
*/
|
|
122
|
-
async getProjectConfig() {
|
|
123
|
-
const data = await this.request("/api/cli/config", {}, "Failed to fetch project config");
|
|
124
|
-
return {
|
|
125
|
-
projectName: data.projectName,
|
|
126
|
-
organizationName: data.organizationName,
|
|
127
|
-
shortCode: data.shortCode,
|
|
128
|
-
sourceLocale: data.sourceLocale,
|
|
129
|
-
targetLocales: data.targetLocales,
|
|
130
|
-
targetBranches: data.targetBranches ?? ["main"],
|
|
131
|
-
primaryBranch: data.primaryBranch,
|
|
132
|
-
syncPolicy: {
|
|
133
|
-
blockingBranches: data.syncPolicy?.blockingBranches ?? [
|
|
134
|
-
"main",
|
|
135
|
-
"master"
|
|
136
|
-
],
|
|
137
|
-
blockingMode: data.syncPolicy?.blockingMode ?? "required",
|
|
138
|
-
nonBlockingMode: data.syncPolicy?.nonBlockingMode ?? "best-effort",
|
|
139
|
-
defaultMaxWaitMs: data.syncPolicy?.defaultMaxWaitMs ?? 6e4
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Submit strings for translation
|
|
145
|
-
* Project is determined from the API key
|
|
146
|
-
*/
|
|
147
|
-
stableTextKey(text2) {
|
|
148
|
-
let hash = 2166136261;
|
|
149
|
-
for (let i = 0; i < text2.length; i++) {
|
|
150
|
-
hash ^= text2.charCodeAt(i);
|
|
151
|
-
hash = Math.imul(hash, 16777619);
|
|
152
|
-
}
|
|
153
|
-
return `SK_TEXT_${(hash >>> 0).toString(16).toUpperCase().padStart(8, "0")}`;
|
|
154
|
-
}
|
|
155
|
-
normalizeStringEntries(entries) {
|
|
156
|
-
if (entries.length === 0) {
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
const first = entries[0];
|
|
160
|
-
if (typeof first === "string") {
|
|
161
|
-
return entries.map((text2) => ({
|
|
162
|
-
key: this.stableTextKey(text2),
|
|
163
|
-
text: text2
|
|
164
|
-
}));
|
|
165
|
-
}
|
|
166
|
-
return entries.map((entry, index) => ({
|
|
167
|
-
key: entry.key || this.stableTextKey(`${entry.text}:${index}`),
|
|
168
|
-
text: entry.text,
|
|
169
|
-
...entry.context ? { context: entry.context } : {},
|
|
170
|
-
...entry.formality ? { formality: entry.formality } : {},
|
|
171
|
-
...entry.uiRole ? { uiRole: entry.uiRole } : {},
|
|
172
|
-
...entry.featureArea ? { featureArea: entry.featureArea } : {}
|
|
173
|
-
}));
|
|
174
|
-
}
|
|
175
|
-
async submitTranslation(branch, entries, targetLocales, options, repoIdentity) {
|
|
176
|
-
const stringEntries = this.normalizeStringEntries(entries);
|
|
177
|
-
const strings = stringEntries.map((entry) => entry.text);
|
|
178
|
-
const crypto = await import("crypto");
|
|
179
|
-
const sortedStrings = [...strings].sort();
|
|
180
|
-
const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
|
|
181
|
-
return this.request(
|
|
182
|
-
"/api/cli/sync",
|
|
183
|
-
{
|
|
184
|
-
method: "POST",
|
|
185
|
-
headers: {
|
|
186
|
-
"Content-Type": "application/json"
|
|
187
|
-
},
|
|
188
|
-
body: JSON.stringify({
|
|
189
|
-
branch,
|
|
190
|
-
stringEntries,
|
|
191
|
-
targetLocales,
|
|
192
|
-
...options?.force ? {} : { stringsHash },
|
|
193
|
-
...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
|
|
194
|
-
...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
|
|
195
|
-
...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
|
|
196
|
-
...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
|
|
197
|
-
...repoIdentity?.repoAppDir !== void 0 ? { repoAppDir: repoIdentity.repoAppDir } : {},
|
|
198
|
-
...repoIdentity?.commitSha ? { commitSha: repoIdentity.commitSha } : {},
|
|
199
|
-
...options?.appIndustry ? { appIndustry: options.appIndustry } : {}
|
|
200
|
-
})
|
|
201
|
-
},
|
|
202
|
-
"Translation submission failed"
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* Check translation status
|
|
207
|
-
*/
|
|
208
|
-
async getTranslationStatus(batchId) {
|
|
209
|
-
return this.request(
|
|
210
|
-
`/api/cli/sync/status/${batchId}`,
|
|
211
|
-
{},
|
|
212
|
-
"Failed to check translation status"
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
async getTranslationSnapshot(params) {
|
|
216
|
-
const search = new URLSearchParams();
|
|
217
|
-
search.set("branch", params.branch);
|
|
218
|
-
for (const locale of params.targetLocales) {
|
|
219
|
-
search.append("targetLocale", locale);
|
|
220
|
-
}
|
|
221
|
-
return this.request(
|
|
222
|
-
`/api/cli/sync/snapshot?${search.toString()}`,
|
|
223
|
-
{},
|
|
224
|
-
"Failed to fetch translation snapshot"
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Wait for translation to complete with polling
|
|
229
|
-
*/
|
|
230
|
-
async waitForCompletion(batchId, timeout = 6e4, onProgress) {
|
|
231
|
-
const startTime = Date.now();
|
|
232
|
-
const pollInterval = 1e3;
|
|
233
|
-
while (Date.now() - startTime < timeout) {
|
|
234
|
-
const status = await this.getTranslationStatus(batchId);
|
|
235
|
-
if (onProgress) {
|
|
236
|
-
onProgress(status.progress);
|
|
237
|
-
}
|
|
238
|
-
if (status.status === "COMPLETED") {
|
|
239
|
-
if (!status.translations) {
|
|
240
|
-
throw new Error("Translation completed but no translations returned");
|
|
241
|
-
}
|
|
242
|
-
return {
|
|
243
|
-
translations: status.translations,
|
|
244
|
-
localeMetadata: status.localeMetadata
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
if (status.status === "FAILED") {
|
|
248
|
-
throw new Error(
|
|
249
|
-
`Translation failed: ${status.errorMessage || "Unknown error"}`
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
253
|
-
}
|
|
254
|
-
throw new Error(`Translation timeout after ${timeout}ms`);
|
|
255
|
-
}
|
|
256
|
-
async startInitSession(input) {
|
|
257
|
-
const response = await fetch(`${this.apiUrl}/api/cli/init/start`, {
|
|
258
|
-
method: "POST",
|
|
259
|
-
headers: {
|
|
260
|
-
"Content-Type": "application/json"
|
|
261
|
-
},
|
|
262
|
-
body: JSON.stringify(input)
|
|
263
|
-
});
|
|
264
|
-
const payload = await readPayload(response);
|
|
265
|
-
if (!response.ok) {
|
|
266
|
-
throw new VocoderAPIError({
|
|
267
|
-
message: extractErrorMessage(
|
|
268
|
-
payload,
|
|
269
|
-
`Failed to start init session (${response.status})`
|
|
270
|
-
),
|
|
271
|
-
status: response.status,
|
|
272
|
-
payload
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
return payload;
|
|
276
|
-
}
|
|
277
|
-
async getInitSessionStatus(params) {
|
|
278
|
-
const response = await fetch(
|
|
279
|
-
`${this.apiUrl}/api/cli/init/status/${params.sessionId}`,
|
|
280
|
-
{
|
|
281
|
-
headers: {
|
|
282
|
-
Authorization: `Bearer ${params.pollToken}`
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
);
|
|
286
|
-
const payload = await readPayload(response);
|
|
287
|
-
if (!response.ok) {
|
|
288
|
-
throw new VocoderAPIError({
|
|
289
|
-
message: extractErrorMessage(
|
|
290
|
-
payload,
|
|
291
|
-
`Failed to get init status (${response.status})`
|
|
292
|
-
),
|
|
293
|
-
status: response.status,
|
|
294
|
-
payload
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
return payload;
|
|
298
|
-
}
|
|
299
|
-
// ── CLI Auth endpoints (no project API key needed) ──────────────────────────
|
|
300
|
-
/**
|
|
301
|
-
* Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
|
|
302
|
-
* `sessionId` is the raw poll token — keep it secret, used for polling.
|
|
303
|
-
*/
|
|
304
|
-
async startCliAuthSession(callbackPort, repoCanonical) {
|
|
305
|
-
const response = await fetch(`${this.apiUrl}/api/cli/auth/start`, {
|
|
306
|
-
method: "POST",
|
|
307
|
-
headers: { "Content-Type": "application/json" },
|
|
308
|
-
body: JSON.stringify({
|
|
309
|
-
...callbackPort != null ? { callbackPort } : {},
|
|
310
|
-
...repoCanonical ? { repoCanonical } : {}
|
|
311
|
-
})
|
|
312
|
-
});
|
|
313
|
-
const payload = await readPayload(response);
|
|
314
|
-
if (!response.ok) {
|
|
315
|
-
throw new VocoderAPIError({
|
|
316
|
-
message: extractErrorMessage(
|
|
317
|
-
payload,
|
|
318
|
-
`Failed to start auth session (${response.status})`
|
|
319
|
-
),
|
|
320
|
-
status: response.status,
|
|
321
|
-
payload
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
return payload;
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Poll for CLI auth session completion.
|
|
328
|
-
* Returns `{ token }` on success, throws on failure/expiry.
|
|
329
|
-
* The server returns HTTP 202 while still pending.
|
|
330
|
-
*/
|
|
331
|
-
async pollCliAuthSession(pollToken) {
|
|
332
|
-
const response = await fetch(
|
|
333
|
-
`${this.apiUrl}/api/cli/auth/session?session=${encodeURIComponent(pollToken)}`
|
|
334
|
-
);
|
|
335
|
-
const payload = await readPayload(response);
|
|
336
|
-
if (response.status === 202) {
|
|
337
|
-
return { status: "pending" };
|
|
338
|
-
}
|
|
339
|
-
if (response.status === 410) {
|
|
340
|
-
return {
|
|
341
|
-
status: "failed",
|
|
342
|
-
reason: extractErrorMessage(payload, "Auth session expired or failed")
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
if (!response.ok) {
|
|
346
|
-
return {
|
|
347
|
-
status: "failed",
|
|
348
|
-
reason: extractErrorMessage(
|
|
349
|
-
payload,
|
|
350
|
-
`Auth session error (${response.status})`
|
|
351
|
-
)
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
const result = payload;
|
|
355
|
-
if (!result.token) {
|
|
356
|
-
return { status: "failed", reason: "No token in response" };
|
|
357
|
-
}
|
|
358
|
-
return {
|
|
359
|
-
status: "complete",
|
|
360
|
-
token: result.token,
|
|
361
|
-
...result.organizationId ? { organizationId: result.organizationId } : {}
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
/**
|
|
365
|
-
* Validate a CLI user token and return the authenticated user's info.
|
|
366
|
-
* Used by the CLI to verify stored credentials on startup.
|
|
367
|
-
*/
|
|
368
|
-
async getCliUserInfo(userToken) {
|
|
369
|
-
const response = await fetch(`${this.apiUrl}/api/cli/auth/me`, {
|
|
370
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
371
|
-
});
|
|
372
|
-
const payload = await readPayload(response);
|
|
373
|
-
if (!response.ok) {
|
|
374
|
-
throw new VocoderAPIError({
|
|
375
|
-
message: extractErrorMessage(
|
|
376
|
-
payload,
|
|
377
|
-
`Token validation failed (${response.status})`
|
|
378
|
-
),
|
|
379
|
-
status: response.status,
|
|
380
|
-
payload
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
return payload;
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Revoke the given CLI user token server-side.
|
|
387
|
-
*/
|
|
388
|
-
async revokeCliToken(userToken) {
|
|
389
|
-
const response = await fetch(`${this.apiUrl}/api/cli/auth/token`, {
|
|
390
|
-
method: "DELETE",
|
|
391
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
392
|
-
});
|
|
393
|
-
if (!response.ok) {
|
|
394
|
-
const payload = await readPayload(response);
|
|
395
|
-
throw new VocoderAPIError({
|
|
396
|
-
message: extractErrorMessage(
|
|
397
|
-
payload,
|
|
398
|
-
`Token revocation failed (${response.status})`
|
|
399
|
-
),
|
|
400
|
-
status: response.status,
|
|
401
|
-
payload
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
// ── Workspaces ────────────────────────────────────────────────────────────────
|
|
406
|
-
async listWorkspaces(userToken, params) {
|
|
407
|
-
const url = new URL(`${this.apiUrl}/api/cli/workspaces`);
|
|
408
|
-
if (params?.repo) url.searchParams.set("repo", params.repo);
|
|
409
|
-
const response = await fetch(url.toString(), {
|
|
410
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
411
|
-
});
|
|
412
|
-
const payload = await readPayload(response);
|
|
413
|
-
if (!response.ok) {
|
|
414
|
-
throw new VocoderAPIError({
|
|
415
|
-
message: extractErrorMessage(
|
|
416
|
-
payload,
|
|
417
|
-
`Failed to list workspaces (${response.status})`
|
|
418
|
-
),
|
|
419
|
-
status: response.status,
|
|
420
|
-
payload
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
return payload;
|
|
424
|
-
}
|
|
425
|
-
async listProjects(userToken, organizationId) {
|
|
426
|
-
const url = new URL(`${this.apiUrl}/api/cli/projects`);
|
|
427
|
-
url.searchParams.set("organizationId", organizationId);
|
|
428
|
-
const response = await fetch(url.toString(), {
|
|
429
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
430
|
-
});
|
|
431
|
-
const payload = await readPayload(response);
|
|
432
|
-
if (!response.ok) {
|
|
433
|
-
throw new VocoderAPIError({
|
|
434
|
-
message: extractErrorMessage(
|
|
435
|
-
payload,
|
|
436
|
-
`Failed to list projects (${response.status})`
|
|
437
|
-
),
|
|
438
|
-
status: response.status,
|
|
439
|
-
payload
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
const result = payload;
|
|
443
|
-
return result.projects;
|
|
444
|
-
}
|
|
445
|
-
async regenerateProjectApiKey(userToken, projectId) {
|
|
446
|
-
const response = await fetch(
|
|
447
|
-
`${this.apiUrl}/api/cli/project/regenerate-key`,
|
|
448
|
-
{
|
|
449
|
-
method: "POST",
|
|
450
|
-
headers: {
|
|
451
|
-
"Content-Type": "application/json",
|
|
452
|
-
Authorization: `Bearer ${userToken}`
|
|
453
|
-
},
|
|
454
|
-
body: JSON.stringify({ projectId })
|
|
455
|
-
}
|
|
456
|
-
);
|
|
457
|
-
const payload = await readPayload(response);
|
|
458
|
-
if (!response.ok) {
|
|
459
|
-
throw new VocoderAPIError({
|
|
460
|
-
message: extractErrorMessage(
|
|
461
|
-
payload,
|
|
462
|
-
`Failed to regenerate API key (${response.status})`
|
|
463
|
-
),
|
|
464
|
-
status: response.status,
|
|
465
|
-
payload
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
return payload;
|
|
469
|
-
}
|
|
470
|
-
// ── CLI GitHub endpoints ──────────────────────────────────────────────────────
|
|
471
|
-
async startCliGitHubInstall(userToken, params) {
|
|
472
|
-
const response = await fetch(
|
|
473
|
-
`${this.apiUrl}/api/cli/github/install/start`,
|
|
474
|
-
{
|
|
475
|
-
method: "POST",
|
|
476
|
-
headers: {
|
|
477
|
-
Authorization: `Bearer ${userToken}`,
|
|
478
|
-
"Content-Type": "application/json"
|
|
479
|
-
},
|
|
480
|
-
body: JSON.stringify(params)
|
|
481
|
-
}
|
|
482
|
-
);
|
|
483
|
-
const payload = await readPayload(response);
|
|
484
|
-
if (!response.ok) {
|
|
485
|
-
throw new VocoderAPIError({
|
|
486
|
-
message: extractErrorMessage(
|
|
487
|
-
payload,
|
|
488
|
-
`Failed to start GitHub install (${response.status})`
|
|
489
|
-
),
|
|
490
|
-
status: response.status,
|
|
491
|
-
payload
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
return payload;
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Start the "link existing installation" discovery flow.
|
|
498
|
-
* Unlike startCliGitHubOAuth, this requires no bearer token — the Vocoder
|
|
499
|
-
* account is created from the OAuth code in the callback.
|
|
500
|
-
*/
|
|
501
|
-
async startCliGitHubLinkSession(sessionId, callbackPort) {
|
|
502
|
-
const response = await fetch(
|
|
503
|
-
`${this.apiUrl}/api/cli/github/oauth/link-start`,
|
|
504
|
-
{
|
|
505
|
-
method: "POST",
|
|
506
|
-
headers: { "Content-Type": "application/json" },
|
|
507
|
-
body: JSON.stringify({
|
|
508
|
-
sessionId,
|
|
509
|
-
...callbackPort != null ? { callbackPort } : {}
|
|
510
|
-
})
|
|
511
|
-
}
|
|
512
|
-
);
|
|
513
|
-
const payload = await readPayload(response);
|
|
514
|
-
if (!response.ok) {
|
|
515
|
-
throw new VocoderAPIError({
|
|
516
|
-
message: extractErrorMessage(
|
|
517
|
-
payload,
|
|
518
|
-
`Failed to start GitHub link session (${response.status})`
|
|
519
|
-
),
|
|
520
|
-
status: response.status,
|
|
521
|
-
payload
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
return payload;
|
|
525
|
-
}
|
|
526
|
-
async startCliGitHubOAuth(userToken, params) {
|
|
527
|
-
const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/start`, {
|
|
528
|
-
method: "POST",
|
|
529
|
-
headers: {
|
|
530
|
-
Authorization: `Bearer ${userToken}`,
|
|
531
|
-
"Content-Type": "application/json"
|
|
532
|
-
},
|
|
533
|
-
body: JSON.stringify(params)
|
|
534
|
-
});
|
|
535
|
-
const payload = await readPayload(response);
|
|
536
|
-
if (!response.ok) {
|
|
537
|
-
throw new VocoderAPIError({
|
|
538
|
-
message: extractErrorMessage(
|
|
539
|
-
payload,
|
|
540
|
-
`Failed to start GitHub OAuth (${response.status})`
|
|
541
|
-
),
|
|
542
|
-
status: response.status,
|
|
543
|
-
payload
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
return payload;
|
|
547
|
-
}
|
|
548
|
-
async getCliGitHubDiscovery(userToken) {
|
|
549
|
-
const response = await fetch(`${this.apiUrl}/api/cli/github/discovery`, {
|
|
550
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
551
|
-
});
|
|
552
|
-
const payload = await readPayload(response);
|
|
553
|
-
if (!response.ok) {
|
|
554
|
-
throw new VocoderAPIError({
|
|
555
|
-
message: extractErrorMessage(
|
|
556
|
-
payload,
|
|
557
|
-
`Failed to fetch GitHub discovery (${response.status})`
|
|
558
|
-
),
|
|
559
|
-
status: response.status,
|
|
560
|
-
payload
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
return payload;
|
|
564
|
-
}
|
|
565
|
-
async claimCliGitHubInstallation(userToken, params) {
|
|
566
|
-
const response = await fetch(`${this.apiUrl}/api/cli/github/claim`, {
|
|
567
|
-
method: "POST",
|
|
568
|
-
headers: {
|
|
569
|
-
Authorization: `Bearer ${userToken}`,
|
|
570
|
-
"Content-Type": "application/json"
|
|
571
|
-
},
|
|
572
|
-
body: JSON.stringify(params)
|
|
573
|
-
});
|
|
574
|
-
const payload = await readPayload(response);
|
|
575
|
-
if (!response.ok) {
|
|
576
|
-
throw new VocoderAPIError({
|
|
577
|
-
message: extractErrorMessage(
|
|
578
|
-
payload,
|
|
579
|
-
`Failed to claim GitHub installation (${response.status})`
|
|
580
|
-
),
|
|
581
|
-
status: response.status,
|
|
582
|
-
payload
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
return payload;
|
|
586
|
-
}
|
|
587
|
-
// ── Locales ───────────────────────────────────────────────────────────────────
|
|
588
|
-
async listLocales(userToken) {
|
|
589
|
-
const response = await fetch(`${this.apiUrl}/api/cli/locales`, {
|
|
590
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
591
|
-
});
|
|
592
|
-
const payload = await readPayload(response);
|
|
593
|
-
if (!response.ok) {
|
|
594
|
-
throw new VocoderAPIError({
|
|
595
|
-
message: extractErrorMessage(
|
|
596
|
-
payload,
|
|
597
|
-
`Failed to list locales (${response.status})`
|
|
598
|
-
),
|
|
599
|
-
status: response.status,
|
|
600
|
-
payload
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
const result = payload;
|
|
604
|
-
return result;
|
|
605
|
-
}
|
|
606
|
-
async listCompatibleLocales(userToken, sourceLocale) {
|
|
607
|
-
const url = `${this.apiUrl}/api/cli/locales/compatible?source=${encodeURIComponent(sourceLocale)}`;
|
|
608
|
-
const response = await fetch(url, {
|
|
609
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
610
|
-
});
|
|
611
|
-
const payload = await readPayload(response);
|
|
612
|
-
if (!response.ok) {
|
|
613
|
-
throw new VocoderAPIError({
|
|
614
|
-
message: extractErrorMessage(
|
|
615
|
-
payload,
|
|
616
|
-
`Failed to list compatible locales (${response.status})`
|
|
617
|
-
),
|
|
618
|
-
status: response.status,
|
|
619
|
-
payload
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
const result = payload;
|
|
623
|
-
return result.locales;
|
|
624
|
-
}
|
|
625
|
-
// ── Project creation ──────────────────────────────────────────────────────────
|
|
626
|
-
async createProject(userToken, params) {
|
|
627
|
-
const response = await fetch(`${this.apiUrl}/api/cli/projects`, {
|
|
628
|
-
method: "POST",
|
|
629
|
-
headers: {
|
|
630
|
-
"Content-Type": "application/json",
|
|
631
|
-
Authorization: `Bearer ${userToken}`
|
|
632
|
-
},
|
|
633
|
-
body: JSON.stringify(params)
|
|
634
|
-
});
|
|
635
|
-
const payload = await readPayload(response);
|
|
636
|
-
if (!response.ok) {
|
|
637
|
-
throw new VocoderAPIError({
|
|
638
|
-
message: extractErrorMessage(
|
|
639
|
-
payload,
|
|
640
|
-
`Failed to create project (${response.status})`
|
|
641
|
-
),
|
|
642
|
-
status: response.status,
|
|
643
|
-
payload
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
return payload;
|
|
647
|
-
}
|
|
648
|
-
// ── Project lookup ────────────────────────────────────────────────────────────
|
|
649
|
-
/**
|
|
650
|
-
* Look up all project apps for a given repo. Returns info about exact matches,
|
|
651
|
-
* existing apps in other scopes, and whether a whole-repo app exists.
|
|
652
|
-
* No auth required.
|
|
653
|
-
*/
|
|
654
|
-
async lookupProjectByRepo(params) {
|
|
655
|
-
try {
|
|
656
|
-
const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
|
|
657
|
-
method: "POST",
|
|
658
|
-
headers: { "Content-Type": "application/json" },
|
|
659
|
-
body: JSON.stringify({
|
|
660
|
-
repo: params.repoCanonical,
|
|
661
|
-
appDir: params.appDir
|
|
662
|
-
})
|
|
663
|
-
});
|
|
664
|
-
if (!response.ok) {
|
|
665
|
-
return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
|
|
666
|
-
}
|
|
667
|
-
const data = await response.json();
|
|
668
|
-
return {
|
|
669
|
-
exactMatch: data.exactMatch ?? null,
|
|
670
|
-
existingApps: data.existingApps ?? [],
|
|
671
|
-
hasWholeRepoApp: data.hasWholeRepoApp ?? false
|
|
672
|
-
};
|
|
673
|
-
} catch {
|
|
674
|
-
return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Add a new ProjectApp to an existing project (monorepo: new app directory).
|
|
679
|
-
* Does not check plan limits — no new project is created.
|
|
680
|
-
*/
|
|
681
|
-
async createProjectApp(userToken, params) {
|
|
682
|
-
const response = await fetch(`${this.apiUrl}/api/cli/project/apps`, {
|
|
683
|
-
method: "POST",
|
|
684
|
-
headers: {
|
|
685
|
-
"Content-Type": "application/json",
|
|
686
|
-
Authorization: `Bearer ${userToken}`
|
|
687
|
-
},
|
|
688
|
-
body: JSON.stringify(params)
|
|
689
|
-
});
|
|
690
|
-
const payload = await readPayload(response);
|
|
691
|
-
if (!response.ok) {
|
|
692
|
-
throw new VocoderAPIError({
|
|
693
|
-
message: extractErrorMessage(
|
|
694
|
-
payload,
|
|
695
|
-
`Failed to create project app (${response.status})`
|
|
696
|
-
),
|
|
697
|
-
status: response.status,
|
|
698
|
-
payload
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
return payload;
|
|
702
|
-
}
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
// src/utils/auth-store.ts
|
|
706
|
-
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
707
|
-
import { homedir } from "os";
|
|
708
|
-
import { dirname, join } from "path";
|
|
709
|
-
function getAuthFilePath() {
|
|
710
|
-
return join(homedir(), ".config", "vocoder", "auth.json");
|
|
711
|
-
}
|
|
712
|
-
function readAuthData() {
|
|
713
|
-
const filePath = getAuthFilePath();
|
|
714
|
-
try {
|
|
715
|
-
const raw = readFileSync(filePath, "utf8");
|
|
716
|
-
const parsed = JSON.parse(raw);
|
|
717
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
718
|
-
const data = parsed;
|
|
719
|
-
if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
|
|
720
|
-
return null;
|
|
721
|
-
}
|
|
722
|
-
return {
|
|
723
|
-
token: data.token,
|
|
724
|
-
apiUrl: data.apiUrl,
|
|
725
|
-
userId: data.userId,
|
|
726
|
-
email: data.email,
|
|
727
|
-
name: typeof data.name === "string" ? data.name : null,
|
|
728
|
-
createdAt: data.createdAt
|
|
729
|
-
};
|
|
730
|
-
} catch {
|
|
731
|
-
return null;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
function writeAuthData(data) {
|
|
735
|
-
const filePath = getAuthFilePath();
|
|
736
|
-
const dir = dirname(filePath);
|
|
737
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
738
|
-
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
|
|
739
|
-
}
|
|
740
|
-
function clearAuthData() {
|
|
741
|
-
const filePath = getAuthFilePath();
|
|
742
|
-
try {
|
|
743
|
-
unlinkSync(filePath);
|
|
744
|
-
} catch {
|
|
745
|
-
}
|
|
746
|
-
}
|
|
23
|
+
import { execSync as execSync3, spawn as spawn2 } from "child_process";
|
|
24
|
+
import { existsSync as existsSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
747
25
|
|
|
748
26
|
// src/utils/write-config.ts
|
|
749
|
-
import { existsSync, writeFileSync
|
|
750
|
-
import { join
|
|
27
|
+
import { existsSync, writeFileSync } from "fs";
|
|
28
|
+
import { join } from "path";
|
|
751
29
|
function findExistingConfig(cwd = process.cwd()) {
|
|
752
30
|
for (const name of [
|
|
753
31
|
"vocoder.config.ts",
|
|
754
32
|
"vocoder.config.js",
|
|
755
33
|
"vocoder.config.json"
|
|
756
34
|
]) {
|
|
757
|
-
const candidate =
|
|
35
|
+
const candidate = join(cwd, name);
|
|
758
36
|
if (existsSync(candidate)) return candidate;
|
|
759
37
|
}
|
|
760
38
|
return null;
|
|
@@ -767,7 +45,7 @@ function writeVocoderConfig(options) {
|
|
|
767
45
|
} = options;
|
|
768
46
|
if (findExistingConfig(cwd)) return null;
|
|
769
47
|
const ext = useTypeScript ? "ts" : "js";
|
|
770
|
-
const configPath =
|
|
48
|
+
const configPath = join(cwd, `vocoder.config.${ext}`);
|
|
771
49
|
const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
|
|
772
50
|
const content = `import { defineConfig } from '@vocoder/config'
|
|
773
51
|
|
|
@@ -785,121 +63,13 @@ export default defineConfig({
|
|
|
785
63
|
})
|
|
786
64
|
`;
|
|
787
65
|
try {
|
|
788
|
-
|
|
66
|
+
writeFileSync(configPath, content, "utf-8");
|
|
789
67
|
return `vocoder.config.${ext}`;
|
|
790
68
|
} catch {
|
|
791
69
|
return null;
|
|
792
70
|
}
|
|
793
71
|
}
|
|
794
72
|
|
|
795
|
-
// src/utils/git-identity.ts
|
|
796
|
-
import { execSync } from "child_process";
|
|
797
|
-
import { relative, resolve } from "path";
|
|
798
|
-
var SHA_REGEX = /^[0-9a-f]{40}$/i;
|
|
799
|
-
function detectCommitSha() {
|
|
800
|
-
if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
|
|
801
|
-
return process.env.VOCODER_COMMIT_SHA;
|
|
802
|
-
}
|
|
803
|
-
const knownSha = process.env.GITHUB_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.CI_COMMIT_SHA || process.env.BITBUCKET_COMMIT || process.env.CIRCLE_SHA1 || process.env.RENDER_GIT_COMMIT;
|
|
804
|
-
if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
|
|
805
|
-
return safeExec("git rev-parse HEAD");
|
|
806
|
-
}
|
|
807
|
-
function safeExec(command) {
|
|
808
|
-
try {
|
|
809
|
-
const output = execSync(command, {
|
|
810
|
-
encoding: "utf-8",
|
|
811
|
-
stdio: ["pipe", "pipe", "ignore"]
|
|
812
|
-
}).trim();
|
|
813
|
-
return output.length > 0 ? output : null;
|
|
814
|
-
} catch {
|
|
815
|
-
return null;
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
function normalizePath(pathname) {
|
|
819
|
-
const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
|
|
820
|
-
if (!cleaned || !cleaned.includes("/")) {
|
|
821
|
-
return null;
|
|
822
|
-
}
|
|
823
|
-
return cleaned;
|
|
824
|
-
}
|
|
825
|
-
function parseRemoteUrl(remoteUrl) {
|
|
826
|
-
const trimmed = remoteUrl.trim();
|
|
827
|
-
if (!trimmed) {
|
|
828
|
-
return null;
|
|
829
|
-
}
|
|
830
|
-
if (!trimmed.includes("://")) {
|
|
831
|
-
const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
|
|
832
|
-
if (scpMatch) {
|
|
833
|
-
const host = (scpMatch[1] || "").toLowerCase();
|
|
834
|
-
const ownerRepoPath = normalizePath(scpMatch[2] || "");
|
|
835
|
-
if (!host || !ownerRepoPath) {
|
|
836
|
-
return null;
|
|
837
|
-
}
|
|
838
|
-
return { host, ownerRepoPath };
|
|
839
|
-
}
|
|
840
|
-
return null;
|
|
841
|
-
}
|
|
842
|
-
try {
|
|
843
|
-
const parsed = new URL(trimmed);
|
|
844
|
-
const host = parsed.hostname.toLowerCase();
|
|
845
|
-
const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
|
|
846
|
-
if (!host || !ownerRepoPath) {
|
|
847
|
-
return null;
|
|
848
|
-
}
|
|
849
|
-
return { host, ownerRepoPath };
|
|
850
|
-
} catch {
|
|
851
|
-
return null;
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
function toCanonical(host, ownerRepoPath) {
|
|
855
|
-
if (host.includes("github.com")) {
|
|
856
|
-
return `github:${ownerRepoPath.toLowerCase()}`;
|
|
857
|
-
}
|
|
858
|
-
if (host.includes("gitlab.com")) {
|
|
859
|
-
return `gitlab:${ownerRepoPath.toLowerCase()}`;
|
|
860
|
-
}
|
|
861
|
-
if (host.includes("bitbucket.org")) {
|
|
862
|
-
return `bitbucket:${ownerRepoPath.toLowerCase()}`;
|
|
863
|
-
}
|
|
864
|
-
return `git:${host}/${ownerRepoPath.toLowerCase()}`;
|
|
865
|
-
}
|
|
866
|
-
function resolveGitRepositoryIdentity() {
|
|
867
|
-
const remoteUrl = safeExec("git config --get remote.origin.url");
|
|
868
|
-
if (!remoteUrl) {
|
|
869
|
-
return null;
|
|
870
|
-
}
|
|
871
|
-
const parsed = parseRemoteUrl(remoteUrl);
|
|
872
|
-
if (!parsed) {
|
|
873
|
-
return null;
|
|
874
|
-
}
|
|
875
|
-
const repositoryRoot = safeExec("git rev-parse --show-toplevel");
|
|
876
|
-
const currentDirectory = process.cwd();
|
|
877
|
-
let repoAppDir = "";
|
|
878
|
-
if (repositoryRoot) {
|
|
879
|
-
const relativePath = relative(
|
|
880
|
-
resolve(repositoryRoot),
|
|
881
|
-
resolve(currentDirectory)
|
|
882
|
-
).replace(/\\/g, "/").trim();
|
|
883
|
-
if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
|
|
884
|
-
repoAppDir = relativePath;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return {
|
|
888
|
-
repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
|
|
889
|
-
repoAppDir
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
function resolveGitContext() {
|
|
893
|
-
const warnings = [];
|
|
894
|
-
const identity = resolveGitRepositoryIdentity();
|
|
895
|
-
if (!identity) {
|
|
896
|
-
warnings.push(
|
|
897
|
-
"Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
|
|
898
|
-
);
|
|
899
|
-
}
|
|
900
|
-
return { identity, warnings };
|
|
901
|
-
}
|
|
902
|
-
|
|
903
73
|
// src/utils/github-connect.ts
|
|
904
74
|
import { spawn } from "child_process";
|
|
905
75
|
import * as p from "@clack/prompts";
|
|
@@ -1185,7 +355,7 @@ import * as p3 from "@clack/prompts";
|
|
|
1185
355
|
import chalk4 from "chalk";
|
|
1186
356
|
|
|
1187
357
|
// src/utils/branch-select.ts
|
|
1188
|
-
import { execSync
|
|
358
|
+
import { execSync } from "child_process";
|
|
1189
359
|
import { isCancel as isCancel2, Prompt } from "@clack/core";
|
|
1190
360
|
import chalk2 from "chalk";
|
|
1191
361
|
var S_BAR = "\u2502";
|
|
@@ -1216,14 +386,14 @@ function symbol(state) {
|
|
|
1216
386
|
function detectGitBranches(cwd) {
|
|
1217
387
|
const workDir = cwd ?? process.cwd();
|
|
1218
388
|
try {
|
|
1219
|
-
const localOut =
|
|
389
|
+
const localOut = execSync("git branch", {
|
|
1220
390
|
cwd: workDir,
|
|
1221
391
|
stdio: "pipe"
|
|
1222
392
|
}).toString();
|
|
1223
393
|
const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
|
|
1224
394
|
let remoteBranches = [];
|
|
1225
395
|
try {
|
|
1226
|
-
const remoteOut =
|
|
396
|
+
const remoteOut = execSync("git branch -r", {
|
|
1227
397
|
cwd: workDir,
|
|
1228
398
|
stdio: "pipe"
|
|
1229
399
|
}).toString();
|
|
@@ -1233,7 +403,7 @@ function detectGitBranches(cwd) {
|
|
|
1233
403
|
const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
|
|
1234
404
|
let defaultBranch = "main";
|
|
1235
405
|
try {
|
|
1236
|
-
const ref =
|
|
406
|
+
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1237
407
|
cwd: workDir,
|
|
1238
408
|
stdio: "pipe"
|
|
1239
409
|
}).toString().trim();
|
|
@@ -1870,53 +1040,166 @@ async function runProjectAppCreate(params) {
|
|
|
1870
1040
|
"No target languages selected \u2014 you can add them later from the dashboard."
|
|
1871
1041
|
);
|
|
1872
1042
|
}
|
|
1873
|
-
const detectedApp = detectGitBranches();
|
|
1874
|
-
let appPushBranches = [];
|
|
1875
|
-
{
|
|
1876
|
-
let initial = [detectedApp.defaultBranch];
|
|
1877
|
-
while (appPushBranches.length === 0) {
|
|
1878
|
-
const result = await filterableBranchSelect({
|
|
1879
|
-
message: "Which branches should trigger translations?",
|
|
1880
|
-
branches: detectedApp.branches,
|
|
1881
|
-
defaultBranch: detectedApp.defaultBranch,
|
|
1882
|
-
initialValues: initial
|
|
1883
|
-
});
|
|
1884
|
-
if (result === null) return null;
|
|
1885
|
-
if (result.length === 0) {
|
|
1886
|
-
p3.log.warn("At least one branch is required.");
|
|
1887
|
-
initial = [detectedApp.defaultBranch];
|
|
1888
|
-
} else {
|
|
1889
|
-
appPushBranches = result;
|
|
1890
|
-
}
|
|
1043
|
+
const detectedApp = detectGitBranches();
|
|
1044
|
+
let appPushBranches = [];
|
|
1045
|
+
{
|
|
1046
|
+
let initial = [detectedApp.defaultBranch];
|
|
1047
|
+
while (appPushBranches.length === 0) {
|
|
1048
|
+
const result = await filterableBranchSelect({
|
|
1049
|
+
message: "Which branches should trigger translations?",
|
|
1050
|
+
branches: detectedApp.branches,
|
|
1051
|
+
defaultBranch: detectedApp.defaultBranch,
|
|
1052
|
+
initialValues: initial
|
|
1053
|
+
});
|
|
1054
|
+
if (result === null) return null;
|
|
1055
|
+
if (result.length === 0) {
|
|
1056
|
+
p3.log.warn("At least one branch is required.");
|
|
1057
|
+
initial = [detectedApp.defaultBranch];
|
|
1058
|
+
} else {
|
|
1059
|
+
appPushBranches = result;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const targetBranches = appPushBranches;
|
|
1064
|
+
try {
|
|
1065
|
+
const result = await api.createProjectApp(userToken, {
|
|
1066
|
+
projectId,
|
|
1067
|
+
appDir,
|
|
1068
|
+
sourceLocale,
|
|
1069
|
+
targetLocales,
|
|
1070
|
+
targetBranches,
|
|
1071
|
+
repoCanonical: repoCanonical ?? ""
|
|
1072
|
+
});
|
|
1073
|
+
p3.log.success(
|
|
1074
|
+
`App ${chalk4.bold(appDir)} added to ${chalk4.bold(projectName)}!`
|
|
1075
|
+
);
|
|
1076
|
+
return {
|
|
1077
|
+
projectId: result.projectId,
|
|
1078
|
+
projectName: result.projectName,
|
|
1079
|
+
apiKey: result.apiKey,
|
|
1080
|
+
appDir: result.appDir,
|
|
1081
|
+
sourceLocale,
|
|
1082
|
+
targetLocales,
|
|
1083
|
+
targetBranches
|
|
1084
|
+
};
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1087
|
+
p3.log.error(`Failed to add app: ${message}`);
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// src/commands/init.ts
|
|
1093
|
+
import chalk6 from "chalk";
|
|
1094
|
+
import { join as join2 } from "path";
|
|
1095
|
+
import { config as loadEnv } from "dotenv";
|
|
1096
|
+
|
|
1097
|
+
// src/utils/git-identity.ts
|
|
1098
|
+
import { execSync as execSync2 } from "child_process";
|
|
1099
|
+
import { relative, resolve } from "path";
|
|
1100
|
+
var SHA_REGEX = /^[0-9a-f]{40}$/i;
|
|
1101
|
+
function detectCommitSha() {
|
|
1102
|
+
if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
|
|
1103
|
+
return process.env.VOCODER_COMMIT_SHA;
|
|
1104
|
+
}
|
|
1105
|
+
const knownSha = process.env.GITHUB_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.CI_COMMIT_SHA || process.env.BITBUCKET_COMMIT || process.env.CIRCLE_SHA1 || process.env.RENDER_GIT_COMMIT;
|
|
1106
|
+
if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
|
|
1107
|
+
return safeExec("git rev-parse HEAD");
|
|
1108
|
+
}
|
|
1109
|
+
function safeExec(command) {
|
|
1110
|
+
try {
|
|
1111
|
+
const output = execSync2(command, {
|
|
1112
|
+
encoding: "utf-8",
|
|
1113
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
1114
|
+
}).trim();
|
|
1115
|
+
return output.length > 0 ? output : null;
|
|
1116
|
+
} catch {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function normalizePath(pathname) {
|
|
1121
|
+
const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
|
|
1122
|
+
if (!cleaned || !cleaned.includes("/")) {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
return cleaned;
|
|
1126
|
+
}
|
|
1127
|
+
function parseRemoteUrl(remoteUrl) {
|
|
1128
|
+
const trimmed = remoteUrl.trim();
|
|
1129
|
+
if (!trimmed) {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
if (!trimmed.includes("://")) {
|
|
1133
|
+
const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
|
|
1134
|
+
if (scpMatch) {
|
|
1135
|
+
const host = (scpMatch[1] || "").toLowerCase();
|
|
1136
|
+
const ownerRepoPath = normalizePath(scpMatch[2] || "");
|
|
1137
|
+
if (!host || !ownerRepoPath) {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
return { host, ownerRepoPath };
|
|
1141
|
+
}
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const parsed = new URL(trimmed);
|
|
1146
|
+
const host = parsed.hostname.toLowerCase();
|
|
1147
|
+
const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
|
|
1148
|
+
if (!host || !ownerRepoPath) {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
return { host, ownerRepoPath };
|
|
1152
|
+
} catch {
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
function toCanonical(host, ownerRepoPath) {
|
|
1157
|
+
if (host.includes("github.com")) {
|
|
1158
|
+
return `github:${ownerRepoPath.toLowerCase()}`;
|
|
1159
|
+
}
|
|
1160
|
+
if (host.includes("gitlab.com")) {
|
|
1161
|
+
return `gitlab:${ownerRepoPath.toLowerCase()}`;
|
|
1162
|
+
}
|
|
1163
|
+
if (host.includes("bitbucket.org")) {
|
|
1164
|
+
return `bitbucket:${ownerRepoPath.toLowerCase()}`;
|
|
1165
|
+
}
|
|
1166
|
+
return `git:${host}/${ownerRepoPath.toLowerCase()}`;
|
|
1167
|
+
}
|
|
1168
|
+
function resolveGitRepositoryIdentity() {
|
|
1169
|
+
const remoteUrl = safeExec("git config --get remote.origin.url");
|
|
1170
|
+
if (!remoteUrl) {
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
1174
|
+
if (!parsed) {
|
|
1175
|
+
return null;
|
|
1176
|
+
}
|
|
1177
|
+
const repositoryRoot = safeExec("git rev-parse --show-toplevel");
|
|
1178
|
+
const currentDirectory = process.cwd();
|
|
1179
|
+
let repoAppDir = "";
|
|
1180
|
+
if (repositoryRoot) {
|
|
1181
|
+
const relativePath = relative(
|
|
1182
|
+
resolve(repositoryRoot),
|
|
1183
|
+
resolve(currentDirectory)
|
|
1184
|
+
).replace(/\\/g, "/").trim();
|
|
1185
|
+
if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
|
|
1186
|
+
repoAppDir = relativePath;
|
|
1891
1187
|
}
|
|
1892
1188
|
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
`App ${chalk4.bold(appDir)} added to ${chalk4.bold(projectName)}!`
|
|
1189
|
+
return {
|
|
1190
|
+
repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
|
|
1191
|
+
repoAppDir
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
function resolveGitContext() {
|
|
1195
|
+
const warnings = [];
|
|
1196
|
+
const identity = resolveGitRepositoryIdentity();
|
|
1197
|
+
if (!identity) {
|
|
1198
|
+
warnings.push(
|
|
1199
|
+
"Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
|
|
1905
1200
|
);
|
|
1906
|
-
return {
|
|
1907
|
-
projectId: result.projectId,
|
|
1908
|
-
projectName: result.projectName,
|
|
1909
|
-
apiKey: result.apiKey,
|
|
1910
|
-
appDir: result.appDir,
|
|
1911
|
-
sourceLocale,
|
|
1912
|
-
targetLocales,
|
|
1913
|
-
targetBranches
|
|
1914
|
-
};
|
|
1915
|
-
} catch (error) {
|
|
1916
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1917
|
-
p3.log.error(`Failed to add app: ${message}`);
|
|
1918
|
-
return null;
|
|
1919
1201
|
}
|
|
1202
|
+
return { identity, warnings };
|
|
1920
1203
|
}
|
|
1921
1204
|
|
|
1922
1205
|
// src/utils/workspace.ts
|
|
@@ -2112,10 +1395,10 @@ function runScaffold(params) {
|
|
|
2112
1395
|
p5.log.message(chalk6.gray(" Docs: https://vocoder.app/docs/getting-started"));
|
|
2113
1396
|
}
|
|
2114
1397
|
function writeApiKeyToEnv(apiKey) {
|
|
2115
|
-
const envPath =
|
|
1398
|
+
const envPath = join2(process.cwd(), ".env");
|
|
2116
1399
|
if (!existsSync2(envPath)) return false;
|
|
2117
1400
|
try {
|
|
2118
|
-
const content =
|
|
1401
|
+
const content = readFileSync(envPath, "utf-8");
|
|
2119
1402
|
const keyLine = `VOCODER_API_KEY=${apiKey}`;
|
|
2120
1403
|
let updated;
|
|
2121
1404
|
if (/^VOCODER_API_KEY=/m.test(content)) {
|
|
@@ -2125,7 +1408,7 @@ function writeApiKeyToEnv(apiKey) {
|
|
|
2125
1408
|
updated = `${content}${sep}${keyLine}
|
|
2126
1409
|
`;
|
|
2127
1410
|
}
|
|
2128
|
-
|
|
1411
|
+
writeFileSync2(envPath, updated);
|
|
2129
1412
|
return true;
|
|
2130
1413
|
} catch {
|
|
2131
1414
|
return false;
|
|
@@ -2165,17 +1448,6 @@ function printCodeBlock(code) {
|
|
|
2165
1448
|
`
|
|
2166
1449
|
);
|
|
2167
1450
|
}
|
|
2168
|
-
async function verifyStoredToken(api, token) {
|
|
2169
|
-
try {
|
|
2170
|
-
return await api.getCliUserInfo(token);
|
|
2171
|
-
} catch (err) {
|
|
2172
|
-
clearAuthData();
|
|
2173
|
-
if (err instanceof VocoderAPIError && err.status === 404) {
|
|
2174
|
-
return { userGone: true };
|
|
2175
|
-
}
|
|
2176
|
-
return null;
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
1451
|
async function runAuthFlow(api, options, reauth = false, repoCanonical) {
|
|
2180
1452
|
let server = null;
|
|
2181
1453
|
if (!options.ci) {
|
|
@@ -2356,17 +1628,17 @@ async function init(options = {}) {
|
|
|
2356
1628
|
true
|
|
2357
1629
|
);
|
|
2358
1630
|
if (!authResult) return 1;
|
|
2359
|
-
const
|
|
2360
|
-
|
|
1631
|
+
const spinner7 = p5.spinner();
|
|
1632
|
+
spinner7.start("Generating new API key...");
|
|
2361
1633
|
try {
|
|
2362
1634
|
const { apiKey } = await anonApi2.regenerateProjectApiKey(
|
|
2363
1635
|
authResult.token,
|
|
2364
1636
|
exactMatch.projectId
|
|
2365
1637
|
);
|
|
2366
|
-
|
|
1638
|
+
spinner7.stop("New API key generated");
|
|
2367
1639
|
printApiKey(apiKey);
|
|
2368
1640
|
} catch (err) {
|
|
2369
|
-
|
|
1641
|
+
spinner7.stop("Failed to generate key");
|
|
2370
1642
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2371
1643
|
p5.log.error(`Could not generate API key: ${msg}`);
|
|
2372
1644
|
p5.log.info("Try again or generate one from the dashboard.");
|
|
@@ -2401,47 +1673,23 @@ async function init(options = {}) {
|
|
|
2401
1673
|
let userEmail;
|
|
2402
1674
|
let userName;
|
|
2403
1675
|
let authOrganizationId;
|
|
2404
|
-
const
|
|
2405
|
-
if (
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
userEmail = verified.email;
|
|
2411
|
-
userName = verified.name;
|
|
2412
|
-
} else {
|
|
2413
|
-
const isFirstTime = verified !== null && "userGone" in verified;
|
|
2414
|
-
if (isFirstTime) {
|
|
2415
|
-
p5.log.warn("Account not found \u2014 starting fresh setup");
|
|
2416
|
-
} else {
|
|
2417
|
-
p5.log.warn("Stored credentials expired \u2014 signing in again");
|
|
2418
|
-
}
|
|
2419
|
-
const authResult = await runAuthFlow(
|
|
2420
|
-
api,
|
|
2421
|
-
options,
|
|
2422
|
-
/* reauth */
|
|
2423
|
-
!isFirstTime,
|
|
2424
|
-
identity?.repoCanonical
|
|
2425
|
-
);
|
|
2426
|
-
if (!authResult) return 1;
|
|
2427
|
-
userToken = authResult.token;
|
|
2428
|
-
userEmail = authResult.email;
|
|
2429
|
-
userName = authResult.name;
|
|
2430
|
-
authOrganizationId = authResult.organizationId;
|
|
2431
|
-
writeAuthData({
|
|
2432
|
-
token: userToken,
|
|
2433
|
-
apiUrl,
|
|
2434
|
-
userId: authResult.userId,
|
|
2435
|
-
email: userEmail,
|
|
2436
|
-
name: userName,
|
|
2437
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2438
|
-
});
|
|
2439
|
-
}
|
|
1676
|
+
const storedAuth = await verifyStoredAuth(api);
|
|
1677
|
+
if (storedAuth.status === "valid") {
|
|
1678
|
+
p5.log.success(`Authenticated as ${chalk6.bold(storedAuth.email)}`);
|
|
1679
|
+
userToken = storedAuth.token;
|
|
1680
|
+
userEmail = storedAuth.email;
|
|
1681
|
+
userName = storedAuth.name;
|
|
2440
1682
|
} else {
|
|
1683
|
+
const reauth = storedAuth.status === "expired";
|
|
1684
|
+
if (reauth) {
|
|
1685
|
+
p5.log.warn("Stored credentials expired \u2014 signing in again");
|
|
1686
|
+
} else if (storedAuth.status === "gone") {
|
|
1687
|
+
p5.log.warn("Account not found \u2014 starting fresh setup");
|
|
1688
|
+
}
|
|
2441
1689
|
const authResult = await runAuthFlow(
|
|
2442
1690
|
api,
|
|
2443
1691
|
options,
|
|
2444
|
-
|
|
1692
|
+
reauth,
|
|
2445
1693
|
identity?.repoCanonical
|
|
2446
1694
|
);
|
|
2447
1695
|
if (!authResult) return 1;
|
|
@@ -2451,7 +1699,6 @@ async function init(options = {}) {
|
|
|
2451
1699
|
authOrganizationId = authResult.organizationId;
|
|
2452
1700
|
writeAuthData({
|
|
2453
1701
|
token: userToken,
|
|
2454
|
-
apiUrl,
|
|
2455
1702
|
userId: authResult.userId,
|
|
2456
1703
|
email: userEmail,
|
|
2457
1704
|
name: userName,
|
|
@@ -2862,30 +2109,16 @@ Translations won't run automatically until you grant access.
|
|
|
2862
2109
|
}
|
|
2863
2110
|
}
|
|
2864
2111
|
|
|
2865
|
-
// src/commands/
|
|
2866
|
-
import * as
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
if (!stored) {
|
|
2870
|
-
p6.log.info("Not currently authenticated.");
|
|
2871
|
-
return 0;
|
|
2872
|
-
}
|
|
2873
|
-
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
2874
|
-
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
2875
|
-
try {
|
|
2876
|
-
await api.revokeCliToken(stored.token);
|
|
2877
|
-
} catch {
|
|
2878
|
-
}
|
|
2879
|
-
clearAuthData();
|
|
2880
|
-
p6.log.success(`Logged out (was ${stored.email})`);
|
|
2881
|
-
return 0;
|
|
2882
|
-
}
|
|
2112
|
+
// src/commands/locales.ts
|
|
2113
|
+
import * as p8 from "@clack/prompts";
|
|
2114
|
+
import chalk9 from "chalk";
|
|
2115
|
+
import { config as loadEnv3 } from "dotenv";
|
|
2883
2116
|
|
|
2884
2117
|
// src/commands/sync.ts
|
|
2885
2118
|
import { createHash, randomUUID } from "crypto";
|
|
2886
|
-
import { existsSync as existsSync3, mkdirSync
|
|
2887
|
-
import { join as
|
|
2888
|
-
import * as
|
|
2119
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
2120
|
+
import { join as join3 } from "path";
|
|
2121
|
+
import * as p7 from "@clack/prompts";
|
|
2889
2122
|
import chalk8 from "chalk";
|
|
2890
2123
|
|
|
2891
2124
|
// src/utils/branch.ts
|
|
@@ -2955,7 +2188,7 @@ function matchBranchPattern(branch, pattern) {
|
|
|
2955
2188
|
}
|
|
2956
2189
|
|
|
2957
2190
|
// src/utils/config.ts
|
|
2958
|
-
import * as
|
|
2191
|
+
import * as p6 from "@clack/prompts";
|
|
2959
2192
|
import chalk7 from "chalk";
|
|
2960
2193
|
import { config as loadEnv2 } from "dotenv";
|
|
2961
2194
|
loadEnv2();
|
|
@@ -3014,7 +2247,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
|
|
|
3014
2247
|
};
|
|
3015
2248
|
const fileConfig = loadVocoderConfig(process.cwd());
|
|
3016
2249
|
if (!fileConfig) {
|
|
3017
|
-
|
|
2250
|
+
p6.log.warn(
|
|
3018
2251
|
`No ${chalk7.cyan("vocoder.config.ts")} found \u2014 run ${chalk7.cyan("npx @vocoder/cli init")} to generate one.`
|
|
3019
2252
|
);
|
|
3020
2253
|
}
|
|
@@ -3045,7 +2278,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
|
|
|
3045
2278
|
excludePattern = fileConfig.exclude;
|
|
3046
2279
|
configSources.excludePattern = "vocoder.config";
|
|
3047
2280
|
} else if (envExcludePattern) {
|
|
3048
|
-
excludePattern = envExcludePattern.split(",").map((
|
|
2281
|
+
excludePattern = envExcludePattern.split(",").map((p14) => p14.trim()).filter(Boolean);
|
|
3049
2282
|
configSources.excludePattern = "environment";
|
|
3050
2283
|
} else {
|
|
3051
2284
|
excludePattern = defaults.excludePattern;
|
|
@@ -3102,7 +2335,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
|
|
|
3102
2335
|
...maxWaitMs ? [`Max wait: ${chalk7.cyan(String(configSources.maxWaitMs))}`] : [],
|
|
3103
2336
|
`No fallback: ${chalk7.cyan(String(configSources.noFallback))}`
|
|
3104
2337
|
];
|
|
3105
|
-
|
|
2338
|
+
p6.note(lines.join("\n"), "Configuration sources");
|
|
3106
2339
|
}
|
|
3107
2340
|
return {
|
|
3108
2341
|
includePattern,
|
|
@@ -3165,7 +2398,7 @@ function parseTranslations(value) {
|
|
|
3165
2398
|
return Object.keys(translations).length > 0 ? translations : null;
|
|
3166
2399
|
}
|
|
3167
2400
|
function getCacheFilePath(projectRoot, fingerprint) {
|
|
3168
|
-
return
|
|
2401
|
+
return join3(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
|
|
3169
2402
|
}
|
|
3170
2403
|
function buildTranslationData(params) {
|
|
3171
2404
|
const textToHash = new Map(params.stringEntries.map((e) => [e.text, e.key]));
|
|
@@ -3192,7 +2425,7 @@ function readLocalCache(params) {
|
|
|
3192
2425
|
const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
|
|
3193
2426
|
if (!existsSync3(cacheFilePath)) return null;
|
|
3194
2427
|
try {
|
|
3195
|
-
const raw =
|
|
2428
|
+
const raw = readFileSync2(cacheFilePath, "utf-8");
|
|
3196
2429
|
const parsed = JSON.parse(raw);
|
|
3197
2430
|
if (!isRecord(parsed)) return null;
|
|
3198
2431
|
const inner = isRecord(parsed.config) ? parsed : null;
|
|
@@ -3206,10 +2439,10 @@ function readLocalCache(params) {
|
|
|
3206
2439
|
}
|
|
3207
2440
|
}
|
|
3208
2441
|
function writeCache(params) {
|
|
3209
|
-
const cacheDir =
|
|
3210
|
-
|
|
2442
|
+
const cacheDir = join3(params.projectRoot, "node_modules", ".vocoder", "cache");
|
|
2443
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
3211
2444
|
const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
|
|
3212
|
-
|
|
2445
|
+
writeFileSync3(cacheFilePath, JSON.stringify(params.data), "utf-8");
|
|
3213
2446
|
return cacheFilePath;
|
|
3214
2447
|
}
|
|
3215
2448
|
function resolveEffectiveModeFromPolicy(params) {
|
|
@@ -3280,6 +2513,13 @@ function getLimitErrorGuidance(limitError) {
|
|
|
3280
2513
|
`Upgrade plan: ${limitError.upgradeUrl}`
|
|
3281
2514
|
];
|
|
3282
2515
|
}
|
|
2516
|
+
if (limitError.limitType === "target_locales") {
|
|
2517
|
+
return [
|
|
2518
|
+
`Current target locales: ${limitError.current}`,
|
|
2519
|
+
`Plan limit: ${limitError.current} (${limitError.planId})`,
|
|
2520
|
+
`Upgrade plan: ${limitError.upgradeUrl}`
|
|
2521
|
+
];
|
|
2522
|
+
}
|
|
3283
2523
|
return [
|
|
3284
2524
|
`Plan: ${limitError.planId}`,
|
|
3285
2525
|
`Current: ${limitError.current}`,
|
|
@@ -3329,8 +2569,7 @@ function buildStringEntries(extractedStrings) {
|
|
|
3329
2569
|
text: str.text,
|
|
3330
2570
|
...str.context ? { context: str.context } : {},
|
|
3331
2571
|
...str.formality ? { formality: str.formality } : {},
|
|
3332
|
-
...str.uiRole ? { uiRole: str.uiRole } : {}
|
|
3333
|
-
...str.featureArea ? { featureArea: str.featureArea } : {}
|
|
2572
|
+
...str.uiRole ? { uiRole: str.uiRole } : {}
|
|
3334
2573
|
});
|
|
3335
2574
|
continue;
|
|
3336
2575
|
}
|
|
@@ -3365,22 +2604,22 @@ async function fetchApiSnapshot(api, params) {
|
|
|
3365
2604
|
async function sync(options = {}) {
|
|
3366
2605
|
const startTime = Date.now();
|
|
3367
2606
|
const projectRoot = process.cwd();
|
|
3368
|
-
|
|
2607
|
+
p7.intro("Vocoder Sync");
|
|
3369
2608
|
const mergedConfig = await getMergedConfig(options, options.verbose);
|
|
3370
2609
|
if (!mergedConfig.apiKey) {
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
2610
|
+
p7.log.warn("No API key found. Run init to get started:");
|
|
2611
|
+
p7.log.info(" npx @vocoder/cli init");
|
|
2612
|
+
p7.log.info("");
|
|
2613
|
+
p7.log.info(
|
|
3375
2614
|
" Or add your key to .env: VOCODER_API_KEY=vcp_..."
|
|
3376
2615
|
);
|
|
3377
|
-
|
|
2616
|
+
p7.outro("Run `npx @vocoder/cli init` to set up your project.");
|
|
3378
2617
|
return 1;
|
|
3379
2618
|
}
|
|
3380
|
-
const
|
|
2619
|
+
const spinner7 = p7.spinner();
|
|
3381
2620
|
try {
|
|
3382
2621
|
const branch = detectBranch(options.branch);
|
|
3383
|
-
|
|
2622
|
+
spinner7.start("Loading project configuration");
|
|
3384
2623
|
const localConfig = {
|
|
3385
2624
|
apiKey: mergedConfig.apiKey,
|
|
3386
2625
|
apiUrl: mergedConfig.apiUrl || "https://vocoder.app"
|
|
@@ -3404,18 +2643,18 @@ async function sync(options = {}) {
|
|
|
3404
2643
|
...fileConfig?.appIndustry ? { appIndustry: fileConfig.appIndustry } : {},
|
|
3405
2644
|
...fileConfig?.formality ? { formality: fileConfig.formality } : {}
|
|
3406
2645
|
};
|
|
3407
|
-
|
|
2646
|
+
spinner7.stop(`Branch: ${chalk8.cyan(branch)}`);
|
|
3408
2647
|
if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
|
|
3409
|
-
|
|
2648
|
+
p7.log.warn(
|
|
3410
2649
|
`Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
|
|
3411
2650
|
);
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
2651
|
+
p7.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
|
|
2652
|
+
p7.log.info("Use --force to translate anyway");
|
|
2653
|
+
p7.outro("");
|
|
3415
2654
|
return 0;
|
|
3416
2655
|
}
|
|
3417
2656
|
const patternsDisplay = Array.isArray(config.includePattern) ? config.includePattern.join(", ") : config.includePattern;
|
|
3418
|
-
|
|
2657
|
+
spinner7.start(`Extracting strings from ${patternsDisplay}`);
|
|
3419
2658
|
const extractor = new StringExtractor();
|
|
3420
2659
|
const extractedStrings = await extractor.extractFromProject(
|
|
3421
2660
|
config.includePattern,
|
|
@@ -3423,14 +2662,14 @@ async function sync(options = {}) {
|
|
|
3423
2662
|
config.excludePattern
|
|
3424
2663
|
);
|
|
3425
2664
|
if (extractedStrings.length === 0) {
|
|
3426
|
-
|
|
3427
|
-
|
|
2665
|
+
spinner7.stop("No translatable strings found");
|
|
2666
|
+
p7.log.warn(
|
|
3428
2667
|
"Make sure you are wrapping translatable strings with Vocoder"
|
|
3429
2668
|
);
|
|
3430
|
-
|
|
2669
|
+
p7.outro("");
|
|
3431
2670
|
return 0;
|
|
3432
2671
|
}
|
|
3433
|
-
|
|
2672
|
+
spinner7.stop(
|
|
3434
2673
|
`Extracted ${chalk8.cyan(extractedStrings.length)} strings from ${chalk8.cyan(patternsDisplay)}`
|
|
3435
2674
|
);
|
|
3436
2675
|
if (options.verbose) {
|
|
@@ -3438,10 +2677,10 @@ async function sync(options = {}) {
|
|
|
3438
2677
|
if (extractedStrings.length > 5) {
|
|
3439
2678
|
sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
|
|
3440
2679
|
}
|
|
3441
|
-
|
|
2680
|
+
p7.note(sampleLines.join("\n"), "Sample strings");
|
|
3442
2681
|
}
|
|
3443
2682
|
if (options.dryRun) {
|
|
3444
|
-
|
|
2683
|
+
p7.note(
|
|
3445
2684
|
[
|
|
3446
2685
|
`Strings: ${extractedStrings.length}`,
|
|
3447
2686
|
`Branch: ${branch}`,
|
|
@@ -3452,12 +2691,12 @@ async function sync(options = {}) {
|
|
|
3452
2691
|
].join("\n"),
|
|
3453
2692
|
"Dry run - would translate"
|
|
3454
2693
|
);
|
|
3455
|
-
|
|
2694
|
+
p7.outro("No API calls made.");
|
|
3456
2695
|
return 0;
|
|
3457
2696
|
}
|
|
3458
2697
|
const repoIdentity = resolveGitRepositoryIdentity();
|
|
3459
2698
|
if (!repoIdentity && options.verbose) {
|
|
3460
|
-
|
|
2699
|
+
p7.log.warn(
|
|
3461
2700
|
"Could not detect git remote origin. Sync will continue without repo metadata."
|
|
3462
2701
|
);
|
|
3463
2702
|
}
|
|
@@ -3465,7 +2704,7 @@ async function sync(options = {}) {
|
|
|
3465
2704
|
const stringEntries = buildStringEntries(extractedStrings);
|
|
3466
2705
|
const sourceStrings = stringEntries.map((entry) => entry.text);
|
|
3467
2706
|
if (options.verbose && stringEntries.length !== extractedStrings.length) {
|
|
3468
|
-
|
|
2707
|
+
p7.log.info(
|
|
3469
2708
|
`Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
|
|
3470
2709
|
);
|
|
3471
2710
|
}
|
|
@@ -3474,17 +2713,17 @@ async function sync(options = {}) {
|
|
|
3474
2713
|
const cacheFile = getCacheFilePath(projectRoot, fingerprint);
|
|
3475
2714
|
if (existsSync3(cacheFile)) {
|
|
3476
2715
|
if (options.verbose) {
|
|
3477
|
-
|
|
2716
|
+
p7.log.info(`Cache hit: ${chalk8.dim(cacheFile)} (fingerprint ${chalk8.cyan(fingerprint)})`);
|
|
3478
2717
|
}
|
|
3479
2718
|
const duration2 = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
3480
|
-
|
|
2719
|
+
p7.outro(`Up to date (${duration2}s)`);
|
|
3481
2720
|
return 0;
|
|
3482
2721
|
}
|
|
3483
2722
|
if (options.verbose) {
|
|
3484
|
-
|
|
2723
|
+
p7.log.info(`No cache for fingerprint ${chalk8.cyan(fingerprint)} \u2014 will submit to API`);
|
|
3485
2724
|
}
|
|
3486
2725
|
}
|
|
3487
|
-
|
|
2726
|
+
spinner7.start("Submitting strings to Vocoder API");
|
|
3488
2727
|
const batchResponse = await api.submitTranslation(
|
|
3489
2728
|
branch,
|
|
3490
2729
|
stringEntries,
|
|
@@ -3499,33 +2738,33 @@ async function sync(options = {}) {
|
|
|
3499
2738
|
},
|
|
3500
2739
|
repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
|
|
3501
2740
|
);
|
|
3502
|
-
|
|
2741
|
+
spinner7.stop("Strings submitted");
|
|
3503
2742
|
const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
|
|
3504
2743
|
branch,
|
|
3505
2744
|
requestedMode,
|
|
3506
2745
|
policy: config.syncPolicy
|
|
3507
2746
|
});
|
|
3508
2747
|
if (options.verbose) {
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
2748
|
+
p7.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
|
|
2749
|
+
p7.log.info(`Requested mode: ${requestedMode}`);
|
|
2750
|
+
p7.log.info(`Effective mode: ${effectiveMode}`);
|
|
2751
|
+
p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
|
|
3513
2752
|
if (batchResponse.queueStatus) {
|
|
3514
|
-
|
|
2753
|
+
p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
|
|
3515
2754
|
}
|
|
3516
2755
|
}
|
|
3517
2756
|
if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
|
|
3518
|
-
|
|
2757
|
+
p7.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
|
|
3519
2758
|
} else if (batchResponse.newStrings === 0) {
|
|
3520
2759
|
const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk8.yellow(batchResponse.deletedStrings)} archived` : "";
|
|
3521
|
-
|
|
2760
|
+
p7.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
|
|
3522
2761
|
} else {
|
|
3523
2762
|
const statParts = [`${chalk8.cyan(batchResponse.newStrings)} new, ${chalk8.cyan(batchResponse.totalStrings)} total`];
|
|
3524
2763
|
if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
|
|
3525
2764
|
statParts.push(`${chalk8.yellow(batchResponse.deletedStrings)} archived`);
|
|
3526
2765
|
}
|
|
3527
2766
|
const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
|
|
3528
|
-
|
|
2767
|
+
p7.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
|
|
3529
2768
|
}
|
|
3530
2769
|
let artifacts = null;
|
|
3531
2770
|
if (batchResponse.translations) {
|
|
@@ -3537,7 +2776,7 @@ async function sync(options = {}) {
|
|
|
3537
2776
|
let waitError = null;
|
|
3538
2777
|
if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
|
|
3539
2778
|
const waitTimeoutSecs = Math.round(waitTimeoutMs / 1e3);
|
|
3540
|
-
|
|
2779
|
+
spinner7.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
|
|
3541
2780
|
let lastProgress = 0;
|
|
3542
2781
|
try {
|
|
3543
2782
|
const completion = await api.waitForCompletion(
|
|
@@ -3546,7 +2785,7 @@ async function sync(options = {}) {
|
|
|
3546
2785
|
(progress) => {
|
|
3547
2786
|
const percent = Math.round(progress * 100);
|
|
3548
2787
|
if (percent > lastProgress) {
|
|
3549
|
-
|
|
2788
|
+
spinner7.message(`Translating... ${percent}%`);
|
|
3550
2789
|
lastProgress = percent;
|
|
3551
2790
|
}
|
|
3552
2791
|
}
|
|
@@ -3556,14 +2795,14 @@ async function sync(options = {}) {
|
|
|
3556
2795
|
translations: completion.translations,
|
|
3557
2796
|
localeMetadata: completion.localeMetadata
|
|
3558
2797
|
};
|
|
3559
|
-
|
|
2798
|
+
spinner7.stop("Translations complete");
|
|
3560
2799
|
} catch (error) {
|
|
3561
|
-
|
|
2800
|
+
spinner7.stop("Translation wait incomplete");
|
|
3562
2801
|
waitError = error instanceof Error ? error : new Error(String(error));
|
|
3563
2802
|
if (effectiveMode === "required") {
|
|
3564
2803
|
throw waitError;
|
|
3565
2804
|
}
|
|
3566
|
-
|
|
2805
|
+
p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
|
|
3567
2806
|
}
|
|
3568
2807
|
}
|
|
3569
2808
|
if (!artifacts) {
|
|
@@ -3572,14 +2811,14 @@ async function sync(options = {}) {
|
|
|
3572
2811
|
"Fresh translations are not available and fallback is disabled (--no-fallback)."
|
|
3573
2812
|
);
|
|
3574
2813
|
}
|
|
3575
|
-
|
|
2814
|
+
spinner7.start("Loading fallback translations");
|
|
3576
2815
|
const localFallback = readLocalCache({
|
|
3577
2816
|
projectRoot,
|
|
3578
2817
|
fingerprint
|
|
3579
2818
|
});
|
|
3580
2819
|
if (localFallback) {
|
|
3581
2820
|
artifacts = localFallback;
|
|
3582
|
-
|
|
2821
|
+
spinner7.stop(`Using local cached snapshot (${fingerprint})`);
|
|
3583
2822
|
} else {
|
|
3584
2823
|
try {
|
|
3585
2824
|
const apiSnapshot = await fetchApiSnapshot(api, {
|
|
@@ -3588,15 +2827,15 @@ async function sync(options = {}) {
|
|
|
3588
2827
|
});
|
|
3589
2828
|
if (apiSnapshot) {
|
|
3590
2829
|
artifacts = apiSnapshot;
|
|
3591
|
-
|
|
2830
|
+
spinner7.stop("Using latest completed API snapshot");
|
|
3592
2831
|
} else {
|
|
3593
|
-
|
|
2832
|
+
spinner7.stop("No completed API snapshot available");
|
|
3594
2833
|
}
|
|
3595
2834
|
} catch (error) {
|
|
3596
|
-
|
|
2835
|
+
spinner7.stop("Failed to fetch API snapshot");
|
|
3597
2836
|
if (options.verbose) {
|
|
3598
2837
|
const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
|
|
3599
|
-
|
|
2838
|
+
p7.log.warn(`Snapshot fetch error: ${message}`);
|
|
3600
2839
|
}
|
|
3601
2840
|
}
|
|
3602
2841
|
}
|
|
@@ -3628,85 +2867,433 @@ async function sync(options = {}) {
|
|
|
3628
2867
|
});
|
|
3629
2868
|
const cachePath = writeCache({ projectRoot, fingerprint, data });
|
|
3630
2869
|
if (options.verbose) {
|
|
3631
|
-
|
|
2870
|
+
p7.log.info(`Cache written: ${cachePath}`);
|
|
3632
2871
|
}
|
|
3633
2872
|
} catch (error) {
|
|
3634
2873
|
if (options.verbose) {
|
|
3635
2874
|
const message = error instanceof Error ? error.message : "Unknown cache write error";
|
|
3636
|
-
|
|
2875
|
+
p7.log.warn(`Failed to write cache: ${message}`);
|
|
3637
2876
|
}
|
|
3638
2877
|
}
|
|
3639
2878
|
if (artifacts.source !== "fresh") {
|
|
3640
2879
|
const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
|
|
3641
|
-
|
|
2880
|
+
p7.log.warn(
|
|
3642
2881
|
`Using ${sourceLabel}. New strings may appear after the background sync completes.`
|
|
3643
2882
|
);
|
|
3644
2883
|
}
|
|
3645
2884
|
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
3646
|
-
|
|
2885
|
+
p7.outro(`Sync complete! (${duration}s)`);
|
|
3647
2886
|
return 0;
|
|
3648
2887
|
} catch (error) {
|
|
3649
|
-
|
|
2888
|
+
spinner7.stop();
|
|
3650
2889
|
if (error instanceof VocoderAPIError && error.syncPolicyError) {
|
|
3651
|
-
|
|
2890
|
+
p7.log.error(error.syncPolicyError.message);
|
|
3652
2891
|
const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
|
|
3653
2892
|
for (const line of guidance) {
|
|
3654
|
-
|
|
2893
|
+
p7.log.info(line);
|
|
3655
2894
|
}
|
|
3656
2895
|
return 1;
|
|
3657
2896
|
}
|
|
3658
2897
|
if (error instanceof VocoderAPIError && error.limitError) {
|
|
3659
2898
|
const { limitError } = error;
|
|
3660
|
-
|
|
2899
|
+
p7.log.error(limitError.message);
|
|
3661
2900
|
const guidance = getLimitErrorGuidance(limitError);
|
|
3662
2901
|
for (const line of guidance) {
|
|
3663
|
-
|
|
2902
|
+
p7.log.info(line);
|
|
3664
2903
|
}
|
|
3665
2904
|
return 1;
|
|
3666
2905
|
}
|
|
3667
2906
|
if (error instanceof Error) {
|
|
3668
|
-
|
|
2907
|
+
p7.log.error(error.message);
|
|
3669
2908
|
const isInvalidKey = error.message.toLowerCase().includes("invalid api key") || error instanceof VocoderAPIError && error.status === 401;
|
|
3670
2909
|
if (isInvalidKey) {
|
|
3671
|
-
|
|
2910
|
+
p7.log.warn(
|
|
3672
2911
|
"API key rejected \u2014 the project may have been deleted or the key revoked."
|
|
3673
2912
|
);
|
|
3674
|
-
|
|
2913
|
+
p7.log.info(
|
|
3675
2914
|
" Run `npx @vocoder/cli init` to create a new project and key."
|
|
3676
2915
|
);
|
|
3677
2916
|
} else if (error.message.includes("git branch")) {
|
|
3678
|
-
|
|
3679
|
-
|
|
2917
|
+
p7.log.warn("Run from a git repository, or use:");
|
|
2918
|
+
p7.log.info(" vocoder sync --branch main");
|
|
3680
2919
|
}
|
|
3681
2920
|
if (options.verbose) {
|
|
3682
|
-
|
|
2921
|
+
p7.log.info(`Full error: ${error.stack ?? error}`);
|
|
3683
2922
|
}
|
|
3684
2923
|
}
|
|
3685
2924
|
return 1;
|
|
3686
2925
|
}
|
|
3687
2926
|
}
|
|
3688
2927
|
|
|
3689
|
-
// src/commands/
|
|
2928
|
+
// src/commands/locales.ts
|
|
2929
|
+
loadEnv3();
|
|
2930
|
+
function getApiConfig(options) {
|
|
2931
|
+
const apiKey = process.env.VOCODER_API_KEY;
|
|
2932
|
+
if (!apiKey) {
|
|
2933
|
+
p8.log.error(
|
|
2934
|
+
"VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
|
|
2935
|
+
);
|
|
2936
|
+
return null;
|
|
2937
|
+
}
|
|
2938
|
+
return {
|
|
2939
|
+
apiKey,
|
|
2940
|
+
apiUrl: options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app"
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
async function listProjectLocales(options = {}) {
|
|
2944
|
+
const config = getApiConfig(options);
|
|
2945
|
+
if (!config) return 1;
|
|
2946
|
+
const api = new VocoderAPI(config);
|
|
2947
|
+
try {
|
|
2948
|
+
const projectConfig2 = await api.getProjectConfig();
|
|
2949
|
+
p8.log.info(
|
|
2950
|
+
`Source locale: ${chalk9.cyan(projectConfig2.sourceLocale)}`
|
|
2951
|
+
);
|
|
2952
|
+
if (projectConfig2.targetLocales.length === 0) {
|
|
2953
|
+
p8.log.info("Target locales: (none configured)");
|
|
2954
|
+
} else {
|
|
2955
|
+
p8.log.info(
|
|
2956
|
+
`Target locales: ${projectConfig2.targetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
return 0;
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
p8.log.error(
|
|
2962
|
+
error instanceof Error ? error.message : "Failed to fetch project locales."
|
|
2963
|
+
);
|
|
2964
|
+
return 1;
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
async function addLocales(locales, options = {}) {
|
|
2968
|
+
if (locales.length === 0) {
|
|
2969
|
+
p8.log.error("No locale codes provided.");
|
|
2970
|
+
return 1;
|
|
2971
|
+
}
|
|
2972
|
+
const config = getApiConfig(options);
|
|
2973
|
+
if (!config) return 1;
|
|
2974
|
+
const api = new VocoderAPI(config);
|
|
2975
|
+
let lastTargetLocales = [];
|
|
2976
|
+
let hadError = false;
|
|
2977
|
+
for (const locale of locales) {
|
|
2978
|
+
const spinner7 = p8.spinner();
|
|
2979
|
+
spinner7.start(`Adding ${locale}\u2026`);
|
|
2980
|
+
try {
|
|
2981
|
+
const result = await api.addLocale(locale);
|
|
2982
|
+
lastTargetLocales = result.targetLocales;
|
|
2983
|
+
spinner7.stop(`Added ${chalk9.cyan(locale)}`);
|
|
2984
|
+
} catch (error) {
|
|
2985
|
+
spinner7.stop(`Failed to add ${chalk9.red(locale)}`);
|
|
2986
|
+
hadError = true;
|
|
2987
|
+
if (error instanceof VocoderAPIError && error.limitError) {
|
|
2988
|
+
const { limitError } = error;
|
|
2989
|
+
p8.log.error(limitError.message);
|
|
2990
|
+
for (const line of getLimitErrorGuidance(limitError)) {
|
|
2991
|
+
p8.log.info(line);
|
|
2992
|
+
}
|
|
2993
|
+
break;
|
|
2994
|
+
}
|
|
2995
|
+
p8.log.error(
|
|
2996
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
if (lastTargetLocales.length > 0) {
|
|
3001
|
+
p8.log.info(
|
|
3002
|
+
`Target locales now: ${lastTargetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
return hadError ? 1 : 0;
|
|
3006
|
+
}
|
|
3007
|
+
async function removeLocales(locales, options = {}) {
|
|
3008
|
+
if (locales.length === 0) {
|
|
3009
|
+
p8.log.error("No locale codes provided.");
|
|
3010
|
+
return 1;
|
|
3011
|
+
}
|
|
3012
|
+
const config = getApiConfig(options);
|
|
3013
|
+
if (!config) return 1;
|
|
3014
|
+
const api = new VocoderAPI(config);
|
|
3015
|
+
let lastTargetLocales = [];
|
|
3016
|
+
let hadError = false;
|
|
3017
|
+
for (const locale of locales) {
|
|
3018
|
+
const spinner7 = p8.spinner();
|
|
3019
|
+
spinner7.start(`Removing ${locale}\u2026`);
|
|
3020
|
+
try {
|
|
3021
|
+
const result = await api.removeLocale(locale);
|
|
3022
|
+
lastTargetLocales = result.targetLocales;
|
|
3023
|
+
spinner7.stop(`Removed ${chalk9.cyan(locale)}`);
|
|
3024
|
+
} catch (error) {
|
|
3025
|
+
spinner7.stop(`Failed to remove ${chalk9.red(locale)}`);
|
|
3026
|
+
hadError = true;
|
|
3027
|
+
p8.log.error(
|
|
3028
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
if (lastTargetLocales.length > 0) {
|
|
3033
|
+
p8.log.info(
|
|
3034
|
+
`Target locales now: ${lastTargetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
|
|
3035
|
+
);
|
|
3036
|
+
} else if (!hadError) {
|
|
3037
|
+
p8.log.info("Target locales now: (none configured)");
|
|
3038
|
+
}
|
|
3039
|
+
return hadError ? 1 : 0;
|
|
3040
|
+
}
|
|
3041
|
+
async function listSupportedLocales(options = {}) {
|
|
3042
|
+
const config = getApiConfig(options);
|
|
3043
|
+
if (!config) return 1;
|
|
3044
|
+
const api = new VocoderAPI(config);
|
|
3045
|
+
try {
|
|
3046
|
+
const result = await api.listLocales(config.apiKey);
|
|
3047
|
+
p8.log.info(chalk9.bold("Source locales:"));
|
|
3048
|
+
printLocaleTable(result.sourceLocales);
|
|
3049
|
+
p8.log.info("");
|
|
3050
|
+
p8.log.info(chalk9.bold("Target locales:"));
|
|
3051
|
+
printLocaleTable(result.targetLocales);
|
|
3052
|
+
return 0;
|
|
3053
|
+
} catch (error) {
|
|
3054
|
+
p8.log.error(
|
|
3055
|
+
error instanceof Error ? error.message : "Failed to fetch supported locales."
|
|
3056
|
+
);
|
|
3057
|
+
return 1;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
function printLocaleTable(locales) {
|
|
3061
|
+
for (const locale of locales) {
|
|
3062
|
+
const native = locale.nativeName && locale.nativeName !== locale.name ? ` (${locale.nativeName})` : "";
|
|
3063
|
+
p8.log.info(` ${chalk9.cyan(locale.code.padEnd(10))} ${locale.name}${native}`);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// src/commands/logout.ts
|
|
3690
3068
|
import * as p9 from "@clack/prompts";
|
|
3691
|
-
|
|
3069
|
+
async function logout(options = {}) {
|
|
3070
|
+
const stored = readAuthData();
|
|
3071
|
+
if (!stored) {
|
|
3072
|
+
p9.log.info("Not currently authenticated.");
|
|
3073
|
+
return 0;
|
|
3074
|
+
}
|
|
3075
|
+
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
3076
|
+
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
3077
|
+
try {
|
|
3078
|
+
await api.revokeCliToken(stored.token);
|
|
3079
|
+
} catch {
|
|
3080
|
+
}
|
|
3081
|
+
clearAuthData();
|
|
3082
|
+
p9.log.success(`Logged out (was ${stored.email})`);
|
|
3083
|
+
return 0;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// src/commands/project-config.ts
|
|
3087
|
+
import * as p10 from "@clack/prompts";
|
|
3088
|
+
import chalk10 from "chalk";
|
|
3089
|
+
import { config as loadEnv4 } from "dotenv";
|
|
3090
|
+
loadEnv4();
|
|
3091
|
+
async function projectConfig(options = {}) {
|
|
3092
|
+
const apiKey = process.env.VOCODER_API_KEY;
|
|
3093
|
+
if (!apiKey) {
|
|
3094
|
+
p10.log.error(
|
|
3095
|
+
"VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
|
|
3096
|
+
);
|
|
3097
|
+
return 1;
|
|
3098
|
+
}
|
|
3099
|
+
const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
|
|
3100
|
+
const api = new VocoderAPI({ apiKey, apiUrl });
|
|
3101
|
+
try {
|
|
3102
|
+
const config = await api.getProjectConfig();
|
|
3103
|
+
const lines = [
|
|
3104
|
+
`Project: ${chalk10.bold(config.projectName)}`,
|
|
3105
|
+
`Organization: ${config.organizationName}`,
|
|
3106
|
+
`Source locale: ${chalk10.cyan(config.sourceLocale)}`,
|
|
3107
|
+
`Target locales: ${config.targetLocales.length > 0 ? config.targetLocales.map((l) => chalk10.cyan(l)).join(", ") : chalk10.dim("(none)")}`,
|
|
3108
|
+
`Target branches: ${config.targetBranches.map((b) => chalk10.cyan(b)).join(", ")}`,
|
|
3109
|
+
...config.primaryBranch ? [`Primary branch: ${chalk10.cyan(config.primaryBranch)}`] : [],
|
|
3110
|
+
`Sync policy:`,
|
|
3111
|
+
` Blocking branches: ${config.syncPolicy.blockingBranches.map((b) => chalk10.cyan(b)).join(", ")}`,
|
|
3112
|
+
` Blocking mode: ${chalk10.cyan(config.syncPolicy.blockingMode)}`,
|
|
3113
|
+
` Non-blocking mode: ${chalk10.cyan(config.syncPolicy.nonBlockingMode)}`,
|
|
3114
|
+
` Max wait: ${chalk10.cyan(String(config.syncPolicy.defaultMaxWaitMs))} ms`
|
|
3115
|
+
];
|
|
3116
|
+
p10.note(lines.join("\n"), `${config.projectName} \u2014 project config`);
|
|
3117
|
+
return 0;
|
|
3118
|
+
} catch (error) {
|
|
3119
|
+
p10.log.error(
|
|
3120
|
+
error instanceof Error ? error.message : "Failed to fetch project config."
|
|
3121
|
+
);
|
|
3122
|
+
return 1;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
// src/commands/translations.ts
|
|
3127
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
3128
|
+
import { join as join4 } from "path";
|
|
3129
|
+
import * as p11 from "@clack/prompts";
|
|
3130
|
+
import chalk11 from "chalk";
|
|
3131
|
+
import { config as loadEnv5 } from "dotenv";
|
|
3132
|
+
loadEnv5();
|
|
3133
|
+
async function getTranslations(options = {}) {
|
|
3134
|
+
const apiKey = process.env.VOCODER_API_KEY;
|
|
3135
|
+
if (!apiKey) {
|
|
3136
|
+
p11.log.error(
|
|
3137
|
+
"VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
|
|
3138
|
+
);
|
|
3139
|
+
return 1;
|
|
3140
|
+
}
|
|
3141
|
+
const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
|
|
3142
|
+
const api = new VocoderAPI({ apiKey, apiUrl });
|
|
3143
|
+
let branch;
|
|
3144
|
+
try {
|
|
3145
|
+
branch = detectBranch(options.branch);
|
|
3146
|
+
} catch (error) {
|
|
3147
|
+
p11.log.error(
|
|
3148
|
+
error instanceof Error ? error.message : "Failed to detect branch."
|
|
3149
|
+
);
|
|
3150
|
+
return 1;
|
|
3151
|
+
}
|
|
3152
|
+
const spinner7 = p11.spinner();
|
|
3153
|
+
spinner7.start(`Fetching translations for ${chalk11.cyan(branch)}\u2026`);
|
|
3154
|
+
try {
|
|
3155
|
+
const projectConfig2 = await api.getProjectConfig();
|
|
3156
|
+
const targetLocales = options.locale ? [options.locale] : projectConfig2.targetLocales;
|
|
3157
|
+
if (targetLocales.length === 0) {
|
|
3158
|
+
spinner7.stop("No target locales configured.");
|
|
3159
|
+
p11.log.info("Add target locales with `vocoder locales add <code>`.");
|
|
3160
|
+
return 1;
|
|
3161
|
+
}
|
|
3162
|
+
const snapshot = await api.getTranslationSnapshot({ branch, targetLocales });
|
|
3163
|
+
spinner7.stop(`Fetched translations for ${chalk11.cyan(branch)}`);
|
|
3164
|
+
if (snapshot.status === "NOT_FOUND") {
|
|
3165
|
+
p11.log.warn(
|
|
3166
|
+
`No translation snapshot found for branch "${branch}". Run \`vocoder sync\` to generate one.`
|
|
3167
|
+
);
|
|
3168
|
+
return 1;
|
|
3169
|
+
}
|
|
3170
|
+
const translations = snapshot.translations ?? {};
|
|
3171
|
+
if (options.output) {
|
|
3172
|
+
writeLocaleFiles(translations, options.output);
|
|
3173
|
+
} else {
|
|
3174
|
+
process.stdout.write(JSON.stringify(translations, null, 2));
|
|
3175
|
+
process.stdout.write("\n");
|
|
3176
|
+
}
|
|
3177
|
+
return 0;
|
|
3178
|
+
} catch (error) {
|
|
3179
|
+
spinner7.stop("Failed to fetch translations.");
|
|
3180
|
+
p11.log.error(
|
|
3181
|
+
error instanceof Error ? error.message : "Unknown error."
|
|
3182
|
+
);
|
|
3183
|
+
return 1;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
function writeLocaleFiles(translations, outputDir) {
|
|
3187
|
+
mkdirSync2(outputDir, { recursive: true });
|
|
3188
|
+
for (const [locale, strings] of Object.entries(translations)) {
|
|
3189
|
+
const filePath = join4(outputDir, `${locale}.json`);
|
|
3190
|
+
writeFileSync4(filePath, JSON.stringify(strings, null, 2) + "\n", "utf-8");
|
|
3191
|
+
p11.log.success(`Wrote ${chalk11.cyan(filePath)}`);
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
// src/commands/create-project.ts
|
|
3196
|
+
import * as p12 from "@clack/prompts";
|
|
3197
|
+
import chalk12 from "chalk";
|
|
3198
|
+
import { config as loadEnv6 } from "dotenv";
|
|
3199
|
+
loadEnv6();
|
|
3200
|
+
async function createProject(options) {
|
|
3201
|
+
const authData = readAuthData();
|
|
3202
|
+
if (!authData) {
|
|
3203
|
+
p12.log.error(
|
|
3204
|
+
"Not logged in. Run `npx @vocoder/cli init` to authenticate first."
|
|
3205
|
+
);
|
|
3206
|
+
return 1;
|
|
3207
|
+
}
|
|
3208
|
+
const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
|
|
3209
|
+
const api = new VocoderAPI({ apiKey: "", apiUrl });
|
|
3210
|
+
let repoCanonical;
|
|
3211
|
+
let appDir = options.appDir ?? ".";
|
|
3212
|
+
if (options.repo) {
|
|
3213
|
+
repoCanonical = options.repo;
|
|
3214
|
+
} else {
|
|
3215
|
+
const identity = resolveGitRepositoryIdentity();
|
|
3216
|
+
if (identity) {
|
|
3217
|
+
repoCanonical = identity.repoCanonical;
|
|
3218
|
+
if (!options.appDir && identity.repoAppDir) {
|
|
3219
|
+
appDir = identity.repoAppDir;
|
|
3220
|
+
}
|
|
3221
|
+
} else {
|
|
3222
|
+
p12.log.warn(
|
|
3223
|
+
"Could not detect a git remote. The project will be created without repo binding \u2014 sync-on-push will not function until a repository is connected via the Vocoder dashboard."
|
|
3224
|
+
);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
const targetLocales = options.targetLocales ? options.targetLocales.split(",").map((l) => l.trim()).filter(Boolean) : [];
|
|
3228
|
+
const targetBranches = options.targetBranches ? options.targetBranches.split(",").map((b) => b.trim()).filter(Boolean) : ["main"];
|
|
3229
|
+
const spinner7 = p12.spinner();
|
|
3230
|
+
spinner7.start(`Creating project "${options.name}"\u2026`);
|
|
3231
|
+
try {
|
|
3232
|
+
const result = await api.createProject(authData.token, {
|
|
3233
|
+
organizationId: options.workspace,
|
|
3234
|
+
name: options.name,
|
|
3235
|
+
sourceLocale: options.sourceLocale,
|
|
3236
|
+
targetLocales,
|
|
3237
|
+
targetBranches,
|
|
3238
|
+
appDirs: [appDir],
|
|
3239
|
+
...repoCanonical ? { repoCanonical } : {}
|
|
3240
|
+
});
|
|
3241
|
+
spinner7.stop(`Created project ${chalk12.bold(result.projectName)}`);
|
|
3242
|
+
const lines = [
|
|
3243
|
+
`Project ID: ${result.projectId}`,
|
|
3244
|
+
`Source locale: ${chalk12.cyan(result.sourceLocale)}`,
|
|
3245
|
+
`Target locales: ${result.targetLocales.length > 0 ? result.targetLocales.map((l) => chalk12.cyan(l)).join(", ") : chalk12.dim("(none)")}`,
|
|
3246
|
+
`Branches: ${result.targetBranches.map((b) => chalk12.cyan(b)).join(", ")}`,
|
|
3247
|
+
...repoCanonical ? [`Repository: ${chalk12.cyan(repoCanonical)}${appDir !== "." ? ` (${appDir})` : ""}`] : [],
|
|
3248
|
+
"",
|
|
3249
|
+
`Add this to your .env file:`,
|
|
3250
|
+
` ${chalk12.bold("VOCODER_API_KEY")}=${chalk12.cyan(result.apiKey)}`
|
|
3251
|
+
];
|
|
3252
|
+
p12.note(lines.join("\n"), "Project created");
|
|
3253
|
+
if (!result.repositoryBound && repoCanonical) {
|
|
3254
|
+
p12.log.warn(
|
|
3255
|
+
`Repository "${repoCanonical}" was not automatically connected. Ensure your GitHub App installation covers this repository.`
|
|
3256
|
+
);
|
|
3257
|
+
}
|
|
3258
|
+
return 0;
|
|
3259
|
+
} catch (error) {
|
|
3260
|
+
spinner7.stop("Failed to create project.");
|
|
3261
|
+
if (error instanceof VocoderAPIError && error.limitError) {
|
|
3262
|
+
const { limitError } = error;
|
|
3263
|
+
p12.log.error(limitError.message);
|
|
3264
|
+
for (const line of getLimitErrorGuidance(limitError)) {
|
|
3265
|
+
p12.log.info(line);
|
|
3266
|
+
}
|
|
3267
|
+
return 1;
|
|
3268
|
+
}
|
|
3269
|
+
p12.log.error(
|
|
3270
|
+
error instanceof Error ? error.message : "Unknown error."
|
|
3271
|
+
);
|
|
3272
|
+
return 1;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
// src/commands/whoami.ts
|
|
3277
|
+
import * as p13 from "@clack/prompts";
|
|
3278
|
+
import chalk13 from "chalk";
|
|
3692
3279
|
async function whoami(options = {}) {
|
|
3693
3280
|
const stored = readAuthData();
|
|
3694
3281
|
if (!stored) {
|
|
3695
|
-
|
|
3282
|
+
p13.log.info("Not logged in. Run `vocoder init` to authenticate.");
|
|
3696
3283
|
return 1;
|
|
3697
3284
|
}
|
|
3698
3285
|
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
3699
3286
|
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
3700
3287
|
try {
|
|
3701
3288
|
const info = await api.getCliUserInfo(stored.token);
|
|
3702
|
-
|
|
3289
|
+
p13.log.info(`Logged in as ${chalk13.bold(info.email)}`);
|
|
3703
3290
|
if (info.name) {
|
|
3704
|
-
|
|
3291
|
+
p13.log.info(`Name: ${info.name}`);
|
|
3705
3292
|
}
|
|
3706
|
-
|
|
3293
|
+
p13.log.info(`API: ${apiUrl}`);
|
|
3707
3294
|
return 0;
|
|
3708
3295
|
} catch {
|
|
3709
|
-
|
|
3296
|
+
p13.log.error(
|
|
3710
3297
|
"Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate."
|
|
3711
3298
|
);
|
|
3712
3299
|
return 1;
|
|
@@ -3738,5 +3325,38 @@ program.command("sync").description("Extract strings and sync translations").opt
|
|
|
3738
3325
|
});
|
|
3739
3326
|
program.command("logout").description("Log out and remove stored credentials").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(logout, options));
|
|
3740
3327
|
program.command("whoami").description("Show the currently authenticated user").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(whoami, options));
|
|
3328
|
+
var localesCmd = program.command("locales").description("Manage project target locales").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listProjectLocales, options));
|
|
3329
|
+
localesCmd.command("add <codes...>").description("Add one or more target locales by BCP 47 code (e.g. fr de pt-BR)").option("--api-url <url>", "Override Vocoder API URL").action(
|
|
3330
|
+
(codes, options) => runCommand((opts) => addLocales(codes, opts), options)
|
|
3331
|
+
);
|
|
3332
|
+
localesCmd.command("remove <codes...>").description("Remove one or more target locales by BCP 47 code").option("--api-url <url>", "Override Vocoder API URL").action(
|
|
3333
|
+
(codes, options) => runCommand((opts) => removeLocales(codes, opts), options)
|
|
3334
|
+
);
|
|
3335
|
+
localesCmd.command("supported").description("List all locales supported by Vocoder").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listSupportedLocales, options));
|
|
3336
|
+
program.command("project").description("Show current project configuration").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(projectConfig, options));
|
|
3337
|
+
program.command("translations").description("Download the current translation snapshot").option("--branch <branch>", "Git branch (auto-detected if omitted)").option("--locale <locale>", "Fetch a specific locale only").option("--output <dir>", "Write locale JSON files to this directory").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(getTranslations, options));
|
|
3338
|
+
program.command("create-project").description("Create a new Vocoder project (requires prior `vocoder init`)").requiredOption("--name <name>", "Project display name").requiredOption("--source-locale <code>", "Source language BCP 47 code (e.g. en)").requiredOption("--workspace <org-id>", "Workspace organization ID").option(
|
|
3339
|
+
"--target-locales <codes>",
|
|
3340
|
+
"Comma-separated target locale codes (e.g. fr,de,pt-BR)"
|
|
3341
|
+
).option(
|
|
3342
|
+
"--target-branches <branches>",
|
|
3343
|
+
"Comma-separated branch names to sync (default: main)"
|
|
3344
|
+
).option(
|
|
3345
|
+
"--repo <canonical>",
|
|
3346
|
+
"Git repo canonical (e.g. github:owner/repo). Auto-detected from git remote if omitted."
|
|
3347
|
+
).option(
|
|
3348
|
+
"--app-dir <path>",
|
|
3349
|
+
"App directory within the repo for monorepos (default: .)"
|
|
3350
|
+
).option("--api-url <url>", "Override Vocoder API URL").action((options) => {
|
|
3351
|
+
const translated = {
|
|
3352
|
+
...options,
|
|
3353
|
+
// Commander camelCases dashed options
|
|
3354
|
+
sourceLocale: options.sourceLocale,
|
|
3355
|
+
targetLocales: options.targetLocales,
|
|
3356
|
+
targetBranches: options.targetBranches,
|
|
3357
|
+
workspace: options.workspace
|
|
3358
|
+
};
|
|
3359
|
+
return runCommand(createProject, translated);
|
|
3360
|
+
});
|
|
3741
3361
|
program.parse(process.argv);
|
|
3742
3362
|
//# sourceMappingURL=bin.mjs.map
|