@vocoder/cli 0.1.6 → 0.1.8
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 +134 -28
- package/dist/bin.mjs +1737 -784
- package/dist/bin.mjs.map +1 -1
- package/dist/chunk-OC5N5C5X.mjs +546 -0
- package/dist/chunk-OC5N5C5X.mjs.map +1 -0
- package/dist/lib.d.mts +175 -0
- package/dist/lib.mjs +15 -0
- package/dist/lib.mjs.map +1 -0
- package/package.json +8 -1
package/dist/bin.mjs
CHANGED
|
@@ -1,11 +1,320 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
StringExtractor,
|
|
4
|
+
buildInstallCommand,
|
|
5
|
+
detectLocalEcosystem,
|
|
6
|
+
getPackagesToInstall,
|
|
7
|
+
getSetupSnippets
|
|
8
|
+
} from "./chunk-OC5N5C5X.mjs";
|
|
2
9
|
|
|
3
10
|
// src/bin.ts
|
|
4
11
|
import { Command } from "commander";
|
|
5
12
|
|
|
6
13
|
// src/commands/init.ts
|
|
14
|
+
import * as p5 from "@clack/prompts";
|
|
15
|
+
|
|
16
|
+
// src/utils/auth-store.ts
|
|
17
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
18
|
+
import { homedir } from "os";
|
|
19
|
+
import { dirname, join } from "path";
|
|
20
|
+
function getAuthFilePath() {
|
|
21
|
+
return join(homedir(), ".config", "vocoder", "auth.json");
|
|
22
|
+
}
|
|
23
|
+
function readAuthData() {
|
|
24
|
+
const filePath = getAuthFilePath();
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(filePath, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
29
|
+
const data = parsed;
|
|
30
|
+
if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
token: data.token,
|
|
35
|
+
apiUrl: data.apiUrl,
|
|
36
|
+
userId: data.userId,
|
|
37
|
+
email: data.email,
|
|
38
|
+
name: typeof data.name === "string" ? data.name : null,
|
|
39
|
+
createdAt: data.createdAt
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function writeAuthData(data) {
|
|
46
|
+
const filePath = getAuthFilePath();
|
|
47
|
+
const dir = dirname(filePath);
|
|
48
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
49
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
|
|
50
|
+
}
|
|
51
|
+
function clearAuthData() {
|
|
52
|
+
const filePath = getAuthFilePath();
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(filePath);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/utils/github-connect.ts
|
|
7
60
|
import * as p from "@clack/prompts";
|
|
8
61
|
import chalk from "chalk";
|
|
62
|
+
import { spawn } from "child_process";
|
|
63
|
+
|
|
64
|
+
// src/utils/local-server.ts
|
|
65
|
+
import { createServer } from "http";
|
|
66
|
+
import { URL as URL2 } from "url";
|
|
67
|
+
function startCallbackServer() {
|
|
68
|
+
return new Promise((resolve2, reject) => {
|
|
69
|
+
let settled = false;
|
|
70
|
+
let callbackResolve = null;
|
|
71
|
+
let callbackReject = null;
|
|
72
|
+
const callbackPromise = new Promise((res, rej) => {
|
|
73
|
+
callbackResolve = res;
|
|
74
|
+
callbackReject = rej;
|
|
75
|
+
});
|
|
76
|
+
const server = createServer((req, res) => {
|
|
77
|
+
if (!req.url) {
|
|
78
|
+
res.writeHead(400);
|
|
79
|
+
res.end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
let pathname;
|
|
83
|
+
let params;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = new URL2(req.url, "http://localhost");
|
|
86
|
+
pathname = parsed.pathname;
|
|
87
|
+
params = Object.fromEntries(parsed.searchParams.entries());
|
|
88
|
+
} catch {
|
|
89
|
+
res.writeHead(400);
|
|
90
|
+
res.end("Bad request");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (pathname !== "/callback") {
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end("Not found");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
99
|
+
res.end(
|
|
100
|
+
'<!DOCTYPE html><html><head><title>Authenticated</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;"><h2>Authenticated</h2><p>Return to your terminal to continue. You can close this tab.</p></body></html>'
|
|
101
|
+
);
|
|
102
|
+
if (callbackResolve) {
|
|
103
|
+
callbackResolve(params);
|
|
104
|
+
callbackResolve = null;
|
|
105
|
+
}
|
|
106
|
+
setImmediate(() => server.close());
|
|
107
|
+
});
|
|
108
|
+
server.on("error", (err) => {
|
|
109
|
+
if (!settled) {
|
|
110
|
+
settled = true;
|
|
111
|
+
if (callbackReject) callbackReject(err);
|
|
112
|
+
reject(err);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
server.listen(0, "127.0.0.1", () => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
settled = true;
|
|
118
|
+
const port = server.address().port;
|
|
119
|
+
resolve2({
|
|
120
|
+
port,
|
|
121
|
+
waitForCallback: () => callbackPromise,
|
|
122
|
+
close: () => server.close()
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/utils/github-connect.ts
|
|
129
|
+
async function tryOpenBrowser(url) {
|
|
130
|
+
if (!process.stdout.isTTY || process.env.CI === "true") {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const platform = process.platform;
|
|
134
|
+
let command;
|
|
135
|
+
let args;
|
|
136
|
+
if (platform === "darwin") {
|
|
137
|
+
command = "open";
|
|
138
|
+
args = [url];
|
|
139
|
+
} else if (platform === "win32") {
|
|
140
|
+
command = "rundll32";
|
|
141
|
+
args = ["url.dll,FileProtocolHandler", url];
|
|
142
|
+
} else {
|
|
143
|
+
command = "xdg-open";
|
|
144
|
+
args = [url];
|
|
145
|
+
}
|
|
146
|
+
return new Promise((resolve2) => {
|
|
147
|
+
try {
|
|
148
|
+
const child = spawn(command, args, {
|
|
149
|
+
detached: true,
|
|
150
|
+
stdio: "ignore",
|
|
151
|
+
windowsHide: true
|
|
152
|
+
});
|
|
153
|
+
let settled = false;
|
|
154
|
+
child.once("spawn", () => {
|
|
155
|
+
if (settled) return;
|
|
156
|
+
settled = true;
|
|
157
|
+
child.unref();
|
|
158
|
+
resolve2(true);
|
|
159
|
+
});
|
|
160
|
+
child.once("error", () => {
|
|
161
|
+
if (settled) return;
|
|
162
|
+
settled = true;
|
|
163
|
+
resolve2(false);
|
|
164
|
+
});
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
if (settled) return;
|
|
167
|
+
settled = true;
|
|
168
|
+
resolve2(false);
|
|
169
|
+
}, 300);
|
|
170
|
+
} catch {
|
|
171
|
+
resolve2(false);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function runGitHubInstallFlow(params) {
|
|
176
|
+
let server = null;
|
|
177
|
+
try {
|
|
178
|
+
server = await startCallbackServer();
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
const { installUrl } = await params.api.startCliGitHubInstall(params.userToken, {
|
|
182
|
+
organizationId: params.organizationId,
|
|
183
|
+
callbackPort: server?.port
|
|
184
|
+
});
|
|
185
|
+
p.log.info("Opening GitHub to install the Vocoder App...");
|
|
186
|
+
p.note(installUrl, "Install URL");
|
|
187
|
+
if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
|
|
188
|
+
const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
|
|
189
|
+
if (p.isCancel(shouldOpen)) {
|
|
190
|
+
server?.close();
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
if (shouldOpen) {
|
|
194
|
+
const opened = await tryOpenBrowser(installUrl);
|
|
195
|
+
if (!opened) {
|
|
196
|
+
p.log.info("Could not open a browser automatically. Use the URL above.");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const connectSpinner = p.spinner();
|
|
201
|
+
connectSpinner.start("Waiting for GitHub App installation...");
|
|
202
|
+
if (server) {
|
|
203
|
+
try {
|
|
204
|
+
const params_timeout = 15 * 60 * 1e3;
|
|
205
|
+
const callbackParams = await Promise.race([
|
|
206
|
+
server.waitForCallback(),
|
|
207
|
+
new Promise((resolve2) => setTimeout(() => resolve2(null), params_timeout))
|
|
208
|
+
]);
|
|
209
|
+
server.close();
|
|
210
|
+
if (!callbackParams) {
|
|
211
|
+
connectSpinner.stop("GitHub App installation timed out");
|
|
212
|
+
p.log.error("The installation flow timed out. Run `vocoder init` again.");
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (callbackParams.error) {
|
|
216
|
+
connectSpinner.stop("GitHub App installation failed");
|
|
217
|
+
p.log.error(callbackParams.error);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const { organizationId, connectionLabel, workspace_created } = callbackParams;
|
|
221
|
+
if (!organizationId || !connectionLabel) {
|
|
222
|
+
connectSpinner.stop("GitHub App installation incomplete");
|
|
223
|
+
p.log.error("Missing organization or connection data from callback.");
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
connectSpinner.stop(`Connected to GitHub as ${chalk.bold(connectionLabel)}`);
|
|
227
|
+
const orgName = workspace_created ? connectionLabel : organizationId;
|
|
228
|
+
return {
|
|
229
|
+
organizationId,
|
|
230
|
+
organizationName: orgName,
|
|
231
|
+
connectionLabel
|
|
232
|
+
};
|
|
233
|
+
} catch {
|
|
234
|
+
server.close();
|
|
235
|
+
connectSpinner.stop("GitHub App installation failed");
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
connectSpinner.stop("Could not detect GitHub App installation automatically");
|
|
240
|
+
p.log.warn("Complete the installation in your browser, then run `vocoder init` again.");
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
async function runGitHubDiscoveryFlow(params) {
|
|
244
|
+
let server = null;
|
|
245
|
+
try {
|
|
246
|
+
server = await startCallbackServer();
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
|
|
250
|
+
organizationId: params.organizationId,
|
|
251
|
+
callbackPort: server?.port
|
|
252
|
+
});
|
|
253
|
+
p.log.info("Opening GitHub to authorize your account...");
|
|
254
|
+
p.note("Complete authorization in your browser.");
|
|
255
|
+
if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
|
|
256
|
+
const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
|
|
257
|
+
if (p.isCancel(shouldOpen)) {
|
|
258
|
+
server?.close();
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (shouldOpen) {
|
|
262
|
+
const opened = await tryOpenBrowser(oauthUrl);
|
|
263
|
+
if (!opened) {
|
|
264
|
+
p.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const oauthSpinner = p.spinner();
|
|
269
|
+
oauthSpinner.start("Waiting for GitHub authorization...");
|
|
270
|
+
if (server) {
|
|
271
|
+
try {
|
|
272
|
+
const timeoutMs = 10 * 60 * 1e3;
|
|
273
|
+
const callbackParams = await Promise.race([
|
|
274
|
+
server.waitForCallback(),
|
|
275
|
+
new Promise((resolve2) => setTimeout(() => resolve2(null), timeoutMs))
|
|
276
|
+
]);
|
|
277
|
+
server.close();
|
|
278
|
+
if (!callbackParams) {
|
|
279
|
+
oauthSpinner.stop("GitHub authorization timed out");
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (callbackParams.error) {
|
|
283
|
+
oauthSpinner.stop("GitHub authorization failed");
|
|
284
|
+
p.log.error(callbackParams.error);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
server.close();
|
|
289
|
+
oauthSpinner.stop("GitHub authorization failed");
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
oauthSpinner.stop("GitHub account authorized");
|
|
294
|
+
const discoveryResult = await params.api.getCliGitHubDiscovery(params.userToken);
|
|
295
|
+
return discoveryResult.installations;
|
|
296
|
+
}
|
|
297
|
+
async function selectGitHubInstallation(installations, canInstallNew) {
|
|
298
|
+
const options = installations.map((inst) => ({
|
|
299
|
+
value: String(inst.installationId),
|
|
300
|
+
label: inst.accountLogin,
|
|
301
|
+
hint: [
|
|
302
|
+
inst.accountType === "Organization" ? "organization" : "personal",
|
|
303
|
+
inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
|
|
304
|
+
inst.isSuspended ? "suspended" : ""
|
|
305
|
+
].filter(Boolean).join(" \xB7 ") || void 0
|
|
306
|
+
}));
|
|
307
|
+
if (canInstallNew) {
|
|
308
|
+
options.push({ value: "install_new", label: "Install on a new account" });
|
|
309
|
+
}
|
|
310
|
+
const selected = await p.select({
|
|
311
|
+
message: "Select a GitHub installation",
|
|
312
|
+
options
|
|
313
|
+
});
|
|
314
|
+
if (p.isCancel(selected)) return null;
|
|
315
|
+
if (selected === "install_new") return "install_new";
|
|
316
|
+
return Number(selected);
|
|
317
|
+
}
|
|
9
318
|
|
|
10
319
|
// src/utils/api.ts
|
|
11
320
|
function isLimitErrorResponse(value) {
|
|
@@ -117,10 +426,10 @@ var VocoderAPI = class {
|
|
|
117
426
|
* Submit strings for translation
|
|
118
427
|
* Project is determined from the API key
|
|
119
428
|
*/
|
|
120
|
-
stableTextKey(
|
|
429
|
+
stableTextKey(text2) {
|
|
121
430
|
let hash = 2166136261;
|
|
122
|
-
for (let i = 0; i <
|
|
123
|
-
hash ^=
|
|
431
|
+
for (let i = 0; i < text2.length; i++) {
|
|
432
|
+
hash ^= text2.charCodeAt(i);
|
|
124
433
|
hash = Math.imul(hash, 16777619);
|
|
125
434
|
}
|
|
126
435
|
return `SK_TEXT_${(hash >>> 0).toString(16).toUpperCase().padStart(8, "0")}`;
|
|
@@ -131,9 +440,9 @@ var VocoderAPI = class {
|
|
|
131
440
|
}
|
|
132
441
|
const first = entries[0];
|
|
133
442
|
if (typeof first === "string") {
|
|
134
|
-
return entries.map((
|
|
135
|
-
key: this.stableTextKey(
|
|
136
|
-
text
|
|
443
|
+
return entries.map((text2) => ({
|
|
444
|
+
key: this.stableTextKey(text2),
|
|
445
|
+
text: text2
|
|
137
446
|
}));
|
|
138
447
|
}
|
|
139
448
|
return entries.map((entry, index) => ({
|
|
@@ -255,6 +564,245 @@ var VocoderAPI = class {
|
|
|
255
564
|
}
|
|
256
565
|
return payload;
|
|
257
566
|
}
|
|
567
|
+
// ── CLI Auth endpoints (no project API key needed) ──────────────────────────
|
|
568
|
+
/**
|
|
569
|
+
* Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
|
|
570
|
+
* `sessionId` is the raw poll token — keep it secret, used for polling.
|
|
571
|
+
*/
|
|
572
|
+
async startCliAuthSession(callbackPort, repoCanonical) {
|
|
573
|
+
const response = await fetch(`${this.apiUrl}/api/cli/auth/start`, {
|
|
574
|
+
method: "POST",
|
|
575
|
+
headers: { "Content-Type": "application/json" },
|
|
576
|
+
body: JSON.stringify({
|
|
577
|
+
...callbackPort != null ? { callbackPort } : {},
|
|
578
|
+
...repoCanonical ? { repoCanonical } : {}
|
|
579
|
+
})
|
|
580
|
+
});
|
|
581
|
+
const payload = await readPayload(response);
|
|
582
|
+
if (!response.ok) {
|
|
583
|
+
throw new VocoderAPIError({
|
|
584
|
+
message: extractErrorMessage(payload, `Failed to start auth session (${response.status})`),
|
|
585
|
+
status: response.status,
|
|
586
|
+
payload
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return payload;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Poll for CLI auth session completion.
|
|
593
|
+
* Returns `{ token }` on success, throws on failure/expiry.
|
|
594
|
+
* The server returns HTTP 202 while still pending.
|
|
595
|
+
*/
|
|
596
|
+
async pollCliAuthSession(pollToken) {
|
|
597
|
+
const response = await fetch(
|
|
598
|
+
`${this.apiUrl}/api/cli/auth/session?session=${encodeURIComponent(pollToken)}`
|
|
599
|
+
);
|
|
600
|
+
const payload = await readPayload(response);
|
|
601
|
+
if (response.status === 202) {
|
|
602
|
+
return { status: "pending" };
|
|
603
|
+
}
|
|
604
|
+
if (response.status === 410) {
|
|
605
|
+
return {
|
|
606
|
+
status: "failed",
|
|
607
|
+
reason: extractErrorMessage(payload, "Auth session expired or failed")
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (!response.ok) {
|
|
611
|
+
return {
|
|
612
|
+
status: "failed",
|
|
613
|
+
reason: extractErrorMessage(payload, `Auth session error (${response.status})`)
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const result = payload;
|
|
617
|
+
if (!result.token) {
|
|
618
|
+
return { status: "failed", reason: "No token in response" };
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
status: "complete",
|
|
622
|
+
token: result.token,
|
|
623
|
+
...result.organizationId ? { organizationId: result.organizationId } : {}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Validate a CLI user token and return the authenticated user's info.
|
|
628
|
+
* Used by the CLI to verify stored credentials on startup.
|
|
629
|
+
*/
|
|
630
|
+
async getCliUserInfo(userToken) {
|
|
631
|
+
const response = await fetch(`${this.apiUrl}/api/cli/auth/me`, {
|
|
632
|
+
headers: { Authorization: `Bearer ${userToken}` }
|
|
633
|
+
});
|
|
634
|
+
const payload = await readPayload(response);
|
|
635
|
+
if (!response.ok) {
|
|
636
|
+
throw new VocoderAPIError({
|
|
637
|
+
message: extractErrorMessage(payload, `Token validation failed (${response.status})`),
|
|
638
|
+
status: response.status,
|
|
639
|
+
payload
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return payload;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Revoke the given CLI user token server-side.
|
|
646
|
+
*/
|
|
647
|
+
async revokeCliToken(userToken) {
|
|
648
|
+
const response = await fetch(`${this.apiUrl}/api/cli/auth/token`, {
|
|
649
|
+
method: "DELETE",
|
|
650
|
+
headers: { Authorization: `Bearer ${userToken}` }
|
|
651
|
+
});
|
|
652
|
+
if (!response.ok) {
|
|
653
|
+
const payload = await readPayload(response);
|
|
654
|
+
throw new VocoderAPIError({
|
|
655
|
+
message: extractErrorMessage(payload, `Token revocation failed (${response.status})`),
|
|
656
|
+
status: response.status,
|
|
657
|
+
payload
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// ── Workspaces ────────────────────────────────────────────────────────────────
|
|
662
|
+
async listWorkspaces(userToken) {
|
|
663
|
+
const response = await fetch(`${this.apiUrl}/api/cli/workspaces`, {
|
|
664
|
+
headers: { Authorization: `Bearer ${userToken}` }
|
|
665
|
+
});
|
|
666
|
+
const payload = await readPayload(response);
|
|
667
|
+
if (!response.ok) {
|
|
668
|
+
throw new VocoderAPIError({
|
|
669
|
+
message: extractErrorMessage(payload, `Failed to list workspaces (${response.status})`),
|
|
670
|
+
status: response.status,
|
|
671
|
+
payload
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return payload;
|
|
675
|
+
}
|
|
676
|
+
// ── CLI GitHub endpoints ──────────────────────────────────────────────────────
|
|
677
|
+
async startCliGitHubInstall(userToken, params) {
|
|
678
|
+
const response = await fetch(`${this.apiUrl}/api/cli/github/install/start`, {
|
|
679
|
+
method: "POST",
|
|
680
|
+
headers: {
|
|
681
|
+
Authorization: `Bearer ${userToken}`,
|
|
682
|
+
"Content-Type": "application/json"
|
|
683
|
+
},
|
|
684
|
+
body: JSON.stringify(params)
|
|
685
|
+
});
|
|
686
|
+
const payload = await readPayload(response);
|
|
687
|
+
if (!response.ok) {
|
|
688
|
+
throw new VocoderAPIError({
|
|
689
|
+
message: extractErrorMessage(payload, `Failed to start GitHub install (${response.status})`),
|
|
690
|
+
status: response.status,
|
|
691
|
+
payload
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return payload;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Start the "link existing installation" discovery flow.
|
|
698
|
+
* Unlike startCliGitHubOAuth, this requires no bearer token — the Vocoder
|
|
699
|
+
* account is created from the OAuth code in the callback.
|
|
700
|
+
*/
|
|
701
|
+
async startCliGitHubLinkSession(sessionId, callbackPort) {
|
|
702
|
+
const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/link-start`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: { "Content-Type": "application/json" },
|
|
705
|
+
body: JSON.stringify({ sessionId, ...callbackPort != null ? { callbackPort } : {} })
|
|
706
|
+
});
|
|
707
|
+
const payload = await readPayload(response);
|
|
708
|
+
if (!response.ok) {
|
|
709
|
+
throw new VocoderAPIError({
|
|
710
|
+
message: extractErrorMessage(payload, `Failed to start GitHub link session (${response.status})`),
|
|
711
|
+
status: response.status,
|
|
712
|
+
payload
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return payload;
|
|
716
|
+
}
|
|
717
|
+
async startCliGitHubOAuth(userToken, params) {
|
|
718
|
+
const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/start`, {
|
|
719
|
+
method: "POST",
|
|
720
|
+
headers: {
|
|
721
|
+
Authorization: `Bearer ${userToken}`,
|
|
722
|
+
"Content-Type": "application/json"
|
|
723
|
+
},
|
|
724
|
+
body: JSON.stringify(params)
|
|
725
|
+
});
|
|
726
|
+
const payload = await readPayload(response);
|
|
727
|
+
if (!response.ok) {
|
|
728
|
+
throw new VocoderAPIError({
|
|
729
|
+
message: extractErrorMessage(payload, `Failed to start GitHub OAuth (${response.status})`),
|
|
730
|
+
status: response.status,
|
|
731
|
+
payload
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return payload;
|
|
735
|
+
}
|
|
736
|
+
async getCliGitHubDiscovery(userToken) {
|
|
737
|
+
const response = await fetch(`${this.apiUrl}/api/cli/github/discovery`, {
|
|
738
|
+
headers: { Authorization: `Bearer ${userToken}` }
|
|
739
|
+
});
|
|
740
|
+
const payload = await readPayload(response);
|
|
741
|
+
if (!response.ok) {
|
|
742
|
+
throw new VocoderAPIError({
|
|
743
|
+
message: extractErrorMessage(payload, `Failed to fetch GitHub discovery (${response.status})`),
|
|
744
|
+
status: response.status,
|
|
745
|
+
payload
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
return payload;
|
|
749
|
+
}
|
|
750
|
+
async claimCliGitHubInstallation(userToken, params) {
|
|
751
|
+
const response = await fetch(`${this.apiUrl}/api/cli/github/claim`, {
|
|
752
|
+
method: "POST",
|
|
753
|
+
headers: {
|
|
754
|
+
Authorization: `Bearer ${userToken}`,
|
|
755
|
+
"Content-Type": "application/json"
|
|
756
|
+
},
|
|
757
|
+
body: JSON.stringify(params)
|
|
758
|
+
});
|
|
759
|
+
const payload = await readPayload(response);
|
|
760
|
+
if (!response.ok) {
|
|
761
|
+
throw new VocoderAPIError({
|
|
762
|
+
message: extractErrorMessage(payload, `Failed to claim GitHub installation (${response.status})`),
|
|
763
|
+
status: response.status,
|
|
764
|
+
payload
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
return payload;
|
|
768
|
+
}
|
|
769
|
+
// ── Locales ───────────────────────────────────────────────────────────────────
|
|
770
|
+
async listLocales(userToken) {
|
|
771
|
+
const response = await fetch(`${this.apiUrl}/api/cli/locales`, {
|
|
772
|
+
headers: { Authorization: `Bearer ${userToken}` }
|
|
773
|
+
});
|
|
774
|
+
const payload = await readPayload(response);
|
|
775
|
+
if (!response.ok) {
|
|
776
|
+
throw new VocoderAPIError({
|
|
777
|
+
message: extractErrorMessage(payload, `Failed to list locales (${response.status})`),
|
|
778
|
+
status: response.status,
|
|
779
|
+
payload
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
const result = payload;
|
|
783
|
+
return result.locales;
|
|
784
|
+
}
|
|
785
|
+
// ── Project creation ──────────────────────────────────────────────────────────
|
|
786
|
+
async createProject(userToken, params) {
|
|
787
|
+
const response = await fetch(`${this.apiUrl}/api/cli/projects`, {
|
|
788
|
+
method: "POST",
|
|
789
|
+
headers: {
|
|
790
|
+
"Content-Type": "application/json",
|
|
791
|
+
Authorization: `Bearer ${userToken}`
|
|
792
|
+
},
|
|
793
|
+
body: JSON.stringify(params)
|
|
794
|
+
});
|
|
795
|
+
const payload = await readPayload(response);
|
|
796
|
+
if (!response.ok) {
|
|
797
|
+
throw new VocoderAPIError({
|
|
798
|
+
message: extractErrorMessage(payload, `Failed to create project (${response.status})`),
|
|
799
|
+
status: response.status,
|
|
800
|
+
payload
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
return payload;
|
|
804
|
+
}
|
|
805
|
+
// ── Project lookup ────────────────────────────────────────────────────────────
|
|
258
806
|
/**
|
|
259
807
|
* Look up whether a project already exists for a given repo + scope.
|
|
260
808
|
* Returns { projectId, projectName, organizationName } or null if not found.
|
|
@@ -278,271 +826,10 @@ var VocoderAPI = class {
|
|
|
278
826
|
}
|
|
279
827
|
};
|
|
280
828
|
|
|
281
|
-
// src/
|
|
282
|
-
import
|
|
283
|
-
import {
|
|
284
|
-
|
|
285
|
-
const packageManager = detectPackageManager(cwd);
|
|
286
|
-
const pkg = readPackageJson(cwd);
|
|
287
|
-
if (!pkg) {
|
|
288
|
-
return {
|
|
289
|
-
ecosystem: null,
|
|
290
|
-
framework: null,
|
|
291
|
-
packageManager,
|
|
292
|
-
uiPackage: null,
|
|
293
|
-
hasUnplugin: false,
|
|
294
|
-
hasUiPackage: false,
|
|
295
|
-
sourceLocale: null
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
const allDeps = {
|
|
299
|
-
...pkg.dependencies ?? {},
|
|
300
|
-
...pkg.devDependencies ?? {}
|
|
301
|
-
};
|
|
302
|
-
const hasUnplugin = "@vocoder/unplugin" in allDeps;
|
|
303
|
-
const { ecosystem, framework, uiPackage } = detectFromDeps(allDeps, cwd);
|
|
304
|
-
const hasUiPackage = uiPackage !== null && uiPackage in allDeps;
|
|
305
|
-
return {
|
|
306
|
-
ecosystem,
|
|
307
|
-
framework,
|
|
308
|
-
packageManager,
|
|
309
|
-
uiPackage,
|
|
310
|
-
hasUnplugin,
|
|
311
|
-
hasUiPackage,
|
|
312
|
-
sourceLocale: null
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
function detectPackageManager(cwd) {
|
|
316
|
-
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
317
|
-
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return "bun";
|
|
318
|
-
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
319
|
-
return "npm";
|
|
320
|
-
}
|
|
321
|
-
function readPackageJson(cwd) {
|
|
322
|
-
const pkgPath = join(cwd, "package.json");
|
|
323
|
-
if (!existsSync(pkgPath)) return null;
|
|
324
|
-
try {
|
|
325
|
-
return JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
326
|
-
} catch {
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
function detectFromDeps(allDeps, cwd) {
|
|
331
|
-
if ("vue" in allDeps) {
|
|
332
|
-
const framework = "nuxt" in allDeps ? "nuxt" : null;
|
|
333
|
-
return { ecosystem: "vue", framework, uiPackage: "@vocoder/vue" };
|
|
334
|
-
}
|
|
335
|
-
if ("svelte" in allDeps) {
|
|
336
|
-
const framework = "@sveltejs/kit" in allDeps ? "sveltekit" : null;
|
|
337
|
-
return { ecosystem: "svelte", framework, uiPackage: "@vocoder/svelte" };
|
|
338
|
-
}
|
|
339
|
-
if ("@angular/core" in allDeps || existsSync(join(cwd, "angular.json"))) {
|
|
340
|
-
return { ecosystem: "angular", framework: "angular", uiPackage: "@vocoder/angular" };
|
|
341
|
-
}
|
|
342
|
-
if ("react" in allDeps) {
|
|
343
|
-
let framework = null;
|
|
344
|
-
if ("next" in allDeps) framework = "nextjs";
|
|
345
|
-
else if ("@remix-run/react" in allDeps) framework = "remix";
|
|
346
|
-
else if ("gatsby" in allDeps) framework = "gatsby";
|
|
347
|
-
else if ("vite" in allDeps) framework = "vite";
|
|
348
|
-
return { ecosystem: "react", framework, uiPackage: "@vocoder/react" };
|
|
349
|
-
}
|
|
350
|
-
return { ecosystem: null, framework: null, uiPackage: null };
|
|
351
|
-
}
|
|
352
|
-
function buildInstallCommand(packageManager, packages) {
|
|
353
|
-
if (packages.length === 0) return "";
|
|
354
|
-
const pkgList = packages.join(" ");
|
|
355
|
-
switch (packageManager) {
|
|
356
|
-
case "pnpm":
|
|
357
|
-
return `pnpm add ${pkgList}`;
|
|
358
|
-
case "yarn":
|
|
359
|
-
return `yarn add ${pkgList}`;
|
|
360
|
-
case "bun":
|
|
361
|
-
return `bun add ${pkgList}`;
|
|
362
|
-
default:
|
|
363
|
-
return `npm install ${pkgList}`;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
function getPackagesToInstall(detection) {
|
|
367
|
-
const packages = [];
|
|
368
|
-
if (!detection.hasUnplugin) packages.push("@vocoder/unplugin");
|
|
369
|
-
if (detection.uiPackage && !detection.hasUiPackage) packages.push(detection.uiPackage);
|
|
370
|
-
return packages;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// src/utils/setup-snippets.ts
|
|
374
|
-
function getSetupSnippets(params) {
|
|
375
|
-
const { framework, ecosystem, sourceLocale, translationTriggers } = params;
|
|
376
|
-
return {
|
|
377
|
-
pluginStep: getPluginSnippet(framework, ecosystem),
|
|
378
|
-
providerStep: getProviderSnippet(ecosystem, sourceLocale),
|
|
379
|
-
wrapStep: getWrapSnippet(ecosystem),
|
|
380
|
-
whatsNext: getWhatsNextMessage(translationTriggers)
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
function getPluginSnippet(framework, ecosystem) {
|
|
384
|
-
switch (framework) {
|
|
385
|
-
case "nextjs":
|
|
386
|
-
return {
|
|
387
|
-
file: "next.config.ts",
|
|
388
|
-
code: `import { withVocoder } from '@vocoder/unplugin/next';
|
|
389
|
-
|
|
390
|
-
export default withVocoder({
|
|
391
|
-
// your existing Next.js config
|
|
392
|
-
});`
|
|
393
|
-
};
|
|
394
|
-
case "vite":
|
|
395
|
-
case "remix":
|
|
396
|
-
return {
|
|
397
|
-
file: "vite.config.ts",
|
|
398
|
-
code: `import vocoder from '@vocoder/unplugin/vite';
|
|
399
|
-
|
|
400
|
-
export default defineConfig({
|
|
401
|
-
plugins: [
|
|
402
|
-
vocoder(),
|
|
403
|
-
// your other plugins
|
|
404
|
-
],
|
|
405
|
-
});`
|
|
406
|
-
};
|
|
407
|
-
case "nuxt":
|
|
408
|
-
return {
|
|
409
|
-
file: "nuxt.config.ts",
|
|
410
|
-
code: `import vocoder from '@vocoder/unplugin/vite';
|
|
411
|
-
|
|
412
|
-
export default defineNuxtConfig({
|
|
413
|
-
vite: {
|
|
414
|
-
plugins: [vocoder()],
|
|
415
|
-
},
|
|
416
|
-
});`
|
|
417
|
-
};
|
|
418
|
-
case "sveltekit":
|
|
419
|
-
return {
|
|
420
|
-
file: "vite.config.ts",
|
|
421
|
-
code: `import vocoder from '@vocoder/unplugin/vite';
|
|
422
|
-
import { sveltekit } from '@sveltejs/kit/vite';
|
|
423
|
-
|
|
424
|
-
export default defineConfig({
|
|
425
|
-
plugins: [
|
|
426
|
-
sveltekit(),
|
|
427
|
-
vocoder(),
|
|
428
|
-
],
|
|
429
|
-
});`
|
|
430
|
-
};
|
|
431
|
-
case "gatsby":
|
|
432
|
-
return {
|
|
433
|
-
file: "gatsby-node.js",
|
|
434
|
-
code: `const vocoder = require('@vocoder/unplugin/webpack');
|
|
435
|
-
|
|
436
|
-
exports.onCreateWebpackConfig = ({ actions }) => {
|
|
437
|
-
actions.setWebpackConfig({
|
|
438
|
-
plugins: [vocoder()],
|
|
439
|
-
});
|
|
440
|
-
};`
|
|
441
|
-
};
|
|
442
|
-
case "angular":
|
|
443
|
-
return null;
|
|
444
|
-
// Angular CLI doesn't expose plugin config easily
|
|
445
|
-
default:
|
|
446
|
-
if (ecosystem) {
|
|
447
|
-
return {
|
|
448
|
-
file: "your bundler config",
|
|
449
|
-
code: `// Vite
|
|
450
|
-
import vocoder from '@vocoder/unplugin/vite';
|
|
451
|
-
// Webpack
|
|
452
|
-
const vocoder = require('@vocoder/unplugin/webpack');
|
|
453
|
-
|
|
454
|
-
// Add vocoder() to your plugins array`
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
return null;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
function getProviderSnippet(ecosystem, sourceLocale) {
|
|
461
|
-
switch (ecosystem) {
|
|
462
|
-
case "react":
|
|
463
|
-
return {
|
|
464
|
-
file: "your root layout or App component",
|
|
465
|
-
code: `import { VocoderProvider } from '@vocoder/react';
|
|
466
|
-
|
|
467
|
-
<VocoderProvider defaultLocale="${sourceLocale}">
|
|
468
|
-
{children}
|
|
469
|
-
</VocoderProvider>`
|
|
470
|
-
};
|
|
471
|
-
case "vue":
|
|
472
|
-
return {
|
|
473
|
-
file: "your app entry",
|
|
474
|
-
code: `import { createVocoder } from '@vocoder/vue';
|
|
475
|
-
|
|
476
|
-
const vocoder = createVocoder({
|
|
477
|
-
defaultLocale: '${sourceLocale}',
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
app.use(vocoder);`
|
|
481
|
-
};
|
|
482
|
-
case "svelte":
|
|
483
|
-
return {
|
|
484
|
-
file: "your root layout",
|
|
485
|
-
code: `<script>
|
|
486
|
-
import { VocoderProvider } from '@vocoder/svelte';
|
|
487
|
-
</script>
|
|
488
|
-
|
|
489
|
-
<VocoderProvider defaultLocale="${sourceLocale}">
|
|
490
|
-
<slot />
|
|
491
|
-
</VocoderProvider>`
|
|
492
|
-
};
|
|
493
|
-
default:
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
function getWrapSnippet(ecosystem) {
|
|
498
|
-
switch (ecosystem) {
|
|
499
|
-
case "react":
|
|
500
|
-
return {
|
|
501
|
-
code: `import { T } from '@vocoder/react';
|
|
502
|
-
|
|
503
|
-
<T>Hello, world!</T>`
|
|
504
|
-
};
|
|
505
|
-
case "vue":
|
|
506
|
-
return {
|
|
507
|
-
code: `<template>
|
|
508
|
-
<T>Hello, world!</T>
|
|
509
|
-
</template>
|
|
510
|
-
|
|
511
|
-
<script setup>
|
|
512
|
-
import { T } from '@vocoder/vue';
|
|
513
|
-
</script>`
|
|
514
|
-
};
|
|
515
|
-
case "svelte":
|
|
516
|
-
return {
|
|
517
|
-
code: `<script>
|
|
518
|
-
import { T } from '@vocoder/svelte';
|
|
519
|
-
</script>
|
|
520
|
-
|
|
521
|
-
<T>Hello, world!</T>`
|
|
522
|
-
};
|
|
523
|
-
default:
|
|
524
|
-
return {
|
|
525
|
-
code: `// Wrap translatable strings with <T>
|
|
526
|
-
<T>Hello, world!</T>`
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
function getWhatsNextMessage(triggers) {
|
|
531
|
-
const parts = [];
|
|
532
|
-
if (triggers.includes("push")) {
|
|
533
|
-
parts.push("Push to a target branch to trigger translations.");
|
|
534
|
-
}
|
|
535
|
-
if (triggers.includes("pull_request")) {
|
|
536
|
-
parts.push("Open a pull request to trigger translations.");
|
|
537
|
-
}
|
|
538
|
-
if (triggers.includes("manual")) {
|
|
539
|
-
parts.push("Run `vocoder sync` to extract and translate.");
|
|
540
|
-
}
|
|
541
|
-
if (parts.length === 0) {
|
|
542
|
-
parts.push("Push to a target branch to trigger translations.");
|
|
543
|
-
}
|
|
544
|
-
return parts.join("\n");
|
|
545
|
-
}
|
|
829
|
+
// src/commands/init.ts
|
|
830
|
+
import chalk6 from "chalk";
|
|
831
|
+
import { execSync as execSync3 } from "child_process";
|
|
832
|
+
import { config as loadEnv } from "dotenv";
|
|
546
833
|
|
|
547
834
|
// src/utils/git-identity.ts
|
|
548
835
|
import { execSync } from "child_process";
|
|
@@ -580,81 +867,636 @@ function parseRemoteUrl(remoteUrl) {
|
|
|
580
867
|
}
|
|
581
868
|
return { host, ownerRepoPath };
|
|
582
869
|
}
|
|
583
|
-
return null;
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
const parsed = new URL(trimmed);
|
|
874
|
+
const host = parsed.hostname.toLowerCase();
|
|
875
|
+
const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
|
|
876
|
+
if (!host || !ownerRepoPath) {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
return { host, ownerRepoPath };
|
|
880
|
+
} catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function toCanonical(host, ownerRepoPath) {
|
|
885
|
+
if (host.includes("github.com")) {
|
|
886
|
+
return `github:${ownerRepoPath.toLowerCase()}`;
|
|
887
|
+
}
|
|
888
|
+
if (host.includes("gitlab.com")) {
|
|
889
|
+
return `gitlab:${ownerRepoPath.toLowerCase()}`;
|
|
890
|
+
}
|
|
891
|
+
if (host.includes("bitbucket.org")) {
|
|
892
|
+
return `bitbucket:${ownerRepoPath.toLowerCase()}`;
|
|
893
|
+
}
|
|
894
|
+
return `git:${host}/${ownerRepoPath.toLowerCase()}`;
|
|
895
|
+
}
|
|
896
|
+
function resolveGitRepositoryIdentity() {
|
|
897
|
+
const remoteUrl = safeExec("git config --get remote.origin.url");
|
|
898
|
+
if (!remoteUrl) {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
902
|
+
if (!parsed) {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
const repositoryRoot = safeExec("git rev-parse --show-toplevel");
|
|
906
|
+
const currentDirectory = process.cwd();
|
|
907
|
+
let repoScopePath = "";
|
|
908
|
+
if (repositoryRoot) {
|
|
909
|
+
const relativePath = relative(resolve(repositoryRoot), resolve(currentDirectory)).replace(/\\/g, "/").trim();
|
|
910
|
+
if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
|
|
911
|
+
repoScopePath = relativePath;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
|
|
916
|
+
repoScopePath
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
function resolveGitContext() {
|
|
920
|
+
const warnings = [];
|
|
921
|
+
const identity = resolveGitRepositoryIdentity();
|
|
922
|
+
if (!identity) {
|
|
923
|
+
warnings.push(
|
|
924
|
+
"Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
return { identity, warnings };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/utils/project-create.ts
|
|
931
|
+
import * as p3 from "@clack/prompts";
|
|
932
|
+
import chalk4 from "chalk";
|
|
933
|
+
|
|
934
|
+
// src/utils/locale-search.ts
|
|
935
|
+
import { Prompt, isCancel as isCancel2 } from "@clack/core";
|
|
936
|
+
import * as p2 from "@clack/prompts";
|
|
937
|
+
import chalk2 from "chalk";
|
|
938
|
+
var S_BAR = "\u2502";
|
|
939
|
+
var S_BAR_END = "\u2514";
|
|
940
|
+
var S_ACTIVE = "\u25C6";
|
|
941
|
+
var S_SUBMIT = "\u25C6";
|
|
942
|
+
var S_CANCEL = "\u25A0";
|
|
943
|
+
var S_ERROR = "\u25B2";
|
|
944
|
+
var noColor = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
|
|
945
|
+
var dim = (s) => noColor ? s : chalk2.gray(s);
|
|
946
|
+
var cyan = (s) => noColor ? s : chalk2.cyan(s);
|
|
947
|
+
var grn = (s) => noColor ? s : chalk2.green(s);
|
|
948
|
+
var ylw = (s) => noColor ? s : chalk2.yellow(s);
|
|
949
|
+
var red = (s) => noColor ? s : chalk2.red(s);
|
|
950
|
+
var bld = (s) => noColor ? s : chalk2.bold(s);
|
|
951
|
+
function symbol(state) {
|
|
952
|
+
switch (state) {
|
|
953
|
+
case "submit":
|
|
954
|
+
return grn(S_SUBMIT);
|
|
955
|
+
case "cancel":
|
|
956
|
+
return red(S_CANCEL);
|
|
957
|
+
case "error":
|
|
958
|
+
return ylw(S_ERROR);
|
|
959
|
+
default:
|
|
960
|
+
return cyan(S_ACTIVE);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
var MAX_VISIBLE = 12;
|
|
964
|
+
function filterLocales(options, query) {
|
|
965
|
+
if (!query.trim()) return options;
|
|
966
|
+
const lower = query.toLowerCase();
|
|
967
|
+
return options.filter(
|
|
968
|
+
(o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
function buildList(filtered, cursor, scrollOffset, selected) {
|
|
972
|
+
const isMulti = selected !== null;
|
|
973
|
+
const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
|
|
974
|
+
const visibleLines = [];
|
|
975
|
+
for (let i = scrollOffset; i < end; i++) {
|
|
976
|
+
const opt = filtered[i];
|
|
977
|
+
const isCursor = i === cursor;
|
|
978
|
+
const isChecked = isMulti && selected.has(opt.bcp47);
|
|
979
|
+
const icon = isMulti ? isChecked ? isCursor ? grn("\u25FC") : "\u25FC" : isCursor ? grn("\u25FB") : dim("\u25FB") : isCursor ? grn("\u25CF") : dim("\u25CB");
|
|
980
|
+
visibleLines.push(`${cyan(S_BAR)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`);
|
|
981
|
+
}
|
|
982
|
+
const hidden = filtered.length - (end - scrollOffset);
|
|
983
|
+
if (hidden > 0) visibleLines.push(dim(`${S_BAR} ${hidden} more \u2014 keep typing to narrow`));
|
|
984
|
+
if (filtered.length === 0) visibleLines.push(dim(`${S_BAR} No matches`));
|
|
985
|
+
if (isMulti && selected.size > 0) {
|
|
986
|
+
visibleLines.push(dim(`${S_BAR} ${selected.size} selected \u2014 Enter to confirm`));
|
|
987
|
+
}
|
|
988
|
+
return visibleLines.join("\n");
|
|
989
|
+
}
|
|
990
|
+
async function runFilterablePrompt(opts) {
|
|
991
|
+
const { message, options, multi } = opts;
|
|
992
|
+
let filter = "";
|
|
993
|
+
let cursor = 0;
|
|
994
|
+
let scrollOffset = 0;
|
|
995
|
+
const selected = new Set(multi ? opts.initialValues ?? [] : []);
|
|
996
|
+
if (!multi && opts.initialValue) {
|
|
997
|
+
const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
|
|
998
|
+
if (idx >= 0) cursor = idx;
|
|
999
|
+
}
|
|
1000
|
+
const getFiltered = () => filterLocales(options, filter);
|
|
1001
|
+
const clampCursor = (filtered) => {
|
|
1002
|
+
if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
|
|
1003
|
+
if (cursor < scrollOffset) scrollOffset = cursor;
|
|
1004
|
+
if (cursor >= scrollOffset + MAX_VISIBLE) scrollOffset = cursor - MAX_VISIBLE + 1;
|
|
1005
|
+
if (scrollOffset < 0) scrollOffset = 0;
|
|
1006
|
+
};
|
|
1007
|
+
const prompt = new Prompt(
|
|
1008
|
+
{
|
|
1009
|
+
initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
|
|
1010
|
+
validate() {
|
|
1011
|
+
const f = getFiltered();
|
|
1012
|
+
if (multi && selected.size === 0) return "At least one target language is required.";
|
|
1013
|
+
if (!multi && !f[cursor]) return "Please select a language.";
|
|
1014
|
+
return void 0;
|
|
1015
|
+
},
|
|
1016
|
+
render() {
|
|
1017
|
+
const filtered = getFiltered();
|
|
1018
|
+
clampCursor(filtered);
|
|
1019
|
+
const hdr = `${dim(S_BAR)}
|
|
1020
|
+
${symbol(this.state)} ${message}
|
|
1021
|
+
`;
|
|
1022
|
+
const hint = filter.length > 0 ? filter : dim("type to filter, \u2191\u2193 navigate" + (multi ? ", space select" : ""));
|
|
1023
|
+
switch (this.state) {
|
|
1024
|
+
case "submit": {
|
|
1025
|
+
const val = multi ? Array.from(selected).map((id) => options.find((o) => o.bcp47 === id)?.label ?? id).join(", ") : options.find((o) => o.bcp47 === this.value)?.label ?? "";
|
|
1026
|
+
return `${hdr}${dim(S_BAR)} ${bld(val || dim("none"))}`;
|
|
1027
|
+
}
|
|
1028
|
+
case "cancel":
|
|
1029
|
+
return `${hdr}${dim(S_BAR)}`;
|
|
1030
|
+
case "error":
|
|
1031
|
+
return [
|
|
1032
|
+
hdr.trimEnd(),
|
|
1033
|
+
`${ylw(S_BAR)} ${dim("/")} ${hint}`,
|
|
1034
|
+
buildList(filtered, cursor, scrollOffset, multi ? selected : null),
|
|
1035
|
+
`${ylw(S_BAR_END)} ${ylw(this.error)}`,
|
|
1036
|
+
""
|
|
1037
|
+
].join("\n");
|
|
1038
|
+
default:
|
|
1039
|
+
return [
|
|
1040
|
+
hdr.trimEnd(),
|
|
1041
|
+
`${cyan(S_BAR)} ${dim("/")} ${hint}`,
|
|
1042
|
+
buildList(filtered, cursor, scrollOffset, multi ? selected : null),
|
|
1043
|
+
`${cyan(S_BAR_END)}`,
|
|
1044
|
+
""
|
|
1045
|
+
].join("\n");
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
},
|
|
1049
|
+
false
|
|
1050
|
+
// trackValue=false — we manage value manually
|
|
1051
|
+
);
|
|
1052
|
+
prompt.on("key", (key) => {
|
|
1053
|
+
if (!key || key === " ") return;
|
|
1054
|
+
const cp = key.codePointAt(0) ?? 0;
|
|
1055
|
+
if (cp === 127 || cp === 8) {
|
|
1056
|
+
filter = filter.slice(0, -1);
|
|
1057
|
+
cursor = 0;
|
|
1058
|
+
scrollOffset = 0;
|
|
1059
|
+
} else if (cp >= 32 && cp !== 127) {
|
|
1060
|
+
filter += key;
|
|
1061
|
+
cursor = 0;
|
|
1062
|
+
scrollOffset = 0;
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
prompt.on("cursor", (action) => {
|
|
1066
|
+
const filtered = getFiltered();
|
|
1067
|
+
switch (action) {
|
|
1068
|
+
case "up":
|
|
1069
|
+
cursor = Math.max(0, cursor - 1);
|
|
1070
|
+
break;
|
|
1071
|
+
case "down":
|
|
1072
|
+
cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
|
|
1073
|
+
break;
|
|
1074
|
+
case "space":
|
|
1075
|
+
if (multi) {
|
|
1076
|
+
const opt = filtered[cursor];
|
|
1077
|
+
if (opt) {
|
|
1078
|
+
if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
|
|
1079
|
+
else selected.add(opt.bcp47);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
if (!multi) {
|
|
1085
|
+
const opt = getFiltered()[cursor];
|
|
1086
|
+
prompt.value = opt?.bcp47 ?? null;
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
prompt.on("finalize", () => {
|
|
1090
|
+
if (prompt.state === "submit") {
|
|
1091
|
+
if (multi) {
|
|
1092
|
+
prompt.value = Array.from(selected);
|
|
1093
|
+
} else {
|
|
1094
|
+
const f = getFiltered();
|
|
1095
|
+
prompt.value = f[cursor]?.bcp47 ?? null;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
const result = await prompt.prompt();
|
|
1100
|
+
if (isCancel2(result)) return null;
|
|
1101
|
+
return result;
|
|
1102
|
+
}
|
|
1103
|
+
async function searchSelectLocale(options, message, initialValue) {
|
|
1104
|
+
const result = await runFilterablePrompt({ message, options, multi: false, initialValue });
|
|
1105
|
+
return typeof result === "string" ? result : null;
|
|
1106
|
+
}
|
|
1107
|
+
async function searchMultiSelectLocales(options, message, initialValues) {
|
|
1108
|
+
const result = await runFilterablePrompt({ message, options, multi: true, initialValues });
|
|
1109
|
+
if (result === null) return null;
|
|
1110
|
+
const picks = result;
|
|
1111
|
+
if (picks.length === 0) {
|
|
1112
|
+
p2.log.warn("At least one target language is required. Please select at least one.");
|
|
1113
|
+
return searchMultiSelectLocales(options, message, initialValues);
|
|
1114
|
+
}
|
|
1115
|
+
return picks;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/utils/branch-select.ts
|
|
1119
|
+
import { Prompt as Prompt2, isCancel as isCancel3 } from "@clack/core";
|
|
1120
|
+
import chalk3 from "chalk";
|
|
1121
|
+
import { execSync as execSync2 } from "child_process";
|
|
1122
|
+
var S_BAR2 = "\u2502";
|
|
1123
|
+
var S_BAR_END2 = "\u2514";
|
|
1124
|
+
var S_ACTIVE2 = "\u25C6";
|
|
1125
|
+
var S_SUBMIT2 = "\u25C6";
|
|
1126
|
+
var S_CANCEL2 = "\u25A0";
|
|
1127
|
+
var S_ERROR2 = "\u25B2";
|
|
1128
|
+
var noColor2 = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
|
|
1129
|
+
var dim2 = (s) => noColor2 ? s : chalk3.gray(s);
|
|
1130
|
+
var cyan2 = (s) => noColor2 ? s : chalk3.cyan(s);
|
|
1131
|
+
var grn2 = (s) => noColor2 ? s : chalk3.green(s);
|
|
1132
|
+
var ylw2 = (s) => noColor2 ? s : chalk3.yellow(s);
|
|
1133
|
+
var red2 = (s) => noColor2 ? s : chalk3.red(s);
|
|
1134
|
+
var bld2 = (s) => noColor2 ? s : chalk3.bold(s);
|
|
1135
|
+
function symbol2(state) {
|
|
1136
|
+
switch (state) {
|
|
1137
|
+
case "submit":
|
|
1138
|
+
return grn2(S_SUBMIT2);
|
|
1139
|
+
case "cancel":
|
|
1140
|
+
return red2(S_CANCEL2);
|
|
1141
|
+
case "error":
|
|
1142
|
+
return ylw2(S_ERROR2);
|
|
1143
|
+
default:
|
|
1144
|
+
return cyan2(S_ACTIVE2);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function detectGitBranches(cwd) {
|
|
1148
|
+
const workDir = cwd ?? process.cwd();
|
|
1149
|
+
try {
|
|
1150
|
+
const localOut = execSync2("git branch", { cwd: workDir, stdio: "pipe" }).toString();
|
|
1151
|
+
const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
|
|
1152
|
+
let remoteBranches = [];
|
|
1153
|
+
try {
|
|
1154
|
+
const remoteOut = execSync2("git branch -r", { cwd: workDir, stdio: "pipe" }).toString();
|
|
1155
|
+
remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
|
|
1156
|
+
} catch {
|
|
1157
|
+
}
|
|
1158
|
+
const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
|
|
1159
|
+
let defaultBranch = "main";
|
|
1160
|
+
try {
|
|
1161
|
+
const ref = execSync2("git symbolic-ref refs/remotes/origin/HEAD", { cwd: workDir, stdio: "pipe" }).toString().trim();
|
|
1162
|
+
defaultBranch = ref.split("/").pop() ?? "main";
|
|
1163
|
+
} catch {
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
branches: branches.length > 0 ? branches : [defaultBranch],
|
|
1167
|
+
defaultBranch
|
|
1168
|
+
};
|
|
1169
|
+
} catch {
|
|
1170
|
+
return { branches: ["main"], defaultBranch: "main" };
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
var INVALID_CHARS = /[\s?^~:[\]\\]/;
|
|
1174
|
+
function validateBranchPattern(pattern) {
|
|
1175
|
+
const t = pattern.trim();
|
|
1176
|
+
if (!t) return "Pattern cannot be empty";
|
|
1177
|
+
if (INVALID_CHARS.test(t)) return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
|
|
1178
|
+
if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
|
|
1179
|
+
if (t.includes("//")) return "Cannot contain //";
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
var MAX_VISIBLE2 = 10;
|
|
1183
|
+
function buildItems(branches, defaultBranch, customPatterns) {
|
|
1184
|
+
const items = branches.map((b) => ({
|
|
1185
|
+
value: b,
|
|
1186
|
+
label: b === defaultBranch ? `${b} (default branch)` : b
|
|
1187
|
+
}));
|
|
1188
|
+
for (const pt of customPatterns) {
|
|
1189
|
+
if (!branches.includes(pt)) {
|
|
1190
|
+
items.push({ value: pt, label: pt, isCustom: true });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return items;
|
|
1194
|
+
}
|
|
1195
|
+
function filterItems(items, query) {
|
|
1196
|
+
if (!query.trim()) return items;
|
|
1197
|
+
const lower = query.toLowerCase();
|
|
1198
|
+
return items.filter((i) => i.value.toLowerCase().includes(lower));
|
|
1199
|
+
}
|
|
1200
|
+
function buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor) {
|
|
1201
|
+
const lines = [];
|
|
1202
|
+
const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
|
|
1203
|
+
for (let i = scrollOffset; i < end; i++) {
|
|
1204
|
+
const item = filtered[i];
|
|
1205
|
+
const isCursor = i === cursor && !addCursor;
|
|
1206
|
+
const isChecked = selected.has(item.value);
|
|
1207
|
+
const icon = isChecked ? isCursor ? grn2("\u25FC") : "\u25FC" : isCursor ? grn2("\u25FB") : dim2("\u25FB");
|
|
1208
|
+
let label = item.isCustom ? `${item.label} ${dim2("(custom)")}` : item.label;
|
|
1209
|
+
if (isCursor) label = bld2(label);
|
|
1210
|
+
lines.push(`${cyan2(S_BAR2)} ${icon} ${label}`);
|
|
1211
|
+
}
|
|
1212
|
+
const trimmed = filter.trim();
|
|
1213
|
+
const allItems = [...filtered];
|
|
1214
|
+
const isNewPattern = trimmed.length > 0 && !allItems.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
|
|
1215
|
+
if (isNewPattern) {
|
|
1216
|
+
const err = validateBranchPattern(trimmed);
|
|
1217
|
+
const icon = addCursor ? grn2("\u25FB") : dim2("\u25FB");
|
|
1218
|
+
const label = err ? `${ylw2("+")} ${dim2(`"${trimmed}" \u2014 ${err}`)}` : `${grn2("+")} Add "${trimmed}" as branch pattern`;
|
|
1219
|
+
lines.push(`${cyan2(S_BAR2)} ${icon} ${label}`);
|
|
1220
|
+
} else if (filtered.length === 0 && trimmed.length === 0) {
|
|
1221
|
+
lines.push(dim2(`${S_BAR2} No branches detected`));
|
|
1222
|
+
}
|
|
1223
|
+
const hidden = filtered.length - (end - scrollOffset);
|
|
1224
|
+
if (hidden > 0) lines.push(dim2(`${S_BAR2} ${hidden} more`));
|
|
1225
|
+
if (selected.size > 0) lines.push(dim2(`${S_BAR2} ${selected.size} selected \u2014 Enter to confirm`));
|
|
1226
|
+
return lines.join("\n");
|
|
1227
|
+
}
|
|
1228
|
+
async function filterableBranchSelect(params) {
|
|
1229
|
+
const { message, branches, defaultBranch } = params;
|
|
1230
|
+
let filter = "";
|
|
1231
|
+
let cursor = 0;
|
|
1232
|
+
let scrollOffset = 0;
|
|
1233
|
+
let addCursor = false;
|
|
1234
|
+
const customPatterns = [];
|
|
1235
|
+
const selected = new Set(params.initialValues ?? [defaultBranch]);
|
|
1236
|
+
const getItems = () => buildItems(branches, defaultBranch, customPatterns);
|
|
1237
|
+
const getFiltered = () => filterItems(getItems(), filter);
|
|
1238
|
+
const isNewPattern = () => {
|
|
1239
|
+
const t = filter.trim();
|
|
1240
|
+
if (!t) return false;
|
|
1241
|
+
return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
|
|
1242
|
+
};
|
|
1243
|
+
const clampCursor = (filtered) => {
|
|
1244
|
+
const hasAdd = isNewPattern();
|
|
1245
|
+
const max = filtered.length - 1 + (hasAdd ? 1 : 0);
|
|
1246
|
+
if (cursor > max && !addCursor) cursor = Math.max(0, max);
|
|
1247
|
+
if (!addCursor) {
|
|
1248
|
+
if (cursor < scrollOffset) scrollOffset = cursor;
|
|
1249
|
+
if (cursor >= scrollOffset + MAX_VISIBLE2) scrollOffset = cursor - MAX_VISIBLE2 + 1;
|
|
1250
|
+
if (scrollOffset < 0) scrollOffset = 0;
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
const prompt = new Prompt2(
|
|
1254
|
+
{
|
|
1255
|
+
validate() {
|
|
1256
|
+
if (selected.size === 0) return "At least one branch is required.";
|
|
1257
|
+
return void 0;
|
|
1258
|
+
},
|
|
1259
|
+
render() {
|
|
1260
|
+
const filtered = getFiltered();
|
|
1261
|
+
clampCursor(filtered);
|
|
1262
|
+
const hdr = `${dim2(S_BAR2)}
|
|
1263
|
+
${symbol2(this.state)} ${message}
|
|
1264
|
+
`;
|
|
1265
|
+
const hint = filter.length > 0 ? filter : dim2("type to filter or add pattern, \u2191\u2193 navigate, space select");
|
|
1266
|
+
switch (this.state) {
|
|
1267
|
+
case "submit": {
|
|
1268
|
+
const summary = selected.size > 0 ? bld2(Array.from(selected).join(", ")) : dim2("none");
|
|
1269
|
+
return `${hdr}${dim2(S_BAR2)} ${summary}`;
|
|
1270
|
+
}
|
|
1271
|
+
case "cancel":
|
|
1272
|
+
return `${hdr}${dim2(S_BAR2)}`;
|
|
1273
|
+
case "error":
|
|
1274
|
+
return [
|
|
1275
|
+
hdr.trimEnd(),
|
|
1276
|
+
`${ylw2(S_BAR2)} ${dim2("/")} ${hint}`,
|
|
1277
|
+
buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor),
|
|
1278
|
+
`${ylw2(S_BAR_END2)} ${ylw2(this.error)}`,
|
|
1279
|
+
""
|
|
1280
|
+
].join("\n");
|
|
1281
|
+
default:
|
|
1282
|
+
return [
|
|
1283
|
+
hdr.trimEnd(),
|
|
1284
|
+
`${cyan2(S_BAR2)} ${dim2("/")} ${hint}`,
|
|
1285
|
+
buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor),
|
|
1286
|
+
`${cyan2(S_BAR_END2)}`,
|
|
1287
|
+
""
|
|
1288
|
+
].join("\n");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
false
|
|
1293
|
+
);
|
|
1294
|
+
prompt.on("key", (key) => {
|
|
1295
|
+
if (!key || key === " ") return;
|
|
1296
|
+
const cp = key.codePointAt(0) ?? 0;
|
|
1297
|
+
if (cp === 127 || cp === 8) {
|
|
1298
|
+
filter = filter.slice(0, -1);
|
|
1299
|
+
cursor = 0;
|
|
1300
|
+
scrollOffset = 0;
|
|
1301
|
+
addCursor = false;
|
|
1302
|
+
} else if (cp >= 32 && cp !== 127) {
|
|
1303
|
+
filter += key;
|
|
1304
|
+
cursor = 0;
|
|
1305
|
+
scrollOffset = 0;
|
|
1306
|
+
addCursor = false;
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
prompt.on("cursor", (action) => {
|
|
1310
|
+
const filtered = getFiltered();
|
|
1311
|
+
const hasAdd = isNewPattern();
|
|
1312
|
+
switch (action) {
|
|
1313
|
+
case "up":
|
|
1314
|
+
if (addCursor) {
|
|
1315
|
+
addCursor = false;
|
|
1316
|
+
cursor = Math.max(0, filtered.length - 1);
|
|
1317
|
+
} else cursor = Math.max(0, cursor - 1);
|
|
1318
|
+
break;
|
|
1319
|
+
case "down":
|
|
1320
|
+
if (!addCursor && cursor >= filtered.length - 1 && hasAdd) addCursor = true;
|
|
1321
|
+
else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
|
|
1322
|
+
break;
|
|
1323
|
+
case "space":
|
|
1324
|
+
if (addCursor) {
|
|
1325
|
+
const t = filter.trim();
|
|
1326
|
+
const err = validateBranchPattern(t);
|
|
1327
|
+
if (!err) {
|
|
1328
|
+
customPatterns.push(t);
|
|
1329
|
+
selected.add(t);
|
|
1330
|
+
filter = "";
|
|
1331
|
+
cursor = 0;
|
|
1332
|
+
scrollOffset = 0;
|
|
1333
|
+
addCursor = false;
|
|
1334
|
+
}
|
|
1335
|
+
} else {
|
|
1336
|
+
const item = filtered[cursor];
|
|
1337
|
+
if (item) {
|
|
1338
|
+
if (selected.has(item.value)) selected.delete(item.value);
|
|
1339
|
+
else selected.add(item.value);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
break;
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
prompt.on("finalize", () => {
|
|
1346
|
+
if (prompt.state === "submit") {
|
|
1347
|
+
prompt.value = Array.from(selected);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
const result = await prompt.prompt();
|
|
1351
|
+
if (isCancel3(result)) return null;
|
|
1352
|
+
return result;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/utils/project-create.ts
|
|
1356
|
+
function buildLocaleOptions(locales) {
|
|
1357
|
+
return locales.map((l) => ({
|
|
1358
|
+
bcp47: l.code,
|
|
1359
|
+
label: `${l.name} \u2014 ${l.code}`
|
|
1360
|
+
}));
|
|
1361
|
+
}
|
|
1362
|
+
function buildLanguageOptions(locales) {
|
|
1363
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
1364
|
+
for (const l of locales) {
|
|
1365
|
+
const family = l.code.split("-")[0].toLowerCase();
|
|
1366
|
+
const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
|
|
1367
|
+
const existing = byFamily.get(family);
|
|
1368
|
+
if (!existing || l.code.length < existing.bcp47.length) {
|
|
1369
|
+
byFamily.set(family, opt);
|
|
1370
|
+
}
|
|
584
1371
|
}
|
|
1372
|
+
return Array.from(byFamily.values());
|
|
1373
|
+
}
|
|
1374
|
+
async function runProjectCreate(params) {
|
|
1375
|
+
const { api, userToken, organizationId, repoCanonical } = params;
|
|
1376
|
+
const projectName = (params.defaultName ?? "my-project").trim();
|
|
1377
|
+
p3.log.success(`Project: ${chalk4.bold(projectName)}`);
|
|
1378
|
+
let rawLocales;
|
|
585
1379
|
try {
|
|
586
|
-
|
|
587
|
-
const host = parsed.hostname.toLowerCase();
|
|
588
|
-
const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
|
|
589
|
-
if (!host || !ownerRepoPath) {
|
|
590
|
-
return null;
|
|
591
|
-
}
|
|
592
|
-
return { host, ownerRepoPath };
|
|
1380
|
+
rawLocales = await api.listLocales(userToken);
|
|
593
1381
|
} catch {
|
|
1382
|
+
p3.log.error("Failed to fetch supported locales. Check your connection and try again.");
|
|
594
1383
|
return null;
|
|
595
1384
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1385
|
+
const languageOptions = buildLanguageOptions(rawLocales);
|
|
1386
|
+
const localeOptions = buildLocaleOptions(rawLocales);
|
|
1387
|
+
const rawScope = await p3.text({
|
|
1388
|
+
message: "App directory (leave blank for the entire repo)",
|
|
1389
|
+
placeholder: "e.g. apps/web",
|
|
1390
|
+
initialValue: params.defaultScopePath ?? "",
|
|
1391
|
+
validate(value) {
|
|
1392
|
+
const v = value.trim();
|
|
1393
|
+
if (!v) return;
|
|
1394
|
+
if (v.startsWith("/")) return "Use a relative path, not an absolute path";
|
|
1395
|
+
if (v.includes("..")) return 'Path must not contain ".."';
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
if (p3.isCancel(rawScope)) return null;
|
|
1399
|
+
const scopePath = rawScope.trim();
|
|
1400
|
+
const sourceLocale = await searchSelectLocale(
|
|
1401
|
+
languageOptions,
|
|
1402
|
+
"Source language (the language your code is written in)",
|
|
1403
|
+
params.defaultSourceLocale ?? "en"
|
|
1404
|
+
);
|
|
1405
|
+
if (sourceLocale === null) return null;
|
|
1406
|
+
const targetOptions = localeOptions.filter((opt) => opt.bcp47 !== sourceLocale);
|
|
1407
|
+
const targetLocales = await searchMultiSelectLocales(
|
|
1408
|
+
targetOptions,
|
|
1409
|
+
"Target languages (languages to translate into)"
|
|
1410
|
+
);
|
|
1411
|
+
if (targetLocales === null) return null;
|
|
1412
|
+
if (targetLocales.length === 0) {
|
|
1413
|
+
p3.log.warn("No target languages selected \u2014 you can add them later from the dashboard.");
|
|
1414
|
+
}
|
|
1415
|
+
const detected = detectGitBranches();
|
|
1416
|
+
const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
|
|
1417
|
+
let targetBranches = [];
|
|
1418
|
+
{
|
|
1419
|
+
let initial = initialBranches;
|
|
1420
|
+
while (targetBranches.length === 0) {
|
|
1421
|
+
const result = await filterableBranchSelect({
|
|
1422
|
+
message: "Target branches (translations will run when you push to these)",
|
|
1423
|
+
branches: detected.branches,
|
|
1424
|
+
defaultBranch: detected.defaultBranch,
|
|
1425
|
+
initialValues: initial
|
|
1426
|
+
});
|
|
1427
|
+
if (result === null) return null;
|
|
1428
|
+
if (result.length === 0) {
|
|
1429
|
+
p3.log.warn("At least one branch is required. Please select at least one.");
|
|
1430
|
+
initial = [detected.defaultBranch];
|
|
1431
|
+
} else {
|
|
1432
|
+
targetBranches = result;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
606
1435
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1436
|
+
try {
|
|
1437
|
+
const result = await api.createProject(userToken, {
|
|
1438
|
+
organizationId,
|
|
1439
|
+
name: projectName,
|
|
1440
|
+
sourceLocale,
|
|
1441
|
+
targetLocales,
|
|
1442
|
+
targetBranches,
|
|
1443
|
+
translationTriggers: ["push"],
|
|
1444
|
+
scopePaths: scopePath ? [scopePath] : [],
|
|
1445
|
+
repoCanonical
|
|
1446
|
+
});
|
|
1447
|
+
p3.log.success(`Project ${chalk4.bold(result.projectName)} created!`);
|
|
1448
|
+
return result;
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1451
|
+
p3.log.error(`Failed to create project: ${message}`);
|
|
612
1452
|
return null;
|
|
613
1453
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/utils/workspace.ts
|
|
1457
|
+
import * as p4 from "@clack/prompts";
|
|
1458
|
+
import chalk5 from "chalk";
|
|
1459
|
+
async function selectWorkspace(result) {
|
|
1460
|
+
const { workspaces, canCreateWorkspace } = result;
|
|
1461
|
+
if (workspaces.length === 0) {
|
|
1462
|
+
return { action: "create" };
|
|
1463
|
+
}
|
|
1464
|
+
const options = workspaces.map((ws) => ({
|
|
1465
|
+
value: ws.id,
|
|
1466
|
+
label: ws.name,
|
|
1467
|
+
hint: [
|
|
1468
|
+
ws.projectCount > 0 ? `${ws.projectCount} project${ws.projectCount !== 1 ? "s" : ""}` : "",
|
|
1469
|
+
ws.connectionLabel ? `GitHub: ${ws.connectionLabel}` : ""
|
|
1470
|
+
].filter(Boolean).join(" \xB7 ") || void 0
|
|
1471
|
+
}));
|
|
1472
|
+
if (canCreateWorkspace) {
|
|
1473
|
+
options.push({ value: "create", label: "Create new workspace" });
|
|
1474
|
+
}
|
|
1475
|
+
const selected = await p4.select({
|
|
1476
|
+
message: "Select workspace",
|
|
1477
|
+
options
|
|
1478
|
+
});
|
|
1479
|
+
if (p4.isCancel(selected)) {
|
|
1480
|
+
return { action: "cancelled" };
|
|
617
1481
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
let repoScopePath = "";
|
|
621
|
-
if (repositoryRoot) {
|
|
622
|
-
const relativePath = relative(resolve(repositoryRoot), resolve(currentDirectory)).replace(/\\/g, "/").trim();
|
|
623
|
-
if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
|
|
624
|
-
repoScopePath = relativePath;
|
|
625
|
-
}
|
|
1482
|
+
if (selected === "create") {
|
|
1483
|
+
return { action: "create" };
|
|
626
1484
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
function resolveGitContext() {
|
|
633
|
-
const warnings = [];
|
|
634
|
-
const identity = resolveGitRepositoryIdentity();
|
|
635
|
-
if (!identity) {
|
|
636
|
-
warnings.push(
|
|
637
|
-
"Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
|
|
638
|
-
);
|
|
1485
|
+
const workspace = workspaces.find((ws) => ws.id === selected);
|
|
1486
|
+
if (!workspace) {
|
|
1487
|
+
return { action: "cancelled" };
|
|
639
1488
|
}
|
|
640
|
-
return {
|
|
1489
|
+
return { action: "use", workspace };
|
|
641
1490
|
}
|
|
642
1491
|
|
|
643
1492
|
// src/commands/init.ts
|
|
644
|
-
import {
|
|
645
|
-
import { execSync as execSync2 } from "child_process";
|
|
646
|
-
import { spawn } from "child_process";
|
|
1493
|
+
import { spawn as spawn2 } from "child_process";
|
|
647
1494
|
loadEnv();
|
|
648
1495
|
var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
|
|
649
|
-
function parseTargetLocales(value) {
|
|
650
|
-
if (!value) return void 0;
|
|
651
|
-
const locales = value.split(",").map((locale) => locale.trim()).filter(Boolean);
|
|
652
|
-
return locales.length > 0 ? locales : void 0;
|
|
653
|
-
}
|
|
654
1496
|
async function sleep(ms) {
|
|
655
1497
|
await new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
656
1498
|
}
|
|
657
|
-
async function
|
|
1499
|
+
async function tryOpenBrowser2(url) {
|
|
658
1500
|
if (!process.stdout.isTTY || process.env.CI === "true") {
|
|
659
1501
|
return false;
|
|
660
1502
|
}
|
|
@@ -672,7 +1514,7 @@ async function tryOpenBrowser(url) {
|
|
|
672
1514
|
}
|
|
673
1515
|
return await new Promise((resolve2) => {
|
|
674
1516
|
try {
|
|
675
|
-
const child =
|
|
1517
|
+
const child = spawn2(command, args, {
|
|
676
1518
|
detached: true,
|
|
677
1519
|
stdio: "ignore",
|
|
678
1520
|
windowsHide: true
|
|
@@ -707,35 +1549,35 @@ function getSubscriptionSettingsUrl(apiUrl) {
|
|
|
707
1549
|
return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
|
|
708
1550
|
}
|
|
709
1551
|
function printPlanLimitMessage(apiUrl, message) {
|
|
710
|
-
|
|
1552
|
+
p5.log.error(`You are over your plan limits.
|
|
711
1553
|
${message}`);
|
|
712
|
-
|
|
1554
|
+
p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
|
|
713
1555
|
}
|
|
714
1556
|
function runScaffold(params) {
|
|
715
1557
|
const { projectName, organizationName, sourceLocale, translationTriggers } = params;
|
|
716
|
-
|
|
717
|
-
|
|
1558
|
+
p5.log.info(`Project: ${chalk6.bold(projectName)}`);
|
|
1559
|
+
p5.log.info(`Workspace: ${chalk6.bold(organizationName)}`);
|
|
718
1560
|
const detection = detectLocalEcosystem();
|
|
719
1561
|
if (detection.ecosystem) {
|
|
720
1562
|
const frameworkLabel = detection.framework ?? detection.ecosystem;
|
|
721
1563
|
const pmLabel = detection.packageManager;
|
|
722
|
-
|
|
1564
|
+
p5.log.info(`Detected: ${chalk6.bold(frameworkLabel)} (${pmLabel})`);
|
|
723
1565
|
}
|
|
724
1566
|
const packagesToInstall = getPackagesToInstall(detection);
|
|
725
1567
|
if (packagesToInstall.length > 0) {
|
|
726
1568
|
const installCmd = buildInstallCommand(detection.packageManager, packagesToInstall);
|
|
727
|
-
|
|
728
|
-
const installSpinner =
|
|
1569
|
+
p5.log.info("");
|
|
1570
|
+
const installSpinner = p5.spinner();
|
|
729
1571
|
installSpinner.start(`Installing ${packagesToInstall.join(", ")}...`);
|
|
730
1572
|
try {
|
|
731
|
-
|
|
1573
|
+
execSync3(installCmd, { stdio: "pipe", cwd: process.cwd() });
|
|
732
1574
|
installSpinner.stop(`Installed ${packagesToInstall.join(", ")}`);
|
|
733
1575
|
} catch {
|
|
734
1576
|
installSpinner.stop("Package installation failed");
|
|
735
|
-
|
|
1577
|
+
p5.log.warn(`Run manually: ${chalk6.cyan(installCmd)}`);
|
|
736
1578
|
}
|
|
737
1579
|
} else if (detection.ecosystem) {
|
|
738
|
-
|
|
1580
|
+
p5.log.info(`Packages: ${chalk6.green("already installed")}`);
|
|
739
1581
|
}
|
|
740
1582
|
const snippets = getSetupSnippets({
|
|
741
1583
|
framework: detection.framework,
|
|
@@ -745,162 +1587,518 @@ function runScaffold(params) {
|
|
|
745
1587
|
});
|
|
746
1588
|
let stepNum = 1;
|
|
747
1589
|
if (snippets.pluginStep) {
|
|
748
|
-
|
|
749
|
-
|
|
1590
|
+
p5.log.message("");
|
|
1591
|
+
p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the plugin to ${chalk6.cyan(snippets.pluginStep.file)}`);
|
|
750
1592
|
printCodeBlock(snippets.pluginStep.code);
|
|
751
1593
|
stepNum++;
|
|
752
1594
|
}
|
|
753
1595
|
if (snippets.providerStep) {
|
|
754
|
-
|
|
1596
|
+
p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the provider to ${chalk6.cyan(snippets.providerStep.file)}`);
|
|
755
1597
|
printCodeBlock(snippets.providerStep.code);
|
|
756
1598
|
stepNum++;
|
|
757
1599
|
}
|
|
758
|
-
|
|
1600
|
+
p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Wrap translatable strings`);
|
|
759
1601
|
printCodeBlock(snippets.wrapStep.code);
|
|
760
|
-
|
|
1602
|
+
p5.log.message("");
|
|
761
1603
|
for (const line of snippets.whatsNext.split("\n")) {
|
|
762
|
-
|
|
1604
|
+
p5.log.success(line);
|
|
763
1605
|
}
|
|
764
1606
|
}
|
|
1607
|
+
function printMcpSetup(apiKey) {
|
|
1608
|
+
const addCommand = `claude mcp add --scope project --transport stdio \\
|
|
1609
|
+
--env VOCODER_API_KEY=${apiKey} \\
|
|
1610
|
+
vocoder -- npx -y @vocoder/mcp`;
|
|
1611
|
+
const teamConfig = JSON.stringify(
|
|
1612
|
+
{
|
|
1613
|
+
mcpServers: {
|
|
1614
|
+
vocoder: {
|
|
1615
|
+
type: "stdio",
|
|
1616
|
+
command: "npx",
|
|
1617
|
+
args: ["-y", "@vocoder/mcp"],
|
|
1618
|
+
env: { VOCODER_API_KEY: "${env:VOCODER_API_KEY}" }
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
null,
|
|
1623
|
+
2
|
|
1624
|
+
);
|
|
1625
|
+
p5.log.message("");
|
|
1626
|
+
p5.log.message(chalk6.bold("Use Vocoder with Claude Code"));
|
|
1627
|
+
p5.log.message("Run this to add the MCP server to your project:");
|
|
1628
|
+
p5.log.message("");
|
|
1629
|
+
printCodeBlock(addCommand);
|
|
1630
|
+
p5.log.message("");
|
|
1631
|
+
p5.log.message("To share with your team, commit " + chalk6.cyan(".mcp.json") + " with an env var reference");
|
|
1632
|
+
p5.log.message("so each developer supplies their own key:");
|
|
1633
|
+
p5.log.message("");
|
|
1634
|
+
printCodeBlock(teamConfig);
|
|
1635
|
+
p5.log.message("");
|
|
1636
|
+
p5.log.message(chalk6.gray("Setup instructions: https://vocoder.app/docs/mcp"));
|
|
1637
|
+
}
|
|
765
1638
|
function printCodeBlock(code) {
|
|
766
1639
|
const lines = code.split("\n");
|
|
767
1640
|
const maxLen = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
768
|
-
const bar =
|
|
1641
|
+
const bar = chalk6.gray("\u2502");
|
|
769
1642
|
const pad = (s) => s + " ".repeat(maxLen - s.length);
|
|
770
|
-
process.stdout.write(`${
|
|
1643
|
+
process.stdout.write(`${chalk6.gray("\u2502")}
|
|
771
1644
|
`);
|
|
772
|
-
process.stdout.write(`${
|
|
1645
|
+
process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u250C" + "\u2500".repeat(maxLen + 2) + "\u2510")}
|
|
773
1646
|
`);
|
|
774
1647
|
for (const line of lines) {
|
|
775
|
-
process.stdout.write(`${
|
|
1648
|
+
process.stdout.write(`${chalk6.gray("\u2502")} ${bar} ${pad(line)} ${bar}
|
|
1649
|
+
`);
|
|
1650
|
+
}
|
|
1651
|
+
process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u2514" + "\u2500".repeat(maxLen + 2) + "\u2518")}
|
|
776
1652
|
`);
|
|
1653
|
+
}
|
|
1654
|
+
async function verifyStoredToken(api, token) {
|
|
1655
|
+
try {
|
|
1656
|
+
return await api.getCliUserInfo(token);
|
|
1657
|
+
} catch {
|
|
1658
|
+
clearAuthData();
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
async function runAuthFlow(api, options, reauth = false, repoCanonical) {
|
|
1663
|
+
let server = null;
|
|
1664
|
+
if (!options.ci) {
|
|
1665
|
+
try {
|
|
1666
|
+
server = await startCallbackServer();
|
|
1667
|
+
} catch {
|
|
1668
|
+
}
|
|
777
1669
|
}
|
|
778
|
-
|
|
1670
|
+
const session = await api.startCliAuthSession(server?.port, repoCanonical);
|
|
1671
|
+
const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
|
|
1672
|
+
const expiresAt = new Date(session.expiresAt).getTime();
|
|
1673
|
+
if (options.ci) {
|
|
1674
|
+
process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
|
|
779
1675
|
`);
|
|
1676
|
+
process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
|
|
1677
|
+
`);
|
|
1678
|
+
} else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
|
|
1679
|
+
if (reauth) {
|
|
1680
|
+
if (!options.yes) {
|
|
1681
|
+
const shouldOpen = await p5.confirm({ message: "Open your browser to sign in again?" });
|
|
1682
|
+
if (p5.isCancel(shouldOpen)) {
|
|
1683
|
+
server?.close();
|
|
1684
|
+
p5.cancel("Setup cancelled.");
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
if (!shouldOpen) {
|
|
1688
|
+
p5.log.info("Open the URL above manually in your browser to continue.");
|
|
1689
|
+
} else {
|
|
1690
|
+
const opened = await tryOpenBrowser2(browserUrl);
|
|
1691
|
+
if (!opened) {
|
|
1692
|
+
p5.note(browserUrl, "Sign In");
|
|
1693
|
+
p5.log.info("Open the URL above manually to continue.");
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
} else {
|
|
1697
|
+
await tryOpenBrowser2(browserUrl);
|
|
1698
|
+
}
|
|
1699
|
+
} else {
|
|
1700
|
+
let isLinkFlow = false;
|
|
1701
|
+
if (!options.yes) {
|
|
1702
|
+
const connectChoice = await p5.select({
|
|
1703
|
+
message: "Vocoder needs to be installed on your GitHub account to get started",
|
|
1704
|
+
options: [
|
|
1705
|
+
{ value: "install", label: "Install GitHub App", hint: "recommended" },
|
|
1706
|
+
{ value: "link", label: "Already installed? Link your account" }
|
|
1707
|
+
]
|
|
1708
|
+
});
|
|
1709
|
+
if (p5.isCancel(connectChoice)) {
|
|
1710
|
+
server?.close();
|
|
1711
|
+
p5.cancel("Setup cancelled.");
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
isLinkFlow = connectChoice === "link";
|
|
1715
|
+
}
|
|
1716
|
+
let urlToOpen = browserUrl;
|
|
1717
|
+
if (isLinkFlow) {
|
|
1718
|
+
try {
|
|
1719
|
+
const linkSession = await api.startCliGitHubLinkSession(
|
|
1720
|
+
session.sessionId,
|
|
1721
|
+
server?.port
|
|
1722
|
+
);
|
|
1723
|
+
urlToOpen = linkSession.oauthUrl;
|
|
1724
|
+
} catch {
|
|
1725
|
+
urlToOpen = browserUrl;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
const opened = await tryOpenBrowser2(urlToOpen);
|
|
1729
|
+
if (!opened) {
|
|
1730
|
+
p5.log.warn("Could not open your browser automatically.");
|
|
1731
|
+
p5.note(urlToOpen, "GitHub");
|
|
1732
|
+
p5.log.info("Open the URL above to continue.");
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
const authSpinner = p5.spinner();
|
|
1737
|
+
authSpinner.start("Waiting for GitHub authorization...");
|
|
1738
|
+
let rawToken = null;
|
|
1739
|
+
let callbackOrganizationId;
|
|
1740
|
+
let callbackDiscoveryReady = false;
|
|
1741
|
+
if (server) {
|
|
1742
|
+
try {
|
|
1743
|
+
const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
|
|
1744
|
+
const timeoutMs = deadline - Date.now();
|
|
1745
|
+
const params = await Promise.race([
|
|
1746
|
+
server.waitForCallback(),
|
|
1747
|
+
new Promise((resolve2) => setTimeout(() => resolve2(null), timeoutMs))
|
|
1748
|
+
]);
|
|
1749
|
+
if (params && typeof params.token === "string") {
|
|
1750
|
+
rawToken = params.token;
|
|
1751
|
+
if (typeof params.organizationId === "string" && params.organizationId) {
|
|
1752
|
+
callbackOrganizationId = params.organizationId;
|
|
1753
|
+
}
|
|
1754
|
+
if (params.discovery_ready === "1") {
|
|
1755
|
+
callbackDiscoveryReady = true;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
} catch {
|
|
1759
|
+
} finally {
|
|
1760
|
+
server.close();
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (!rawToken) {
|
|
1764
|
+
while (Date.now() < expiresAt) {
|
|
1765
|
+
const result = await api.pollCliAuthSession(session.sessionId);
|
|
1766
|
+
if (result.status === "complete") {
|
|
1767
|
+
rawToken = result.token;
|
|
1768
|
+
if (result.organizationId) {
|
|
1769
|
+
callbackOrganizationId = result.organizationId;
|
|
1770
|
+
}
|
|
1771
|
+
break;
|
|
1772
|
+
}
|
|
1773
|
+
if (result.status === "failed") {
|
|
1774
|
+
authSpinner.stop();
|
|
1775
|
+
p5.log.error(result.reason);
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
await sleep(2e3);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (!rawToken) {
|
|
1782
|
+
authSpinner.stop();
|
|
1783
|
+
p5.log.error("The authentication link expired. Run `vocoder init` again.");
|
|
1784
|
+
return null;
|
|
1785
|
+
}
|
|
1786
|
+
const userInfo = await api.getCliUserInfo(rawToken);
|
|
1787
|
+
authSpinner.stop();
|
|
1788
|
+
p5.log.success(`Authenticated as ${chalk6.bold(userInfo.email)}`);
|
|
1789
|
+
return { token: rawToken, ...userInfo, organizationId: callbackOrganizationId, discoveryReady: callbackDiscoveryReady };
|
|
780
1790
|
}
|
|
781
1791
|
async function init(options = {}) {
|
|
782
1792
|
const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
|
|
783
|
-
|
|
784
|
-
const spinner3 = p.spinner();
|
|
1793
|
+
p5.intro("Vocoder Setup");
|
|
785
1794
|
try {
|
|
786
1795
|
const gitContext = resolveGitContext();
|
|
787
1796
|
const identity = gitContext.identity;
|
|
788
1797
|
if (gitContext.warnings.length > 0) {
|
|
789
1798
|
for (const warning of gitContext.warnings) {
|
|
790
|
-
|
|
1799
|
+
p5.log.warn(warning);
|
|
791
1800
|
}
|
|
792
1801
|
}
|
|
793
1802
|
if (identity) {
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
const existing = await api2.lookupProjectByRepo({
|
|
1803
|
+
const anonApi = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
1804
|
+
const existing = await anonApi.lookupProjectByRepo({
|
|
797
1805
|
repoCanonical: identity.repoCanonical,
|
|
798
1806
|
scopePath: identity.repoScopePath
|
|
799
1807
|
});
|
|
800
1808
|
if (existing) {
|
|
801
|
-
spinner3.stop("Found existing project!");
|
|
802
|
-
p.outro("Vocoder is already set up for this repository.");
|
|
803
1809
|
runScaffold({
|
|
804
1810
|
projectName: existing.projectName,
|
|
805
1811
|
organizationName: existing.organizationName,
|
|
806
1812
|
sourceLocale: existing.sourceLocale ?? "en",
|
|
807
1813
|
translationTriggers: existing.translationTriggers ?? ["push"]
|
|
808
1814
|
});
|
|
1815
|
+
p5.outro("Vocoder is already set up for this repository.");
|
|
809
1816
|
return 0;
|
|
810
1817
|
}
|
|
811
|
-
spinner3.stop("No existing project found for this repo.");
|
|
812
1818
|
}
|
|
813
|
-
spinner3.start("Creating setup session");
|
|
814
1819
|
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1820
|
+
let userToken;
|
|
1821
|
+
let userEmail;
|
|
1822
|
+
let userName;
|
|
1823
|
+
let authOrganizationId;
|
|
1824
|
+
let authDiscoveryReady = false;
|
|
1825
|
+
const stored = readAuthData();
|
|
1826
|
+
if (stored && stored.apiUrl === apiUrl) {
|
|
1827
|
+
const verified = await verifyStoredToken(api, stored.token);
|
|
1828
|
+
if (verified) {
|
|
1829
|
+
p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
|
|
1830
|
+
userToken = stored.token;
|
|
1831
|
+
userEmail = verified.email;
|
|
1832
|
+
userName = verified.name;
|
|
1833
|
+
} else {
|
|
1834
|
+
p5.log.warn("Stored credentials expired \u2014 signing in again");
|
|
1835
|
+
const authResult = await runAuthFlow(
|
|
1836
|
+
api,
|
|
1837
|
+
options,
|
|
1838
|
+
/* reauth */
|
|
1839
|
+
true
|
|
1840
|
+
);
|
|
1841
|
+
if (!authResult) return 1;
|
|
1842
|
+
userToken = authResult.token;
|
|
1843
|
+
userEmail = authResult.email;
|
|
1844
|
+
userName = authResult.name;
|
|
1845
|
+
authOrganizationId = authResult.organizationId;
|
|
1846
|
+
authDiscoveryReady = authResult.discoveryReady ?? false;
|
|
1847
|
+
writeAuthData({
|
|
1848
|
+
token: userToken,
|
|
1849
|
+
apiUrl,
|
|
1850
|
+
userId: authResult.userId,
|
|
1851
|
+
email: userEmail,
|
|
1852
|
+
name: userName,
|
|
1853
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1854
|
+
});
|
|
839
1855
|
}
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1856
|
+
} else {
|
|
1857
|
+
const authResult = await runAuthFlow(api, options, false, identity?.repoCanonical);
|
|
1858
|
+
if (!authResult) return 1;
|
|
1859
|
+
userToken = authResult.token;
|
|
1860
|
+
userEmail = authResult.email;
|
|
1861
|
+
userName = authResult.name;
|
|
1862
|
+
authOrganizationId = authResult.organizationId;
|
|
1863
|
+
writeAuthData({
|
|
1864
|
+
token: userToken,
|
|
1865
|
+
apiUrl,
|
|
1866
|
+
userId: authResult.userId,
|
|
1867
|
+
email: userEmail,
|
|
1868
|
+
name: userName,
|
|
1869
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
847
1870
|
});
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1871
|
+
}
|
|
1872
|
+
let selectedWorkspaceId;
|
|
1873
|
+
let selectedWorkspaceName;
|
|
1874
|
+
if (authOrganizationId) {
|
|
1875
|
+
const workspaceData = await api.listWorkspaces(userToken);
|
|
1876
|
+
const ws = workspaceData.workspaces.find((w) => w.id === authOrganizationId);
|
|
1877
|
+
selectedWorkspaceId = authOrganizationId;
|
|
1878
|
+
selectedWorkspaceName = ws?.name ?? userEmail;
|
|
1879
|
+
p5.log.success(`Connected as ${chalk6.bold(userEmail)} \u2014 workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1880
|
+
} else {
|
|
1881
|
+
const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
|
|
1882
|
+
const cachedInstallations = discoveryResult?.installations ?? [];
|
|
1883
|
+
if (cachedInstallations.length > 0) {
|
|
1884
|
+
if (identity?.repoCanonical) {
|
|
1885
|
+
const repoOwner = identity.repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
|
|
1886
|
+
if (repoOwner) {
|
|
1887
|
+
const hasMatchingAccount = cachedInstallations.some(
|
|
1888
|
+
(i) => i.accountLogin.toLowerCase() === repoOwner
|
|
1889
|
+
);
|
|
1890
|
+
if (!hasMatchingAccount) {
|
|
1891
|
+
p5.log.warn(
|
|
1892
|
+
`None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
|
|
1893
|
+
The project will be created but translations won't trigger automatically.
|
|
1894
|
+
To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
852
1898
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
printPlanLimitMessage(apiUrl, status.message);
|
|
1899
|
+
const validInstallations = cachedInstallations.filter(
|
|
1900
|
+
(i) => !i.isSuspended && !i.conflictLabel
|
|
1901
|
+
);
|
|
1902
|
+
let selectedInstallationId = null;
|
|
1903
|
+
if (validInstallations.length === 1 && cachedInstallations.length === 1) {
|
|
1904
|
+
selectedInstallationId = validInstallations[0].installationId;
|
|
860
1905
|
} else {
|
|
861
|
-
|
|
1906
|
+
selectedInstallationId = await selectGitHubInstallation(
|
|
1907
|
+
cachedInstallations.map((inst) => ({
|
|
1908
|
+
installationId: inst.installationId,
|
|
1909
|
+
accountLogin: inst.accountLogin,
|
|
1910
|
+
accountType: inst.accountType,
|
|
1911
|
+
isSuspended: inst.isSuspended,
|
|
1912
|
+
conflictLabel: inst.conflictLabel
|
|
1913
|
+
})),
|
|
1914
|
+
false
|
|
1915
|
+
);
|
|
862
1916
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
runScaffold({
|
|
871
|
-
projectName: credentials.projectName,
|
|
872
|
-
organizationName: credentials.organizationName,
|
|
873
|
-
sourceLocale: credentials.sourceLocale,
|
|
874
|
-
translationTriggers: credentials.translationTriggers ?? ["push"]
|
|
1917
|
+
if (selectedInstallationId === null || selectedInstallationId === "install_new") {
|
|
1918
|
+
p5.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
|
|
1919
|
+
return 1;
|
|
1920
|
+
}
|
|
1921
|
+
const claimResult = await api.claimCliGitHubInstallation(userToken, {
|
|
1922
|
+
installationId: String(selectedInstallationId),
|
|
1923
|
+
organizationId: null
|
|
875
1924
|
});
|
|
876
|
-
|
|
1925
|
+
selectedWorkspaceId = claimResult.organizationId;
|
|
1926
|
+
selectedWorkspaceName = claimResult.organizationName;
|
|
1927
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1928
|
+
} else {
|
|
1929
|
+
const workspaceData = await api.listWorkspaces(userToken);
|
|
1930
|
+
if (workspaceData.workspaces.length === 1 && !workspaceData.canCreateWorkspace) {
|
|
1931
|
+
const ws = workspaceData.workspaces[0];
|
|
1932
|
+
selectedWorkspaceId = ws.id;
|
|
1933
|
+
selectedWorkspaceName = ws.name;
|
|
1934
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1935
|
+
} else {
|
|
1936
|
+
const workspaceResult = await selectWorkspace(workspaceData);
|
|
1937
|
+
if (workspaceResult.action === "cancelled") {
|
|
1938
|
+
p5.cancel("Setup cancelled.");
|
|
1939
|
+
return 1;
|
|
1940
|
+
}
|
|
1941
|
+
if (workspaceResult.action === "use") {
|
|
1942
|
+
selectedWorkspaceId = workspaceResult.workspace.id;
|
|
1943
|
+
selectedWorkspaceName = workspaceResult.workspace.name;
|
|
1944
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1945
|
+
} else {
|
|
1946
|
+
const connectChoice = await p5.select({
|
|
1947
|
+
message: "Connect your new workspace to GitHub",
|
|
1948
|
+
options: [
|
|
1949
|
+
{ value: "install", label: "Install the Vocoder GitHub App" },
|
|
1950
|
+
{ value: "link", label: "Link an existing installation" }
|
|
1951
|
+
]
|
|
1952
|
+
});
|
|
1953
|
+
if (p5.isCancel(connectChoice)) {
|
|
1954
|
+
p5.cancel("Setup cancelled.");
|
|
1955
|
+
return 1;
|
|
1956
|
+
}
|
|
1957
|
+
if (connectChoice === "install") {
|
|
1958
|
+
const connectResult = await runGitHubInstallFlow({
|
|
1959
|
+
api,
|
|
1960
|
+
userToken,
|
|
1961
|
+
yes: options.yes
|
|
1962
|
+
});
|
|
1963
|
+
if (!connectResult) {
|
|
1964
|
+
p5.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
|
|
1965
|
+
return 1;
|
|
1966
|
+
}
|
|
1967
|
+
selectedWorkspaceId = connectResult.organizationId;
|
|
1968
|
+
selectedWorkspaceName = connectResult.organizationName;
|
|
1969
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1970
|
+
} else {
|
|
1971
|
+
const installations = await runGitHubDiscoveryFlow({
|
|
1972
|
+
api,
|
|
1973
|
+
userToken,
|
|
1974
|
+
yes: options.yes
|
|
1975
|
+
});
|
|
1976
|
+
if (!installations) return 1;
|
|
1977
|
+
if (installations.length === 0) {
|
|
1978
|
+
p5.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
|
|
1979
|
+
const installNow = await p5.confirm({ message: "Open GitHub to install the App?" });
|
|
1980
|
+
if (p5.isCancel(installNow) || !installNow) return 1;
|
|
1981
|
+
const connectResult = await runGitHubInstallFlow({
|
|
1982
|
+
api,
|
|
1983
|
+
userToken,
|
|
1984
|
+
yes: options.yes
|
|
1985
|
+
});
|
|
1986
|
+
if (!connectResult) return 1;
|
|
1987
|
+
selectedWorkspaceId = connectResult.organizationId;
|
|
1988
|
+
selectedWorkspaceName = connectResult.organizationName;
|
|
1989
|
+
} else {
|
|
1990
|
+
const selectedInstallationId = await selectGitHubInstallation(
|
|
1991
|
+
installations.map((inst) => ({
|
|
1992
|
+
installationId: inst.installationId,
|
|
1993
|
+
accountLogin: inst.accountLogin,
|
|
1994
|
+
accountType: inst.accountType,
|
|
1995
|
+
isSuspended: inst.isSuspended,
|
|
1996
|
+
conflictLabel: inst.conflictLabel
|
|
1997
|
+
})),
|
|
1998
|
+
true
|
|
1999
|
+
);
|
|
2000
|
+
if (selectedInstallationId === null) {
|
|
2001
|
+
p5.cancel("Setup cancelled.");
|
|
2002
|
+
return 1;
|
|
2003
|
+
}
|
|
2004
|
+
if (selectedInstallationId === "install_new") {
|
|
2005
|
+
const connectResult = await runGitHubInstallFlow({
|
|
2006
|
+
api,
|
|
2007
|
+
userToken,
|
|
2008
|
+
yes: options.yes
|
|
2009
|
+
});
|
|
2010
|
+
if (!connectResult) return 1;
|
|
2011
|
+
selectedWorkspaceId = connectResult.organizationId;
|
|
2012
|
+
selectedWorkspaceName = connectResult.organizationName;
|
|
2013
|
+
} else {
|
|
2014
|
+
const claimResult = await api.claimCliGitHubInstallation(userToken, {
|
|
2015
|
+
installationId: String(selectedInstallationId),
|
|
2016
|
+
organizationId: null
|
|
2017
|
+
});
|
|
2018
|
+
selectedWorkspaceId = claimResult.organizationId;
|
|
2019
|
+
selectedWorkspaceName = claimResult.organizationName;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
877
2026
|
}
|
|
878
2027
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
2028
|
+
const projectResult = await runProjectCreate({
|
|
2029
|
+
api,
|
|
2030
|
+
userToken,
|
|
2031
|
+
organizationId: selectedWorkspaceId,
|
|
2032
|
+
defaultName: identity?.repoCanonical ? identity.repoCanonical.split("/").pop() : void 0,
|
|
2033
|
+
defaultSourceLocale: "en",
|
|
2034
|
+
repoCanonical: identity?.repoCanonical,
|
|
2035
|
+
defaultBranches: ["main"],
|
|
2036
|
+
defaultScopePath: identity?.repoScopePath
|
|
2037
|
+
});
|
|
2038
|
+
if (!projectResult) {
|
|
2039
|
+
p5.log.error("Project creation failed. Run `vocoder init` again.");
|
|
2040
|
+
return 1;
|
|
2041
|
+
}
|
|
2042
|
+
if (!projectResult.repositoryBound && identity?.repoCanonical) {
|
|
2043
|
+
p5.log.warn(
|
|
2044
|
+
`This repository isn't accessible to your GitHub App installation.
|
|
2045
|
+
Translations won't run automatically until you grant access.
|
|
2046
|
+
|
|
2047
|
+
To fix: go to your GitHub App installation settings and add this
|
|
2048
|
+
repository to the allowed list, or switch to "All repositories".
|
|
2049
|
+
` + (projectResult.configureUrl ? `
|
|
2050
|
+
${chalk6.dim(projectResult.configureUrl)}
|
|
2051
|
+
` : "")
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
runScaffold({
|
|
2055
|
+
projectName: projectResult.projectName,
|
|
2056
|
+
organizationName: selectedWorkspaceName,
|
|
2057
|
+
sourceLocale: projectResult.sourceLocale,
|
|
2058
|
+
translationTriggers: projectResult.translationTriggers
|
|
2059
|
+
});
|
|
2060
|
+
printMcpSetup(projectResult.apiKey);
|
|
2061
|
+
p5.outro("You're all set.");
|
|
2062
|
+
return 0;
|
|
883
2063
|
} catch (error) {
|
|
884
|
-
spinner3.stop();
|
|
885
2064
|
if (error instanceof Error) {
|
|
886
2065
|
if (isPlanLimitFailure(error.message)) {
|
|
887
2066
|
printPlanLimitMessage(apiUrl, error.message);
|
|
888
2067
|
return 1;
|
|
889
2068
|
}
|
|
890
|
-
|
|
2069
|
+
p5.log.error(`Error: ${error.message}`);
|
|
891
2070
|
} else {
|
|
892
|
-
|
|
2071
|
+
p5.log.error("Unknown setup error");
|
|
893
2072
|
}
|
|
894
2073
|
return 1;
|
|
895
2074
|
}
|
|
896
2075
|
}
|
|
897
2076
|
|
|
2077
|
+
// src/commands/logout.ts
|
|
2078
|
+
import * as p6 from "@clack/prompts";
|
|
2079
|
+
async function logout(options = {}) {
|
|
2080
|
+
const stored = readAuthData();
|
|
2081
|
+
if (!stored) {
|
|
2082
|
+
p6.log.info("Not currently authenticated.");
|
|
2083
|
+
return 0;
|
|
2084
|
+
}
|
|
2085
|
+
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
2086
|
+
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
2087
|
+
try {
|
|
2088
|
+
await api.revokeCliToken(stored.token);
|
|
2089
|
+
} catch {
|
|
2090
|
+
}
|
|
2091
|
+
clearAuthData();
|
|
2092
|
+
p6.log.success(`Logged out (was ${stored.email})`);
|
|
2093
|
+
return 0;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
898
2096
|
// src/commands/sync.ts
|
|
899
|
-
import * as
|
|
900
|
-
import { createHash
|
|
2097
|
+
import * as p7 from "@clack/prompts";
|
|
2098
|
+
import { createHash, randomUUID } from "crypto";
|
|
901
2099
|
|
|
902
2100
|
// src/utils/branch.ts
|
|
903
|
-
import { execSync as
|
|
2101
|
+
import { execSync as execSync4 } from "child_process";
|
|
904
2102
|
var REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/g;
|
|
905
2103
|
function escapeRegexChar(value) {
|
|
906
2104
|
return value.replace(REGEX_SPECIAL_CHARS, "\\$&");
|
|
@@ -922,7 +2120,7 @@ function detectBranch(override) {
|
|
|
922
2120
|
return envBranch;
|
|
923
2121
|
}
|
|
924
2122
|
try {
|
|
925
|
-
const branch =
|
|
2123
|
+
const branch = execSync4("git rev-parse --abbrev-ref HEAD", {
|
|
926
2124
|
encoding: "utf-8",
|
|
927
2125
|
stdio: ["pipe", "pipe", "ignore"]
|
|
928
2126
|
}).trim();
|
|
@@ -966,10 +2164,10 @@ function matchBranchPattern(branch, pattern) {
|
|
|
966
2164
|
}
|
|
967
2165
|
|
|
968
2166
|
// src/commands/sync.ts
|
|
969
|
-
import { existsSync as
|
|
2167
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
970
2168
|
|
|
971
2169
|
// src/utils/config.ts
|
|
972
|
-
import
|
|
2170
|
+
import chalk7 from "chalk";
|
|
973
2171
|
import { config as loadEnv2 } from "dotenv";
|
|
974
2172
|
loadEnv2();
|
|
975
2173
|
function validateLocalConfig(config) {
|
|
@@ -1061,19 +2259,19 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
|
|
|
1061
2259
|
configSources.noFallback = "environment";
|
|
1062
2260
|
}
|
|
1063
2261
|
if (verbose) {
|
|
1064
|
-
console.log(
|
|
1065
|
-
console.log(
|
|
2262
|
+
console.log(chalk7.dim("\n Configuration sources:"));
|
|
2263
|
+
console.log(chalk7.dim(` Include patterns: ${configSources.extractionPattern}`));
|
|
1066
2264
|
if (excludePattern.length > 0) {
|
|
1067
|
-
console.log(
|
|
2265
|
+
console.log(chalk7.dim(` Exclude patterns: ${configSources.excludePattern}`));
|
|
1068
2266
|
}
|
|
1069
|
-
console.log(
|
|
1070
|
-
console.log(
|
|
2267
|
+
console.log(chalk7.dim(` API key: ${configSources.apiKey}`));
|
|
2268
|
+
console.log(chalk7.dim(` API URL: ${configSources.apiUrl}
|
|
1071
2269
|
`));
|
|
1072
|
-
console.log(
|
|
2270
|
+
console.log(chalk7.dim(` Sync mode: ${configSources.mode}`));
|
|
1073
2271
|
if (maxWaitMs) {
|
|
1074
|
-
console.log(
|
|
2272
|
+
console.log(chalk7.dim(` Max wait: ${configSources.maxWaitMs}`));
|
|
1075
2273
|
}
|
|
1076
|
-
console.log(
|
|
2274
|
+
console.log(chalk7.dim(` No fallback: ${configSources.noFallback}
|
|
1077
2275
|
`));
|
|
1078
2276
|
}
|
|
1079
2277
|
return {
|
|
@@ -1088,280 +2286,8 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
|
|
|
1088
2286
|
};
|
|
1089
2287
|
}
|
|
1090
2288
|
|
|
1091
|
-
// src/utils/extract.ts
|
|
1092
|
-
import { createHash } from "crypto";
|
|
1093
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
1094
|
-
import { parse } from "@babel/parser";
|
|
1095
|
-
import babelTraverse from "@babel/traverse";
|
|
1096
|
-
import { glob } from "glob";
|
|
1097
|
-
import { relative as pathRelative } from "path";
|
|
1098
|
-
var traverse = babelTraverse.default || babelTraverse;
|
|
1099
|
-
var StringExtractor = class {
|
|
1100
|
-
/**
|
|
1101
|
-
* Extract strings from all files matching the pattern(s)
|
|
1102
|
-
*
|
|
1103
|
-
* @param pattern - Glob pattern(s) to include
|
|
1104
|
-
* @param projectRoot - Project root directory
|
|
1105
|
-
* @param excludePattern - Glob pattern(s) to exclude (optional)
|
|
1106
|
-
*/
|
|
1107
|
-
async extractFromProject(pattern, projectRoot = process.cwd(), excludePattern) {
|
|
1108
|
-
const includePatterns = Array.isArray(pattern) ? pattern : [pattern];
|
|
1109
|
-
const defaultIgnore = ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"];
|
|
1110
|
-
const ignorePatterns = excludePattern ? [...defaultIgnore, ...Array.isArray(excludePattern) ? excludePattern : [excludePattern]] : defaultIgnore;
|
|
1111
|
-
const allFiles = /* @__PURE__ */ new Set();
|
|
1112
|
-
for (const includePattern of includePatterns) {
|
|
1113
|
-
const files = await glob(includePattern, {
|
|
1114
|
-
cwd: projectRoot,
|
|
1115
|
-
absolute: true,
|
|
1116
|
-
ignore: ignorePatterns
|
|
1117
|
-
});
|
|
1118
|
-
files.forEach((file) => allFiles.add(file));
|
|
1119
|
-
}
|
|
1120
|
-
const allStrings = [];
|
|
1121
|
-
const sortedFiles = Array.from(allFiles).sort();
|
|
1122
|
-
for (const file of sortedFiles) {
|
|
1123
|
-
try {
|
|
1124
|
-
const strings = await this.extractFromFile(file, projectRoot);
|
|
1125
|
-
allStrings.push(...strings);
|
|
1126
|
-
} catch (error) {
|
|
1127
|
-
console.warn(`Warning: Failed to extract from ${file}:`, error);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
const unique = this.deduplicateStrings(allStrings);
|
|
1131
|
-
return unique;
|
|
1132
|
-
}
|
|
1133
|
-
/**
|
|
1134
|
-
* Extract strings from a single file
|
|
1135
|
-
*/
|
|
1136
|
-
async extractFromFile(filePath, projectRoot) {
|
|
1137
|
-
const code = readFileSync2(filePath, "utf-8");
|
|
1138
|
-
const strings = [];
|
|
1139
|
-
const relativeFilePath = pathRelative(projectRoot, filePath).split("\\").join("/");
|
|
1140
|
-
try {
|
|
1141
|
-
const ast = parse(code, {
|
|
1142
|
-
sourceType: "module",
|
|
1143
|
-
plugins: ["jsx", "typescript"]
|
|
1144
|
-
});
|
|
1145
|
-
const vocoderImports = /* @__PURE__ */ new Map();
|
|
1146
|
-
const tFunctionNames = /* @__PURE__ */ new Set();
|
|
1147
|
-
traverse(ast, {
|
|
1148
|
-
// Track imports of <T> component and t function
|
|
1149
|
-
ImportDeclaration: (path) => {
|
|
1150
|
-
const source = path.node.source.value;
|
|
1151
|
-
if (source === "@vocoder/react") {
|
|
1152
|
-
path.node.specifiers.forEach((spec) => {
|
|
1153
|
-
if (spec.type === "ImportSpecifier") {
|
|
1154
|
-
const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
|
|
1155
|
-
const local = spec.local.name;
|
|
1156
|
-
if (imported === "T") {
|
|
1157
|
-
vocoderImports.set(local, "T");
|
|
1158
|
-
}
|
|
1159
|
-
if (imported === "t") {
|
|
1160
|
-
tFunctionNames.add(local);
|
|
1161
|
-
}
|
|
1162
|
-
if (imported === "useVocoder") {
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
},
|
|
1168
|
-
// Track destructured 't' from useVocoder hook
|
|
1169
|
-
VariableDeclarator: (path) => {
|
|
1170
|
-
const init2 = path.node.init;
|
|
1171
|
-
if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === "useVocoder" && path.node.id.type === "ObjectPattern") {
|
|
1172
|
-
path.node.id.properties.forEach((prop) => {
|
|
1173
|
-
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "t") {
|
|
1174
|
-
const localName = prop.value.type === "Identifier" ? prop.value.name : "t";
|
|
1175
|
-
tFunctionNames.add(localName);
|
|
1176
|
-
}
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
1179
|
-
},
|
|
1180
|
-
// Extract from t() function calls
|
|
1181
|
-
CallExpression: (path) => {
|
|
1182
|
-
const callee = path.node.callee;
|
|
1183
|
-
const isTFunction = callee.type === "Identifier" && tFunctionNames.has(callee.name);
|
|
1184
|
-
if (!isTFunction) return;
|
|
1185
|
-
const firstArg = path.node.arguments[0];
|
|
1186
|
-
if (!firstArg) return;
|
|
1187
|
-
let text = null;
|
|
1188
|
-
if (firstArg.type === "StringLiteral") {
|
|
1189
|
-
text = firstArg.value;
|
|
1190
|
-
} else if (firstArg.type === "TemplateLiteral") {
|
|
1191
|
-
text = this.extractTemplateText(firstArg);
|
|
1192
|
-
}
|
|
1193
|
-
if (!text || text.trim().length === 0) return;
|
|
1194
|
-
const secondArg = path.node.arguments[1];
|
|
1195
|
-
let context;
|
|
1196
|
-
let formality;
|
|
1197
|
-
let explicitKey;
|
|
1198
|
-
if (secondArg && secondArg.type === "ObjectExpression") {
|
|
1199
|
-
secondArg.properties.forEach((prop) => {
|
|
1200
|
-
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier") {
|
|
1201
|
-
if (prop.key.name === "context" && prop.value.type === "StringLiteral") {
|
|
1202
|
-
context = prop.value.value;
|
|
1203
|
-
}
|
|
1204
|
-
if (prop.key.name === "formality" && prop.value.type === "StringLiteral") {
|
|
1205
|
-
formality = prop.value.value;
|
|
1206
|
-
}
|
|
1207
|
-
if (prop.key.name === "id" && prop.value.type === "StringLiteral") {
|
|
1208
|
-
explicitKey = prop.value.value.trim();
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
1213
|
-
const line = path.node.loc?.start.line || 0;
|
|
1214
|
-
const column = path.node.loc?.start.column || 0;
|
|
1215
|
-
const key = explicitKey && explicitKey.length > 0 ? explicitKey : this.generateStableKey({
|
|
1216
|
-
filePath: relativeFilePath,
|
|
1217
|
-
kind: "t-call",
|
|
1218
|
-
line,
|
|
1219
|
-
column
|
|
1220
|
-
});
|
|
1221
|
-
strings.push({
|
|
1222
|
-
key,
|
|
1223
|
-
text: text.trim(),
|
|
1224
|
-
file: filePath,
|
|
1225
|
-
line,
|
|
1226
|
-
context,
|
|
1227
|
-
formality
|
|
1228
|
-
});
|
|
1229
|
-
},
|
|
1230
|
-
// Extract from JSX elements
|
|
1231
|
-
JSXElement: (path) => {
|
|
1232
|
-
const opening = path.node.openingElement;
|
|
1233
|
-
const tagName = opening.name.type === "JSXIdentifier" ? opening.name.name : null;
|
|
1234
|
-
if (!tagName) return;
|
|
1235
|
-
const isTranslationComponent = vocoderImports.has(tagName);
|
|
1236
|
-
if (!isTranslationComponent) return;
|
|
1237
|
-
const msgAttribute = this.getStringAttribute(opening.attributes, "msg");
|
|
1238
|
-
const text = msgAttribute || this.extractTextContent(path.node.children);
|
|
1239
|
-
if (!text || text.trim().length === 0) return;
|
|
1240
|
-
const id = this.getStringAttribute(opening.attributes, "id");
|
|
1241
|
-
const context = this.getStringAttribute(opening.attributes, "context");
|
|
1242
|
-
const formality = this.getStringAttribute(
|
|
1243
|
-
opening.attributes,
|
|
1244
|
-
"formality"
|
|
1245
|
-
);
|
|
1246
|
-
const line = path.node.loc?.start.line || 0;
|
|
1247
|
-
const column = path.node.loc?.start.column || 0;
|
|
1248
|
-
const key = id && id.trim().length > 0 ? id.trim() : this.generateStableKey({
|
|
1249
|
-
filePath: relativeFilePath,
|
|
1250
|
-
kind: "jsx",
|
|
1251
|
-
line,
|
|
1252
|
-
column
|
|
1253
|
-
});
|
|
1254
|
-
strings.push({
|
|
1255
|
-
key,
|
|
1256
|
-
text: text.trim(),
|
|
1257
|
-
file: filePath,
|
|
1258
|
-
line,
|
|
1259
|
-
context,
|
|
1260
|
-
formality
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
});
|
|
1264
|
-
} catch (error) {
|
|
1265
|
-
throw new Error(
|
|
1266
|
-
`Failed to parse ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1267
|
-
);
|
|
1268
|
-
}
|
|
1269
|
-
return strings;
|
|
1270
|
-
}
|
|
1271
|
-
/**
|
|
1272
|
-
* Extract text from template literal
|
|
1273
|
-
* Converts template literals like `Hello ${name}` to `Hello {name}`
|
|
1274
|
-
*/
|
|
1275
|
-
extractTemplateText(node) {
|
|
1276
|
-
let text = "";
|
|
1277
|
-
for (let i = 0; i < node.quasis.length; i++) {
|
|
1278
|
-
const quasi = node.quasis[i];
|
|
1279
|
-
text += quasi.value.raw;
|
|
1280
|
-
if (i < node.expressions.length) {
|
|
1281
|
-
const expr = node.expressions[i];
|
|
1282
|
-
if (expr.type === "Identifier") {
|
|
1283
|
-
text += `{${expr.name}}`;
|
|
1284
|
-
} else {
|
|
1285
|
-
text += "{value}";
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
return text;
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Extract text content from JSX children
|
|
1293
|
-
*/
|
|
1294
|
-
extractTextContent(children) {
|
|
1295
|
-
let text = "";
|
|
1296
|
-
for (const child of children) {
|
|
1297
|
-
if (child.type === "JSXText") {
|
|
1298
|
-
text += child.value;
|
|
1299
|
-
} else if (child.type === "JSXExpressionContainer") {
|
|
1300
|
-
const expr = child.expression;
|
|
1301
|
-
if (expr.type === "Identifier") {
|
|
1302
|
-
text += `{${expr.name}}`;
|
|
1303
|
-
} else if (expr.type === "StringLiteral") {
|
|
1304
|
-
text += expr.value;
|
|
1305
|
-
} else if (expr.type === "TemplateLiteral") {
|
|
1306
|
-
text += this.extractTemplateText(expr);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
return text;
|
|
1311
|
-
}
|
|
1312
|
-
/**
|
|
1313
|
-
* Get string value from JSX attribute
|
|
1314
|
-
* Handles both string literals and template literals
|
|
1315
|
-
*/
|
|
1316
|
-
getStringAttribute(attributes, name) {
|
|
1317
|
-
const attr = attributes.find(
|
|
1318
|
-
(a) => a.type === "JSXAttribute" && a.name.name === name
|
|
1319
|
-
);
|
|
1320
|
-
if (!attr || !attr.value) return void 0;
|
|
1321
|
-
if (attr.value.type === "StringLiteral") {
|
|
1322
|
-
return attr.value.value;
|
|
1323
|
-
}
|
|
1324
|
-
if (attr.value.type === "JSXExpressionContainer") {
|
|
1325
|
-
const expr = attr.value.expression;
|
|
1326
|
-
if (expr.type === "TemplateLiteral") {
|
|
1327
|
-
return this.extractTemplateText(expr);
|
|
1328
|
-
}
|
|
1329
|
-
if (expr.type === "StringLiteral") {
|
|
1330
|
-
return expr.value;
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
return void 0;
|
|
1334
|
-
}
|
|
1335
|
-
/**
|
|
1336
|
-
* Deduplicate strings (keep first occurrence)
|
|
1337
|
-
*/
|
|
1338
|
-
deduplicateStrings(strings) {
|
|
1339
|
-
const seen = /* @__PURE__ */ new Map();
|
|
1340
|
-
const unique = [];
|
|
1341
|
-
for (const str of strings) {
|
|
1342
|
-
const dedupeKey = `${str.text}|${str.context || ""}|${str.formality || ""}`;
|
|
1343
|
-
const existingIndex = seen.get(dedupeKey);
|
|
1344
|
-
if (existingIndex === void 0) {
|
|
1345
|
-
seen.set(dedupeKey, unique.length);
|
|
1346
|
-
unique.push(str);
|
|
1347
|
-
continue;
|
|
1348
|
-
}
|
|
1349
|
-
const existing = unique[existingIndex];
|
|
1350
|
-
if (existing && str.key < existing.key) {
|
|
1351
|
-
existing.key = str.key;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
return unique;
|
|
1355
|
-
}
|
|
1356
|
-
generateStableKey(params) {
|
|
1357
|
-
const payload = `${params.filePath}|${params.kind}|${params.line}:${params.column}`;
|
|
1358
|
-
const digest = createHash("sha1").update(payload).digest("hex");
|
|
1359
|
-
return `SK_${digest.slice(0, 24).toUpperCase()}`;
|
|
1360
|
-
}
|
|
1361
|
-
};
|
|
1362
|
-
|
|
1363
2289
|
// src/commands/sync.ts
|
|
1364
|
-
import
|
|
2290
|
+
import chalk8 from "chalk";
|
|
1365
2291
|
import { join as join2 } from "path";
|
|
1366
2292
|
function isRecord(value) {
|
|
1367
2293
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -1408,7 +2334,7 @@ function parseTranslations(value) {
|
|
|
1408
2334
|
}
|
|
1409
2335
|
function getCacheFilePath(projectRoot, branch) {
|
|
1410
2336
|
const slug = branch.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
|
|
1411
|
-
const branchHash =
|
|
2337
|
+
const branchHash = createHash("sha1").update(branch).digest("hex").slice(0, 12);
|
|
1412
2338
|
const filename = `${slug || "branch"}-${branchHash}.json`;
|
|
1413
2339
|
return join2(projectRoot, "node_modules", ".vocoder", "cache", "sync", filename);
|
|
1414
2340
|
}
|
|
@@ -1416,11 +2342,11 @@ function readLocalSnapshotCache(params) {
|
|
|
1416
2342
|
const candidateBranches = params.branch === "main" ? ["main"] : [params.branch, "main"];
|
|
1417
2343
|
for (const candidateBranch of candidateBranches) {
|
|
1418
2344
|
const cacheFilePath = getCacheFilePath(params.projectRoot, candidateBranch);
|
|
1419
|
-
if (!
|
|
2345
|
+
if (!existsSync(cacheFilePath)) {
|
|
1420
2346
|
continue;
|
|
1421
2347
|
}
|
|
1422
2348
|
try {
|
|
1423
|
-
const raw =
|
|
2349
|
+
const raw = readFileSync2(cacheFilePath, "utf-8");
|
|
1424
2350
|
const parsed = JSON.parse(raw);
|
|
1425
2351
|
if (!isRecord(parsed)) {
|
|
1426
2352
|
continue;
|
|
@@ -1446,7 +2372,7 @@ function readLocalSnapshotCache(params) {
|
|
|
1446
2372
|
}
|
|
1447
2373
|
function writeLocalSnapshotCache(params) {
|
|
1448
2374
|
const cacheFilePath = getCacheFilePath(params.projectRoot, params.branch);
|
|
1449
|
-
|
|
2375
|
+
mkdirSync2(join2(params.projectRoot, "node_modules", ".vocoder", "cache", "sync"), {
|
|
1450
2376
|
recursive: true
|
|
1451
2377
|
});
|
|
1452
2378
|
const payload = {
|
|
@@ -1460,7 +2386,7 @@ function writeLocalSnapshotCache(params) {
|
|
|
1460
2386
|
...params.localeMetadata ? { localeMetadata: params.localeMetadata } : {},
|
|
1461
2387
|
translations: params.translations
|
|
1462
2388
|
};
|
|
1463
|
-
|
|
2389
|
+
writeFileSync2(cacheFilePath, JSON.stringify(payload, null, 2), "utf-8");
|
|
1464
2390
|
return cacheFilePath;
|
|
1465
2391
|
}
|
|
1466
2392
|
function resolveEffectiveModeFromPolicy(params) {
|
|
@@ -1615,13 +2541,13 @@ async function fetchApiSnapshot(api, params) {
|
|
|
1615
2541
|
async function sync(options = {}) {
|
|
1616
2542
|
const startTime = Date.now();
|
|
1617
2543
|
const projectRoot = process.cwd();
|
|
1618
|
-
|
|
1619
|
-
const
|
|
2544
|
+
p7.intro("Vocoder Sync");
|
|
2545
|
+
const spinner4 = p7.spinner();
|
|
1620
2546
|
try {
|
|
1621
|
-
|
|
2547
|
+
spinner4.start("Detecting branch");
|
|
1622
2548
|
const branch = detectBranch(options.branch);
|
|
1623
|
-
|
|
1624
|
-
|
|
2549
|
+
spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
|
|
2550
|
+
spinner4.start("Loading project configuration");
|
|
1625
2551
|
const mergedConfig = await getMergedConfig(options, options.verbose);
|
|
1626
2552
|
const localConfig = {
|
|
1627
2553
|
apiKey: mergedConfig.apiKey || "",
|
|
@@ -1643,18 +2569,18 @@ async function sync(options = {}) {
|
|
|
1643
2569
|
excludePattern: mergedConfig.excludePattern,
|
|
1644
2570
|
timeout: waitTimeoutMs
|
|
1645
2571
|
};
|
|
1646
|
-
|
|
2572
|
+
spinner4.stop("Project configuration loaded");
|
|
1647
2573
|
if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
|
|
1648
|
-
|
|
1649
|
-
`Skipping translations (${
|
|
2574
|
+
p7.log.warn(
|
|
2575
|
+
`Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
|
|
1650
2576
|
);
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2577
|
+
p7.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
|
|
2578
|
+
p7.log.info("Use --force to translate anyway");
|
|
2579
|
+
p7.outro("");
|
|
1654
2580
|
return 0;
|
|
1655
2581
|
}
|
|
1656
2582
|
const patternsDisplay = Array.isArray(config.extractionPattern) ? config.extractionPattern.join(", ") : config.extractionPattern;
|
|
1657
|
-
|
|
2583
|
+
spinner4.start(`Extracting strings from ${patternsDisplay}`);
|
|
1658
2584
|
const extractor = new StringExtractor();
|
|
1659
2585
|
const extractedStrings = await extractor.extractFromProject(
|
|
1660
2586
|
config.extractionPattern,
|
|
@@ -1662,23 +2588,23 @@ async function sync(options = {}) {
|
|
|
1662
2588
|
config.excludePattern
|
|
1663
2589
|
);
|
|
1664
2590
|
if (extractedStrings.length === 0) {
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
2591
|
+
spinner4.stop("No translatable strings found");
|
|
2592
|
+
p7.log.warn("Make sure you are wrapping translatable strings with Vocoder");
|
|
2593
|
+
p7.outro("");
|
|
1668
2594
|
return 0;
|
|
1669
2595
|
}
|
|
1670
|
-
|
|
1671
|
-
`Extracted ${
|
|
2596
|
+
spinner4.stop(
|
|
2597
|
+
`Extracted ${chalk8.cyan(extractedStrings.length)} strings from ${chalk8.cyan(patternsDisplay)}`
|
|
1672
2598
|
);
|
|
1673
2599
|
if (options.verbose) {
|
|
1674
2600
|
const sampleLines = extractedStrings.slice(0, 5).map((s) => ` "${s.text}" (${s.file}:${s.line})`);
|
|
1675
2601
|
if (extractedStrings.length > 5) {
|
|
1676
2602
|
sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
|
|
1677
2603
|
}
|
|
1678
|
-
|
|
2604
|
+
p7.note(sampleLines.join("\n"), "Sample strings");
|
|
1679
2605
|
}
|
|
1680
2606
|
if (options.dryRun) {
|
|
1681
|
-
|
|
2607
|
+
p7.note(
|
|
1682
2608
|
[
|
|
1683
2609
|
`Strings: ${extractedStrings.length}`,
|
|
1684
2610
|
`Branch: ${branch}`,
|
|
@@ -1689,23 +2615,23 @@ async function sync(options = {}) {
|
|
|
1689
2615
|
].join("\n"),
|
|
1690
2616
|
"Dry run - would translate"
|
|
1691
2617
|
);
|
|
1692
|
-
|
|
2618
|
+
p7.outro("No API calls made.");
|
|
1693
2619
|
return 0;
|
|
1694
2620
|
}
|
|
1695
2621
|
const repoIdentity = resolveGitRepositoryIdentity();
|
|
1696
2622
|
if (!repoIdentity && options.verbose) {
|
|
1697
|
-
|
|
2623
|
+
p7.log.warn(
|
|
1698
2624
|
"Could not detect git remote origin. Sync will continue without repo metadata."
|
|
1699
2625
|
);
|
|
1700
2626
|
}
|
|
1701
2627
|
const stringEntries = buildStringEntries(extractedStrings);
|
|
1702
2628
|
const sourceStrings = stringEntries.map((entry) => entry.text);
|
|
1703
2629
|
if (options.verbose && stringEntries.length !== extractedStrings.length) {
|
|
1704
|
-
|
|
2630
|
+
p7.log.info(
|
|
1705
2631
|
`Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
|
|
1706
2632
|
);
|
|
1707
2633
|
}
|
|
1708
|
-
|
|
2634
|
+
spinner4.start("Submitting strings to Vocoder API");
|
|
1709
2635
|
const batchResponse = await api.submitTranslation(
|
|
1710
2636
|
branch,
|
|
1711
2637
|
stringEntries,
|
|
@@ -1717,38 +2643,38 @@ async function sync(options = {}) {
|
|
|
1717
2643
|
},
|
|
1718
2644
|
repoIdentity ?? void 0
|
|
1719
2645
|
);
|
|
1720
|
-
|
|
2646
|
+
spinner4.stop(`Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`);
|
|
1721
2647
|
const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
|
|
1722
2648
|
branch,
|
|
1723
2649
|
requestedMode,
|
|
1724
2650
|
policy: config.syncPolicy
|
|
1725
2651
|
});
|
|
1726
2652
|
if (options.verbose) {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
2653
|
+
p7.log.info(`Requested mode: ${requestedMode}`);
|
|
2654
|
+
p7.log.info(`Effective mode: ${effectiveMode}`);
|
|
2655
|
+
p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
|
|
1730
2656
|
if (batchResponse.queueStatus) {
|
|
1731
|
-
|
|
2657
|
+
p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
|
|
1732
2658
|
}
|
|
1733
2659
|
}
|
|
1734
2660
|
if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
|
|
1735
|
-
|
|
2661
|
+
p7.log.success("No changes detected - strings are up to date");
|
|
1736
2662
|
}
|
|
1737
|
-
|
|
2663
|
+
p7.log.info(`New strings: ${chalk8.cyan(batchResponse.newStrings)}`);
|
|
1738
2664
|
if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
|
|
1739
|
-
|
|
1740
|
-
`Deleted strings: ${
|
|
2665
|
+
p7.log.info(
|
|
2666
|
+
`Deleted strings: ${chalk8.yellow(batchResponse.deletedStrings)} (archived)`
|
|
1741
2667
|
);
|
|
1742
2668
|
}
|
|
1743
|
-
|
|
2669
|
+
p7.log.info(`Total strings: ${chalk8.cyan(batchResponse.totalStrings)}`);
|
|
1744
2670
|
if (batchResponse.newStrings === 0) {
|
|
1745
|
-
|
|
2671
|
+
p7.log.success("No new strings - using existing translations");
|
|
1746
2672
|
} else {
|
|
1747
|
-
|
|
2673
|
+
p7.log.info(
|
|
1748
2674
|
`Syncing to ${config.targetLocales.length} locales (${config.targetLocales.join(", ")})`
|
|
1749
2675
|
);
|
|
1750
2676
|
if (batchResponse.estimatedTime) {
|
|
1751
|
-
|
|
2677
|
+
p7.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
|
|
1752
2678
|
}
|
|
1753
2679
|
}
|
|
1754
2680
|
let artifacts = null;
|
|
@@ -1760,7 +2686,7 @@ async function sync(options = {}) {
|
|
|
1760
2686
|
}
|
|
1761
2687
|
let waitError = null;
|
|
1762
2688
|
if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
|
|
1763
|
-
|
|
2689
|
+
spinner4.start(`Waiting for translations (max ${waitTimeoutMs}ms)`);
|
|
1764
2690
|
let lastProgress = 0;
|
|
1765
2691
|
try {
|
|
1766
2692
|
const completion = await api.waitForCompletion(
|
|
@@ -1769,7 +2695,7 @@ async function sync(options = {}) {
|
|
|
1769
2695
|
(progress) => {
|
|
1770
2696
|
const percent = Math.round(progress * 100);
|
|
1771
2697
|
if (percent > lastProgress) {
|
|
1772
|
-
|
|
2698
|
+
spinner4.message(`Translating... ${percent}%`);
|
|
1773
2699
|
lastProgress = percent;
|
|
1774
2700
|
}
|
|
1775
2701
|
}
|
|
@@ -1779,14 +2705,14 @@ async function sync(options = {}) {
|
|
|
1779
2705
|
translations: completion.translations,
|
|
1780
2706
|
localeMetadata: completion.localeMetadata
|
|
1781
2707
|
};
|
|
1782
|
-
|
|
2708
|
+
spinner4.stop("Translations complete");
|
|
1783
2709
|
} catch (error) {
|
|
1784
|
-
|
|
2710
|
+
spinner4.stop("Translation wait incomplete");
|
|
1785
2711
|
waitError = error instanceof Error ? error : new Error(String(error));
|
|
1786
2712
|
if (effectiveMode === "required") {
|
|
1787
2713
|
throw waitError;
|
|
1788
2714
|
}
|
|
1789
|
-
|
|
2715
|
+
p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
|
|
1790
2716
|
}
|
|
1791
2717
|
}
|
|
1792
2718
|
if (!artifacts) {
|
|
@@ -1795,7 +2721,7 @@ async function sync(options = {}) {
|
|
|
1795
2721
|
"Fresh translations are not available and fallback is disabled (--no-fallback)."
|
|
1796
2722
|
);
|
|
1797
2723
|
}
|
|
1798
|
-
|
|
2724
|
+
spinner4.start("Loading fallback translations");
|
|
1799
2725
|
const localFallback = readLocalSnapshotCache({
|
|
1800
2726
|
projectRoot,
|
|
1801
2727
|
branch
|
|
@@ -1803,7 +2729,7 @@ async function sync(options = {}) {
|
|
|
1803
2729
|
if (localFallback) {
|
|
1804
2730
|
artifacts = localFallback;
|
|
1805
2731
|
const cacheBranchLabel = localFallback.cacheBranch && localFallback.cacheBranch !== branch ? `${localFallback.cacheBranch} fallback` : localFallback.cacheBranch || branch;
|
|
1806
|
-
|
|
2732
|
+
spinner4.stop(`Using local cached snapshot (${cacheBranchLabel})`);
|
|
1807
2733
|
} else {
|
|
1808
2734
|
try {
|
|
1809
2735
|
const apiSnapshot = await fetchApiSnapshot(api, {
|
|
@@ -1812,15 +2738,15 @@ async function sync(options = {}) {
|
|
|
1812
2738
|
});
|
|
1813
2739
|
if (apiSnapshot) {
|
|
1814
2740
|
artifacts = apiSnapshot;
|
|
1815
|
-
|
|
2741
|
+
spinner4.stop("Using latest completed API snapshot");
|
|
1816
2742
|
} else {
|
|
1817
|
-
|
|
2743
|
+
spinner4.stop("No completed API snapshot available");
|
|
1818
2744
|
}
|
|
1819
2745
|
} catch (error) {
|
|
1820
|
-
|
|
2746
|
+
spinner4.stop("Failed to fetch API snapshot");
|
|
1821
2747
|
if (options.verbose) {
|
|
1822
2748
|
const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
|
|
1823
|
-
|
|
2749
|
+
p7.log.warn(`Snapshot fetch error: ${message}`);
|
|
1824
2750
|
}
|
|
1825
2751
|
}
|
|
1826
2752
|
}
|
|
@@ -1853,81 +2779,108 @@ async function sync(options = {}) {
|
|
|
1853
2779
|
completedAt: artifacts.completedAt ?? (artifacts.source === "fresh" ? (/* @__PURE__ */ new Date()).toISOString() : null)
|
|
1854
2780
|
});
|
|
1855
2781
|
if (options.verbose) {
|
|
1856
|
-
|
|
2782
|
+
p7.log.info(`Cached snapshot: ${cachePath}`);
|
|
1857
2783
|
}
|
|
1858
2784
|
} catch (error) {
|
|
1859
2785
|
if (options.verbose) {
|
|
1860
2786
|
const message = error instanceof Error ? error.message : "Unknown cache write error";
|
|
1861
|
-
|
|
2787
|
+
p7.log.warn(`Failed to write local snapshot cache: ${message}`);
|
|
1862
2788
|
}
|
|
1863
2789
|
}
|
|
1864
2790
|
if (artifacts.source !== "fresh") {
|
|
1865
2791
|
const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
|
|
1866
|
-
|
|
2792
|
+
p7.log.warn(
|
|
1867
2793
|
`Using ${sourceLabel}. New strings may appear after the background sync completes.`
|
|
1868
2794
|
);
|
|
1869
2795
|
}
|
|
1870
2796
|
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
2797
|
+
p7.outro(`Sync complete! (${duration}s)`);
|
|
2798
|
+
p7.log.info("Translations will be injected at build time by @vocoder/unplugin.");
|
|
2799
|
+
p7.log.info("Just use <VocoderProvider> and <T> \u2014 no manual imports needed.");
|
|
1874
2800
|
return 0;
|
|
1875
2801
|
} catch (error) {
|
|
1876
|
-
|
|
2802
|
+
spinner4.stop();
|
|
1877
2803
|
if (error instanceof VocoderAPIError && error.syncPolicyError) {
|
|
1878
|
-
|
|
2804
|
+
p7.log.error(error.syncPolicyError.message);
|
|
1879
2805
|
const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
|
|
1880
2806
|
for (const line of guidance) {
|
|
1881
|
-
|
|
2807
|
+
p7.log.info(line);
|
|
1882
2808
|
}
|
|
1883
2809
|
return 1;
|
|
1884
2810
|
}
|
|
1885
2811
|
if (error instanceof VocoderAPIError && error.limitError) {
|
|
1886
2812
|
const { limitError } = error;
|
|
1887
|
-
|
|
2813
|
+
p7.log.error(limitError.message);
|
|
1888
2814
|
const guidance = getLimitErrorGuidance(limitError);
|
|
1889
2815
|
for (const line of guidance) {
|
|
1890
|
-
|
|
2816
|
+
p7.log.info(line);
|
|
1891
2817
|
}
|
|
1892
2818
|
return 1;
|
|
1893
2819
|
}
|
|
1894
2820
|
if (error instanceof Error) {
|
|
1895
|
-
|
|
2821
|
+
p7.log.error(error.message);
|
|
1896
2822
|
if (error.message.includes("VOCODER_API_KEY")) {
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2823
|
+
p7.log.warn("VOCODER_API_KEY is only needed for `vocoder sync` (CLI push).");
|
|
2824
|
+
p7.log.info(" Create one at: https://vocoder.app/dashboard");
|
|
2825
|
+
p7.log.info(' Then: export VOCODER_API_KEY="vc_..." or add it to .env');
|
|
2826
|
+
p7.log.info("");
|
|
2827
|
+
p7.log.info(" Note: If you use @vocoder/unplugin, `vocoder sync` is optional.");
|
|
2828
|
+
p7.log.info(" Translations are fetched automatically at build time.");
|
|
1903
2829
|
} else if (error.message.includes("git branch")) {
|
|
1904
|
-
|
|
1905
|
-
|
|
2830
|
+
p7.log.warn("Run from a git repository, or use:");
|
|
2831
|
+
p7.log.info(" vocoder sync --branch main");
|
|
1906
2832
|
}
|
|
1907
2833
|
if (options.verbose) {
|
|
1908
|
-
|
|
2834
|
+
p7.log.info(`Full error: ${error.stack ?? error}`);
|
|
1909
2835
|
}
|
|
1910
2836
|
}
|
|
1911
2837
|
return 1;
|
|
1912
2838
|
}
|
|
1913
2839
|
}
|
|
1914
2840
|
|
|
2841
|
+
// src/commands/whoami.ts
|
|
2842
|
+
import * as p8 from "@clack/prompts";
|
|
2843
|
+
import chalk9 from "chalk";
|
|
2844
|
+
async function whoami(options = {}) {
|
|
2845
|
+
const stored = readAuthData();
|
|
2846
|
+
if (!stored) {
|
|
2847
|
+
p8.log.info("Not logged in. Run `vocoder init` to authenticate.");
|
|
2848
|
+
return 1;
|
|
2849
|
+
}
|
|
2850
|
+
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
2851
|
+
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
2852
|
+
try {
|
|
2853
|
+
const info = await api.getCliUserInfo(stored.token);
|
|
2854
|
+
p8.log.info(`Logged in as ${chalk9.bold(info.email)}`);
|
|
2855
|
+
if (info.name) {
|
|
2856
|
+
p8.log.info(`Name: ${info.name}`);
|
|
2857
|
+
}
|
|
2858
|
+
p8.log.info(`API: ${apiUrl}`);
|
|
2859
|
+
return 0;
|
|
2860
|
+
} catch {
|
|
2861
|
+
p8.log.error("Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate.");
|
|
2862
|
+
return 1;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
1915
2866
|
// src/bin.ts
|
|
1916
2867
|
function collect(value, previous = []) {
|
|
1917
2868
|
return previous.concat([value]);
|
|
1918
2869
|
}
|
|
1919
2870
|
async function runCommand(command, options) {
|
|
1920
2871
|
const exitCode = await command(options);
|
|
1921
|
-
process.exitCode
|
|
2872
|
+
process.exit(exitCode);
|
|
1922
2873
|
}
|
|
1923
2874
|
var program = new Command();
|
|
1924
2875
|
program.name("vocoder").description("Vocoder CLI - Project setup and string extraction").version("0.1.5");
|
|
1925
|
-
program.command("init").description("Authenticate and provision Vocoder for this project").option("--api-url <url>", "Override Vocoder API URL").option("--yes", "Allow overwriting existing local config values").option("--project-name <name>", "Starter project name to create").option("--source-locale <locale>", "Source locale for the starter project").option("--target-locales <list>", "Comma-separated target locales (e.g. es,fr,de)").action((options) => runCommand(init, options));
|
|
2876
|
+
program.command("init").description("Authenticate and provision Vocoder for this project").option("--api-url <url>", "Override Vocoder API URL").option("--yes", "Allow overwriting existing local config values").option("--ci", "Non-interactive mode: print auth URL to stdout, skip browser open").option("--project-name <name>", "Starter project name to create").option("--source-locale <locale>", "Source locale for the starter project").option("--target-locales <list>", "Comma-separated target locales (e.g. es,fr,de)").action((options) => runCommand(init, options));
|
|
1926
2877
|
program.command("sync").description("Extract strings and sync translations").option("--branch <branch>", "Override detected branch").option("--mode <mode>", "Sync mode: auto, required, best-effort", "auto").option("--max-wait <ms>", "Max wait for translations (ms)").option("--force", "Force re-extraction even if no changes").option("--dry-run", "Preview without syncing").option("--no-fallback", "Disable fallback to cached translations").option("--include <pattern>", "Include glob pattern", collect, []).option("--exclude <pattern>", "Exclude glob pattern", collect, []).option("--verbose", "Detailed output").action((options) => {
|
|
1927
2878
|
const translated = { ...options };
|
|
1928
2879
|
if (options.maxWait) translated.maxWaitMs = Number(options.maxWait);
|
|
1929
2880
|
if (options.fallback === false) translated.noFallback = true;
|
|
1930
2881
|
return runCommand(sync, translated);
|
|
1931
2882
|
});
|
|
2883
|
+
program.command("logout").description("Log out and remove stored credentials").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(logout, options));
|
|
2884
|
+
program.command("whoami").description("Show the currently authenticated user").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(whoami, options));
|
|
1932
2885
|
program.parse(process.argv);
|
|
1933
2886
|
//# sourceMappingURL=bin.mjs.map
|