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