@vocoder/cli 0.1.4 → 0.1.7
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 +173 -45
- package/dist/bin.mjs +1803 -1679
- 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 -2
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) {
|
|
@@ -255,85 +564,329 @@ var VocoderAPI = class {
|
|
|
255
564
|
}
|
|
256
565
|
return payload;
|
|
257
566
|
}
|
|
567
|
+
// ── CLI Auth endpoints (no project API key needed) ──────────────────────────
|
|
258
568
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
569
|
+
* Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
|
|
570
|
+
* `sessionId` is the raw poll token — keep it secret, used for polling.
|
|
261
571
|
*/
|
|
262
|
-
async
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
271
587
|
});
|
|
272
|
-
if (response.status === 404) return null;
|
|
273
|
-
if (!response.ok) return null;
|
|
274
|
-
return await response.json();
|
|
275
|
-
} catch {
|
|
276
|
-
return null;
|
|
277
588
|
}
|
|
589
|
+
return payload;
|
|
278
590
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
};
|
|
306
625
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
+
});
|
|
316
641
|
}
|
|
317
|
-
return
|
|
642
|
+
return payload;
|
|
318
643
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
});
|
|
325
659
|
}
|
|
326
|
-
return { host, ownerRepoPath };
|
|
327
|
-
} catch {
|
|
328
|
-
return null;
|
|
329
660
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
|
334
675
|
}
|
|
335
|
-
|
|
336
|
-
|
|
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 ────────────────────────────────────────────────────────────
|
|
806
|
+
/**
|
|
807
|
+
* Look up whether a project already exists for a given repo + scope.
|
|
808
|
+
* Returns { projectId, projectName, organizationName } or null if not found.
|
|
809
|
+
*/
|
|
810
|
+
async lookupProjectByRepo(params) {
|
|
811
|
+
try {
|
|
812
|
+
const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
|
|
813
|
+
method: "POST",
|
|
814
|
+
headers: { "Content-Type": "application/json" },
|
|
815
|
+
body: JSON.stringify({
|
|
816
|
+
repo: params.repoCanonical,
|
|
817
|
+
scopePath: params.scopePath
|
|
818
|
+
})
|
|
819
|
+
});
|
|
820
|
+
if (response.status === 404) return null;
|
|
821
|
+
if (!response.ok) return null;
|
|
822
|
+
return await response.json();
|
|
823
|
+
} catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
|
|
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";
|
|
833
|
+
|
|
834
|
+
// src/utils/git-identity.ts
|
|
835
|
+
import { execSync } from "child_process";
|
|
836
|
+
import { relative, resolve } from "path";
|
|
837
|
+
function safeExec(command) {
|
|
838
|
+
try {
|
|
839
|
+
const output = execSync(command, {
|
|
840
|
+
encoding: "utf-8",
|
|
841
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
842
|
+
}).trim();
|
|
843
|
+
return output.length > 0 ? output : null;
|
|
844
|
+
} catch {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function normalizePath(pathname) {
|
|
849
|
+
const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
|
|
850
|
+
if (!cleaned || !cleaned.includes("/")) {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
return cleaned;
|
|
854
|
+
}
|
|
855
|
+
function parseRemoteUrl(remoteUrl) {
|
|
856
|
+
const trimmed = remoteUrl.trim();
|
|
857
|
+
if (!trimmed) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
if (!trimmed.includes("://")) {
|
|
861
|
+
const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
|
|
862
|
+
if (scpMatch) {
|
|
863
|
+
const host = (scpMatch[1] || "").toLowerCase();
|
|
864
|
+
const ownerRepoPath = normalizePath(scpMatch[2] || "");
|
|
865
|
+
if (!host || !ownerRepoPath) {
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
return { host, ownerRepoPath };
|
|
869
|
+
}
|
|
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()}`;
|
|
337
890
|
}
|
|
338
891
|
if (host.includes("bitbucket.org")) {
|
|
339
892
|
return `bitbucket:${ownerRepoPath.toLowerCase()}`;
|
|
@@ -374,18 +927,563 @@ function resolveGitContext() {
|
|
|
374
927
|
return { identity, warnings };
|
|
375
928
|
}
|
|
376
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
|
+
}
|
|
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;
|
|
1379
|
+
try {
|
|
1380
|
+
rawLocales = await api.listLocales(userToken);
|
|
1381
|
+
} catch {
|
|
1382
|
+
p3.log.error("Failed to fetch supported locales. Check your connection and try again.");
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
const languageOptions = buildLanguageOptions(rawLocales);
|
|
1386
|
+
const localeOptions = buildLocaleOptions(rawLocales);
|
|
1387
|
+
const sourceLocale = await searchSelectLocale(
|
|
1388
|
+
languageOptions,
|
|
1389
|
+
"Source language (the language your code is written in)",
|
|
1390
|
+
params.defaultSourceLocale ?? "en"
|
|
1391
|
+
);
|
|
1392
|
+
if (sourceLocale === null) return null;
|
|
1393
|
+
const targetOptions = localeOptions.filter((opt) => opt.bcp47 !== sourceLocale);
|
|
1394
|
+
const targetLocales = await searchMultiSelectLocales(
|
|
1395
|
+
targetOptions,
|
|
1396
|
+
"Target languages (languages to translate into)"
|
|
1397
|
+
);
|
|
1398
|
+
if (targetLocales === null) return null;
|
|
1399
|
+
if (targetLocales.length === 0) {
|
|
1400
|
+
p3.log.warn("No target languages selected \u2014 you can add them later from the dashboard.");
|
|
1401
|
+
}
|
|
1402
|
+
const detected = detectGitBranches();
|
|
1403
|
+
const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
|
|
1404
|
+
let targetBranches = [];
|
|
1405
|
+
{
|
|
1406
|
+
let initial = initialBranches;
|
|
1407
|
+
while (targetBranches.length === 0) {
|
|
1408
|
+
const result = await filterableBranchSelect({
|
|
1409
|
+
message: "Target branches (translations will run when you push to these)",
|
|
1410
|
+
branches: detected.branches,
|
|
1411
|
+
defaultBranch: detected.defaultBranch,
|
|
1412
|
+
initialValues: initial
|
|
1413
|
+
});
|
|
1414
|
+
if (result === null) return null;
|
|
1415
|
+
if (result.length === 0) {
|
|
1416
|
+
p3.log.warn("At least one branch is required. Please select at least one.");
|
|
1417
|
+
initial = [detected.defaultBranch];
|
|
1418
|
+
} else {
|
|
1419
|
+
targetBranches = result;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const result = await api.createProject(userToken, {
|
|
1425
|
+
organizationId,
|
|
1426
|
+
name: projectName,
|
|
1427
|
+
sourceLocale,
|
|
1428
|
+
targetLocales,
|
|
1429
|
+
targetBranches,
|
|
1430
|
+
translationTriggers: ["push"],
|
|
1431
|
+
scopePaths: [],
|
|
1432
|
+
repoCanonical
|
|
1433
|
+
});
|
|
1434
|
+
p3.log.success(`Project ${chalk4.bold(result.projectName)} created!`);
|
|
1435
|
+
return result;
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1438
|
+
p3.log.error(`Failed to create project: ${message}`);
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/utils/workspace.ts
|
|
1444
|
+
import * as p4 from "@clack/prompts";
|
|
1445
|
+
import chalk5 from "chalk";
|
|
1446
|
+
async function selectWorkspace(result) {
|
|
1447
|
+
const { workspaces, canCreateWorkspace } = result;
|
|
1448
|
+
if (workspaces.length === 0) {
|
|
1449
|
+
return { action: "create" };
|
|
1450
|
+
}
|
|
1451
|
+
const options = workspaces.map((ws) => ({
|
|
1452
|
+
value: ws.id,
|
|
1453
|
+
label: ws.name,
|
|
1454
|
+
hint: [
|
|
1455
|
+
ws.projectCount > 0 ? `${ws.projectCount} project${ws.projectCount !== 1 ? "s" : ""}` : "",
|
|
1456
|
+
ws.connectionLabel ? `GitHub: ${ws.connectionLabel}` : ""
|
|
1457
|
+
].filter(Boolean).join(" \xB7 ") || void 0
|
|
1458
|
+
}));
|
|
1459
|
+
if (canCreateWorkspace) {
|
|
1460
|
+
options.push({ value: "create", label: "Create new workspace" });
|
|
1461
|
+
}
|
|
1462
|
+
const selected = await p4.select({
|
|
1463
|
+
message: "Select workspace",
|
|
1464
|
+
options
|
|
1465
|
+
});
|
|
1466
|
+
if (p4.isCancel(selected)) {
|
|
1467
|
+
return { action: "cancelled" };
|
|
1468
|
+
}
|
|
1469
|
+
if (selected === "create") {
|
|
1470
|
+
return { action: "create" };
|
|
1471
|
+
}
|
|
1472
|
+
const workspace = workspaces.find((ws) => ws.id === selected);
|
|
1473
|
+
if (!workspace) {
|
|
1474
|
+
return { action: "cancelled" };
|
|
1475
|
+
}
|
|
1476
|
+
return { action: "use", workspace };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
377
1479
|
// src/commands/init.ts
|
|
378
|
-
import { spawn } from "child_process";
|
|
1480
|
+
import { spawn as spawn2 } from "child_process";
|
|
1481
|
+
loadEnv();
|
|
379
1482
|
var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
|
|
380
|
-
function parseTargetLocales(value) {
|
|
381
|
-
if (!value) return void 0;
|
|
382
|
-
const locales = value.split(",").map((locale) => locale.trim()).filter(Boolean);
|
|
383
|
-
return locales.length > 0 ? locales : void 0;
|
|
384
|
-
}
|
|
385
1483
|
async function sleep(ms) {
|
|
386
1484
|
await new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
387
1485
|
}
|
|
388
|
-
async function
|
|
1486
|
+
async function tryOpenBrowser2(url) {
|
|
389
1487
|
if (!process.stdout.isTTY || process.env.CI === "true") {
|
|
390
1488
|
return false;
|
|
391
1489
|
}
|
|
@@ -403,7 +1501,7 @@ async function tryOpenBrowser(url) {
|
|
|
403
1501
|
}
|
|
404
1502
|
return await new Promise((resolve2) => {
|
|
405
1503
|
try {
|
|
406
|
-
const child =
|
|
1504
|
+
const child = spawn2(command, args, {
|
|
407
1505
|
detached: true,
|
|
408
1506
|
stdio: "ignore",
|
|
409
1507
|
windowsHide: true
|
|
@@ -438,132 +1536,555 @@ function getSubscriptionSettingsUrl(apiUrl) {
|
|
|
438
1536
|
return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
|
|
439
1537
|
}
|
|
440
1538
|
function printPlanLimitMessage(apiUrl, message) {
|
|
441
|
-
|
|
1539
|
+
p5.log.error(`You are over your plan limits.
|
|
442
1540
|
${message}`);
|
|
443
|
-
|
|
1541
|
+
p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
|
|
444
1542
|
}
|
|
445
|
-
function
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1543
|
+
function runScaffold(params) {
|
|
1544
|
+
const { projectName, organizationName, sourceLocale, translationTriggers } = params;
|
|
1545
|
+
p5.log.info(`Project: ${chalk6.bold(projectName)}`);
|
|
1546
|
+
p5.log.info(`Workspace: ${chalk6.bold(organizationName)}`);
|
|
1547
|
+
const detection = detectLocalEcosystem();
|
|
1548
|
+
if (detection.ecosystem) {
|
|
1549
|
+
const frameworkLabel = detection.framework ?? detection.ecosystem;
|
|
1550
|
+
const pmLabel = detection.packageManager;
|
|
1551
|
+
p5.log.info(`Detected: ${chalk6.bold(frameworkLabel)} (${pmLabel})`);
|
|
1552
|
+
}
|
|
1553
|
+
const packagesToInstall = getPackagesToInstall(detection);
|
|
1554
|
+
if (packagesToInstall.length > 0) {
|
|
1555
|
+
const installCmd = buildInstallCommand(detection.packageManager, packagesToInstall);
|
|
1556
|
+
p5.log.info("");
|
|
1557
|
+
const installSpinner = p5.spinner();
|
|
1558
|
+
installSpinner.start(`Installing ${packagesToInstall.join(", ")}...`);
|
|
1559
|
+
try {
|
|
1560
|
+
execSync3(installCmd, { stdio: "pipe", cwd: process.cwd() });
|
|
1561
|
+
installSpinner.stop(`Installed ${packagesToInstall.join(", ")}`);
|
|
1562
|
+
} catch {
|
|
1563
|
+
installSpinner.stop("Package installation failed");
|
|
1564
|
+
p5.log.warn(`Run manually: ${chalk6.cyan(installCmd)}`);
|
|
1565
|
+
}
|
|
1566
|
+
} else if (detection.ecosystem) {
|
|
1567
|
+
p5.log.info(`Packages: ${chalk6.green("already installed")}`);
|
|
1568
|
+
}
|
|
1569
|
+
const snippets = getSetupSnippets({
|
|
1570
|
+
framework: detection.framework,
|
|
1571
|
+
ecosystem: detection.ecosystem,
|
|
1572
|
+
sourceLocale,
|
|
1573
|
+
translationTriggers
|
|
1574
|
+
});
|
|
1575
|
+
let stepNum = 1;
|
|
1576
|
+
if (snippets.pluginStep) {
|
|
1577
|
+
p5.log.message("");
|
|
1578
|
+
p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the plugin to ${chalk6.cyan(snippets.pluginStep.file)}`);
|
|
1579
|
+
printCodeBlock(snippets.pluginStep.code);
|
|
1580
|
+
stepNum++;
|
|
1581
|
+
}
|
|
1582
|
+
if (snippets.providerStep) {
|
|
1583
|
+
p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the provider to ${chalk6.cyan(snippets.providerStep.file)}`);
|
|
1584
|
+
printCodeBlock(snippets.providerStep.code);
|
|
1585
|
+
stepNum++;
|
|
1586
|
+
}
|
|
1587
|
+
p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Wrap translatable strings`);
|
|
1588
|
+
printCodeBlock(snippets.wrapStep.code);
|
|
1589
|
+
p5.log.message("");
|
|
1590
|
+
for (const line of snippets.whatsNext.split("\n")) {
|
|
1591
|
+
p5.log.success(line);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
function printMcpSetup(apiKey) {
|
|
1595
|
+
const addCommand = `claude mcp add --scope project --transport stdio \\
|
|
1596
|
+
--env VOCODER_API_KEY=${apiKey} \\
|
|
1597
|
+
vocoder -- npx -y @vocoder/mcp`;
|
|
1598
|
+
const teamConfig = JSON.stringify(
|
|
1599
|
+
{
|
|
1600
|
+
mcpServers: {
|
|
1601
|
+
vocoder: {
|
|
1602
|
+
type: "stdio",
|
|
1603
|
+
command: "npx",
|
|
1604
|
+
args: ["-y", "@vocoder/mcp"],
|
|
1605
|
+
env: { VOCODER_API_KEY: "${env:VOCODER_API_KEY}" }
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
},
|
|
1609
|
+
null,
|
|
1610
|
+
2
|
|
1611
|
+
);
|
|
1612
|
+
p5.log.message("");
|
|
1613
|
+
p5.log.message(chalk6.bold("Use Vocoder with Claude Code"));
|
|
1614
|
+
p5.log.message("Run this to add the MCP server to your project:");
|
|
1615
|
+
p5.log.message("");
|
|
1616
|
+
printCodeBlock(addCommand);
|
|
1617
|
+
p5.log.message("");
|
|
1618
|
+
p5.log.message("To share with your team, commit " + chalk6.cyan(".mcp.json") + " with an env var reference");
|
|
1619
|
+
p5.log.message("so each developer supplies their own key:");
|
|
1620
|
+
p5.log.message("");
|
|
1621
|
+
printCodeBlock(teamConfig);
|
|
1622
|
+
p5.log.message("");
|
|
1623
|
+
p5.log.message(chalk6.gray("Setup instructions: https://vocoder.app/docs/mcp"));
|
|
1624
|
+
}
|
|
1625
|
+
function printCodeBlock(code) {
|
|
1626
|
+
const lines = code.split("\n");
|
|
1627
|
+
const maxLen = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
1628
|
+
const bar = chalk6.gray("\u2502");
|
|
1629
|
+
const pad = (s) => s + " ".repeat(maxLen - s.length);
|
|
1630
|
+
process.stdout.write(`${chalk6.gray("\u2502")}
|
|
1631
|
+
`);
|
|
1632
|
+
process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u250C" + "\u2500".repeat(maxLen + 2) + "\u2510")}
|
|
1633
|
+
`);
|
|
1634
|
+
for (const line of lines) {
|
|
1635
|
+
process.stdout.write(`${chalk6.gray("\u2502")} ${bar} ${pad(line)} ${bar}
|
|
1636
|
+
`);
|
|
1637
|
+
}
|
|
1638
|
+
process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u2514" + "\u2500".repeat(maxLen + 2) + "\u2518")}
|
|
1639
|
+
`);
|
|
1640
|
+
}
|
|
1641
|
+
async function verifyStoredToken(api, token) {
|
|
1642
|
+
try {
|
|
1643
|
+
return await api.getCliUserInfo(token);
|
|
1644
|
+
} catch {
|
|
1645
|
+
clearAuthData();
|
|
1646
|
+
return null;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
async function runAuthFlow(api, options, reauth = false, repoCanonical) {
|
|
1650
|
+
let server = null;
|
|
1651
|
+
if (!options.ci) {
|
|
1652
|
+
try {
|
|
1653
|
+
server = await startCallbackServer();
|
|
1654
|
+
} catch {
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
const session = await api.startCliAuthSession(server?.port, repoCanonical);
|
|
1658
|
+
const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
|
|
1659
|
+
const expiresAt = new Date(session.expiresAt).getTime();
|
|
1660
|
+
if (options.ci) {
|
|
1661
|
+
process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
|
|
1662
|
+
`);
|
|
1663
|
+
process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
|
|
1664
|
+
`);
|
|
1665
|
+
} else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
|
|
1666
|
+
if (reauth) {
|
|
1667
|
+
if (!options.yes) {
|
|
1668
|
+
const shouldOpen = await p5.confirm({ message: "Open your browser to sign in again?" });
|
|
1669
|
+
if (p5.isCancel(shouldOpen)) {
|
|
1670
|
+
server?.close();
|
|
1671
|
+
p5.cancel("Setup cancelled.");
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
if (!shouldOpen) {
|
|
1675
|
+
p5.log.info("Open the URL above manually in your browser to continue.");
|
|
1676
|
+
} else {
|
|
1677
|
+
const opened = await tryOpenBrowser2(browserUrl);
|
|
1678
|
+
if (!opened) {
|
|
1679
|
+
p5.note(browserUrl, "Sign In");
|
|
1680
|
+
p5.log.info("Open the URL above manually to continue.");
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
} else {
|
|
1684
|
+
await tryOpenBrowser2(browserUrl);
|
|
1685
|
+
}
|
|
1686
|
+
} else {
|
|
1687
|
+
let isLinkFlow = false;
|
|
1688
|
+
if (!options.yes) {
|
|
1689
|
+
const connectChoice = await p5.select({
|
|
1690
|
+
message: "Vocoder needs to be installed on your GitHub account to get started",
|
|
1691
|
+
options: [
|
|
1692
|
+
{ value: "install", label: "Install GitHub App", hint: "recommended" },
|
|
1693
|
+
{ value: "link", label: "Already installed? Link your account" }
|
|
1694
|
+
]
|
|
1695
|
+
});
|
|
1696
|
+
if (p5.isCancel(connectChoice)) {
|
|
1697
|
+
server?.close();
|
|
1698
|
+
p5.cancel("Setup cancelled.");
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
isLinkFlow = connectChoice === "link";
|
|
1702
|
+
}
|
|
1703
|
+
let urlToOpen = browserUrl;
|
|
1704
|
+
if (isLinkFlow) {
|
|
1705
|
+
try {
|
|
1706
|
+
const linkSession = await api.startCliGitHubLinkSession(
|
|
1707
|
+
session.sessionId,
|
|
1708
|
+
server?.port
|
|
1709
|
+
);
|
|
1710
|
+
urlToOpen = linkSession.oauthUrl;
|
|
1711
|
+
} catch {
|
|
1712
|
+
urlToOpen = browserUrl;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
const opened = await tryOpenBrowser2(urlToOpen);
|
|
1716
|
+
if (!opened) {
|
|
1717
|
+
p5.log.warn("Could not open your browser automatically.");
|
|
1718
|
+
p5.note(urlToOpen, "GitHub");
|
|
1719
|
+
p5.log.info("Open the URL above to continue.");
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
const authSpinner = p5.spinner();
|
|
1724
|
+
authSpinner.start("Waiting for GitHub authorization...");
|
|
1725
|
+
let rawToken = null;
|
|
1726
|
+
let callbackOrganizationId;
|
|
1727
|
+
let callbackDiscoveryReady = false;
|
|
1728
|
+
if (server) {
|
|
1729
|
+
try {
|
|
1730
|
+
const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
|
|
1731
|
+
const timeoutMs = deadline - Date.now();
|
|
1732
|
+
const params = await Promise.race([
|
|
1733
|
+
server.waitForCallback(),
|
|
1734
|
+
new Promise((resolve2) => setTimeout(() => resolve2(null), timeoutMs))
|
|
1735
|
+
]);
|
|
1736
|
+
if (params && typeof params.token === "string") {
|
|
1737
|
+
rawToken = params.token;
|
|
1738
|
+
if (typeof params.organizationId === "string" && params.organizationId) {
|
|
1739
|
+
callbackOrganizationId = params.organizationId;
|
|
1740
|
+
}
|
|
1741
|
+
if (params.discovery_ready === "1") {
|
|
1742
|
+
callbackDiscoveryReady = true;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
} catch {
|
|
1746
|
+
} finally {
|
|
1747
|
+
server.close();
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (!rawToken) {
|
|
1751
|
+
while (Date.now() < expiresAt) {
|
|
1752
|
+
const result = await api.pollCliAuthSession(session.sessionId);
|
|
1753
|
+
if (result.status === "complete") {
|
|
1754
|
+
rawToken = result.token;
|
|
1755
|
+
if (result.organizationId) {
|
|
1756
|
+
callbackOrganizationId = result.organizationId;
|
|
1757
|
+
}
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
1760
|
+
if (result.status === "failed") {
|
|
1761
|
+
authSpinner.stop();
|
|
1762
|
+
p5.log.error(result.reason);
|
|
1763
|
+
return null;
|
|
1764
|
+
}
|
|
1765
|
+
await sleep(2e3);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (!rawToken) {
|
|
1769
|
+
authSpinner.stop();
|
|
1770
|
+
p5.log.error("The authentication link expired. Run `vocoder init` again.");
|
|
1771
|
+
return null;
|
|
1772
|
+
}
|
|
1773
|
+
const userInfo = await api.getCliUserInfo(rawToken);
|
|
1774
|
+
authSpinner.stop();
|
|
1775
|
+
p5.log.success(`Authenticated as ${chalk6.bold(userInfo.email)}`);
|
|
1776
|
+
return { token: rawToken, ...userInfo, organizationId: callbackOrganizationId, discoveryReady: callbackDiscoveryReady };
|
|
453
1777
|
}
|
|
454
1778
|
async function init(options = {}) {
|
|
455
1779
|
const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
|
|
456
|
-
|
|
457
|
-
const spinner4 = p.spinner();
|
|
1780
|
+
p5.intro("Vocoder Setup");
|
|
458
1781
|
try {
|
|
459
1782
|
const gitContext = resolveGitContext();
|
|
460
1783
|
const identity = gitContext.identity;
|
|
461
1784
|
if (gitContext.warnings.length > 0) {
|
|
462
1785
|
for (const warning of gitContext.warnings) {
|
|
463
|
-
|
|
1786
|
+
p5.log.warn(warning);
|
|
464
1787
|
}
|
|
465
1788
|
}
|
|
466
1789
|
if (identity) {
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
const existing = await api2.lookupProjectByRepo({
|
|
1790
|
+
const anonApi = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
1791
|
+
const existing = await anonApi.lookupProjectByRepo({
|
|
470
1792
|
repoCanonical: identity.repoCanonical,
|
|
471
1793
|
scopePath: identity.repoScopePath
|
|
472
1794
|
});
|
|
473
1795
|
if (existing) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1796
|
+
runScaffold({
|
|
1797
|
+
projectName: existing.projectName,
|
|
1798
|
+
organizationName: existing.organizationName,
|
|
1799
|
+
sourceLocale: existing.sourceLocale ?? "en",
|
|
1800
|
+
translationTriggers: existing.translationTriggers ?? ["push"]
|
|
1801
|
+
});
|
|
1802
|
+
p5.outro("Vocoder is already set up for this repository.");
|
|
477
1803
|
return 0;
|
|
478
1804
|
}
|
|
479
|
-
spinner4.stop("No existing project found for this repo.");
|
|
480
1805
|
}
|
|
481
|
-
spinner4.start("Creating setup session");
|
|
482
1806
|
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1807
|
+
let userToken;
|
|
1808
|
+
let userEmail;
|
|
1809
|
+
let userName;
|
|
1810
|
+
let authOrganizationId;
|
|
1811
|
+
let authDiscoveryReady = false;
|
|
1812
|
+
const stored = readAuthData();
|
|
1813
|
+
if (stored && stored.apiUrl === apiUrl) {
|
|
1814
|
+
const verified = await verifyStoredToken(api, stored.token);
|
|
1815
|
+
if (verified) {
|
|
1816
|
+
p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
|
|
1817
|
+
userToken = stored.token;
|
|
1818
|
+
userEmail = verified.email;
|
|
1819
|
+
userName = verified.name;
|
|
1820
|
+
} else {
|
|
1821
|
+
p5.log.warn("Stored credentials expired \u2014 signing in again");
|
|
1822
|
+
const authResult = await runAuthFlow(
|
|
1823
|
+
api,
|
|
1824
|
+
options,
|
|
1825
|
+
/* reauth */
|
|
1826
|
+
true
|
|
1827
|
+
);
|
|
1828
|
+
if (!authResult) return 1;
|
|
1829
|
+
userToken = authResult.token;
|
|
1830
|
+
userEmail = authResult.email;
|
|
1831
|
+
userName = authResult.name;
|
|
1832
|
+
authOrganizationId = authResult.organizationId;
|
|
1833
|
+
authDiscoveryReady = authResult.discoveryReady ?? false;
|
|
1834
|
+
writeAuthData({
|
|
1835
|
+
token: userToken,
|
|
1836
|
+
apiUrl,
|
|
1837
|
+
userId: authResult.userId,
|
|
1838
|
+
email: userEmail,
|
|
1839
|
+
name: userName,
|
|
1840
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1841
|
+
});
|
|
499
1842
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
1843
|
+
} else {
|
|
1844
|
+
const authResult = await runAuthFlow(api, options, false, identity?.repoCanonical);
|
|
1845
|
+
if (!authResult) return 1;
|
|
1846
|
+
userToken = authResult.token;
|
|
1847
|
+
userEmail = authResult.email;
|
|
1848
|
+
userName = authResult.name;
|
|
1849
|
+
authOrganizationId = authResult.organizationId;
|
|
1850
|
+
writeAuthData({
|
|
1851
|
+
token: userToken,
|
|
1852
|
+
apiUrl,
|
|
1853
|
+
userId: authResult.userId,
|
|
1854
|
+
email: userEmail,
|
|
1855
|
+
name: userName,
|
|
1856
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
let selectedWorkspaceId;
|
|
1860
|
+
let selectedWorkspaceName;
|
|
1861
|
+
if (authOrganizationId) {
|
|
1862
|
+
const workspaceData = await api.listWorkspaces(userToken);
|
|
1863
|
+
const ws = workspaceData.workspaces.find((w) => w.id === authOrganizationId);
|
|
1864
|
+
selectedWorkspaceId = authOrganizationId;
|
|
1865
|
+
selectedWorkspaceName = ws?.name ?? userEmail;
|
|
1866
|
+
p5.log.success(`Connected as ${chalk6.bold(userEmail)} \u2014 workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1867
|
+
} else {
|
|
1868
|
+
const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
|
|
1869
|
+
const cachedInstallations = discoveryResult?.installations ?? [];
|
|
1870
|
+
if (cachedInstallations.length > 0) {
|
|
1871
|
+
if (identity?.repoCanonical) {
|
|
1872
|
+
const repoOwner = identity.repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
|
|
1873
|
+
if (repoOwner) {
|
|
1874
|
+
const hasMatchingAccount = cachedInstallations.some(
|
|
1875
|
+
(i) => i.accountLogin.toLowerCase() === repoOwner
|
|
1876
|
+
);
|
|
1877
|
+
if (!hasMatchingAccount) {
|
|
1878
|
+
p5.log.warn(
|
|
1879
|
+
`None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
|
|
1880
|
+
The project will be created but translations won't trigger automatically.
|
|
1881
|
+
To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
const validInstallations = cachedInstallations.filter(
|
|
1887
|
+
(i) => !i.isSuspended && !i.conflictLabel
|
|
1888
|
+
);
|
|
1889
|
+
let selectedInstallationId = null;
|
|
1890
|
+
if (validInstallations.length === 1 && cachedInstallations.length === 1) {
|
|
1891
|
+
selectedInstallationId = validInstallations[0].installationId;
|
|
504
1892
|
} else {
|
|
505
|
-
|
|
1893
|
+
selectedInstallationId = await selectGitHubInstallation(
|
|
1894
|
+
cachedInstallations.map((inst) => ({
|
|
1895
|
+
installationId: inst.installationId,
|
|
1896
|
+
accountLogin: inst.accountLogin,
|
|
1897
|
+
accountType: inst.accountType,
|
|
1898
|
+
isSuspended: inst.isSuspended,
|
|
1899
|
+
conflictLabel: inst.conflictLabel
|
|
1900
|
+
})),
|
|
1901
|
+
false
|
|
1902
|
+
);
|
|
506
1903
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
spinner4.start("Waiting for setup to complete...");
|
|
511
|
-
while (Date.now() < expiresAt) {
|
|
512
|
-
const status = await api.getInitSessionStatus({
|
|
513
|
-
sessionId: start.sessionId,
|
|
514
|
-
pollToken: start.poll.token
|
|
515
|
-
});
|
|
516
|
-
if (status.status === "pending") {
|
|
517
|
-
const pendingMessage = status.message?.trim();
|
|
518
|
-
if (pendingMessage) {
|
|
519
|
-
spinner4.message(`Waiting for setup to complete... (${pendingMessage})`);
|
|
1904
|
+
if (selectedInstallationId === null || selectedInstallationId === "install_new") {
|
|
1905
|
+
p5.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
|
|
1906
|
+
return 1;
|
|
520
1907
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1908
|
+
const claimResult = await api.claimCliGitHubInstallation(userToken, {
|
|
1909
|
+
installationId: String(selectedInstallationId),
|
|
1910
|
+
organizationId: null
|
|
1911
|
+
});
|
|
1912
|
+
selectedWorkspaceId = claimResult.organizationId;
|
|
1913
|
+
selectedWorkspaceName = claimResult.organizationName;
|
|
1914
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1915
|
+
} else {
|
|
1916
|
+
const workspaceData = await api.listWorkspaces(userToken);
|
|
1917
|
+
if (workspaceData.workspaces.length === 1 && !workspaceData.canCreateWorkspace) {
|
|
1918
|
+
const ws = workspaceData.workspaces[0];
|
|
1919
|
+
selectedWorkspaceId = ws.id;
|
|
1920
|
+
selectedWorkspaceName = ws.name;
|
|
1921
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
528
1922
|
} else {
|
|
529
|
-
|
|
1923
|
+
const workspaceResult = await selectWorkspace(workspaceData);
|
|
1924
|
+
if (workspaceResult.action === "cancelled") {
|
|
1925
|
+
p5.cancel("Setup cancelled.");
|
|
1926
|
+
return 1;
|
|
1927
|
+
}
|
|
1928
|
+
if (workspaceResult.action === "use") {
|
|
1929
|
+
selectedWorkspaceId = workspaceResult.workspace.id;
|
|
1930
|
+
selectedWorkspaceName = workspaceResult.workspace.name;
|
|
1931
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1932
|
+
} else {
|
|
1933
|
+
const connectChoice = await p5.select({
|
|
1934
|
+
message: "Connect your new workspace to GitHub",
|
|
1935
|
+
options: [
|
|
1936
|
+
{ value: "install", label: "Install the Vocoder GitHub App" },
|
|
1937
|
+
{ value: "link", label: "Link an existing installation" }
|
|
1938
|
+
]
|
|
1939
|
+
});
|
|
1940
|
+
if (p5.isCancel(connectChoice)) {
|
|
1941
|
+
p5.cancel("Setup cancelled.");
|
|
1942
|
+
return 1;
|
|
1943
|
+
}
|
|
1944
|
+
if (connectChoice === "install") {
|
|
1945
|
+
const connectResult = await runGitHubInstallFlow({
|
|
1946
|
+
api,
|
|
1947
|
+
userToken,
|
|
1948
|
+
yes: options.yes
|
|
1949
|
+
});
|
|
1950
|
+
if (!connectResult) {
|
|
1951
|
+
p5.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
|
|
1952
|
+
return 1;
|
|
1953
|
+
}
|
|
1954
|
+
selectedWorkspaceId = connectResult.organizationId;
|
|
1955
|
+
selectedWorkspaceName = connectResult.organizationName;
|
|
1956
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
1957
|
+
} else {
|
|
1958
|
+
const installations = await runGitHubDiscoveryFlow({
|
|
1959
|
+
api,
|
|
1960
|
+
userToken,
|
|
1961
|
+
yes: options.yes
|
|
1962
|
+
});
|
|
1963
|
+
if (!installations) return 1;
|
|
1964
|
+
if (installations.length === 0) {
|
|
1965
|
+
p5.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
|
|
1966
|
+
const installNow = await p5.confirm({ message: "Open GitHub to install the App?" });
|
|
1967
|
+
if (p5.isCancel(installNow) || !installNow) return 1;
|
|
1968
|
+
const connectResult = await runGitHubInstallFlow({
|
|
1969
|
+
api,
|
|
1970
|
+
userToken,
|
|
1971
|
+
yes: options.yes
|
|
1972
|
+
});
|
|
1973
|
+
if (!connectResult) return 1;
|
|
1974
|
+
selectedWorkspaceId = connectResult.organizationId;
|
|
1975
|
+
selectedWorkspaceName = connectResult.organizationName;
|
|
1976
|
+
} else {
|
|
1977
|
+
const selectedInstallationId = await selectGitHubInstallation(
|
|
1978
|
+
installations.map((inst) => ({
|
|
1979
|
+
installationId: inst.installationId,
|
|
1980
|
+
accountLogin: inst.accountLogin,
|
|
1981
|
+
accountType: inst.accountType,
|
|
1982
|
+
isSuspended: inst.isSuspended,
|
|
1983
|
+
conflictLabel: inst.conflictLabel
|
|
1984
|
+
})),
|
|
1985
|
+
true
|
|
1986
|
+
);
|
|
1987
|
+
if (selectedInstallationId === null) {
|
|
1988
|
+
p5.cancel("Setup cancelled.");
|
|
1989
|
+
return 1;
|
|
1990
|
+
}
|
|
1991
|
+
if (selectedInstallationId === "install_new") {
|
|
1992
|
+
const connectResult = await runGitHubInstallFlow({
|
|
1993
|
+
api,
|
|
1994
|
+
userToken,
|
|
1995
|
+
yes: options.yes
|
|
1996
|
+
});
|
|
1997
|
+
if (!connectResult) return 1;
|
|
1998
|
+
selectedWorkspaceId = connectResult.organizationId;
|
|
1999
|
+
selectedWorkspaceName = connectResult.organizationName;
|
|
2000
|
+
} else {
|
|
2001
|
+
const claimResult = await api.claimCliGitHubInstallation(userToken, {
|
|
2002
|
+
installationId: String(selectedInstallationId),
|
|
2003
|
+
organizationId: null
|
|
2004
|
+
});
|
|
2005
|
+
selectedWorkspaceId = claimResult.organizationId;
|
|
2006
|
+
selectedWorkspaceName = claimResult.organizationName;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
530
2012
|
}
|
|
531
|
-
p.cancel("Setup could not be completed.");
|
|
532
|
-
return 1;
|
|
533
|
-
}
|
|
534
|
-
if (status.status === "completed") {
|
|
535
|
-
spinner4.stop("Setup complete!");
|
|
536
|
-
const { credentials } = status;
|
|
537
|
-
p.outro("Vocoder initialized successfully!");
|
|
538
|
-
printNextSteps(credentials.projectName, credentials.organizationName);
|
|
539
|
-
return 0;
|
|
540
2013
|
}
|
|
541
2014
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
2015
|
+
const projectResult = await runProjectCreate({
|
|
2016
|
+
api,
|
|
2017
|
+
userToken,
|
|
2018
|
+
organizationId: selectedWorkspaceId,
|
|
2019
|
+
defaultName: identity?.repoCanonical ? identity.repoCanonical.split("/").pop() : void 0,
|
|
2020
|
+
defaultSourceLocale: "en",
|
|
2021
|
+
repoCanonical: identity?.repoCanonical,
|
|
2022
|
+
defaultBranches: ["main"]
|
|
2023
|
+
});
|
|
2024
|
+
if (!projectResult) {
|
|
2025
|
+
p5.log.error("Project creation failed. Run `vocoder init` again.");
|
|
2026
|
+
return 1;
|
|
2027
|
+
}
|
|
2028
|
+
if (!projectResult.repositoryBound && identity?.repoCanonical) {
|
|
2029
|
+
p5.log.warn(
|
|
2030
|
+
`This repository isn't accessible to your GitHub App installation.
|
|
2031
|
+
Translations won't run automatically until you grant access.
|
|
2032
|
+
|
|
2033
|
+
To fix: go to your GitHub App installation settings and add this
|
|
2034
|
+
repository to the allowed list, or switch to "All repositories".
|
|
2035
|
+
` + (projectResult.configureUrl ? `
|
|
2036
|
+
${chalk6.dim(projectResult.configureUrl)}
|
|
2037
|
+
` : "")
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
runScaffold({
|
|
2041
|
+
projectName: projectResult.projectName,
|
|
2042
|
+
organizationName: selectedWorkspaceName,
|
|
2043
|
+
sourceLocale: projectResult.sourceLocale,
|
|
2044
|
+
translationTriggers: projectResult.translationTriggers
|
|
2045
|
+
});
|
|
2046
|
+
printMcpSetup(projectResult.apiKey);
|
|
2047
|
+
p5.outro("You're all set.");
|
|
2048
|
+
return 0;
|
|
546
2049
|
} catch (error) {
|
|
547
|
-
spinner4.stop();
|
|
548
2050
|
if (error instanceof Error) {
|
|
549
2051
|
if (isPlanLimitFailure(error.message)) {
|
|
550
2052
|
printPlanLimitMessage(apiUrl, error.message);
|
|
551
2053
|
return 1;
|
|
552
2054
|
}
|
|
553
|
-
|
|
2055
|
+
p5.log.error(`Error: ${error.message}`);
|
|
554
2056
|
} else {
|
|
555
|
-
|
|
2057
|
+
p5.log.error("Unknown setup error");
|
|
556
2058
|
}
|
|
557
2059
|
return 1;
|
|
558
2060
|
}
|
|
559
2061
|
}
|
|
560
2062
|
|
|
2063
|
+
// src/commands/logout.ts
|
|
2064
|
+
import * as p6 from "@clack/prompts";
|
|
2065
|
+
async function logout(options = {}) {
|
|
2066
|
+
const stored = readAuthData();
|
|
2067
|
+
if (!stored) {
|
|
2068
|
+
p6.log.info("Not currently authenticated.");
|
|
2069
|
+
return 0;
|
|
2070
|
+
}
|
|
2071
|
+
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
2072
|
+
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
2073
|
+
try {
|
|
2074
|
+
await api.revokeCliToken(stored.token);
|
|
2075
|
+
} catch {
|
|
2076
|
+
}
|
|
2077
|
+
clearAuthData();
|
|
2078
|
+
p6.log.success(`Logged out (was ${stored.email})`);
|
|
2079
|
+
return 0;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
561
2082
|
// src/commands/sync.ts
|
|
562
|
-
import * as
|
|
563
|
-
import { createHash
|
|
2083
|
+
import * as p7 from "@clack/prompts";
|
|
2084
|
+
import { createHash, randomUUID } from "crypto";
|
|
564
2085
|
|
|
565
2086
|
// src/utils/branch.ts
|
|
566
|
-
import { execSync as
|
|
2087
|
+
import { execSync as execSync4 } from "child_process";
|
|
567
2088
|
var REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/g;
|
|
568
2089
|
function escapeRegexChar(value) {
|
|
569
2090
|
return value.replace(REGEX_SPECIAL_CHARS, "\\$&");
|
|
@@ -585,7 +2106,7 @@ function detectBranch(override) {
|
|
|
585
2106
|
return envBranch;
|
|
586
2107
|
}
|
|
587
2108
|
try {
|
|
588
|
-
const branch =
|
|
2109
|
+
const branch = execSync4("git rev-parse --abbrev-ref HEAD", {
|
|
589
2110
|
encoding: "utf-8",
|
|
590
2111
|
stdio: ["pipe", "pipe", "ignore"]
|
|
591
2112
|
}).trim();
|
|
@@ -629,12 +2150,12 @@ function matchBranchPattern(branch, pattern) {
|
|
|
629
2150
|
}
|
|
630
2151
|
|
|
631
2152
|
// src/commands/sync.ts
|
|
632
|
-
import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
2153
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
633
2154
|
|
|
634
2155
|
// src/utils/config.ts
|
|
635
|
-
import
|
|
636
|
-
import { config as
|
|
637
|
-
|
|
2156
|
+
import chalk7 from "chalk";
|
|
2157
|
+
import { config as loadEnv2 } from "dotenv";
|
|
2158
|
+
loadEnv2();
|
|
638
2159
|
function validateLocalConfig(config) {
|
|
639
2160
|
if (!config.apiKey || config.apiKey.length === 0) {
|
|
640
2161
|
throw new Error("VOCODER_API_KEY is required. Set it in your .env file.");
|
|
@@ -715,317 +2236,45 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
|
|
|
715
2236
|
configSources.maxWaitMs = "environment";
|
|
716
2237
|
}
|
|
717
2238
|
}
|
|
718
|
-
let noFallback = false;
|
|
719
|
-
if (typeof cliOptions.noFallback === "boolean") {
|
|
720
|
-
noFallback = cliOptions.noFallback;
|
|
721
|
-
configSources.noFallback = "CLI flag";
|
|
722
|
-
} else if (envSyncNoFallback) {
|
|
723
|
-
noFallback = ["1", "true", "yes", "on"].includes(envSyncNoFallback.toLowerCase());
|
|
724
|
-
configSources.noFallback = "environment";
|
|
725
|
-
}
|
|
726
|
-
if (verbose) {
|
|
727
|
-
console.log(chalk2.dim("\n Configuration sources:"));
|
|
728
|
-
console.log(chalk2.dim(` Include patterns: ${configSources.extractionPattern}`));
|
|
729
|
-
if (excludePattern.length > 0) {
|
|
730
|
-
console.log(chalk2.dim(` Exclude patterns: ${configSources.excludePattern}`));
|
|
731
|
-
}
|
|
732
|
-
console.log(chalk2.dim(` API key: ${configSources.apiKey}`));
|
|
733
|
-
console.log(chalk2.dim(` API URL: ${configSources.apiUrl}
|
|
734
|
-
`));
|
|
735
|
-
console.log(chalk2.dim(` Sync mode: ${configSources.mode}`));
|
|
736
|
-
if (maxWaitMs) {
|
|
737
|
-
console.log(chalk2.dim(` Max wait: ${configSources.maxWaitMs}`));
|
|
738
|
-
}
|
|
739
|
-
console.log(chalk2.dim(` No fallback: ${configSources.noFallback}
|
|
740
|
-
`));
|
|
741
|
-
}
|
|
742
|
-
return {
|
|
743
|
-
extractionPattern,
|
|
744
|
-
excludePattern,
|
|
745
|
-
apiKey,
|
|
746
|
-
apiUrl,
|
|
747
|
-
mode,
|
|
748
|
-
maxWaitMs,
|
|
749
|
-
noFallback,
|
|
750
|
-
configSources
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// src/utils/extract.ts
|
|
755
|
-
import { createHash } from "crypto";
|
|
756
|
-
import { readFileSync } from "fs";
|
|
757
|
-
import { parse } from "@babel/parser";
|
|
758
|
-
import babelTraverse from "@babel/traverse";
|
|
759
|
-
import { glob } from "glob";
|
|
760
|
-
import { relative as pathRelative } from "path";
|
|
761
|
-
var traverse = babelTraverse.default || babelTraverse;
|
|
762
|
-
var StringExtractor = class {
|
|
763
|
-
/**
|
|
764
|
-
* Extract strings from all files matching the pattern(s)
|
|
765
|
-
*
|
|
766
|
-
* @param pattern - Glob pattern(s) to include
|
|
767
|
-
* @param projectRoot - Project root directory
|
|
768
|
-
* @param excludePattern - Glob pattern(s) to exclude (optional)
|
|
769
|
-
*/
|
|
770
|
-
async extractFromProject(pattern, projectRoot = process.cwd(), excludePattern) {
|
|
771
|
-
const includePatterns = Array.isArray(pattern) ? pattern : [pattern];
|
|
772
|
-
const defaultIgnore = ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"];
|
|
773
|
-
const ignorePatterns = excludePattern ? [...defaultIgnore, ...Array.isArray(excludePattern) ? excludePattern : [excludePattern]] : defaultIgnore;
|
|
774
|
-
const allFiles = /* @__PURE__ */ new Set();
|
|
775
|
-
for (const includePattern of includePatterns) {
|
|
776
|
-
const files = await glob(includePattern, {
|
|
777
|
-
cwd: projectRoot,
|
|
778
|
-
absolute: true,
|
|
779
|
-
ignore: ignorePatterns
|
|
780
|
-
});
|
|
781
|
-
files.forEach((file) => allFiles.add(file));
|
|
782
|
-
}
|
|
783
|
-
const allStrings = [];
|
|
784
|
-
const sortedFiles = Array.from(allFiles).sort();
|
|
785
|
-
for (const file of sortedFiles) {
|
|
786
|
-
try {
|
|
787
|
-
const strings = await this.extractFromFile(file, projectRoot);
|
|
788
|
-
allStrings.push(...strings);
|
|
789
|
-
} catch (error) {
|
|
790
|
-
console.warn(`Warning: Failed to extract from ${file}:`, error);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
const unique = this.deduplicateStrings(allStrings);
|
|
794
|
-
return unique;
|
|
795
|
-
}
|
|
796
|
-
/**
|
|
797
|
-
* Extract strings from a single file
|
|
798
|
-
*/
|
|
799
|
-
async extractFromFile(filePath, projectRoot) {
|
|
800
|
-
const code = readFileSync(filePath, "utf-8");
|
|
801
|
-
const strings = [];
|
|
802
|
-
const relativeFilePath = pathRelative(projectRoot, filePath).split("\\").join("/");
|
|
803
|
-
try {
|
|
804
|
-
const ast = parse(code, {
|
|
805
|
-
sourceType: "module",
|
|
806
|
-
plugins: ["jsx", "typescript"]
|
|
807
|
-
});
|
|
808
|
-
const vocoderImports = /* @__PURE__ */ new Map();
|
|
809
|
-
const tFunctionNames = /* @__PURE__ */ new Set();
|
|
810
|
-
traverse(ast, {
|
|
811
|
-
// Track imports of <T> component and t function
|
|
812
|
-
ImportDeclaration: (path) => {
|
|
813
|
-
const source = path.node.source.value;
|
|
814
|
-
if (source === "@vocoder/react") {
|
|
815
|
-
path.node.specifiers.forEach((spec) => {
|
|
816
|
-
if (spec.type === "ImportSpecifier") {
|
|
817
|
-
const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
|
|
818
|
-
const local = spec.local.name;
|
|
819
|
-
if (imported === "T") {
|
|
820
|
-
vocoderImports.set(local, "T");
|
|
821
|
-
}
|
|
822
|
-
if (imported === "t") {
|
|
823
|
-
tFunctionNames.add(local);
|
|
824
|
-
}
|
|
825
|
-
if (imported === "useVocoder") {
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
},
|
|
831
|
-
// Track destructured 't' from useVocoder hook
|
|
832
|
-
VariableDeclarator: (path) => {
|
|
833
|
-
const init2 = path.node.init;
|
|
834
|
-
if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === "useVocoder" && path.node.id.type === "ObjectPattern") {
|
|
835
|
-
path.node.id.properties.forEach((prop) => {
|
|
836
|
-
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "t") {
|
|
837
|
-
const localName = prop.value.type === "Identifier" ? prop.value.name : "t";
|
|
838
|
-
tFunctionNames.add(localName);
|
|
839
|
-
}
|
|
840
|
-
});
|
|
841
|
-
}
|
|
842
|
-
},
|
|
843
|
-
// Extract from t() function calls
|
|
844
|
-
CallExpression: (path) => {
|
|
845
|
-
const callee = path.node.callee;
|
|
846
|
-
const isTFunction = callee.type === "Identifier" && tFunctionNames.has(callee.name);
|
|
847
|
-
if (!isTFunction) return;
|
|
848
|
-
const firstArg = path.node.arguments[0];
|
|
849
|
-
if (!firstArg) return;
|
|
850
|
-
let text = null;
|
|
851
|
-
if (firstArg.type === "StringLiteral") {
|
|
852
|
-
text = firstArg.value;
|
|
853
|
-
} else if (firstArg.type === "TemplateLiteral") {
|
|
854
|
-
text = this.extractTemplateText(firstArg);
|
|
855
|
-
}
|
|
856
|
-
if (!text || text.trim().length === 0) return;
|
|
857
|
-
const secondArg = path.node.arguments[1];
|
|
858
|
-
let context;
|
|
859
|
-
let formality;
|
|
860
|
-
let explicitKey;
|
|
861
|
-
if (secondArg && secondArg.type === "ObjectExpression") {
|
|
862
|
-
secondArg.properties.forEach((prop) => {
|
|
863
|
-
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier") {
|
|
864
|
-
if (prop.key.name === "context" && prop.value.type === "StringLiteral") {
|
|
865
|
-
context = prop.value.value;
|
|
866
|
-
}
|
|
867
|
-
if (prop.key.name === "formality" && prop.value.type === "StringLiteral") {
|
|
868
|
-
formality = prop.value.value;
|
|
869
|
-
}
|
|
870
|
-
if (prop.key.name === "id" && prop.value.type === "StringLiteral") {
|
|
871
|
-
explicitKey = prop.value.value.trim();
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
const line = path.node.loc?.start.line || 0;
|
|
877
|
-
const column = path.node.loc?.start.column || 0;
|
|
878
|
-
const key = explicitKey && explicitKey.length > 0 ? explicitKey : this.generateStableKey({
|
|
879
|
-
filePath: relativeFilePath,
|
|
880
|
-
kind: "t-call",
|
|
881
|
-
line,
|
|
882
|
-
column
|
|
883
|
-
});
|
|
884
|
-
strings.push({
|
|
885
|
-
key,
|
|
886
|
-
text: text.trim(),
|
|
887
|
-
file: filePath,
|
|
888
|
-
line,
|
|
889
|
-
context,
|
|
890
|
-
formality
|
|
891
|
-
});
|
|
892
|
-
},
|
|
893
|
-
// Extract from JSX elements
|
|
894
|
-
JSXElement: (path) => {
|
|
895
|
-
const opening = path.node.openingElement;
|
|
896
|
-
const tagName = opening.name.type === "JSXIdentifier" ? opening.name.name : null;
|
|
897
|
-
if (!tagName) return;
|
|
898
|
-
const isTranslationComponent = vocoderImports.has(tagName);
|
|
899
|
-
if (!isTranslationComponent) return;
|
|
900
|
-
const msgAttribute = this.getStringAttribute(opening.attributes, "msg");
|
|
901
|
-
const text = msgAttribute || this.extractTextContent(path.node.children);
|
|
902
|
-
if (!text || text.trim().length === 0) return;
|
|
903
|
-
const id = this.getStringAttribute(opening.attributes, "id");
|
|
904
|
-
const context = this.getStringAttribute(opening.attributes, "context");
|
|
905
|
-
const formality = this.getStringAttribute(
|
|
906
|
-
opening.attributes,
|
|
907
|
-
"formality"
|
|
908
|
-
);
|
|
909
|
-
const line = path.node.loc?.start.line || 0;
|
|
910
|
-
const column = path.node.loc?.start.column || 0;
|
|
911
|
-
const key = id && id.trim().length > 0 ? id.trim() : this.generateStableKey({
|
|
912
|
-
filePath: relativeFilePath,
|
|
913
|
-
kind: "jsx",
|
|
914
|
-
line,
|
|
915
|
-
column
|
|
916
|
-
});
|
|
917
|
-
strings.push({
|
|
918
|
-
key,
|
|
919
|
-
text: text.trim(),
|
|
920
|
-
file: filePath,
|
|
921
|
-
line,
|
|
922
|
-
context,
|
|
923
|
-
formality
|
|
924
|
-
});
|
|
925
|
-
}
|
|
926
|
-
});
|
|
927
|
-
} catch (error) {
|
|
928
|
-
throw new Error(
|
|
929
|
-
`Failed to parse ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
930
|
-
);
|
|
931
|
-
}
|
|
932
|
-
return strings;
|
|
933
|
-
}
|
|
934
|
-
/**
|
|
935
|
-
* Extract text from template literal
|
|
936
|
-
* Converts template literals like `Hello ${name}` to `Hello {name}`
|
|
937
|
-
*/
|
|
938
|
-
extractTemplateText(node) {
|
|
939
|
-
let text = "";
|
|
940
|
-
for (let i = 0; i < node.quasis.length; i++) {
|
|
941
|
-
const quasi = node.quasis[i];
|
|
942
|
-
text += quasi.value.raw;
|
|
943
|
-
if (i < node.expressions.length) {
|
|
944
|
-
const expr = node.expressions[i];
|
|
945
|
-
if (expr.type === "Identifier") {
|
|
946
|
-
text += `{${expr.name}}`;
|
|
947
|
-
} else {
|
|
948
|
-
text += "{value}";
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
return text;
|
|
953
|
-
}
|
|
954
|
-
/**
|
|
955
|
-
* Extract text content from JSX children
|
|
956
|
-
*/
|
|
957
|
-
extractTextContent(children) {
|
|
958
|
-
let text = "";
|
|
959
|
-
for (const child of children) {
|
|
960
|
-
if (child.type === "JSXText") {
|
|
961
|
-
text += child.value;
|
|
962
|
-
} else if (child.type === "JSXExpressionContainer") {
|
|
963
|
-
const expr = child.expression;
|
|
964
|
-
if (expr.type === "Identifier") {
|
|
965
|
-
text += `{${expr.name}}`;
|
|
966
|
-
} else if (expr.type === "StringLiteral") {
|
|
967
|
-
text += expr.value;
|
|
968
|
-
} else if (expr.type === "TemplateLiteral") {
|
|
969
|
-
text += this.extractTemplateText(expr);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return text;
|
|
974
|
-
}
|
|
975
|
-
/**
|
|
976
|
-
* Get string value from JSX attribute
|
|
977
|
-
* Handles both string literals and template literals
|
|
978
|
-
*/
|
|
979
|
-
getStringAttribute(attributes, name) {
|
|
980
|
-
const attr = attributes.find(
|
|
981
|
-
(a) => a.type === "JSXAttribute" && a.name.name === name
|
|
982
|
-
);
|
|
983
|
-
if (!attr || !attr.value) return void 0;
|
|
984
|
-
if (attr.value.type === "StringLiteral") {
|
|
985
|
-
return attr.value.value;
|
|
986
|
-
}
|
|
987
|
-
if (attr.value.type === "JSXExpressionContainer") {
|
|
988
|
-
const expr = attr.value.expression;
|
|
989
|
-
if (expr.type === "TemplateLiteral") {
|
|
990
|
-
return this.extractTemplateText(expr);
|
|
991
|
-
}
|
|
992
|
-
if (expr.type === "StringLiteral") {
|
|
993
|
-
return expr.value;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return void 0;
|
|
997
|
-
}
|
|
998
|
-
/**
|
|
999
|
-
* Deduplicate strings (keep first occurrence)
|
|
1000
|
-
*/
|
|
1001
|
-
deduplicateStrings(strings) {
|
|
1002
|
-
const seen = /* @__PURE__ */ new Map();
|
|
1003
|
-
const unique = [];
|
|
1004
|
-
for (const str of strings) {
|
|
1005
|
-
const dedupeKey = `${str.text}|${str.context || ""}|${str.formality || ""}`;
|
|
1006
|
-
const existingIndex = seen.get(dedupeKey);
|
|
1007
|
-
if (existingIndex === void 0) {
|
|
1008
|
-
seen.set(dedupeKey, unique.length);
|
|
1009
|
-
unique.push(str);
|
|
1010
|
-
continue;
|
|
1011
|
-
}
|
|
1012
|
-
const existing = unique[existingIndex];
|
|
1013
|
-
if (existing && str.key < existing.key) {
|
|
1014
|
-
existing.key = str.key;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
return unique;
|
|
1018
|
-
}
|
|
1019
|
-
generateStableKey(params) {
|
|
1020
|
-
const payload = `${params.filePath}|${params.kind}|${params.line}:${params.column}`;
|
|
1021
|
-
const digest = createHash("sha1").update(payload).digest("hex");
|
|
1022
|
-
return `SK_${digest.slice(0, 24).toUpperCase()}`;
|
|
2239
|
+
let noFallback = false;
|
|
2240
|
+
if (typeof cliOptions.noFallback === "boolean") {
|
|
2241
|
+
noFallback = cliOptions.noFallback;
|
|
2242
|
+
configSources.noFallback = "CLI flag";
|
|
2243
|
+
} else if (envSyncNoFallback) {
|
|
2244
|
+
noFallback = ["1", "true", "yes", "on"].includes(envSyncNoFallback.toLowerCase());
|
|
2245
|
+
configSources.noFallback = "environment";
|
|
1023
2246
|
}
|
|
1024
|
-
|
|
2247
|
+
if (verbose) {
|
|
2248
|
+
console.log(chalk7.dim("\n Configuration sources:"));
|
|
2249
|
+
console.log(chalk7.dim(` Include patterns: ${configSources.extractionPattern}`));
|
|
2250
|
+
if (excludePattern.length > 0) {
|
|
2251
|
+
console.log(chalk7.dim(` Exclude patterns: ${configSources.excludePattern}`));
|
|
2252
|
+
}
|
|
2253
|
+
console.log(chalk7.dim(` API key: ${configSources.apiKey}`));
|
|
2254
|
+
console.log(chalk7.dim(` API URL: ${configSources.apiUrl}
|
|
2255
|
+
`));
|
|
2256
|
+
console.log(chalk7.dim(` Sync mode: ${configSources.mode}`));
|
|
2257
|
+
if (maxWaitMs) {
|
|
2258
|
+
console.log(chalk7.dim(` Max wait: ${configSources.maxWaitMs}`));
|
|
2259
|
+
}
|
|
2260
|
+
console.log(chalk7.dim(` No fallback: ${configSources.noFallback}
|
|
2261
|
+
`));
|
|
2262
|
+
}
|
|
2263
|
+
return {
|
|
2264
|
+
extractionPattern,
|
|
2265
|
+
excludePattern,
|
|
2266
|
+
apiKey,
|
|
2267
|
+
apiUrl,
|
|
2268
|
+
mode,
|
|
2269
|
+
maxWaitMs,
|
|
2270
|
+
noFallback,
|
|
2271
|
+
configSources
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
1025
2274
|
|
|
1026
2275
|
// src/commands/sync.ts
|
|
1027
|
-
import
|
|
1028
|
-
import { join } from "path";
|
|
2276
|
+
import chalk8 from "chalk";
|
|
2277
|
+
import { join as join2 } from "path";
|
|
1029
2278
|
function isRecord(value) {
|
|
1030
2279
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1031
2280
|
}
|
|
@@ -1071,9 +2320,9 @@ function parseTranslations(value) {
|
|
|
1071
2320
|
}
|
|
1072
2321
|
function getCacheFilePath(projectRoot, branch) {
|
|
1073
2322
|
const slug = branch.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
|
|
1074
|
-
const branchHash =
|
|
2323
|
+
const branchHash = createHash("sha1").update(branch).digest("hex").slice(0, 12);
|
|
1075
2324
|
const filename = `${slug || "branch"}-${branchHash}.json`;
|
|
1076
|
-
return
|
|
2325
|
+
return join2(projectRoot, "node_modules", ".vocoder", "cache", "sync", filename);
|
|
1077
2326
|
}
|
|
1078
2327
|
function readLocalSnapshotCache(params) {
|
|
1079
2328
|
const candidateBranches = params.branch === "main" ? ["main"] : [params.branch, "main"];
|
|
@@ -1109,7 +2358,7 @@ function readLocalSnapshotCache(params) {
|
|
|
1109
2358
|
}
|
|
1110
2359
|
function writeLocalSnapshotCache(params) {
|
|
1111
2360
|
const cacheFilePath = getCacheFilePath(params.projectRoot, params.branch);
|
|
1112
|
-
|
|
2361
|
+
mkdirSync2(join2(params.projectRoot, "node_modules", ".vocoder", "cache", "sync"), {
|
|
1113
2362
|
recursive: true
|
|
1114
2363
|
});
|
|
1115
2364
|
const payload = {
|
|
@@ -1123,7 +2372,7 @@ function writeLocalSnapshotCache(params) {
|
|
|
1123
2372
|
...params.localeMetadata ? { localeMetadata: params.localeMetadata } : {},
|
|
1124
2373
|
translations: params.translations
|
|
1125
2374
|
};
|
|
1126
|
-
|
|
2375
|
+
writeFileSync2(cacheFilePath, JSON.stringify(payload, null, 2), "utf-8");
|
|
1127
2376
|
return cacheFilePath;
|
|
1128
2377
|
}
|
|
1129
2378
|
function resolveEffectiveModeFromPolicy(params) {
|
|
@@ -1278,12 +2527,12 @@ async function fetchApiSnapshot(api, params) {
|
|
|
1278
2527
|
async function sync(options = {}) {
|
|
1279
2528
|
const startTime = Date.now();
|
|
1280
2529
|
const projectRoot = process.cwd();
|
|
1281
|
-
|
|
1282
|
-
const spinner4 =
|
|
2530
|
+
p7.intro("Vocoder Sync");
|
|
2531
|
+
const spinner4 = p7.spinner();
|
|
1283
2532
|
try {
|
|
1284
2533
|
spinner4.start("Detecting branch");
|
|
1285
2534
|
const branch = detectBranch(options.branch);
|
|
1286
|
-
spinner4.stop(`Branch: ${
|
|
2535
|
+
spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
|
|
1287
2536
|
spinner4.start("Loading project configuration");
|
|
1288
2537
|
const mergedConfig = await getMergedConfig(options, options.verbose);
|
|
1289
2538
|
const localConfig = {
|
|
@@ -1308,12 +2557,12 @@ async function sync(options = {}) {
|
|
|
1308
2557
|
};
|
|
1309
2558
|
spinner4.stop("Project configuration loaded");
|
|
1310
2559
|
if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
|
|
1311
|
-
|
|
1312
|
-
`Skipping translations (${
|
|
2560
|
+
p7.log.warn(
|
|
2561
|
+
`Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
|
|
1313
2562
|
);
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
2563
|
+
p7.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
|
|
2564
|
+
p7.log.info("Use --force to translate anyway");
|
|
2565
|
+
p7.outro("");
|
|
1317
2566
|
return 0;
|
|
1318
2567
|
}
|
|
1319
2568
|
const patternsDisplay = Array.isArray(config.extractionPattern) ? config.extractionPattern.join(", ") : config.extractionPattern;
|
|
@@ -1326,22 +2575,22 @@ async function sync(options = {}) {
|
|
|
1326
2575
|
);
|
|
1327
2576
|
if (extractedStrings.length === 0) {
|
|
1328
2577
|
spinner4.stop("No translatable strings found");
|
|
1329
|
-
|
|
1330
|
-
|
|
2578
|
+
p7.log.warn("Make sure you are wrapping translatable strings with Vocoder");
|
|
2579
|
+
p7.outro("");
|
|
1331
2580
|
return 0;
|
|
1332
2581
|
}
|
|
1333
2582
|
spinner4.stop(
|
|
1334
|
-
`Extracted ${
|
|
2583
|
+
`Extracted ${chalk8.cyan(extractedStrings.length)} strings from ${chalk8.cyan(patternsDisplay)}`
|
|
1335
2584
|
);
|
|
1336
2585
|
if (options.verbose) {
|
|
1337
2586
|
const sampleLines = extractedStrings.slice(0, 5).map((s) => ` "${s.text}" (${s.file}:${s.line})`);
|
|
1338
2587
|
if (extractedStrings.length > 5) {
|
|
1339
2588
|
sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
|
|
1340
2589
|
}
|
|
1341
|
-
|
|
2590
|
+
p7.note(sampleLines.join("\n"), "Sample strings");
|
|
1342
2591
|
}
|
|
1343
2592
|
if (options.dryRun) {
|
|
1344
|
-
|
|
2593
|
+
p7.note(
|
|
1345
2594
|
[
|
|
1346
2595
|
`Strings: ${extractedStrings.length}`,
|
|
1347
2596
|
`Branch: ${branch}`,
|
|
@@ -1352,19 +2601,19 @@ async function sync(options = {}) {
|
|
|
1352
2601
|
].join("\n"),
|
|
1353
2602
|
"Dry run - would translate"
|
|
1354
2603
|
);
|
|
1355
|
-
|
|
2604
|
+
p7.outro("No API calls made.");
|
|
1356
2605
|
return 0;
|
|
1357
2606
|
}
|
|
1358
2607
|
const repoIdentity = resolveGitRepositoryIdentity();
|
|
1359
2608
|
if (!repoIdentity && options.verbose) {
|
|
1360
|
-
|
|
2609
|
+
p7.log.warn(
|
|
1361
2610
|
"Could not detect git remote origin. Sync will continue without repo metadata."
|
|
1362
2611
|
);
|
|
1363
2612
|
}
|
|
1364
2613
|
const stringEntries = buildStringEntries(extractedStrings);
|
|
1365
2614
|
const sourceStrings = stringEntries.map((entry) => entry.text);
|
|
1366
2615
|
if (options.verbose && stringEntries.length !== extractedStrings.length) {
|
|
1367
|
-
|
|
2616
|
+
p7.log.info(
|
|
1368
2617
|
`Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
|
|
1369
2618
|
);
|
|
1370
2619
|
}
|
|
@@ -1380,38 +2629,38 @@ async function sync(options = {}) {
|
|
|
1380
2629
|
},
|
|
1381
2630
|
repoIdentity ?? void 0
|
|
1382
2631
|
);
|
|
1383
|
-
spinner4.stop(`Submitted to API - Batch ${
|
|
2632
|
+
spinner4.stop(`Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`);
|
|
1384
2633
|
const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
|
|
1385
2634
|
branch,
|
|
1386
2635
|
requestedMode,
|
|
1387
2636
|
policy: config.syncPolicy
|
|
1388
2637
|
});
|
|
1389
2638
|
if (options.verbose) {
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
2639
|
+
p7.log.info(`Requested mode: ${requestedMode}`);
|
|
2640
|
+
p7.log.info(`Effective mode: ${effectiveMode}`);
|
|
2641
|
+
p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
|
|
1393
2642
|
if (batchResponse.queueStatus) {
|
|
1394
|
-
|
|
2643
|
+
p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
|
|
1395
2644
|
}
|
|
1396
2645
|
}
|
|
1397
2646
|
if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
|
|
1398
|
-
|
|
2647
|
+
p7.log.success("No changes detected - strings are up to date");
|
|
1399
2648
|
}
|
|
1400
|
-
|
|
2649
|
+
p7.log.info(`New strings: ${chalk8.cyan(batchResponse.newStrings)}`);
|
|
1401
2650
|
if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
|
|
1402
|
-
|
|
1403
|
-
`Deleted strings: ${
|
|
2651
|
+
p7.log.info(
|
|
2652
|
+
`Deleted strings: ${chalk8.yellow(batchResponse.deletedStrings)} (archived)`
|
|
1404
2653
|
);
|
|
1405
2654
|
}
|
|
1406
|
-
|
|
2655
|
+
p7.log.info(`Total strings: ${chalk8.cyan(batchResponse.totalStrings)}`);
|
|
1407
2656
|
if (batchResponse.newStrings === 0) {
|
|
1408
|
-
|
|
2657
|
+
p7.log.success("No new strings - using existing translations");
|
|
1409
2658
|
} else {
|
|
1410
|
-
|
|
2659
|
+
p7.log.info(
|
|
1411
2660
|
`Syncing to ${config.targetLocales.length} locales (${config.targetLocales.join(", ")})`
|
|
1412
2661
|
);
|
|
1413
2662
|
if (batchResponse.estimatedTime) {
|
|
1414
|
-
|
|
2663
|
+
p7.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
|
|
1415
2664
|
}
|
|
1416
2665
|
}
|
|
1417
2666
|
let artifacts = null;
|
|
@@ -1449,7 +2698,7 @@ async function sync(options = {}) {
|
|
|
1449
2698
|
if (effectiveMode === "required") {
|
|
1450
2699
|
throw waitError;
|
|
1451
2700
|
}
|
|
1452
|
-
|
|
2701
|
+
p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
|
|
1453
2702
|
}
|
|
1454
2703
|
}
|
|
1455
2704
|
if (!artifacts) {
|
|
@@ -1483,7 +2732,7 @@ async function sync(options = {}) {
|
|
|
1483
2732
|
spinner4.stop("Failed to fetch API snapshot");
|
|
1484
2733
|
if (options.verbose) {
|
|
1485
2734
|
const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
|
|
1486
|
-
|
|
2735
|
+
p7.log.warn(`Snapshot fetch error: ${message}`);
|
|
1487
2736
|
}
|
|
1488
2737
|
}
|
|
1489
2738
|
}
|
|
@@ -1516,1217 +2765,89 @@ async function sync(options = {}) {
|
|
|
1516
2765
|
completedAt: artifacts.completedAt ?? (artifacts.source === "fresh" ? (/* @__PURE__ */ new Date()).toISOString() : null)
|
|
1517
2766
|
});
|
|
1518
2767
|
if (options.verbose) {
|
|
1519
|
-
|
|
2768
|
+
p7.log.info(`Cached snapshot: ${cachePath}`);
|
|
1520
2769
|
}
|
|
1521
2770
|
} catch (error) {
|
|
1522
2771
|
if (options.verbose) {
|
|
1523
2772
|
const message = error instanceof Error ? error.message : "Unknown cache write error";
|
|
1524
|
-
|
|
2773
|
+
p7.log.warn(`Failed to write local snapshot cache: ${message}`);
|
|
1525
2774
|
}
|
|
1526
2775
|
}
|
|
1527
2776
|
if (artifacts.source !== "fresh") {
|
|
1528
2777
|
const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
|
|
1529
|
-
|
|
2778
|
+
p7.log.warn(
|
|
1530
2779
|
`Using ${sourceLabel}. New strings may appear after the background sync completes.`
|
|
1531
2780
|
);
|
|
1532
2781
|
}
|
|
1533
2782
|
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
2783
|
+
p7.outro(`Sync complete! (${duration}s)`);
|
|
2784
|
+
p7.log.info("Translations will be injected at build time by @vocoder/unplugin.");
|
|
2785
|
+
p7.log.info("Just use <VocoderProvider> and <T> \u2014 no manual imports needed.");
|
|
1537
2786
|
return 0;
|
|
1538
2787
|
} catch (error) {
|
|
1539
2788
|
spinner4.stop();
|
|
1540
2789
|
if (error instanceof VocoderAPIError && error.syncPolicyError) {
|
|
1541
|
-
|
|
2790
|
+
p7.log.error(error.syncPolicyError.message);
|
|
1542
2791
|
const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
|
|
1543
2792
|
for (const line of guidance) {
|
|
1544
|
-
|
|
2793
|
+
p7.log.info(line);
|
|
1545
2794
|
}
|
|
1546
2795
|
return 1;
|
|
1547
2796
|
}
|
|
1548
2797
|
if (error instanceof VocoderAPIError && error.limitError) {
|
|
1549
2798
|
const { limitError } = error;
|
|
1550
|
-
|
|
2799
|
+
p7.log.error(limitError.message);
|
|
1551
2800
|
const guidance = getLimitErrorGuidance(limitError);
|
|
1552
2801
|
for (const line of guidance) {
|
|
1553
|
-
|
|
2802
|
+
p7.log.info(line);
|
|
1554
2803
|
}
|
|
1555
2804
|
return 1;
|
|
1556
2805
|
}
|
|
1557
2806
|
if (error instanceof Error) {
|
|
1558
|
-
|
|
2807
|
+
p7.log.error(error.message);
|
|
1559
2808
|
if (error.message.includes("VOCODER_API_KEY")) {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
2809
|
+
p7.log.warn("VOCODER_API_KEY is only needed for `vocoder sync` (CLI push).");
|
|
2810
|
+
p7.log.info(" Create one at: https://vocoder.app/dashboard");
|
|
2811
|
+
p7.log.info(' Then: export VOCODER_API_KEY="vc_..." or add it to .env');
|
|
2812
|
+
p7.log.info("");
|
|
2813
|
+
p7.log.info(" Note: If you use @vocoder/unplugin, `vocoder sync` is optional.");
|
|
2814
|
+
p7.log.info(" Translations are fetched automatically at build time.");
|
|
1566
2815
|
} else if (error.message.includes("git branch")) {
|
|
1567
|
-
|
|
1568
|
-
|
|
2816
|
+
p7.log.warn("Run from a git repository, or use:");
|
|
2817
|
+
p7.log.info(" vocoder sync --branch main");
|
|
1569
2818
|
}
|
|
1570
2819
|
if (options.verbose) {
|
|
1571
|
-
|
|
2820
|
+
p7.log.info(`Full error: ${error.stack ?? error}`);
|
|
1572
2821
|
}
|
|
1573
2822
|
}
|
|
1574
2823
|
return 1;
|
|
1575
2824
|
}
|
|
1576
2825
|
}
|
|
1577
2826
|
|
|
1578
|
-
// src/commands/
|
|
1579
|
-
import
|
|
1580
|
-
import
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
import { parse as parse2 } from "@babel/parser";
|
|
1587
|
-
import babelTraverse2 from "@babel/traverse";
|
|
1588
|
-
import { glob as glob2 } from "glob";
|
|
1589
|
-
|
|
1590
|
-
// src/utils/wrap/heuristics.ts
|
|
1591
|
-
var URL_REGEX = /^(https?:\/\/|\/\/|mailto:|tel:|ftp:\/\/)/i;
|
|
1592
|
-
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1593
|
-
var FILE_PATH_REGEX = /^(\.{0,2}\/|[a-zA-Z]:\\)/;
|
|
1594
|
-
var COLOR_HEX_REGEX = /^#([0-9a-fA-F]{3,8})$/;
|
|
1595
|
-
var COLOR_FUNC_REGEX = /^(rgb|rgba|hsl|hsla)\s*\(/i;
|
|
1596
|
-
var CAMEL_CASE_REGEX = /^[a-z][a-zA-Z0-9]*$/;
|
|
1597
|
-
var PASCAL_CASE_REGEX = /^[A-Z][a-zA-Z0-9]*$/;
|
|
1598
|
-
var SCREAMING_SNAKE_REGEX = /^[A-Z][A-Z0-9_]+$/;
|
|
1599
|
-
var KEBAB_CASE_REGEX = /^[a-z][a-z0-9-]+$/;
|
|
1600
|
-
var MIME_TYPE_REGEX = /^(application|text|image|audio|video|font|multipart)\//;
|
|
1601
|
-
var DATE_FORMAT_REGEX = /^[YMDHhmsaAZz\-\/\.\s:,]+$/;
|
|
1602
|
-
var CSS_UNIT_REGEX = /^\d+(\.\d+)?(px|em|rem|vh|vw|%|ch|ex|pt|pc|in|cm|mm)$/;
|
|
1603
|
-
var TAILWIND_REGEX = /^[a-z][\w-]*(\s+[a-z][\w-]*)*$/;
|
|
1604
|
-
var TAILWIND_PREFIXES = [
|
|
1605
|
-
"flex",
|
|
1606
|
-
"grid",
|
|
1607
|
-
"block",
|
|
1608
|
-
"inline",
|
|
1609
|
-
"hidden",
|
|
1610
|
-
"absolute",
|
|
1611
|
-
"relative",
|
|
1612
|
-
"fixed",
|
|
1613
|
-
"sticky",
|
|
1614
|
-
"top",
|
|
1615
|
-
"bottom",
|
|
1616
|
-
"left",
|
|
1617
|
-
"right",
|
|
1618
|
-
"inset",
|
|
1619
|
-
"w-",
|
|
1620
|
-
"h-",
|
|
1621
|
-
"min-",
|
|
1622
|
-
"max-",
|
|
1623
|
-
"p-",
|
|
1624
|
-
"px-",
|
|
1625
|
-
"py-",
|
|
1626
|
-
"pt-",
|
|
1627
|
-
"pb-",
|
|
1628
|
-
"pl-",
|
|
1629
|
-
"pr-",
|
|
1630
|
-
"m-",
|
|
1631
|
-
"mx-",
|
|
1632
|
-
"my-",
|
|
1633
|
-
"mt-",
|
|
1634
|
-
"mb-",
|
|
1635
|
-
"ml-",
|
|
1636
|
-
"mr-",
|
|
1637
|
-
"text-",
|
|
1638
|
-
"font-",
|
|
1639
|
-
"leading-",
|
|
1640
|
-
"tracking-",
|
|
1641
|
-
"bg-",
|
|
1642
|
-
"border-",
|
|
1643
|
-
"rounded-",
|
|
1644
|
-
"shadow-",
|
|
1645
|
-
"opacity-",
|
|
1646
|
-
"z-",
|
|
1647
|
-
"gap-",
|
|
1648
|
-
"space-",
|
|
1649
|
-
"items-",
|
|
1650
|
-
"justify-",
|
|
1651
|
-
"self-",
|
|
1652
|
-
"place-",
|
|
1653
|
-
"overflow-",
|
|
1654
|
-
"cursor-",
|
|
1655
|
-
"transition-",
|
|
1656
|
-
"duration-",
|
|
1657
|
-
"ease-",
|
|
1658
|
-
"sm:",
|
|
1659
|
-
"md:",
|
|
1660
|
-
"lg:",
|
|
1661
|
-
"xl:",
|
|
1662
|
-
"2xl:",
|
|
1663
|
-
"dark:",
|
|
1664
|
-
"hover:",
|
|
1665
|
-
"focus:",
|
|
1666
|
-
"active:",
|
|
1667
|
-
"group-",
|
|
1668
|
-
"peer-"
|
|
1669
|
-
];
|
|
1670
|
-
var NON_TRANSLATABLE_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
1671
|
-
"className",
|
|
1672
|
-
"class",
|
|
1673
|
-
"href",
|
|
1674
|
-
"src",
|
|
1675
|
-
"id",
|
|
1676
|
-
"key",
|
|
1677
|
-
"ref",
|
|
1678
|
-
"style",
|
|
1679
|
-
"data-testid",
|
|
1680
|
-
"data-cy",
|
|
1681
|
-
"data-test",
|
|
1682
|
-
"type",
|
|
1683
|
-
"name",
|
|
1684
|
-
"value",
|
|
1685
|
-
"action",
|
|
1686
|
-
"method",
|
|
1687
|
-
"encType",
|
|
1688
|
-
"target",
|
|
1689
|
-
"rel",
|
|
1690
|
-
"role",
|
|
1691
|
-
"tabIndex",
|
|
1692
|
-
"htmlFor",
|
|
1693
|
-
"for",
|
|
1694
|
-
"width",
|
|
1695
|
-
"height",
|
|
1696
|
-
"viewBox",
|
|
1697
|
-
"xmlns",
|
|
1698
|
-
"fill",
|
|
1699
|
-
"stroke",
|
|
1700
|
-
"onClick",
|
|
1701
|
-
"onChange",
|
|
1702
|
-
"onSubmit",
|
|
1703
|
-
"onBlur",
|
|
1704
|
-
"onFocus",
|
|
1705
|
-
"onKeyDown",
|
|
1706
|
-
"onKeyUp",
|
|
1707
|
-
"onKeyPress",
|
|
1708
|
-
"onMouseEnter",
|
|
1709
|
-
"onMouseLeave"
|
|
1710
|
-
]);
|
|
1711
|
-
var TRANSLATABLE_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
1712
|
-
"title",
|
|
1713
|
-
"placeholder",
|
|
1714
|
-
"alt",
|
|
1715
|
-
"aria-label",
|
|
1716
|
-
"aria-description",
|
|
1717
|
-
"aria-placeholder",
|
|
1718
|
-
"aria-roledescription",
|
|
1719
|
-
"aria-valuetext",
|
|
1720
|
-
"label",
|
|
1721
|
-
"description",
|
|
1722
|
-
"message",
|
|
1723
|
-
"heading",
|
|
1724
|
-
"caption",
|
|
1725
|
-
"helperText",
|
|
1726
|
-
"errorMessage",
|
|
1727
|
-
"successMessage",
|
|
1728
|
-
"tooltip"
|
|
1729
|
-
]);
|
|
1730
|
-
var NON_TRANSLATABLE_CALLS = /* @__PURE__ */ new Set([
|
|
1731
|
-
"console.log",
|
|
1732
|
-
"console.warn",
|
|
1733
|
-
"console.error",
|
|
1734
|
-
"console.info",
|
|
1735
|
-
"console.debug",
|
|
1736
|
-
"require",
|
|
1737
|
-
"import",
|
|
1738
|
-
"addEventListener",
|
|
1739
|
-
"removeEventListener",
|
|
1740
|
-
"querySelector",
|
|
1741
|
-
"querySelectorAll",
|
|
1742
|
-
"getElementById",
|
|
1743
|
-
"getAttribute",
|
|
1744
|
-
"setAttribute",
|
|
1745
|
-
"createElement",
|
|
1746
|
-
"JSON.parse",
|
|
1747
|
-
"JSON.stringify",
|
|
1748
|
-
"parseInt",
|
|
1749
|
-
"parseFloat",
|
|
1750
|
-
"encodeURIComponent",
|
|
1751
|
-
"decodeURIComponent",
|
|
1752
|
-
"encodeURI",
|
|
1753
|
-
"decodeURI",
|
|
1754
|
-
"RegExp"
|
|
1755
|
-
]);
|
|
1756
|
-
var TRANSLATABLE_VAR_NAMES = /* @__PURE__ */ new Set([
|
|
1757
|
-
"label",
|
|
1758
|
-
"message",
|
|
1759
|
-
"title",
|
|
1760
|
-
"description",
|
|
1761
|
-
"heading",
|
|
1762
|
-
"text",
|
|
1763
|
-
"caption",
|
|
1764
|
-
"subtitle",
|
|
1765
|
-
"tooltip",
|
|
1766
|
-
"errorMessage",
|
|
1767
|
-
"successMessage",
|
|
1768
|
-
"warningMessage",
|
|
1769
|
-
"infoMessage",
|
|
1770
|
-
"placeholder",
|
|
1771
|
-
"helperText",
|
|
1772
|
-
"hint",
|
|
1773
|
-
"buttonText",
|
|
1774
|
-
"linkText",
|
|
1775
|
-
"headerText",
|
|
1776
|
-
"footerText",
|
|
1777
|
-
"confirmText",
|
|
1778
|
-
"cancelText",
|
|
1779
|
-
"submitText",
|
|
1780
|
-
"greeting",
|
|
1781
|
-
"welcome",
|
|
1782
|
-
"instructions"
|
|
1783
|
-
]);
|
|
1784
|
-
function classifyString(text, context, metadata = {}) {
|
|
1785
|
-
const trimmed = text.trim();
|
|
1786
|
-
if (trimmed.length === 0) {
|
|
1787
|
-
return { translatable: false, confidence: "high", reason: "Empty or whitespace-only" };
|
|
1788
|
-
}
|
|
1789
|
-
if (trimmed.length === 1) {
|
|
1790
|
-
return { translatable: false, confidence: "high", reason: "Single character" };
|
|
1791
|
-
}
|
|
1792
|
-
if (!/[a-zA-Z]/.test(trimmed)) {
|
|
1793
|
-
return { translatable: false, confidence: "high", reason: "No alphabetic characters" };
|
|
1794
|
-
}
|
|
1795
|
-
if (URL_REGEX.test(trimmed)) {
|
|
1796
|
-
return { translatable: false, confidence: "high", reason: "URL" };
|
|
1797
|
-
}
|
|
1798
|
-
if (EMAIL_REGEX.test(trimmed)) {
|
|
1799
|
-
return { translatable: false, confidence: "high", reason: "Email address" };
|
|
1800
|
-
}
|
|
1801
|
-
if (FILE_PATH_REGEX.test(trimmed) && !trimmed.includes(" ")) {
|
|
1802
|
-
return { translatable: false, confidence: "high", reason: "File path" };
|
|
1803
|
-
}
|
|
1804
|
-
if (COLOR_HEX_REGEX.test(trimmed) || COLOR_FUNC_REGEX.test(trimmed)) {
|
|
1805
|
-
return { translatable: false, confidence: "high", reason: "Color code" };
|
|
1806
|
-
}
|
|
1807
|
-
if (CSS_UNIT_REGEX.test(trimmed)) {
|
|
1808
|
-
return { translatable: false, confidence: "high", reason: "CSS unit value" };
|
|
1809
|
-
}
|
|
1810
|
-
if (MIME_TYPE_REGEX.test(trimmed)) {
|
|
1811
|
-
return { translatable: false, confidence: "high", reason: "MIME type" };
|
|
1812
|
-
}
|
|
1813
|
-
if (DATE_FORMAT_REGEX.test(trimmed) && trimmed.length > 1) {
|
|
1814
|
-
return { translatable: false, confidence: "high", reason: "Date format string" };
|
|
1815
|
-
}
|
|
1816
|
-
if (context === "jsx-attribute" && metadata.attributeName) {
|
|
1817
|
-
if (NON_TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
|
|
1818
|
-
return { translatable: false, confidence: "high", reason: `Non-translatable attribute: ${metadata.attributeName}` };
|
|
1819
|
-
}
|
|
1820
|
-
if (metadata.attributeName.startsWith("data-") && !TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
|
|
1821
|
-
return { translatable: false, confidence: "high", reason: "data-* attribute" };
|
|
1822
|
-
}
|
|
1823
|
-
if (metadata.attributeName.startsWith("on") && metadata.attributeName.length > 2) {
|
|
1824
|
-
const thirdChar = metadata.attributeName[2];
|
|
1825
|
-
if (thirdChar && thirdChar === thirdChar.toUpperCase()) {
|
|
1826
|
-
return { translatable: false, confidence: "high", reason: "Event handler attribute" };
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
if (TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
|
|
1830
|
-
return { translatable: true, confidence: "high", reason: `Translatable attribute: ${metadata.attributeName}` };
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
if (context === "jsx-text") {
|
|
1834
|
-
const hasWords = /[a-zA-Z]{2,}/.test(trimmed);
|
|
1835
|
-
if (hasWords) {
|
|
1836
|
-
return { translatable: true, confidence: "high", reason: "JSX text with words" };
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
if (!trimmed.includes(" ") && (CAMEL_CASE_REGEX.test(trimmed) || PASCAL_CASE_REGEX.test(trimmed) || SCREAMING_SNAKE_REGEX.test(trimmed) || KEBAB_CASE_REGEX.test(trimmed))) {
|
|
1840
|
-
return { translatable: false, confidence: "high", reason: "Code identifier" };
|
|
1841
|
-
}
|
|
1842
|
-
if (isTailwindClasses(trimmed)) {
|
|
1843
|
-
return { translatable: false, confidence: "high", reason: "CSS/Tailwind classes" };
|
|
1844
|
-
}
|
|
1845
|
-
if (metadata.isInsideCallExpression) {
|
|
1846
|
-
if (NON_TRANSLATABLE_CALLS.has(metadata.isInsideCallExpression)) {
|
|
1847
|
-
return { translatable: false, confidence: "high", reason: `Inside ${metadata.isInsideCallExpression}()` };
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
if (metadata.parentType === "ThrowStatement" || metadata.isInsideCallExpression === "Error") {
|
|
1851
|
-
return { translatable: false, confidence: "high", reason: "Error message" };
|
|
1852
|
-
}
|
|
1853
|
-
if ((context === "string-literal" || context === "template-literal") && metadata.parentType === "VariableDeclarator") {
|
|
1854
|
-
return { translatable: true, confidence: "medium", reason: "String in variable declaration" };
|
|
1855
|
-
}
|
|
1856
|
-
const wordCount = trimmed.split(/\s+/).length;
|
|
1857
|
-
if (wordCount >= 3) {
|
|
1858
|
-
return { translatable: true, confidence: "medium", reason: `Multi-word string (${wordCount} words)` };
|
|
1859
|
-
}
|
|
1860
|
-
if (wordCount === 2 && /[a-zA-Z]{2,}/.test(trimmed)) {
|
|
1861
|
-
return { translatable: true, confidence: "low", reason: "Short phrase (2 words)" };
|
|
1862
|
-
}
|
|
1863
|
-
if (/^[A-Z][a-z]/.test(trimmed) && context !== "string-literal") {
|
|
1864
|
-
return { translatable: true, confidence: "low", reason: "Capitalized word, possibly UI text" };
|
|
1865
|
-
}
|
|
1866
|
-
return { translatable: false, confidence: "low", reason: "Ambiguous single-word string" };
|
|
1867
|
-
}
|
|
1868
|
-
function isTranslatableVarName(name) {
|
|
1869
|
-
const lower = name.toLowerCase();
|
|
1870
|
-
for (const varName of TRANSLATABLE_VAR_NAMES) {
|
|
1871
|
-
if (lower === varName.toLowerCase() || lower.endsWith(varName.toLowerCase())) {
|
|
1872
|
-
return true;
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
return false;
|
|
1876
|
-
}
|
|
1877
|
-
function isTailwindClasses(text) {
|
|
1878
|
-
if (!TAILWIND_REGEX.test(text)) return false;
|
|
1879
|
-
const parts = text.split(/\s+/);
|
|
1880
|
-
let tailwindCount = 0;
|
|
1881
|
-
for (const part of parts) {
|
|
1882
|
-
if (TAILWIND_PREFIXES.some((prefix) => part.startsWith(prefix))) {
|
|
1883
|
-
tailwindCount++;
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
return tailwindCount > parts.length / 2;
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
// src/utils/wrap/analyzer.ts
|
|
1890
|
-
var traverse2 = babelTraverse2.default || babelTraverse2;
|
|
1891
|
-
var StringAnalyzer = class {
|
|
1892
|
-
constructor(adapter) {
|
|
1893
|
-
this.adapter = adapter;
|
|
1894
|
-
}
|
|
1895
|
-
/**
|
|
1896
|
-
* Analyze all files matching the given patterns and return wrap candidates.
|
|
1897
|
-
*/
|
|
1898
|
-
async analyzeProject(options, projectRoot = process.cwd()) {
|
|
1899
|
-
const includePatterns = options.include?.length ? options.include : ["src/**/*.{tsx,jsx,ts,js}"];
|
|
1900
|
-
const defaultIgnore = [
|
|
1901
|
-
"**/node_modules/**",
|
|
1902
|
-
"**/.next/**",
|
|
1903
|
-
"**/dist/**",
|
|
1904
|
-
"**/build/**",
|
|
1905
|
-
"**/*.test.*",
|
|
1906
|
-
"**/*.spec.*",
|
|
1907
|
-
"**/*.stories.*",
|
|
1908
|
-
"**/__tests__/**"
|
|
1909
|
-
];
|
|
1910
|
-
const ignorePatterns = options.exclude ? [...defaultIgnore, ...options.exclude] : defaultIgnore;
|
|
1911
|
-
const allFiles = /* @__PURE__ */ new Set();
|
|
1912
|
-
for (const pattern of includePatterns) {
|
|
1913
|
-
const files = await glob2(pattern, {
|
|
1914
|
-
cwd: projectRoot,
|
|
1915
|
-
absolute: true,
|
|
1916
|
-
ignore: ignorePatterns
|
|
1917
|
-
});
|
|
1918
|
-
files.forEach((file) => allFiles.add(file));
|
|
1919
|
-
}
|
|
1920
|
-
const allCandidates = [];
|
|
1921
|
-
for (const file of allFiles) {
|
|
1922
|
-
try {
|
|
1923
|
-
const candidates = this.analyzeFile(file);
|
|
1924
|
-
allCandidates.push(...candidates);
|
|
1925
|
-
} catch (error) {
|
|
1926
|
-
if (options.verbose) {
|
|
1927
|
-
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
1928
|
-
console.warn(`Warning: Failed to analyze ${file}: ${msg}`);
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
return allCandidates;
|
|
1933
|
-
}
|
|
1934
|
-
/**
|
|
1935
|
-
* Analyze a single file and return wrap candidates.
|
|
1936
|
-
*/
|
|
1937
|
-
analyzeFile(filePath) {
|
|
1938
|
-
const code = readFileSync3(filePath, "utf-8");
|
|
1939
|
-
return this.analyzeCode(code, filePath);
|
|
1940
|
-
}
|
|
1941
|
-
/**
|
|
1942
|
-
* Analyze source code and return wrap candidates.
|
|
1943
|
-
*/
|
|
1944
|
-
analyzeCode(code, filePath = "<input>") {
|
|
1945
|
-
const candidates = [];
|
|
1946
|
-
const ast = parse2(code, {
|
|
1947
|
-
sourceType: "module",
|
|
1948
|
-
plugins: ["jsx", "typescript"]
|
|
1949
|
-
});
|
|
1950
|
-
const vocoderImports = /* @__PURE__ */ new Map();
|
|
1951
|
-
const tFunctionNames = /* @__PURE__ */ new Set();
|
|
1952
|
-
traverse2(ast, {
|
|
1953
|
-
// Track imports from @vocoder/react
|
|
1954
|
-
ImportDeclaration: (path) => {
|
|
1955
|
-
const source = path.node.source.value;
|
|
1956
|
-
if (source === this.adapter.importSource) {
|
|
1957
|
-
path.node.specifiers.forEach((spec) => {
|
|
1958
|
-
if (spec.type === "ImportSpecifier") {
|
|
1959
|
-
const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
|
|
1960
|
-
const local = spec.local.name;
|
|
1961
|
-
if (imported === this.adapter.componentName) {
|
|
1962
|
-
vocoderImports.set(local, this.adapter.componentName);
|
|
1963
|
-
}
|
|
1964
|
-
if (imported === this.adapter.functionName) {
|
|
1965
|
-
tFunctionNames.add(local);
|
|
1966
|
-
}
|
|
1967
|
-
if (imported === this.adapter.hookName) {
|
|
1968
|
-
vocoderImports.set(local, this.adapter.hookName);
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
});
|
|
1972
|
-
}
|
|
1973
|
-
},
|
|
1974
|
-
// Track destructured t from useVocoder()
|
|
1975
|
-
VariableDeclarator: (path) => {
|
|
1976
|
-
const init2 = path.node.init;
|
|
1977
|
-
if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === this.adapter.hookName && path.node.id.type === "ObjectPattern") {
|
|
1978
|
-
path.node.id.properties.forEach((prop) => {
|
|
1979
|
-
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === this.adapter.functionName) {
|
|
1980
|
-
const localName = prop.value.type === "Identifier" ? prop.value.name : this.adapter.functionName;
|
|
1981
|
-
tFunctionNames.add(localName);
|
|
1982
|
-
}
|
|
1983
|
-
});
|
|
1984
|
-
}
|
|
1985
|
-
},
|
|
1986
|
-
// Find bare JSX text
|
|
1987
|
-
JSXText: (path) => {
|
|
1988
|
-
const text = path.node.value;
|
|
1989
|
-
const trimmed = text.trim();
|
|
1990
|
-
if (!trimmed) return;
|
|
1991
|
-
const ancestors = path.getAncestry().map((a) => a.node);
|
|
1992
|
-
if (this.adapter.isAlreadyWrapped(ancestors, vocoderImports)) return;
|
|
1993
|
-
const classification = classifyString(trimmed, "jsx-text", {
|
|
1994
|
-
isInsideComponent: true
|
|
1995
|
-
});
|
|
1996
|
-
if (classification.translatable) {
|
|
1997
|
-
candidates.push({
|
|
1998
|
-
file: filePath,
|
|
1999
|
-
line: path.node.loc?.start.line || 0,
|
|
2000
|
-
column: path.node.loc?.start.column || 0,
|
|
2001
|
-
text: trimmed,
|
|
2002
|
-
confidence: classification.confidence,
|
|
2003
|
-
strategy: "T-component",
|
|
2004
|
-
context: "jsx-text",
|
|
2005
|
-
reason: classification.reason
|
|
2006
|
-
});
|
|
2007
|
-
}
|
|
2008
|
-
},
|
|
2009
|
-
// Find translatable JSX attributes
|
|
2010
|
-
JSXAttribute: (path) => {
|
|
2011
|
-
const attrName = path.node.name?.name;
|
|
2012
|
-
if (!attrName) return;
|
|
2013
|
-
const value = path.node.value;
|
|
2014
|
-
if (!value) return;
|
|
2015
|
-
let text = null;
|
|
2016
|
-
let context = "jsx-attribute";
|
|
2017
|
-
if (value.type === "StringLiteral") {
|
|
2018
|
-
text = value.value;
|
|
2019
|
-
} else if (value.type === "JSXExpressionContainer" && value.expression.type === "StringLiteral") {
|
|
2020
|
-
text = value.expression.value;
|
|
2021
|
-
}
|
|
2022
|
-
if (!text || !text.trim()) return;
|
|
2023
|
-
if (value.type === "JSXExpressionContainer" && value.expression.type === "CallExpression") {
|
|
2024
|
-
if (this.adapter.isAlreadyWrappedCall(value.expression, tFunctionNames)) return;
|
|
2025
|
-
}
|
|
2026
|
-
const classification = classifyString(text.trim(), context, {
|
|
2027
|
-
attributeName: attrName,
|
|
2028
|
-
isInsideComponent: true
|
|
2029
|
-
});
|
|
2030
|
-
if (classification.translatable) {
|
|
2031
|
-
candidates.push({
|
|
2032
|
-
file: filePath,
|
|
2033
|
-
line: path.node.loc?.start.line || 0,
|
|
2034
|
-
column: path.node.loc?.start.column || 0,
|
|
2035
|
-
text: text.trim(),
|
|
2036
|
-
confidence: classification.confidence,
|
|
2037
|
-
strategy: "t-function",
|
|
2038
|
-
context,
|
|
2039
|
-
reason: classification.reason
|
|
2040
|
-
});
|
|
2041
|
-
}
|
|
2042
|
-
},
|
|
2043
|
-
// Find string literals in non-JSX contexts
|
|
2044
|
-
StringLiteral: (path) => {
|
|
2045
|
-
if (path.parent.type === "ImportDeclaration") return;
|
|
2046
|
-
if (path.parent.type === "ExportDeclaration") return;
|
|
2047
|
-
if (path.parent.type === "JSXAttribute") return;
|
|
2048
|
-
if (path.parent.type === "JSXExpressionContainer" && path.parentPath?.parent?.type === "JSXAttribute") return;
|
|
2049
|
-
if (path.parent.type === "JSXExpressionContainer") return;
|
|
2050
|
-
if (path.parent.type === "ObjectProperty" && path.parent.key === path.node) return;
|
|
2051
|
-
if (path.parent.type === "TSLiteralType") return;
|
|
2052
|
-
if (isInsideTCall(path, tFunctionNames)) return;
|
|
2053
|
-
const text = path.node.value;
|
|
2054
|
-
if (!text.trim()) return;
|
|
2055
|
-
const callExpr = getEnclosingCallExpression(path);
|
|
2056
|
-
const parentType = path.parent.type;
|
|
2057
|
-
const classification = classifyString(text.trim(), "string-literal", {
|
|
2058
|
-
parentType,
|
|
2059
|
-
isInsideCallExpression: callExpr,
|
|
2060
|
-
isInsideComponent: false
|
|
2061
|
-
});
|
|
2062
|
-
let { confidence } = classification;
|
|
2063
|
-
if (parentType === "VariableDeclarator" && path.parent.id?.type === "Identifier") {
|
|
2064
|
-
const varName = path.parent.id.name;
|
|
2065
|
-
if (isTranslatableVarName(varName) && classification.translatable) {
|
|
2066
|
-
confidence = "high";
|
|
2067
|
-
}
|
|
2068
|
-
}
|
|
2069
|
-
if (classification.translatable) {
|
|
2070
|
-
candidates.push({
|
|
2071
|
-
file: filePath,
|
|
2072
|
-
line: path.node.loc?.start.line || 0,
|
|
2073
|
-
column: path.node.loc?.start.column || 0,
|
|
2074
|
-
text: text.trim(),
|
|
2075
|
-
confidence,
|
|
2076
|
-
strategy: "t-function",
|
|
2077
|
-
context: "string-literal",
|
|
2078
|
-
reason: classification.reason
|
|
2079
|
-
});
|
|
2080
|
-
}
|
|
2081
|
-
},
|
|
2082
|
-
// Find template literals
|
|
2083
|
-
TemplateLiteral: (path) => {
|
|
2084
|
-
if (path.parent.type === "ImportDeclaration") return;
|
|
2085
|
-
if (path.parent.type === "TaggedTemplateExpression") return;
|
|
2086
|
-
if (isInsideTCall(path, tFunctionNames)) return;
|
|
2087
|
-
const quasis = path.node.quasis;
|
|
2088
|
-
if (quasis.length === 0) return;
|
|
2089
|
-
const parts = [];
|
|
2090
|
-
for (let i = 0; i < quasis.length; i++) {
|
|
2091
|
-
const quasi = quasis[i];
|
|
2092
|
-
parts.push(quasi.value.raw);
|
|
2093
|
-
if (i < path.node.expressions.length) {
|
|
2094
|
-
const expr = path.node.expressions[i];
|
|
2095
|
-
if (expr.type === "Identifier") {
|
|
2096
|
-
parts.push(`{${expr.name}}`);
|
|
2097
|
-
} else {
|
|
2098
|
-
parts.push("{value}");
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
const text = parts.join("").trim();
|
|
2103
|
-
if (!text) return;
|
|
2104
|
-
const callExpr = getEnclosingCallExpression(path);
|
|
2105
|
-
const parentType = path.parent.type;
|
|
2106
|
-
const classification = classifyString(text, "template-literal", {
|
|
2107
|
-
parentType,
|
|
2108
|
-
isInsideCallExpression: callExpr,
|
|
2109
|
-
isInsideComponent: false
|
|
2110
|
-
});
|
|
2111
|
-
if (classification.translatable) {
|
|
2112
|
-
candidates.push({
|
|
2113
|
-
file: filePath,
|
|
2114
|
-
line: path.node.loc?.start.line || 0,
|
|
2115
|
-
column: path.node.loc?.start.column || 0,
|
|
2116
|
-
text,
|
|
2117
|
-
confidence: classification.confidence,
|
|
2118
|
-
strategy: "t-function",
|
|
2119
|
-
context: "template-literal",
|
|
2120
|
-
reason: classification.reason
|
|
2121
|
-
});
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
});
|
|
2125
|
-
return candidates;
|
|
2126
|
-
}
|
|
2127
|
-
};
|
|
2128
|
-
function isInsideTCall(path, tNames) {
|
|
2129
|
-
let current = path.parentPath;
|
|
2130
|
-
while (current) {
|
|
2131
|
-
if (current.node.type === "CallExpression") {
|
|
2132
|
-
const callee = current.node.callee;
|
|
2133
|
-
if (callee.type === "Identifier" && tNames.has(callee.name)) {
|
|
2134
|
-
return true;
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
current = current.parentPath;
|
|
2138
|
-
}
|
|
2139
|
-
return false;
|
|
2140
|
-
}
|
|
2141
|
-
function getEnclosingCallExpression(path) {
|
|
2142
|
-
let current = path.parentPath;
|
|
2143
|
-
while (current) {
|
|
2144
|
-
if (current.node.type === "CallExpression") {
|
|
2145
|
-
const callee = current.node.callee;
|
|
2146
|
-
if (callee.type === "Identifier") {
|
|
2147
|
-
return callee.name;
|
|
2148
|
-
}
|
|
2149
|
-
if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
|
|
2150
|
-
return `${callee.object.name}.${callee.property.name}`;
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
if (current.node.type === "NewExpression") {
|
|
2154
|
-
const callee = current.node.callee;
|
|
2155
|
-
if (callee.type === "Identifier") {
|
|
2156
|
-
return callee.name;
|
|
2157
|
-
}
|
|
2158
|
-
}
|
|
2159
|
-
current = current.parentPath;
|
|
2160
|
-
}
|
|
2161
|
-
return void 0;
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
// src/utils/wrap/transformer.ts
|
|
2165
|
-
import * as recast from "recast";
|
|
2166
|
-
import { parse as babelParse } from "@babel/parser";
|
|
2167
|
-
var babelParser = {
|
|
2168
|
-
parse(source) {
|
|
2169
|
-
return babelParse(source, {
|
|
2170
|
-
sourceType: "module",
|
|
2171
|
-
plugins: ["jsx", "typescript"],
|
|
2172
|
-
tokens: true
|
|
2173
|
-
});
|
|
2174
|
-
}
|
|
2175
|
-
};
|
|
2176
|
-
var StringTransformer = class {
|
|
2177
|
-
constructor(adapter) {
|
|
2178
|
-
this.adapter = adapter;
|
|
2179
|
-
}
|
|
2180
|
-
/**
|
|
2181
|
-
* Transform a file by wrapping the given candidates.
|
|
2182
|
-
* Returns the transformed source code.
|
|
2183
|
-
*/
|
|
2184
|
-
transform(code, candidates, filePath = "<input>") {
|
|
2185
|
-
const ast = recast.parse(code, { parser: babelParser });
|
|
2186
|
-
const b = recast.types.builders;
|
|
2187
|
-
const wrapped = [];
|
|
2188
|
-
const skipped = [];
|
|
2189
|
-
const usedStrategies = /* @__PURE__ */ new Set();
|
|
2190
|
-
const componentsNeedingHook = /* @__PURE__ */ new Set();
|
|
2191
|
-
const candidatesByLocation = /* @__PURE__ */ new Map();
|
|
2192
|
-
for (const c of candidates) {
|
|
2193
|
-
candidatesByLocation.set(`${c.line}:${c.column}`, c);
|
|
2194
|
-
}
|
|
2195
|
-
let existingImportDecl = null;
|
|
2196
|
-
const existingSpecifiers = /* @__PURE__ */ new Set();
|
|
2197
|
-
const adapter = this.adapter;
|
|
2198
|
-
recast.visit(ast, {
|
|
2199
|
-
visitImportDeclaration(path) {
|
|
2200
|
-
const source = path.node.source.value;
|
|
2201
|
-
if (source === adapter.importSource) {
|
|
2202
|
-
existingImportDecl = path;
|
|
2203
|
-
for (const spec of path.node.specifiers || []) {
|
|
2204
|
-
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") {
|
|
2205
|
-
existingSpecifiers.add(spec.imported.name);
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
}
|
|
2209
|
-
this.traverse(path);
|
|
2210
|
-
},
|
|
2211
|
-
visitJSXText(path) {
|
|
2212
|
-
const loc = path.node.loc;
|
|
2213
|
-
if (!loc) {
|
|
2214
|
-
this.traverse(path);
|
|
2215
|
-
return;
|
|
2216
|
-
}
|
|
2217
|
-
const key = `${loc.start.line}:${loc.start.column}`;
|
|
2218
|
-
const candidate = candidatesByLocation.get(key);
|
|
2219
|
-
if (!candidate || candidate.strategy !== "T-component") {
|
|
2220
|
-
this.traverse(path);
|
|
2221
|
-
return;
|
|
2222
|
-
}
|
|
2223
|
-
const tOpen = b.jsxOpeningElement(
|
|
2224
|
-
b.jsxIdentifier(adapter.componentName),
|
|
2225
|
-
[]
|
|
2226
|
-
);
|
|
2227
|
-
const tClose = b.jsxClosingElement(
|
|
2228
|
-
b.jsxIdentifier(adapter.componentName)
|
|
2229
|
-
);
|
|
2230
|
-
const tElement = b.jsxElement(
|
|
2231
|
-
tOpen,
|
|
2232
|
-
tClose,
|
|
2233
|
-
[b.jsxText(candidate.text)]
|
|
2234
|
-
);
|
|
2235
|
-
path.replace(tElement);
|
|
2236
|
-
wrapped.push(candidate);
|
|
2237
|
-
usedStrategies.add("T-component");
|
|
2238
|
-
candidatesByLocation.delete(key);
|
|
2239
|
-
return false;
|
|
2240
|
-
},
|
|
2241
|
-
visitJSXAttribute(path) {
|
|
2242
|
-
const loc = path.node.loc;
|
|
2243
|
-
if (!loc) {
|
|
2244
|
-
this.traverse(path);
|
|
2245
|
-
return;
|
|
2246
|
-
}
|
|
2247
|
-
const key = `${loc.start.line}:${loc.start.column}`;
|
|
2248
|
-
const candidate = candidatesByLocation.get(key);
|
|
2249
|
-
if (!candidate || candidate.strategy !== "t-function") {
|
|
2250
|
-
this.traverse(path);
|
|
2251
|
-
return;
|
|
2252
|
-
}
|
|
2253
|
-
const value = path.node.value;
|
|
2254
|
-
if (!value) {
|
|
2255
|
-
this.traverse(path);
|
|
2256
|
-
return;
|
|
2257
|
-
}
|
|
2258
|
-
const tCall = b.callExpression(
|
|
2259
|
-
b.identifier(adapter.functionName),
|
|
2260
|
-
[b.stringLiteral(candidate.text)]
|
|
2261
|
-
);
|
|
2262
|
-
const exprContainer = b.jsxExpressionContainer(tCall);
|
|
2263
|
-
path.node.value = exprContainer;
|
|
2264
|
-
const componentFunc = findEnclosingComponent(path);
|
|
2265
|
-
if (componentFunc) {
|
|
2266
|
-
componentsNeedingHook.add(componentFunc);
|
|
2267
|
-
}
|
|
2268
|
-
wrapped.push(candidate);
|
|
2269
|
-
usedStrategies.add("t-function");
|
|
2270
|
-
candidatesByLocation.delete(key);
|
|
2271
|
-
this.traverse(path);
|
|
2272
|
-
},
|
|
2273
|
-
visitStringLiteral(path) {
|
|
2274
|
-
const loc = path.node.loc;
|
|
2275
|
-
if (!loc) {
|
|
2276
|
-
this.traverse(path);
|
|
2277
|
-
return;
|
|
2278
|
-
}
|
|
2279
|
-
const key = `${loc.start.line}:${loc.start.column}`;
|
|
2280
|
-
const candidate = candidatesByLocation.get(key);
|
|
2281
|
-
if (!candidate || candidate.strategy !== "t-function") {
|
|
2282
|
-
this.traverse(path);
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
if (path.parent.node.type === "JSXAttribute") {
|
|
2286
|
-
this.traverse(path);
|
|
2287
|
-
return;
|
|
2288
|
-
}
|
|
2289
|
-
const tCall = b.callExpression(
|
|
2290
|
-
b.identifier(adapter.functionName),
|
|
2291
|
-
[b.stringLiteral(candidate.text)]
|
|
2292
|
-
);
|
|
2293
|
-
path.replace(tCall);
|
|
2294
|
-
const componentFunc = findEnclosingComponent(path);
|
|
2295
|
-
if (componentFunc) {
|
|
2296
|
-
componentsNeedingHook.add(componentFunc);
|
|
2297
|
-
}
|
|
2298
|
-
wrapped.push(candidate);
|
|
2299
|
-
usedStrategies.add("t-function");
|
|
2300
|
-
candidatesByLocation.delete(key);
|
|
2301
|
-
return false;
|
|
2302
|
-
}
|
|
2303
|
-
});
|
|
2304
|
-
for (const candidate of candidatesByLocation.values()) {
|
|
2305
|
-
skipped.push(candidate);
|
|
2306
|
-
}
|
|
2307
|
-
if (componentsNeedingHook.size > 0) {
|
|
2308
|
-
this.injectUseVocoderHooks(ast, componentsNeedingHook, b);
|
|
2309
|
-
}
|
|
2310
|
-
this.manageImports(ast, usedStrategies, existingImportDecl, existingSpecifiers, componentsNeedingHook.size > 0, b);
|
|
2311
|
-
const output = recast.print(ast).code;
|
|
2312
|
-
return {
|
|
2313
|
-
file: filePath,
|
|
2314
|
-
output,
|
|
2315
|
-
wrappedCount: wrapped.length,
|
|
2316
|
-
wrapped,
|
|
2317
|
-
skipped
|
|
2318
|
-
};
|
|
2319
|
-
}
|
|
2320
|
-
/**
|
|
2321
|
-
* Inject `const { t } = useVocoder();` at the top of component functions.
|
|
2322
|
-
*/
|
|
2323
|
-
injectUseVocoderHooks(ast, componentFuncs, b) {
|
|
2324
|
-
const adapterFunctionName = this.adapter.functionName;
|
|
2325
|
-
const adapterHookName = this.adapter.hookName;
|
|
2326
|
-
const buildHookDecl = () => b.variableDeclaration("const", [
|
|
2327
|
-
b.variableDeclarator(
|
|
2328
|
-
b.objectPattern([
|
|
2329
|
-
b.property.from({
|
|
2330
|
-
kind: "init",
|
|
2331
|
-
key: b.identifier(adapterFunctionName),
|
|
2332
|
-
value: b.identifier(adapterFunctionName),
|
|
2333
|
-
shorthand: true
|
|
2334
|
-
})
|
|
2335
|
-
]),
|
|
2336
|
-
b.callExpression(b.identifier(adapterHookName), [])
|
|
2337
|
-
)
|
|
2338
|
-
]);
|
|
2339
|
-
recast.visit(ast, {
|
|
2340
|
-
visitFunction(path) {
|
|
2341
|
-
if (componentFuncs.has(path.node)) {
|
|
2342
|
-
const body = path.node.body;
|
|
2343
|
-
if (body.type === "BlockStatement") {
|
|
2344
|
-
const alreadyHasHook = body.body.some((stmt) => {
|
|
2345
|
-
if (stmt.type !== "VariableDeclaration") return false;
|
|
2346
|
-
return stmt.declarations.some(
|
|
2347
|
-
(decl) => decl.init?.type === "CallExpression" && decl.init.callee?.type === "Identifier" && decl.init.callee.name === "useVocoder"
|
|
2348
|
-
);
|
|
2349
|
-
});
|
|
2350
|
-
if (!alreadyHasHook) {
|
|
2351
|
-
body.body.unshift(buildHookDecl());
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
this.traverse(path);
|
|
2356
|
-
},
|
|
2357
|
-
visitArrowFunctionExpression(path) {
|
|
2358
|
-
if (componentFuncs.has(path.node)) {
|
|
2359
|
-
const body = path.node.body;
|
|
2360
|
-
if (body.type === "BlockStatement") {
|
|
2361
|
-
const alreadyHasHook = body.body.some((stmt) => {
|
|
2362
|
-
if (stmt.type !== "VariableDeclaration") return false;
|
|
2363
|
-
return stmt.declarations.some(
|
|
2364
|
-
(decl) => decl.init?.type === "CallExpression" && decl.init.callee?.type === "Identifier" && decl.init.callee.name === "useVocoder"
|
|
2365
|
-
);
|
|
2366
|
-
});
|
|
2367
|
-
if (!alreadyHasHook) {
|
|
2368
|
-
body.body.unshift(buildHookDecl());
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
}
|
|
2372
|
-
this.traverse(path);
|
|
2373
|
-
}
|
|
2374
|
-
});
|
|
2375
|
-
}
|
|
2376
|
-
/**
|
|
2377
|
-
* Add or update @vocoder/react imports.
|
|
2378
|
-
*/
|
|
2379
|
-
manageImports(ast, usedStrategies, existingImportPath, existingSpecifiers, needsHook, b) {
|
|
2380
|
-
if (usedStrategies.size === 0) return;
|
|
2381
|
-
const neededSpecifiers = /* @__PURE__ */ new Set();
|
|
2382
|
-
if (usedStrategies.has("T-component")) {
|
|
2383
|
-
neededSpecifiers.add(this.adapter.componentName);
|
|
2384
|
-
}
|
|
2385
|
-
if (usedStrategies.has("t-function") && needsHook) {
|
|
2386
|
-
neededSpecifiers.add(this.adapter.hookName);
|
|
2387
|
-
}
|
|
2388
|
-
const missingSpecifiers = [];
|
|
2389
|
-
for (const spec of neededSpecifiers) {
|
|
2390
|
-
if (!existingSpecifiers.has(spec)) {
|
|
2391
|
-
missingSpecifiers.push(spec);
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
if (missingSpecifiers.length === 0) return;
|
|
2395
|
-
if (existingImportPath) {
|
|
2396
|
-
for (const name of missingSpecifiers) {
|
|
2397
|
-
const specifier = b.importSpecifier(b.identifier(name), b.identifier(name));
|
|
2398
|
-
existingImportPath.node.specifiers.push(specifier);
|
|
2399
|
-
}
|
|
2400
|
-
} else {
|
|
2401
|
-
const specifiers = missingSpecifiers.map(
|
|
2402
|
-
(name) => b.importSpecifier(b.identifier(name), b.identifier(name))
|
|
2403
|
-
);
|
|
2404
|
-
const importDecl = b.importDeclaration(
|
|
2405
|
-
specifiers,
|
|
2406
|
-
b.stringLiteral(this.adapter.importSource)
|
|
2407
|
-
);
|
|
2408
|
-
const body = ast.program.body;
|
|
2409
|
-
let lastImportIndex = -1;
|
|
2410
|
-
for (let i = 0; i < body.length; i++) {
|
|
2411
|
-
if (body[i].type === "ImportDeclaration") {
|
|
2412
|
-
lastImportIndex = i;
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
if (lastImportIndex >= 0) {
|
|
2416
|
-
body.splice(lastImportIndex + 1, 0, importDecl);
|
|
2417
|
-
} else {
|
|
2418
|
-
body.unshift(importDecl);
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
};
|
|
2423
|
-
function findEnclosingComponent(path) {
|
|
2424
|
-
let current = path.parent;
|
|
2425
|
-
while (current) {
|
|
2426
|
-
const node = current.node;
|
|
2427
|
-
if (node.type === "FunctionDeclaration" && node.id?.name) {
|
|
2428
|
-
const name = node.id.name;
|
|
2429
|
-
if (/^[A-Z]/.test(name)) return node;
|
|
2430
|
-
}
|
|
2431
|
-
if (node.type === "ArrowFunctionExpression") {
|
|
2432
|
-
const parent = current.parent?.node;
|
|
2433
|
-
if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
|
|
2434
|
-
const name = parent.id.name;
|
|
2435
|
-
if (/^[A-Z]/.test(name)) return node;
|
|
2436
|
-
}
|
|
2437
|
-
}
|
|
2438
|
-
if (node.type === "FunctionExpression") {
|
|
2439
|
-
const parent = current.parent?.node;
|
|
2440
|
-
if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
|
|
2441
|
-
const name = parent.id.name;
|
|
2442
|
-
if (/^[A-Z]/.test(name)) return node;
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
2445
|
-
current = current.parent;
|
|
2446
|
-
}
|
|
2447
|
-
return null;
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
// src/utils/wrap/adapters/react.ts
|
|
2451
|
-
var reactAdapter = {
|
|
2452
|
-
name: "react",
|
|
2453
|
-
extensions: [".tsx", ".jsx", ".ts", ".js"],
|
|
2454
|
-
importSource: "@vocoder/react",
|
|
2455
|
-
componentName: "T",
|
|
2456
|
-
functionName: "t",
|
|
2457
|
-
hookName: "useVocoder",
|
|
2458
|
-
translatableAttributes: [
|
|
2459
|
-
"title",
|
|
2460
|
-
"placeholder",
|
|
2461
|
-
"alt",
|
|
2462
|
-
"aria-label",
|
|
2463
|
-
"aria-description",
|
|
2464
|
-
"aria-placeholder",
|
|
2465
|
-
"aria-roledescription",
|
|
2466
|
-
"aria-valuetext",
|
|
2467
|
-
"label",
|
|
2468
|
-
"description",
|
|
2469
|
-
"message",
|
|
2470
|
-
"heading",
|
|
2471
|
-
"caption",
|
|
2472
|
-
"helperText",
|
|
2473
|
-
"errorMessage",
|
|
2474
|
-
"successMessage",
|
|
2475
|
-
"tooltip"
|
|
2476
|
-
],
|
|
2477
|
-
nonTranslatableAttributes: [
|
|
2478
|
-
"className",
|
|
2479
|
-
"class",
|
|
2480
|
-
"href",
|
|
2481
|
-
"src",
|
|
2482
|
-
"id",
|
|
2483
|
-
"key",
|
|
2484
|
-
"ref",
|
|
2485
|
-
"style",
|
|
2486
|
-
"data-testid",
|
|
2487
|
-
"data-cy",
|
|
2488
|
-
"data-test",
|
|
2489
|
-
"type",
|
|
2490
|
-
"name",
|
|
2491
|
-
"value",
|
|
2492
|
-
"action",
|
|
2493
|
-
"method",
|
|
2494
|
-
"encType",
|
|
2495
|
-
"target",
|
|
2496
|
-
"rel",
|
|
2497
|
-
"role",
|
|
2498
|
-
"tabIndex",
|
|
2499
|
-
"htmlFor",
|
|
2500
|
-
"for",
|
|
2501
|
-
"width",
|
|
2502
|
-
"height",
|
|
2503
|
-
"viewBox",
|
|
2504
|
-
"xmlns",
|
|
2505
|
-
"fill",
|
|
2506
|
-
"stroke"
|
|
2507
|
-
],
|
|
2508
|
-
isAlreadyWrapped(ancestors, imports) {
|
|
2509
|
-
for (const ancestor of ancestors) {
|
|
2510
|
-
if (ancestor.type === "JSXElement") {
|
|
2511
|
-
const opening = ancestor.openingElement;
|
|
2512
|
-
if (opening && opening.name && opening.name.type === "JSXIdentifier") {
|
|
2513
|
-
const tagName = opening.name.name;
|
|
2514
|
-
if (imports.has(tagName) && imports.get(tagName) === "T") {
|
|
2515
|
-
return true;
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
return false;
|
|
2521
|
-
},
|
|
2522
|
-
isAlreadyWrappedCall(node, tNames) {
|
|
2523
|
-
if (node.type === "CallExpression") {
|
|
2524
|
-
const callee = node.callee;
|
|
2525
|
-
if (callee.type === "Identifier" && tNames.has(callee.name)) {
|
|
2526
|
-
return true;
|
|
2527
|
-
}
|
|
2528
|
-
}
|
|
2529
|
-
return false;
|
|
2530
|
-
},
|
|
2531
|
-
getRequiredImports(strategies) {
|
|
2532
|
-
const specifiers = [];
|
|
2533
|
-
if (strategies.has("T-component")) {
|
|
2534
|
-
specifiers.push("T");
|
|
2535
|
-
}
|
|
2536
|
-
if (strategies.has("t-function")) {
|
|
2537
|
-
specifiers.push("useVocoder");
|
|
2538
|
-
}
|
|
2539
|
-
return { specifiers, source: "@vocoder/react" };
|
|
2827
|
+
// src/commands/whoami.ts
|
|
2828
|
+
import * as p8 from "@clack/prompts";
|
|
2829
|
+
import chalk9 from "chalk";
|
|
2830
|
+
async function whoami(options = {}) {
|
|
2831
|
+
const stored = readAuthData();
|
|
2832
|
+
if (!stored) {
|
|
2833
|
+
p8.log.info("Not logged in. Run `vocoder init` to authenticate.");
|
|
2834
|
+
return 1;
|
|
2540
2835
|
}
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
// src/commands/wrap.ts
|
|
2544
|
-
var CONFIDENCE_ORDER = ["high", "medium", "low"];
|
|
2545
|
-
function meetsConfidenceThreshold(candidate, threshold) {
|
|
2546
|
-
return CONFIDENCE_ORDER.indexOf(candidate) <= CONFIDENCE_ORDER.indexOf(threshold);
|
|
2547
|
-
}
|
|
2548
|
-
async function wrap(options = {}) {
|
|
2549
|
-
const startTime = Date.now();
|
|
2550
|
-
const projectRoot = process.cwd();
|
|
2551
|
-
const confidenceThreshold = options.confidence || "high";
|
|
2552
|
-
p3.intro("Vocoder Wrap");
|
|
2553
|
-
const spinner4 = p3.spinner();
|
|
2836
|
+
const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
|
|
2837
|
+
const api = new VocoderAPI({ apiUrl, apiKey: "" });
|
|
2554
2838
|
try {
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
spinner4.stop("No unwrapped strings found");
|
|
2560
|
-
p3.log.info("All user-facing strings appear to be wrapped already.");
|
|
2561
|
-
p3.outro("");
|
|
2562
|
-
return 0;
|
|
2563
|
-
}
|
|
2564
|
-
spinner4.stop(
|
|
2565
|
-
`Found ${chalk4.cyan(allCandidates.length)} candidate strings`
|
|
2566
|
-
);
|
|
2567
|
-
const filtered = allCandidates.filter(
|
|
2568
|
-
(c) => meetsConfidenceThreshold(c.confidence, confidenceThreshold)
|
|
2569
|
-
);
|
|
2570
|
-
if (filtered.length === 0) {
|
|
2571
|
-
p3.log.warn(
|
|
2572
|
-
`No strings meet the ${chalk4.bold(confidenceThreshold)} confidence threshold.`
|
|
2573
|
-
);
|
|
2574
|
-
p3.log.info("Try --confidence medium or --confidence low to see more candidates.");
|
|
2575
|
-
p3.outro("");
|
|
2576
|
-
return 0;
|
|
2577
|
-
}
|
|
2578
|
-
p3.log.info(
|
|
2579
|
-
`${filtered.length} strings meet ${chalk4.bold(confidenceThreshold)} confidence threshold`
|
|
2580
|
-
);
|
|
2581
|
-
const byFile = /* @__PURE__ */ new Map();
|
|
2582
|
-
for (const c of filtered) {
|
|
2583
|
-
const existing = byFile.get(c.file) || [];
|
|
2584
|
-
existing.push(c);
|
|
2585
|
-
byFile.set(c.file, existing);
|
|
2586
|
-
}
|
|
2587
|
-
if (options.dryRun) {
|
|
2588
|
-
const lines = [];
|
|
2589
|
-
for (const [file, candidates] of byFile) {
|
|
2590
|
-
const relPath = relative2(projectRoot, file);
|
|
2591
|
-
lines.push(chalk4.bold(relPath));
|
|
2592
|
-
for (const c of candidates) {
|
|
2593
|
-
const confidenceColor = c.confidence === "high" ? chalk4.green : c.confidence === "medium" ? chalk4.yellow : chalk4.red;
|
|
2594
|
-
const strategyLabel = c.strategy === "T-component" ? "<T>" : "t()";
|
|
2595
|
-
lines.push(
|
|
2596
|
-
` ${chalk4.dim(`L${c.line}`)} ${confidenceColor(`[${c.confidence}]`)} ${chalk4.cyan(strategyLabel)} "${truncate(c.text, 50)}"`
|
|
2597
|
-
);
|
|
2598
|
-
if (options.verbose) {
|
|
2599
|
-
lines.push(chalk4.dim(` ${c.reason}`));
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
lines.push("");
|
|
2603
|
-
}
|
|
2604
|
-
lines.push(summarizeCandidates(filtered));
|
|
2605
|
-
p3.note(lines.join("\n"), "Dry run \u2014 would wrap");
|
|
2606
|
-
p3.outro("Run without --dry-run to apply changes.");
|
|
2607
|
-
return 0;
|
|
2608
|
-
}
|
|
2609
|
-
let accepted;
|
|
2610
|
-
if (options.interactive) {
|
|
2611
|
-
accepted = await interactiveConfirm(byFile, projectRoot);
|
|
2612
|
-
if (accepted.length === 0) {
|
|
2613
|
-
p3.log.warn("No strings selected for wrapping.");
|
|
2614
|
-
p3.outro("");
|
|
2615
|
-
return 0;
|
|
2616
|
-
}
|
|
2617
|
-
} else {
|
|
2618
|
-
accepted = filtered;
|
|
2619
|
-
}
|
|
2620
|
-
spinner4.start("Wrapping strings");
|
|
2621
|
-
const transformer = new StringTransformer(reactAdapter);
|
|
2622
|
-
let totalWrapped = 0;
|
|
2623
|
-
let filesModified = 0;
|
|
2624
|
-
const acceptedByFile = /* @__PURE__ */ new Map();
|
|
2625
|
-
for (const c of accepted) {
|
|
2626
|
-
const existing = acceptedByFile.get(c.file) || [];
|
|
2627
|
-
existing.push(c);
|
|
2628
|
-
acceptedByFile.set(c.file, existing);
|
|
2629
|
-
}
|
|
2630
|
-
for (const [file, candidates] of acceptedByFile) {
|
|
2631
|
-
const code = readFileSync4(file, "utf-8");
|
|
2632
|
-
const result = transformer.transform(code, candidates, file);
|
|
2633
|
-
if (result.wrappedCount > 0) {
|
|
2634
|
-
writeFileSync2(file, result.output, "utf-8");
|
|
2635
|
-
totalWrapped += result.wrappedCount;
|
|
2636
|
-
filesModified++;
|
|
2637
|
-
}
|
|
2638
|
-
if (options.verbose && result.skipped.length > 0) {
|
|
2639
|
-
const relPath = relative2(projectRoot, file);
|
|
2640
|
-
p3.log.info(`Skipped ${result.skipped.length} strings in ${relPath}`);
|
|
2641
|
-
}
|
|
2839
|
+
const info = await api.getCliUserInfo(stored.token);
|
|
2840
|
+
p8.log.info(`Logged in as ${chalk9.bold(info.email)}`);
|
|
2841
|
+
if (info.name) {
|
|
2842
|
+
p8.log.info(`Name: ${info.name}`);
|
|
2642
2843
|
}
|
|
2643
|
-
|
|
2644
|
-
`Wrapped ${chalk4.cyan(totalWrapped)} strings across ${chalk4.cyan(filesModified)} files`
|
|
2645
|
-
);
|
|
2646
|
-
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2647
|
-
p3.outro(`Done! (${duration}s)`);
|
|
2648
|
-
p3.log.info("Next steps:");
|
|
2649
|
-
p3.log.info(" 1. Review the changes (git diff)");
|
|
2650
|
-
p3.log.info(" 2. Run your tests to verify nothing broke");
|
|
2651
|
-
p3.log.info(' 3. Run "vocoder sync" to extract and translate');
|
|
2844
|
+
p8.log.info(`API: ${apiUrl}`);
|
|
2652
2845
|
return 0;
|
|
2653
|
-
} catch
|
|
2654
|
-
|
|
2655
|
-
if (error instanceof Error) {
|
|
2656
|
-
p3.log.error(error.message);
|
|
2657
|
-
if (options.verbose) {
|
|
2658
|
-
p3.log.info(`Full error: ${error.stack ?? error}`);
|
|
2659
|
-
}
|
|
2660
|
-
}
|
|
2846
|
+
} catch {
|
|
2847
|
+
p8.log.error("Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate.");
|
|
2661
2848
|
return 1;
|
|
2662
2849
|
}
|
|
2663
2850
|
}
|
|
2664
|
-
async function interactiveConfirm(byFile, projectRoot) {
|
|
2665
|
-
const accepted = [];
|
|
2666
|
-
p3.log.info("Interactive mode \u2014 confirm each string:");
|
|
2667
|
-
for (const [file, candidates] of byFile) {
|
|
2668
|
-
const relPath = relative2(projectRoot, file);
|
|
2669
|
-
p3.log.step(chalk4.bold(relPath));
|
|
2670
|
-
let skipFile = false;
|
|
2671
|
-
for (const c of candidates) {
|
|
2672
|
-
if (skipFile) break;
|
|
2673
|
-
const strategyLabel = c.strategy === "T-component" ? "<T>" : "t()";
|
|
2674
|
-
const label = `L${c.line} ${strategyLabel} "${truncate(c.text, 50)}"`;
|
|
2675
|
-
const action = await p3.select({
|
|
2676
|
-
message: label,
|
|
2677
|
-
options: [
|
|
2678
|
-
{ value: "yes", label: "Yes, wrap this string" },
|
|
2679
|
-
{ value: "no", label: "No, skip" },
|
|
2680
|
-
{ value: "all", label: "Accept all remaining" },
|
|
2681
|
-
{ value: "skip", label: "Skip this file" },
|
|
2682
|
-
{ value: "quit", label: "Quit" }
|
|
2683
|
-
]
|
|
2684
|
-
});
|
|
2685
|
-
if (p3.isCancel(action) || action === "quit") {
|
|
2686
|
-
return accepted;
|
|
2687
|
-
}
|
|
2688
|
-
if (action === "yes") {
|
|
2689
|
-
accepted.push(c);
|
|
2690
|
-
} else if (action === "all") {
|
|
2691
|
-
accepted.push(c);
|
|
2692
|
-
const remaining = candidates.slice(candidates.indexOf(c) + 1);
|
|
2693
|
-
accepted.push(...remaining);
|
|
2694
|
-
for (const [, moreCandidates] of byFile) {
|
|
2695
|
-
if (moreCandidates !== candidates) {
|
|
2696
|
-
accepted.push(...moreCandidates);
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
return accepted;
|
|
2700
|
-
} else if (action === "skip") {
|
|
2701
|
-
skipFile = true;
|
|
2702
|
-
}
|
|
2703
|
-
}
|
|
2704
|
-
}
|
|
2705
|
-
return accepted;
|
|
2706
|
-
}
|
|
2707
|
-
function truncate(text, maxLen) {
|
|
2708
|
-
if (text.length <= maxLen) return text;
|
|
2709
|
-
return text.slice(0, maxLen - 3) + "...";
|
|
2710
|
-
}
|
|
2711
|
-
function summarizeCandidates(candidates) {
|
|
2712
|
-
let high = 0;
|
|
2713
|
-
let medium = 0;
|
|
2714
|
-
let low = 0;
|
|
2715
|
-
let tComponent = 0;
|
|
2716
|
-
let tFunction = 0;
|
|
2717
|
-
for (const c of candidates) {
|
|
2718
|
-
if (c.confidence === "high") high++;
|
|
2719
|
-
else if (c.confidence === "medium") medium++;
|
|
2720
|
-
else low++;
|
|
2721
|
-
if (c.strategy === "T-component") tComponent++;
|
|
2722
|
-
else tFunction++;
|
|
2723
|
-
}
|
|
2724
|
-
const parts = [];
|
|
2725
|
-
if (high > 0) parts.push(chalk4.green(`${high} high`));
|
|
2726
|
-
if (medium > 0) parts.push(chalk4.yellow(`${medium} medium`));
|
|
2727
|
-
if (low > 0) parts.push(chalk4.red(`${low} low`));
|
|
2728
|
-
return `${candidates.length} total (${parts.join(", ")}) | ${tComponent} <T>, ${tFunction} t()`;
|
|
2729
|
-
}
|
|
2730
2851
|
|
|
2731
2852
|
// src/bin.ts
|
|
2732
2853
|
function collect(value, previous = []) {
|
|
@@ -2734,15 +2855,18 @@ function collect(value, previous = []) {
|
|
|
2734
2855
|
}
|
|
2735
2856
|
async function runCommand(command, options) {
|
|
2736
2857
|
const exitCode = await command(options);
|
|
2737
|
-
process.exitCode
|
|
2858
|
+
process.exit(exitCode);
|
|
2738
2859
|
}
|
|
2739
2860
|
var program = new Command();
|
|
2740
|
-
program.name("vocoder").description("Vocoder CLI -
|
|
2741
|
-
program.command("
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2861
|
+
program.name("vocoder").description("Vocoder CLI - Project setup and string extraction").version("0.1.5");
|
|
2862
|
+
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));
|
|
2863
|
+
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) => {
|
|
2864
|
+
const translated = { ...options };
|
|
2865
|
+
if (options.maxWait) translated.maxWaitMs = Number(options.maxWait);
|
|
2866
|
+
if (options.fallback === false) translated.noFallback = true;
|
|
2867
|
+
return runCommand(sync, translated);
|
|
2868
|
+
});
|
|
2869
|
+
program.command("logout").description("Log out and remove stored credentials").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(logout, options));
|
|
2870
|
+
program.command("whoami").description("Show the currently authenticated user").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(whoami, options));
|
|
2747
2871
|
program.parse(process.argv);
|
|
2748
2872
|
//# sourceMappingURL=bin.mjs.map
|