@vocoder/cli 0.9.0 → 0.11.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/dist/bin.mjs +165 -885
- package/dist/bin.mjs.map +1 -1
- package/dist/{chunk-IZN5HVYD.mjs → chunk-XF3KGGYQ.mjs} +1024 -220
- package/dist/chunk-XF3KGGYQ.mjs.map +1 -0
- package/dist/lib.d.mts +339 -6
- package/dist/lib.mjs +13 -3
- package/dist/lib.mjs.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-IZN5HVYD.mjs.map +0 -1
package/dist/bin.mjs
CHANGED
|
@@ -2,756 +2,36 @@
|
|
|
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
|
+
writeAuthData
|
|
15
|
+
} from "./chunk-XF3KGGYQ.mjs";
|
|
11
16
|
|
|
12
17
|
// src/bin.ts
|
|
13
18
|
import { Command } from "commander";
|
|
14
19
|
|
|
15
20
|
// 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
21
|
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
|
-
}));
|
|
172
|
-
}
|
|
173
|
-
async submitTranslation(branch, entries, targetLocales, options, repoIdentity) {
|
|
174
|
-
const stringEntries = this.normalizeStringEntries(entries);
|
|
175
|
-
const strings = stringEntries.map((entry) => entry.text);
|
|
176
|
-
const crypto = await import("crypto");
|
|
177
|
-
const sortedStrings = [...strings].sort();
|
|
178
|
-
const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
|
|
179
|
-
return this.request(
|
|
180
|
-
"/api/cli/sync",
|
|
181
|
-
{
|
|
182
|
-
method: "POST",
|
|
183
|
-
headers: {
|
|
184
|
-
"Content-Type": "application/json"
|
|
185
|
-
},
|
|
186
|
-
body: JSON.stringify({
|
|
187
|
-
branch,
|
|
188
|
-
stringEntries,
|
|
189
|
-
targetLocales,
|
|
190
|
-
...options?.force ? {} : { stringsHash },
|
|
191
|
-
...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
|
|
192
|
-
...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
|
|
193
|
-
...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
|
|
194
|
-
...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
|
|
195
|
-
...repoIdentity?.repoAppDir !== void 0 ? { repoAppDir: repoIdentity.repoAppDir } : {},
|
|
196
|
-
...repoIdentity?.commitSha ? { commitSha: repoIdentity.commitSha } : {}
|
|
197
|
-
})
|
|
198
|
-
},
|
|
199
|
-
"Translation submission failed"
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Check translation status
|
|
204
|
-
*/
|
|
205
|
-
async getTranslationStatus(batchId) {
|
|
206
|
-
return this.request(
|
|
207
|
-
`/api/cli/sync/status/${batchId}`,
|
|
208
|
-
{},
|
|
209
|
-
"Failed to check translation status"
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
async getTranslationSnapshot(params) {
|
|
213
|
-
const search = new URLSearchParams();
|
|
214
|
-
search.set("branch", params.branch);
|
|
215
|
-
for (const locale of params.targetLocales) {
|
|
216
|
-
search.append("targetLocale", locale);
|
|
217
|
-
}
|
|
218
|
-
return this.request(
|
|
219
|
-
`/api/cli/sync/snapshot?${search.toString()}`,
|
|
220
|
-
{},
|
|
221
|
-
"Failed to fetch translation snapshot"
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Wait for translation to complete with polling
|
|
226
|
-
*/
|
|
227
|
-
async waitForCompletion(batchId, timeout = 6e4, onProgress) {
|
|
228
|
-
const startTime = Date.now();
|
|
229
|
-
const pollInterval = 1e3;
|
|
230
|
-
while (Date.now() - startTime < timeout) {
|
|
231
|
-
const status = await this.getTranslationStatus(batchId);
|
|
232
|
-
if (onProgress) {
|
|
233
|
-
onProgress(status.progress);
|
|
234
|
-
}
|
|
235
|
-
if (status.status === "COMPLETED") {
|
|
236
|
-
if (!status.translations) {
|
|
237
|
-
throw new Error("Translation completed but no translations returned");
|
|
238
|
-
}
|
|
239
|
-
return {
|
|
240
|
-
translations: status.translations,
|
|
241
|
-
localeMetadata: status.localeMetadata
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
if (status.status === "FAILED") {
|
|
245
|
-
throw new Error(
|
|
246
|
-
`Translation failed: ${status.errorMessage || "Unknown error"}`
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
250
|
-
}
|
|
251
|
-
throw new Error(`Translation timeout after ${timeout}ms`);
|
|
252
|
-
}
|
|
253
|
-
async startInitSession(input) {
|
|
254
|
-
const response = await fetch(`${this.apiUrl}/api/cli/init/start`, {
|
|
255
|
-
method: "POST",
|
|
256
|
-
headers: {
|
|
257
|
-
"Content-Type": "application/json"
|
|
258
|
-
},
|
|
259
|
-
body: JSON.stringify(input)
|
|
260
|
-
});
|
|
261
|
-
const payload = await readPayload(response);
|
|
262
|
-
if (!response.ok) {
|
|
263
|
-
throw new VocoderAPIError({
|
|
264
|
-
message: extractErrorMessage(
|
|
265
|
-
payload,
|
|
266
|
-
`Failed to start init session (${response.status})`
|
|
267
|
-
),
|
|
268
|
-
status: response.status,
|
|
269
|
-
payload
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
return payload;
|
|
273
|
-
}
|
|
274
|
-
async getInitSessionStatus(params) {
|
|
275
|
-
const response = await fetch(
|
|
276
|
-
`${this.apiUrl}/api/cli/init/status/${params.sessionId}`,
|
|
277
|
-
{
|
|
278
|
-
headers: {
|
|
279
|
-
Authorization: `Bearer ${params.pollToken}`
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
);
|
|
283
|
-
const payload = await readPayload(response);
|
|
284
|
-
if (!response.ok) {
|
|
285
|
-
throw new VocoderAPIError({
|
|
286
|
-
message: extractErrorMessage(
|
|
287
|
-
payload,
|
|
288
|
-
`Failed to get init status (${response.status})`
|
|
289
|
-
),
|
|
290
|
-
status: response.status,
|
|
291
|
-
payload
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
return payload;
|
|
295
|
-
}
|
|
296
|
-
// ── CLI Auth endpoints (no project API key needed) ──────────────────────────
|
|
297
|
-
/**
|
|
298
|
-
* Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
|
|
299
|
-
* `sessionId` is the raw poll token — keep it secret, used for polling.
|
|
300
|
-
*/
|
|
301
|
-
async startCliAuthSession(callbackPort, repoCanonical) {
|
|
302
|
-
const response = await fetch(`${this.apiUrl}/api/cli/auth/start`, {
|
|
303
|
-
method: "POST",
|
|
304
|
-
headers: { "Content-Type": "application/json" },
|
|
305
|
-
body: JSON.stringify({
|
|
306
|
-
...callbackPort != null ? { callbackPort } : {},
|
|
307
|
-
...repoCanonical ? { repoCanonical } : {}
|
|
308
|
-
})
|
|
309
|
-
});
|
|
310
|
-
const payload = await readPayload(response);
|
|
311
|
-
if (!response.ok) {
|
|
312
|
-
throw new VocoderAPIError({
|
|
313
|
-
message: extractErrorMessage(
|
|
314
|
-
payload,
|
|
315
|
-
`Failed to start auth session (${response.status})`
|
|
316
|
-
),
|
|
317
|
-
status: response.status,
|
|
318
|
-
payload
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
return payload;
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Poll for CLI auth session completion.
|
|
325
|
-
* Returns `{ token }` on success, throws on failure/expiry.
|
|
326
|
-
* The server returns HTTP 202 while still pending.
|
|
327
|
-
*/
|
|
328
|
-
async pollCliAuthSession(pollToken) {
|
|
329
|
-
const response = await fetch(
|
|
330
|
-
`${this.apiUrl}/api/cli/auth/session?session=${encodeURIComponent(pollToken)}`
|
|
331
|
-
);
|
|
332
|
-
const payload = await readPayload(response);
|
|
333
|
-
if (response.status === 202) {
|
|
334
|
-
return { status: "pending" };
|
|
335
|
-
}
|
|
336
|
-
if (response.status === 410) {
|
|
337
|
-
return {
|
|
338
|
-
status: "failed",
|
|
339
|
-
reason: extractErrorMessage(payload, "Auth session expired or failed")
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
if (!response.ok) {
|
|
343
|
-
return {
|
|
344
|
-
status: "failed",
|
|
345
|
-
reason: extractErrorMessage(
|
|
346
|
-
payload,
|
|
347
|
-
`Auth session error (${response.status})`
|
|
348
|
-
)
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
const result = payload;
|
|
352
|
-
if (!result.token) {
|
|
353
|
-
return { status: "failed", reason: "No token in response" };
|
|
354
|
-
}
|
|
355
|
-
return {
|
|
356
|
-
status: "complete",
|
|
357
|
-
token: result.token,
|
|
358
|
-
...result.organizationId ? { organizationId: result.organizationId } : {}
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
/**
|
|
362
|
-
* Validate a CLI user token and return the authenticated user's info.
|
|
363
|
-
* Used by the CLI to verify stored credentials on startup.
|
|
364
|
-
*/
|
|
365
|
-
async getCliUserInfo(userToken) {
|
|
366
|
-
const response = await fetch(`${this.apiUrl}/api/cli/auth/me`, {
|
|
367
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
368
|
-
});
|
|
369
|
-
const payload = await readPayload(response);
|
|
370
|
-
if (!response.ok) {
|
|
371
|
-
throw new VocoderAPIError({
|
|
372
|
-
message: extractErrorMessage(
|
|
373
|
-
payload,
|
|
374
|
-
`Token validation failed (${response.status})`
|
|
375
|
-
),
|
|
376
|
-
status: response.status,
|
|
377
|
-
payload
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
return payload;
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* Revoke the given CLI user token server-side.
|
|
384
|
-
*/
|
|
385
|
-
async revokeCliToken(userToken) {
|
|
386
|
-
const response = await fetch(`${this.apiUrl}/api/cli/auth/token`, {
|
|
387
|
-
method: "DELETE",
|
|
388
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
389
|
-
});
|
|
390
|
-
if (!response.ok) {
|
|
391
|
-
const payload = await readPayload(response);
|
|
392
|
-
throw new VocoderAPIError({
|
|
393
|
-
message: extractErrorMessage(
|
|
394
|
-
payload,
|
|
395
|
-
`Token revocation failed (${response.status})`
|
|
396
|
-
),
|
|
397
|
-
status: response.status,
|
|
398
|
-
payload
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
// ── Workspaces ────────────────────────────────────────────────────────────────
|
|
403
|
-
async listWorkspaces(userToken, params) {
|
|
404
|
-
const url = new URL(`${this.apiUrl}/api/cli/workspaces`);
|
|
405
|
-
if (params?.repo) url.searchParams.set("repo", params.repo);
|
|
406
|
-
const response = await fetch(url.toString(), {
|
|
407
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
408
|
-
});
|
|
409
|
-
const payload = await readPayload(response);
|
|
410
|
-
if (!response.ok) {
|
|
411
|
-
throw new VocoderAPIError({
|
|
412
|
-
message: extractErrorMessage(
|
|
413
|
-
payload,
|
|
414
|
-
`Failed to list workspaces (${response.status})`
|
|
415
|
-
),
|
|
416
|
-
status: response.status,
|
|
417
|
-
payload
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
return payload;
|
|
421
|
-
}
|
|
422
|
-
async listProjects(userToken, organizationId) {
|
|
423
|
-
const url = new URL(`${this.apiUrl}/api/cli/projects`);
|
|
424
|
-
url.searchParams.set("organizationId", organizationId);
|
|
425
|
-
const response = await fetch(url.toString(), {
|
|
426
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
427
|
-
});
|
|
428
|
-
const payload = await readPayload(response);
|
|
429
|
-
if (!response.ok) {
|
|
430
|
-
throw new VocoderAPIError({
|
|
431
|
-
message: extractErrorMessage(
|
|
432
|
-
payload,
|
|
433
|
-
`Failed to list projects (${response.status})`
|
|
434
|
-
),
|
|
435
|
-
status: response.status,
|
|
436
|
-
payload
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
const result = payload;
|
|
440
|
-
return result.projects;
|
|
441
|
-
}
|
|
442
|
-
async regenerateProjectApiKey(userToken, projectId) {
|
|
443
|
-
const response = await fetch(
|
|
444
|
-
`${this.apiUrl}/api/cli/project/regenerate-key`,
|
|
445
|
-
{
|
|
446
|
-
method: "POST",
|
|
447
|
-
headers: {
|
|
448
|
-
"Content-Type": "application/json",
|
|
449
|
-
Authorization: `Bearer ${userToken}`
|
|
450
|
-
},
|
|
451
|
-
body: JSON.stringify({ projectId })
|
|
452
|
-
}
|
|
453
|
-
);
|
|
454
|
-
const payload = await readPayload(response);
|
|
455
|
-
if (!response.ok) {
|
|
456
|
-
throw new VocoderAPIError({
|
|
457
|
-
message: extractErrorMessage(
|
|
458
|
-
payload,
|
|
459
|
-
`Failed to regenerate API key (${response.status})`
|
|
460
|
-
),
|
|
461
|
-
status: response.status,
|
|
462
|
-
payload
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
return payload;
|
|
466
|
-
}
|
|
467
|
-
// ── CLI GitHub endpoints ──────────────────────────────────────────────────────
|
|
468
|
-
async startCliGitHubInstall(userToken, params) {
|
|
469
|
-
const response = await fetch(
|
|
470
|
-
`${this.apiUrl}/api/cli/github/install/start`,
|
|
471
|
-
{
|
|
472
|
-
method: "POST",
|
|
473
|
-
headers: {
|
|
474
|
-
Authorization: `Bearer ${userToken}`,
|
|
475
|
-
"Content-Type": "application/json"
|
|
476
|
-
},
|
|
477
|
-
body: JSON.stringify(params)
|
|
478
|
-
}
|
|
479
|
-
);
|
|
480
|
-
const payload = await readPayload(response);
|
|
481
|
-
if (!response.ok) {
|
|
482
|
-
throw new VocoderAPIError({
|
|
483
|
-
message: extractErrorMessage(
|
|
484
|
-
payload,
|
|
485
|
-
`Failed to start GitHub install (${response.status})`
|
|
486
|
-
),
|
|
487
|
-
status: response.status,
|
|
488
|
-
payload
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
return payload;
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Start the "link existing installation" discovery flow.
|
|
495
|
-
* Unlike startCliGitHubOAuth, this requires no bearer token — the Vocoder
|
|
496
|
-
* account is created from the OAuth code in the callback.
|
|
497
|
-
*/
|
|
498
|
-
async startCliGitHubLinkSession(sessionId, callbackPort) {
|
|
499
|
-
const response = await fetch(
|
|
500
|
-
`${this.apiUrl}/api/cli/github/oauth/link-start`,
|
|
501
|
-
{
|
|
502
|
-
method: "POST",
|
|
503
|
-
headers: { "Content-Type": "application/json" },
|
|
504
|
-
body: JSON.stringify({
|
|
505
|
-
sessionId,
|
|
506
|
-
...callbackPort != null ? { callbackPort } : {}
|
|
507
|
-
})
|
|
508
|
-
}
|
|
509
|
-
);
|
|
510
|
-
const payload = await readPayload(response);
|
|
511
|
-
if (!response.ok) {
|
|
512
|
-
throw new VocoderAPIError({
|
|
513
|
-
message: extractErrorMessage(
|
|
514
|
-
payload,
|
|
515
|
-
`Failed to start GitHub link session (${response.status})`
|
|
516
|
-
),
|
|
517
|
-
status: response.status,
|
|
518
|
-
payload
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
return payload;
|
|
522
|
-
}
|
|
523
|
-
async startCliGitHubOAuth(userToken, params) {
|
|
524
|
-
const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/start`, {
|
|
525
|
-
method: "POST",
|
|
526
|
-
headers: {
|
|
527
|
-
Authorization: `Bearer ${userToken}`,
|
|
528
|
-
"Content-Type": "application/json"
|
|
529
|
-
},
|
|
530
|
-
body: JSON.stringify(params)
|
|
531
|
-
});
|
|
532
|
-
const payload = await readPayload(response);
|
|
533
|
-
if (!response.ok) {
|
|
534
|
-
throw new VocoderAPIError({
|
|
535
|
-
message: extractErrorMessage(
|
|
536
|
-
payload,
|
|
537
|
-
`Failed to start GitHub OAuth (${response.status})`
|
|
538
|
-
),
|
|
539
|
-
status: response.status,
|
|
540
|
-
payload
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
return payload;
|
|
544
|
-
}
|
|
545
|
-
async getCliGitHubDiscovery(userToken) {
|
|
546
|
-
const response = await fetch(`${this.apiUrl}/api/cli/github/discovery`, {
|
|
547
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
548
|
-
});
|
|
549
|
-
const payload = await readPayload(response);
|
|
550
|
-
if (!response.ok) {
|
|
551
|
-
throw new VocoderAPIError({
|
|
552
|
-
message: extractErrorMessage(
|
|
553
|
-
payload,
|
|
554
|
-
`Failed to fetch GitHub discovery (${response.status})`
|
|
555
|
-
),
|
|
556
|
-
status: response.status,
|
|
557
|
-
payload
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
return payload;
|
|
561
|
-
}
|
|
562
|
-
async claimCliGitHubInstallation(userToken, params) {
|
|
563
|
-
const response = await fetch(`${this.apiUrl}/api/cli/github/claim`, {
|
|
564
|
-
method: "POST",
|
|
565
|
-
headers: {
|
|
566
|
-
Authorization: `Bearer ${userToken}`,
|
|
567
|
-
"Content-Type": "application/json"
|
|
568
|
-
},
|
|
569
|
-
body: JSON.stringify(params)
|
|
570
|
-
});
|
|
571
|
-
const payload = await readPayload(response);
|
|
572
|
-
if (!response.ok) {
|
|
573
|
-
throw new VocoderAPIError({
|
|
574
|
-
message: extractErrorMessage(
|
|
575
|
-
payload,
|
|
576
|
-
`Failed to claim GitHub installation (${response.status})`
|
|
577
|
-
),
|
|
578
|
-
status: response.status,
|
|
579
|
-
payload
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
return payload;
|
|
583
|
-
}
|
|
584
|
-
// ── Locales ───────────────────────────────────────────────────────────────────
|
|
585
|
-
async listLocales(userToken) {
|
|
586
|
-
const response = await fetch(`${this.apiUrl}/api/cli/locales`, {
|
|
587
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
588
|
-
});
|
|
589
|
-
const payload = await readPayload(response);
|
|
590
|
-
if (!response.ok) {
|
|
591
|
-
throw new VocoderAPIError({
|
|
592
|
-
message: extractErrorMessage(
|
|
593
|
-
payload,
|
|
594
|
-
`Failed to list locales (${response.status})`
|
|
595
|
-
),
|
|
596
|
-
status: response.status,
|
|
597
|
-
payload
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
const result = payload;
|
|
601
|
-
return result;
|
|
602
|
-
}
|
|
603
|
-
async listCompatibleLocales(userToken, sourceLocale) {
|
|
604
|
-
const url = `${this.apiUrl}/api/cli/locales/compatible?source=${encodeURIComponent(sourceLocale)}`;
|
|
605
|
-
const response = await fetch(url, {
|
|
606
|
-
headers: { Authorization: `Bearer ${userToken}` }
|
|
607
|
-
});
|
|
608
|
-
const payload = await readPayload(response);
|
|
609
|
-
if (!response.ok) {
|
|
610
|
-
throw new VocoderAPIError({
|
|
611
|
-
message: extractErrorMessage(
|
|
612
|
-
payload,
|
|
613
|
-
`Failed to list compatible locales (${response.status})`
|
|
614
|
-
),
|
|
615
|
-
status: response.status,
|
|
616
|
-
payload
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
const result = payload;
|
|
620
|
-
return result.locales;
|
|
621
|
-
}
|
|
622
|
-
// ── Project creation ──────────────────────────────────────────────────────────
|
|
623
|
-
async createProject(userToken, params) {
|
|
624
|
-
const response = await fetch(`${this.apiUrl}/api/cli/projects`, {
|
|
625
|
-
method: "POST",
|
|
626
|
-
headers: {
|
|
627
|
-
"Content-Type": "application/json",
|
|
628
|
-
Authorization: `Bearer ${userToken}`
|
|
629
|
-
},
|
|
630
|
-
body: JSON.stringify(params)
|
|
631
|
-
});
|
|
632
|
-
const payload = await readPayload(response);
|
|
633
|
-
if (!response.ok) {
|
|
634
|
-
throw new VocoderAPIError({
|
|
635
|
-
message: extractErrorMessage(
|
|
636
|
-
payload,
|
|
637
|
-
`Failed to create project (${response.status})`
|
|
638
|
-
),
|
|
639
|
-
status: response.status,
|
|
640
|
-
payload
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
return payload;
|
|
644
|
-
}
|
|
645
|
-
// ── Project lookup ────────────────────────────────────────────────────────────
|
|
646
|
-
/**
|
|
647
|
-
* Look up all project apps for a given repo. Returns info about exact matches,
|
|
648
|
-
* existing apps in other scopes, and whether a whole-repo app exists.
|
|
649
|
-
* No auth required.
|
|
650
|
-
*/
|
|
651
|
-
async lookupProjectByRepo(params) {
|
|
652
|
-
try {
|
|
653
|
-
const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
|
|
654
|
-
method: "POST",
|
|
655
|
-
headers: { "Content-Type": "application/json" },
|
|
656
|
-
body: JSON.stringify({
|
|
657
|
-
repo: params.repoCanonical,
|
|
658
|
-
appDir: params.appDir
|
|
659
|
-
})
|
|
660
|
-
});
|
|
661
|
-
if (!response.ok) {
|
|
662
|
-
return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
|
|
663
|
-
}
|
|
664
|
-
const data = await response.json();
|
|
665
|
-
return {
|
|
666
|
-
exactMatch: data.exactMatch ?? null,
|
|
667
|
-
existingApps: data.existingApps ?? [],
|
|
668
|
-
hasWholeRepoApp: data.hasWholeRepoApp ?? false
|
|
669
|
-
};
|
|
670
|
-
} catch {
|
|
671
|
-
return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Add a new ProjectApp to an existing project (monorepo: new app directory).
|
|
676
|
-
* Does not check plan limits — no new project is created.
|
|
677
|
-
*/
|
|
678
|
-
async createProjectApp(userToken, params) {
|
|
679
|
-
const response = await fetch(`${this.apiUrl}/api/cli/project/apps`, {
|
|
680
|
-
method: "POST",
|
|
681
|
-
headers: {
|
|
682
|
-
"Content-Type": "application/json",
|
|
683
|
-
Authorization: `Bearer ${userToken}`
|
|
684
|
-
},
|
|
685
|
-
body: JSON.stringify(params)
|
|
686
|
-
});
|
|
687
|
-
const payload = await readPayload(response);
|
|
688
|
-
if (!response.ok) {
|
|
689
|
-
throw new VocoderAPIError({
|
|
690
|
-
message: extractErrorMessage(
|
|
691
|
-
payload,
|
|
692
|
-
`Failed to create project app (${response.status})`
|
|
693
|
-
),
|
|
694
|
-
status: response.status,
|
|
695
|
-
payload
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
return payload;
|
|
699
|
-
}
|
|
700
|
-
};
|
|
701
|
-
|
|
702
|
-
// src/utils/auth-store.ts
|
|
703
|
-
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
704
|
-
import { homedir } from "os";
|
|
705
|
-
import { dirname, join } from "path";
|
|
706
|
-
function getAuthFilePath() {
|
|
707
|
-
return join(homedir(), ".config", "vocoder", "auth.json");
|
|
708
|
-
}
|
|
709
|
-
function readAuthData() {
|
|
710
|
-
const filePath = getAuthFilePath();
|
|
711
|
-
try {
|
|
712
|
-
const raw = readFileSync(filePath, "utf8");
|
|
713
|
-
const parsed = JSON.parse(raw);
|
|
714
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
715
|
-
const data = parsed;
|
|
716
|
-
if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
719
|
-
return {
|
|
720
|
-
token: data.token,
|
|
721
|
-
apiUrl: data.apiUrl,
|
|
722
|
-
userId: data.userId,
|
|
723
|
-
email: data.email,
|
|
724
|
-
name: typeof data.name === "string" ? data.name : null,
|
|
725
|
-
createdAt: data.createdAt
|
|
726
|
-
};
|
|
727
|
-
} catch {
|
|
728
|
-
return null;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
function writeAuthData(data) {
|
|
732
|
-
const filePath = getAuthFilePath();
|
|
733
|
-
const dir = dirname(filePath);
|
|
734
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
735
|
-
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
|
|
736
|
-
}
|
|
737
|
-
function clearAuthData() {
|
|
738
|
-
const filePath = getAuthFilePath();
|
|
739
|
-
try {
|
|
740
|
-
unlinkSync(filePath);
|
|
741
|
-
} catch {
|
|
742
|
-
}
|
|
743
|
-
}
|
|
22
|
+
import { execSync as execSync3, spawn as spawn2 } from "child_process";
|
|
23
|
+
import { existsSync as existsSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
744
24
|
|
|
745
25
|
// src/utils/write-config.ts
|
|
746
|
-
import { existsSync, writeFileSync
|
|
747
|
-
import { join
|
|
26
|
+
import { existsSync, writeFileSync } from "fs";
|
|
27
|
+
import { join } from "path";
|
|
748
28
|
function findExistingConfig(cwd = process.cwd()) {
|
|
749
29
|
for (const name of [
|
|
750
30
|
"vocoder.config.ts",
|
|
751
31
|
"vocoder.config.js",
|
|
752
32
|
"vocoder.config.json"
|
|
753
33
|
]) {
|
|
754
|
-
const candidate =
|
|
34
|
+
const candidate = join(cwd, name);
|
|
755
35
|
if (existsSync(candidate)) return candidate;
|
|
756
36
|
}
|
|
757
37
|
return null;
|
|
@@ -764,7 +44,7 @@ function writeVocoderConfig(options) {
|
|
|
764
44
|
} = options;
|
|
765
45
|
if (findExistingConfig(cwd)) return null;
|
|
766
46
|
const ext = useTypeScript ? "ts" : "js";
|
|
767
|
-
const configPath =
|
|
47
|
+
const configPath = join(cwd, `vocoder.config.${ext}`);
|
|
768
48
|
const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
|
|
769
49
|
const content = `import { defineConfig } from '@vocoder/config'
|
|
770
50
|
|
|
@@ -782,121 +62,13 @@ export default defineConfig({
|
|
|
782
62
|
})
|
|
783
63
|
`;
|
|
784
64
|
try {
|
|
785
|
-
|
|
65
|
+
writeFileSync(configPath, content, "utf-8");
|
|
786
66
|
return `vocoder.config.${ext}`;
|
|
787
67
|
} catch {
|
|
788
68
|
return null;
|
|
789
69
|
}
|
|
790
70
|
}
|
|
791
71
|
|
|
792
|
-
// src/utils/git-identity.ts
|
|
793
|
-
import { execSync } from "child_process";
|
|
794
|
-
import { relative, resolve } from "path";
|
|
795
|
-
var SHA_REGEX = /^[0-9a-f]{40}$/i;
|
|
796
|
-
function detectCommitSha() {
|
|
797
|
-
if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
|
|
798
|
-
return process.env.VOCODER_COMMIT_SHA;
|
|
799
|
-
}
|
|
800
|
-
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;
|
|
801
|
-
if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
|
|
802
|
-
return safeExec("git rev-parse HEAD");
|
|
803
|
-
}
|
|
804
|
-
function safeExec(command) {
|
|
805
|
-
try {
|
|
806
|
-
const output = execSync(command, {
|
|
807
|
-
encoding: "utf-8",
|
|
808
|
-
stdio: ["pipe", "pipe", "ignore"]
|
|
809
|
-
}).trim();
|
|
810
|
-
return output.length > 0 ? output : null;
|
|
811
|
-
} catch {
|
|
812
|
-
return null;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
function normalizePath(pathname) {
|
|
816
|
-
const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
|
|
817
|
-
if (!cleaned || !cleaned.includes("/")) {
|
|
818
|
-
return null;
|
|
819
|
-
}
|
|
820
|
-
return cleaned;
|
|
821
|
-
}
|
|
822
|
-
function parseRemoteUrl(remoteUrl) {
|
|
823
|
-
const trimmed = remoteUrl.trim();
|
|
824
|
-
if (!trimmed) {
|
|
825
|
-
return null;
|
|
826
|
-
}
|
|
827
|
-
if (!trimmed.includes("://")) {
|
|
828
|
-
const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
|
|
829
|
-
if (scpMatch) {
|
|
830
|
-
const host = (scpMatch[1] || "").toLowerCase();
|
|
831
|
-
const ownerRepoPath = normalizePath(scpMatch[2] || "");
|
|
832
|
-
if (!host || !ownerRepoPath) {
|
|
833
|
-
return null;
|
|
834
|
-
}
|
|
835
|
-
return { host, ownerRepoPath };
|
|
836
|
-
}
|
|
837
|
-
return null;
|
|
838
|
-
}
|
|
839
|
-
try {
|
|
840
|
-
const parsed = new URL(trimmed);
|
|
841
|
-
const host = parsed.hostname.toLowerCase();
|
|
842
|
-
const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
|
|
843
|
-
if (!host || !ownerRepoPath) {
|
|
844
|
-
return null;
|
|
845
|
-
}
|
|
846
|
-
return { host, ownerRepoPath };
|
|
847
|
-
} catch {
|
|
848
|
-
return null;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
function toCanonical(host, ownerRepoPath) {
|
|
852
|
-
if (host.includes("github.com")) {
|
|
853
|
-
return `github:${ownerRepoPath.toLowerCase()}`;
|
|
854
|
-
}
|
|
855
|
-
if (host.includes("gitlab.com")) {
|
|
856
|
-
return `gitlab:${ownerRepoPath.toLowerCase()}`;
|
|
857
|
-
}
|
|
858
|
-
if (host.includes("bitbucket.org")) {
|
|
859
|
-
return `bitbucket:${ownerRepoPath.toLowerCase()}`;
|
|
860
|
-
}
|
|
861
|
-
return `git:${host}/${ownerRepoPath.toLowerCase()}`;
|
|
862
|
-
}
|
|
863
|
-
function resolveGitRepositoryIdentity() {
|
|
864
|
-
const remoteUrl = safeExec("git config --get remote.origin.url");
|
|
865
|
-
if (!remoteUrl) {
|
|
866
|
-
return null;
|
|
867
|
-
}
|
|
868
|
-
const parsed = parseRemoteUrl(remoteUrl);
|
|
869
|
-
if (!parsed) {
|
|
870
|
-
return null;
|
|
871
|
-
}
|
|
872
|
-
const repositoryRoot = safeExec("git rev-parse --show-toplevel");
|
|
873
|
-
const currentDirectory = process.cwd();
|
|
874
|
-
let repoAppDir = "";
|
|
875
|
-
if (repositoryRoot) {
|
|
876
|
-
const relativePath = relative(
|
|
877
|
-
resolve(repositoryRoot),
|
|
878
|
-
resolve(currentDirectory)
|
|
879
|
-
).replace(/\\/g, "/").trim();
|
|
880
|
-
if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
|
|
881
|
-
repoAppDir = relativePath;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
return {
|
|
885
|
-
repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
|
|
886
|
-
repoAppDir
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
function resolveGitContext() {
|
|
890
|
-
const warnings = [];
|
|
891
|
-
const identity = resolveGitRepositoryIdentity();
|
|
892
|
-
if (!identity) {
|
|
893
|
-
warnings.push(
|
|
894
|
-
"Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
|
|
895
|
-
);
|
|
896
|
-
}
|
|
897
|
-
return { identity, warnings };
|
|
898
|
-
}
|
|
899
|
-
|
|
900
72
|
// src/utils/github-connect.ts
|
|
901
73
|
import { spawn } from "child_process";
|
|
902
74
|
import * as p from "@clack/prompts";
|
|
@@ -1182,7 +354,7 @@ import * as p3 from "@clack/prompts";
|
|
|
1182
354
|
import chalk4 from "chalk";
|
|
1183
355
|
|
|
1184
356
|
// src/utils/branch-select.ts
|
|
1185
|
-
import { execSync
|
|
357
|
+
import { execSync } from "child_process";
|
|
1186
358
|
import { isCancel as isCancel2, Prompt } from "@clack/core";
|
|
1187
359
|
import chalk2 from "chalk";
|
|
1188
360
|
var S_BAR = "\u2502";
|
|
@@ -1213,14 +385,14 @@ function symbol(state) {
|
|
|
1213
385
|
function detectGitBranches(cwd) {
|
|
1214
386
|
const workDir = cwd ?? process.cwd();
|
|
1215
387
|
try {
|
|
1216
|
-
const localOut =
|
|
388
|
+
const localOut = execSync("git branch", {
|
|
1217
389
|
cwd: workDir,
|
|
1218
390
|
stdio: "pipe"
|
|
1219
391
|
}).toString();
|
|
1220
392
|
const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
|
|
1221
393
|
let remoteBranches = [];
|
|
1222
394
|
try {
|
|
1223
|
-
const remoteOut =
|
|
395
|
+
const remoteOut = execSync("git branch -r", {
|
|
1224
396
|
cwd: workDir,
|
|
1225
397
|
stdio: "pipe"
|
|
1226
398
|
}).toString();
|
|
@@ -1230,7 +402,7 @@ function detectGitBranches(cwd) {
|
|
|
1230
402
|
const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
|
|
1231
403
|
let defaultBranch = "main";
|
|
1232
404
|
try {
|
|
1233
|
-
const ref =
|
|
405
|
+
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
1234
406
|
cwd: workDir,
|
|
1235
407
|
stdio: "pipe"
|
|
1236
408
|
}).toString().trim();
|
|
@@ -1916,6 +1088,119 @@ async function runProjectAppCreate(params) {
|
|
|
1916
1088
|
}
|
|
1917
1089
|
}
|
|
1918
1090
|
|
|
1091
|
+
// src/commands/init.ts
|
|
1092
|
+
import chalk6 from "chalk";
|
|
1093
|
+
import { join as join2 } from "path";
|
|
1094
|
+
import { config as loadEnv } from "dotenv";
|
|
1095
|
+
|
|
1096
|
+
// src/utils/git-identity.ts
|
|
1097
|
+
import { execSync as execSync2 } from "child_process";
|
|
1098
|
+
import { relative, resolve } from "path";
|
|
1099
|
+
var SHA_REGEX = /^[0-9a-f]{40}$/i;
|
|
1100
|
+
function detectCommitSha() {
|
|
1101
|
+
if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
|
|
1102
|
+
return process.env.VOCODER_COMMIT_SHA;
|
|
1103
|
+
}
|
|
1104
|
+
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;
|
|
1105
|
+
if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
|
|
1106
|
+
return safeExec("git rev-parse HEAD");
|
|
1107
|
+
}
|
|
1108
|
+
function safeExec(command) {
|
|
1109
|
+
try {
|
|
1110
|
+
const output = execSync2(command, {
|
|
1111
|
+
encoding: "utf-8",
|
|
1112
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
1113
|
+
}).trim();
|
|
1114
|
+
return output.length > 0 ? output : null;
|
|
1115
|
+
} catch {
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
function normalizePath(pathname) {
|
|
1120
|
+
const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
|
|
1121
|
+
if (!cleaned || !cleaned.includes("/")) {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
return cleaned;
|
|
1125
|
+
}
|
|
1126
|
+
function parseRemoteUrl(remoteUrl) {
|
|
1127
|
+
const trimmed = remoteUrl.trim();
|
|
1128
|
+
if (!trimmed) {
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
if (!trimmed.includes("://")) {
|
|
1132
|
+
const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
|
|
1133
|
+
if (scpMatch) {
|
|
1134
|
+
const host = (scpMatch[1] || "").toLowerCase();
|
|
1135
|
+
const ownerRepoPath = normalizePath(scpMatch[2] || "");
|
|
1136
|
+
if (!host || !ownerRepoPath) {
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
return { host, ownerRepoPath };
|
|
1140
|
+
}
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
const parsed = new URL(trimmed);
|
|
1145
|
+
const host = parsed.hostname.toLowerCase();
|
|
1146
|
+
const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
|
|
1147
|
+
if (!host || !ownerRepoPath) {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
return { host, ownerRepoPath };
|
|
1151
|
+
} catch {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function toCanonical(host, ownerRepoPath) {
|
|
1156
|
+
if (host.includes("github.com")) {
|
|
1157
|
+
return `github:${ownerRepoPath.toLowerCase()}`;
|
|
1158
|
+
}
|
|
1159
|
+
if (host.includes("gitlab.com")) {
|
|
1160
|
+
return `gitlab:${ownerRepoPath.toLowerCase()}`;
|
|
1161
|
+
}
|
|
1162
|
+
if (host.includes("bitbucket.org")) {
|
|
1163
|
+
return `bitbucket:${ownerRepoPath.toLowerCase()}`;
|
|
1164
|
+
}
|
|
1165
|
+
return `git:${host}/${ownerRepoPath.toLowerCase()}`;
|
|
1166
|
+
}
|
|
1167
|
+
function resolveGitRepositoryIdentity() {
|
|
1168
|
+
const remoteUrl = safeExec("git config --get remote.origin.url");
|
|
1169
|
+
if (!remoteUrl) {
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
1173
|
+
if (!parsed) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
const repositoryRoot = safeExec("git rev-parse --show-toplevel");
|
|
1177
|
+
const currentDirectory = process.cwd();
|
|
1178
|
+
let repoAppDir = "";
|
|
1179
|
+
if (repositoryRoot) {
|
|
1180
|
+
const relativePath = relative(
|
|
1181
|
+
resolve(repositoryRoot),
|
|
1182
|
+
resolve(currentDirectory)
|
|
1183
|
+
).replace(/\\/g, "/").trim();
|
|
1184
|
+
if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
|
|
1185
|
+
repoAppDir = relativePath;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return {
|
|
1189
|
+
repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
|
|
1190
|
+
repoAppDir
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
function resolveGitContext() {
|
|
1194
|
+
const warnings = [];
|
|
1195
|
+
const identity = resolveGitRepositoryIdentity();
|
|
1196
|
+
if (!identity) {
|
|
1197
|
+
warnings.push(
|
|
1198
|
+
"Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
return { identity, warnings };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1919
1204
|
// src/utils/workspace.ts
|
|
1920
1205
|
import * as p4 from "@clack/prompts";
|
|
1921
1206
|
import chalk5 from "chalk";
|
|
@@ -2109,10 +1394,10 @@ function runScaffold(params) {
|
|
|
2109
1394
|
p5.log.message(chalk6.gray(" Docs: https://vocoder.app/docs/getting-started"));
|
|
2110
1395
|
}
|
|
2111
1396
|
function writeApiKeyToEnv(apiKey) {
|
|
2112
|
-
const envPath =
|
|
1397
|
+
const envPath = join2(process.cwd(), ".env");
|
|
2113
1398
|
if (!existsSync2(envPath)) return false;
|
|
2114
1399
|
try {
|
|
2115
|
-
const content =
|
|
1400
|
+
const content = readFileSync(envPath, "utf-8");
|
|
2116
1401
|
const keyLine = `VOCODER_API_KEY=${apiKey}`;
|
|
2117
1402
|
let updated;
|
|
2118
1403
|
if (/^VOCODER_API_KEY=/m.test(content)) {
|
|
@@ -2122,7 +1407,7 @@ function writeApiKeyToEnv(apiKey) {
|
|
|
2122
1407
|
updated = `${content}${sep}${keyLine}
|
|
2123
1408
|
`;
|
|
2124
1409
|
}
|
|
2125
|
-
|
|
1410
|
+
writeFileSync2(envPath, updated);
|
|
2126
1411
|
return true;
|
|
2127
1412
|
} catch {
|
|
2128
1413
|
return false;
|
|
@@ -2399,7 +1684,7 @@ async function init(options = {}) {
|
|
|
2399
1684
|
let userName;
|
|
2400
1685
|
let authOrganizationId;
|
|
2401
1686
|
const stored = readAuthData();
|
|
2402
|
-
if (stored
|
|
1687
|
+
if (stored) {
|
|
2403
1688
|
const verified = await verifyStoredToken(api, stored.token);
|
|
2404
1689
|
if (verified && !("userGone" in verified)) {
|
|
2405
1690
|
p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
|
|
@@ -2427,7 +1712,6 @@ async function init(options = {}) {
|
|
|
2427
1712
|
authOrganizationId = authResult.organizationId;
|
|
2428
1713
|
writeAuthData({
|
|
2429
1714
|
token: userToken,
|
|
2430
|
-
apiUrl,
|
|
2431
1715
|
userId: authResult.userId,
|
|
2432
1716
|
email: userEmail,
|
|
2433
1717
|
name: userName,
|
|
@@ -2448,7 +1732,6 @@ async function init(options = {}) {
|
|
|
2448
1732
|
authOrganizationId = authResult.organizationId;
|
|
2449
1733
|
writeAuthData({
|
|
2450
1734
|
token: userToken,
|
|
2451
|
-
apiUrl,
|
|
2452
1735
|
userId: authResult.userId,
|
|
2453
1736
|
email: userEmail,
|
|
2454
1737
|
name: userName,
|
|
@@ -2880,8 +2163,8 @@ async function logout(options = {}) {
|
|
|
2880
2163
|
|
|
2881
2164
|
// src/commands/sync.ts
|
|
2882
2165
|
import { createHash, randomUUID } from "crypto";
|
|
2883
|
-
import { existsSync as existsSync3, mkdirSync
|
|
2884
|
-
import { join as
|
|
2166
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
2167
|
+
import { join as join3 } from "path";
|
|
2885
2168
|
import * as p8 from "@clack/prompts";
|
|
2886
2169
|
import chalk8 from "chalk";
|
|
2887
2170
|
|
|
@@ -3162,7 +2445,7 @@ function parseTranslations(value) {
|
|
|
3162
2445
|
return Object.keys(translations).length > 0 ? translations : null;
|
|
3163
2446
|
}
|
|
3164
2447
|
function getCacheFilePath(projectRoot, fingerprint) {
|
|
3165
|
-
return
|
|
2448
|
+
return join3(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
|
|
3166
2449
|
}
|
|
3167
2450
|
function buildTranslationData(params) {
|
|
3168
2451
|
const textToHash = new Map(params.stringEntries.map((e) => [e.text, e.key]));
|
|
@@ -3189,7 +2472,7 @@ function readLocalCache(params) {
|
|
|
3189
2472
|
const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
|
|
3190
2473
|
if (!existsSync3(cacheFilePath)) return null;
|
|
3191
2474
|
try {
|
|
3192
|
-
const raw =
|
|
2475
|
+
const raw = readFileSync2(cacheFilePath, "utf-8");
|
|
3193
2476
|
const parsed = JSON.parse(raw);
|
|
3194
2477
|
if (!isRecord(parsed)) return null;
|
|
3195
2478
|
const inner = isRecord(parsed.config) ? parsed : null;
|
|
@@ -3203,10 +2486,10 @@ function readLocalCache(params) {
|
|
|
3203
2486
|
}
|
|
3204
2487
|
}
|
|
3205
2488
|
function writeCache(params) {
|
|
3206
|
-
const cacheDir =
|
|
3207
|
-
|
|
2489
|
+
const cacheDir = join3(params.projectRoot, "node_modules", ".vocoder", "cache");
|
|
2490
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
3208
2491
|
const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
|
|
3209
|
-
|
|
2492
|
+
writeFileSync3(cacheFilePath, JSON.stringify(params.data), "utf-8");
|
|
3210
2493
|
return cacheFilePath;
|
|
3211
2494
|
}
|
|
3212
2495
|
function resolveEffectiveModeFromPolicy(params) {
|
|
@@ -3325,7 +2608,8 @@ function buildStringEntries(extractedStrings) {
|
|
|
3325
2608
|
key: str.key,
|
|
3326
2609
|
text: str.text,
|
|
3327
2610
|
...str.context ? { context: str.context } : {},
|
|
3328
|
-
...str.formality ? { formality: str.formality } : {}
|
|
2611
|
+
...str.formality ? { formality: str.formality } : {},
|
|
2612
|
+
...str.uiRole ? { uiRole: str.uiRole } : {}
|
|
3329
2613
|
});
|
|
3330
2614
|
continue;
|
|
3331
2615
|
}
|
|
@@ -3374,9 +2658,7 @@ async function sync(options = {}) {
|
|
|
3374
2658
|
}
|
|
3375
2659
|
const spinner4 = p8.spinner();
|
|
3376
2660
|
try {
|
|
3377
|
-
spinner4.start("Detecting branch");
|
|
3378
2661
|
const branch = detectBranch(options.branch);
|
|
3379
|
-
spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
|
|
3380
2662
|
spinner4.start("Loading project configuration");
|
|
3381
2663
|
const localConfig = {
|
|
3382
2664
|
apiKey: mergedConfig.apiKey,
|
|
@@ -3391,14 +2673,17 @@ async function sync(options = {}) {
|
|
|
3391
2673
|
policyDefaultMaxWaitMs: apiConfig.syncPolicy.defaultMaxWaitMs,
|
|
3392
2674
|
fallbackTimeoutMs: 6e4
|
|
3393
2675
|
});
|
|
2676
|
+
const fileConfig = loadVocoderConfig(process.cwd());
|
|
3394
2677
|
const config = {
|
|
3395
2678
|
...localConfig,
|
|
3396
2679
|
...apiConfig,
|
|
3397
2680
|
includePattern: mergedConfig.includePattern,
|
|
3398
2681
|
excludePattern: mergedConfig.excludePattern,
|
|
3399
|
-
timeout: waitTimeoutMs
|
|
2682
|
+
timeout: waitTimeoutMs,
|
|
2683
|
+
...fileConfig?.appIndustry ? { appIndustry: fileConfig.appIndustry } : {},
|
|
2684
|
+
...fileConfig?.formality ? { formality: fileConfig.formality } : {}
|
|
3400
2685
|
};
|
|
3401
|
-
spinner4.stop(
|
|
2686
|
+
spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
|
|
3402
2687
|
if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
|
|
3403
2688
|
p8.log.warn(
|
|
3404
2689
|
`Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
|
|
@@ -3487,19 +2772,20 @@ async function sync(options = {}) {
|
|
|
3487
2772
|
requestedMode,
|
|
3488
2773
|
requestedMaxWaitMs: waitTimeoutMs,
|
|
3489
2774
|
clientRunId: randomUUID(),
|
|
3490
|
-
force: options.force
|
|
2775
|
+
force: options.force,
|
|
2776
|
+
// Sync appIndustry from vocoder.config.ts to ProjectApp on every push
|
|
2777
|
+
...config.appIndustry ? { appIndustry: config.appIndustry } : {}
|
|
3491
2778
|
},
|
|
3492
2779
|
repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
|
|
3493
2780
|
);
|
|
3494
|
-
spinner4.stop(
|
|
3495
|
-
`Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`
|
|
3496
|
-
);
|
|
2781
|
+
spinner4.stop("Strings submitted");
|
|
3497
2782
|
const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
|
|
3498
2783
|
branch,
|
|
3499
2784
|
requestedMode,
|
|
3500
2785
|
policy: config.syncPolicy
|
|
3501
2786
|
});
|
|
3502
2787
|
if (options.verbose) {
|
|
2788
|
+
p8.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
|
|
3503
2789
|
p8.log.info(`Requested mode: ${requestedMode}`);
|
|
3504
2790
|
p8.log.info(`Effective mode: ${effectiveMode}`);
|
|
3505
2791
|
p8.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
|
|
@@ -3508,24 +2794,17 @@ async function sync(options = {}) {
|
|
|
3508
2794
|
}
|
|
3509
2795
|
}
|
|
3510
2796
|
if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
|
|
3511
|
-
p8.log.success(
|
|
3512
|
-
}
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
p8.log.info(
|
|
3516
|
-
`Deleted strings: ${chalk8.yellow(batchResponse.deletedStrings)} (archived)`
|
|
3517
|
-
);
|
|
3518
|
-
}
|
|
3519
|
-
p8.log.info(`Total strings: ${chalk8.cyan(batchResponse.totalStrings)}`);
|
|
3520
|
-
if (batchResponse.newStrings === 0) {
|
|
3521
|
-
p8.log.success("No new strings - using existing translations");
|
|
2797
|
+
p8.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
|
|
2798
|
+
} else if (batchResponse.newStrings === 0) {
|
|
2799
|
+
const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk8.yellow(batchResponse.deletedStrings)} archived` : "";
|
|
2800
|
+
p8.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
|
|
3522
2801
|
} else {
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
if (batchResponse.estimatedTime) {
|
|
3527
|
-
p8.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
|
|
2802
|
+
const statParts = [`${chalk8.cyan(batchResponse.newStrings)} new, ${chalk8.cyan(batchResponse.totalStrings)} total`];
|
|
2803
|
+
if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
|
|
2804
|
+
statParts.push(`${chalk8.yellow(batchResponse.deletedStrings)} archived`);
|
|
3528
2805
|
}
|
|
2806
|
+
const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
|
|
2807
|
+
p8.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
|
|
3529
2808
|
}
|
|
3530
2809
|
let artifacts = null;
|
|
3531
2810
|
if (batchResponse.translations) {
|
|
@@ -3536,7 +2815,8 @@ async function sync(options = {}) {
|
|
|
3536
2815
|
}
|
|
3537
2816
|
let waitError = null;
|
|
3538
2817
|
if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
|
|
3539
|
-
|
|
2818
|
+
const waitTimeoutSecs = Math.round(waitTimeoutMs / 1e3);
|
|
2819
|
+
spinner4.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
|
|
3540
2820
|
let lastProgress = 0;
|
|
3541
2821
|
try {
|
|
3542
2822
|
const completion = await api.waitForCompletion(
|