@vocoder/cli 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -1,11 +1,320 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ StringExtractor,
4
+ buildInstallCommand,
5
+ detectLocalEcosystem,
6
+ getPackagesToInstall,
7
+ getSetupSnippets
8
+ } from "./chunk-OC5N5C5X.mjs";
2
9
 
3
10
  // src/bin.ts
4
11
  import { Command } from "commander";
5
12
 
6
13
  // src/commands/init.ts
14
+ import * as p5 from "@clack/prompts";
15
+
16
+ // src/utils/auth-store.ts
17
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
18
+ import { homedir } from "os";
19
+ import { dirname, join } from "path";
20
+ function getAuthFilePath() {
21
+ return join(homedir(), ".config", "vocoder", "auth.json");
22
+ }
23
+ function readAuthData() {
24
+ const filePath = getAuthFilePath();
25
+ try {
26
+ const raw = readFileSync(filePath, "utf8");
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || typeof parsed !== "object") return null;
29
+ const data = parsed;
30
+ if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
31
+ return null;
32
+ }
33
+ return {
34
+ token: data.token,
35
+ apiUrl: data.apiUrl,
36
+ userId: data.userId,
37
+ email: data.email,
38
+ name: typeof data.name === "string" ? data.name : null,
39
+ createdAt: data.createdAt
40
+ };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ function writeAuthData(data) {
46
+ const filePath = getAuthFilePath();
47
+ const dir = dirname(filePath);
48
+ mkdirSync(dir, { recursive: true, mode: 448 });
49
+ writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
50
+ }
51
+ function clearAuthData() {
52
+ const filePath = getAuthFilePath();
53
+ try {
54
+ unlinkSync(filePath);
55
+ } catch {
56
+ }
57
+ }
58
+
59
+ // src/utils/github-connect.ts
7
60
  import * as p from "@clack/prompts";
8
61
  import chalk from "chalk";
62
+ import { spawn } from "child_process";
63
+
64
+ // src/utils/local-server.ts
65
+ import { createServer } from "http";
66
+ import { URL as URL2 } from "url";
67
+ function startCallbackServer() {
68
+ return new Promise((resolve2, reject) => {
69
+ let settled = false;
70
+ let callbackResolve = null;
71
+ let callbackReject = null;
72
+ const callbackPromise = new Promise((res, rej) => {
73
+ callbackResolve = res;
74
+ callbackReject = rej;
75
+ });
76
+ const server = createServer((req, res) => {
77
+ if (!req.url) {
78
+ res.writeHead(400);
79
+ res.end();
80
+ return;
81
+ }
82
+ let pathname;
83
+ let params;
84
+ try {
85
+ const parsed = new URL2(req.url, "http://localhost");
86
+ pathname = parsed.pathname;
87
+ params = Object.fromEntries(parsed.searchParams.entries());
88
+ } catch {
89
+ res.writeHead(400);
90
+ res.end("Bad request");
91
+ return;
92
+ }
93
+ if (pathname !== "/callback") {
94
+ res.writeHead(404);
95
+ res.end("Not found");
96
+ return;
97
+ }
98
+ res.writeHead(200, { "Content-Type": "text/html" });
99
+ res.end(
100
+ '<!DOCTYPE html><html><head><title>Authenticated</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;"><h2>Authenticated</h2><p>Return to your terminal to continue. You can close this tab.</p></body></html>'
101
+ );
102
+ if (callbackResolve) {
103
+ callbackResolve(params);
104
+ callbackResolve = null;
105
+ }
106
+ setImmediate(() => server.close());
107
+ });
108
+ server.on("error", (err) => {
109
+ if (!settled) {
110
+ settled = true;
111
+ if (callbackReject) callbackReject(err);
112
+ reject(err);
113
+ }
114
+ });
115
+ server.listen(0, "127.0.0.1", () => {
116
+ if (settled) return;
117
+ settled = true;
118
+ const port = server.address().port;
119
+ resolve2({
120
+ port,
121
+ waitForCallback: () => callbackPromise,
122
+ close: () => server.close()
123
+ });
124
+ });
125
+ });
126
+ }
127
+
128
+ // src/utils/github-connect.ts
129
+ async function tryOpenBrowser(url) {
130
+ if (!process.stdout.isTTY || process.env.CI === "true") {
131
+ return false;
132
+ }
133
+ const platform = process.platform;
134
+ let command;
135
+ let args;
136
+ if (platform === "darwin") {
137
+ command = "open";
138
+ args = [url];
139
+ } else if (platform === "win32") {
140
+ command = "rundll32";
141
+ args = ["url.dll,FileProtocolHandler", url];
142
+ } else {
143
+ command = "xdg-open";
144
+ args = [url];
145
+ }
146
+ return new Promise((resolve2) => {
147
+ try {
148
+ const child = spawn(command, args, {
149
+ detached: true,
150
+ stdio: "ignore",
151
+ windowsHide: true
152
+ });
153
+ let settled = false;
154
+ child.once("spawn", () => {
155
+ if (settled) return;
156
+ settled = true;
157
+ child.unref();
158
+ resolve2(true);
159
+ });
160
+ child.once("error", () => {
161
+ if (settled) return;
162
+ settled = true;
163
+ resolve2(false);
164
+ });
165
+ setTimeout(() => {
166
+ if (settled) return;
167
+ settled = true;
168
+ resolve2(false);
169
+ }, 300);
170
+ } catch {
171
+ resolve2(false);
172
+ }
173
+ });
174
+ }
175
+ async function runGitHubInstallFlow(params) {
176
+ let server = null;
177
+ try {
178
+ server = await startCallbackServer();
179
+ } catch {
180
+ }
181
+ const { installUrl } = await params.api.startCliGitHubInstall(params.userToken, {
182
+ organizationId: params.organizationId,
183
+ callbackPort: server?.port
184
+ });
185
+ p.log.info("Opening GitHub to install the Vocoder App...");
186
+ p.note(installUrl, "Install URL");
187
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
188
+ const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
189
+ if (p.isCancel(shouldOpen)) {
190
+ server?.close();
191
+ return null;
192
+ }
193
+ if (shouldOpen) {
194
+ const opened = await tryOpenBrowser(installUrl);
195
+ if (!opened) {
196
+ p.log.info("Could not open a browser automatically. Use the URL above.");
197
+ }
198
+ }
199
+ }
200
+ const connectSpinner = p.spinner();
201
+ connectSpinner.start("Waiting for GitHub App installation...");
202
+ if (server) {
203
+ try {
204
+ const params_timeout = 15 * 60 * 1e3;
205
+ const callbackParams = await Promise.race([
206
+ server.waitForCallback(),
207
+ new Promise((resolve2) => setTimeout(() => resolve2(null), params_timeout))
208
+ ]);
209
+ server.close();
210
+ if (!callbackParams) {
211
+ connectSpinner.stop("GitHub App installation timed out");
212
+ p.log.error("The installation flow timed out. Run `vocoder init` again.");
213
+ return null;
214
+ }
215
+ if (callbackParams.error) {
216
+ connectSpinner.stop("GitHub App installation failed");
217
+ p.log.error(callbackParams.error);
218
+ return null;
219
+ }
220
+ const { organizationId, connectionLabel, workspace_created } = callbackParams;
221
+ if (!organizationId || !connectionLabel) {
222
+ connectSpinner.stop("GitHub App installation incomplete");
223
+ p.log.error("Missing organization or connection data from callback.");
224
+ return null;
225
+ }
226
+ connectSpinner.stop(`Connected to GitHub as ${chalk.bold(connectionLabel)}`);
227
+ const orgName = workspace_created ? connectionLabel : organizationId;
228
+ return {
229
+ organizationId,
230
+ organizationName: orgName,
231
+ connectionLabel
232
+ };
233
+ } catch {
234
+ server.close();
235
+ connectSpinner.stop("GitHub App installation failed");
236
+ return null;
237
+ }
238
+ }
239
+ connectSpinner.stop("Could not detect GitHub App installation automatically");
240
+ p.log.warn("Complete the installation in your browser, then run `vocoder init` again.");
241
+ return null;
242
+ }
243
+ async function runGitHubDiscoveryFlow(params) {
244
+ let server = null;
245
+ try {
246
+ server = await startCallbackServer();
247
+ } catch {
248
+ }
249
+ const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
250
+ organizationId: params.organizationId,
251
+ callbackPort: server?.port
252
+ });
253
+ p.log.info("Opening GitHub to authorize your account...");
254
+ p.note("Complete authorization in your browser.");
255
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
256
+ const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
257
+ if (p.isCancel(shouldOpen)) {
258
+ server?.close();
259
+ return null;
260
+ }
261
+ if (shouldOpen) {
262
+ const opened = await tryOpenBrowser(oauthUrl);
263
+ if (!opened) {
264
+ p.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
265
+ }
266
+ }
267
+ }
268
+ const oauthSpinner = p.spinner();
269
+ oauthSpinner.start("Waiting for GitHub authorization...");
270
+ if (server) {
271
+ try {
272
+ const timeoutMs = 10 * 60 * 1e3;
273
+ const callbackParams = await Promise.race([
274
+ server.waitForCallback(),
275
+ new Promise((resolve2) => setTimeout(() => resolve2(null), timeoutMs))
276
+ ]);
277
+ server.close();
278
+ if (!callbackParams) {
279
+ oauthSpinner.stop("GitHub authorization timed out");
280
+ return null;
281
+ }
282
+ if (callbackParams.error) {
283
+ oauthSpinner.stop("GitHub authorization failed");
284
+ p.log.error(callbackParams.error);
285
+ return null;
286
+ }
287
+ } catch {
288
+ server.close();
289
+ oauthSpinner.stop("GitHub authorization failed");
290
+ return null;
291
+ }
292
+ }
293
+ oauthSpinner.stop("GitHub account authorized");
294
+ const discoveryResult = await params.api.getCliGitHubDiscovery(params.userToken);
295
+ return discoveryResult.installations;
296
+ }
297
+ async function selectGitHubInstallation(installations, canInstallNew) {
298
+ const options = installations.map((inst) => ({
299
+ value: String(inst.installationId),
300
+ label: inst.accountLogin,
301
+ hint: [
302
+ inst.accountType === "Organization" ? "organization" : "personal",
303
+ inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
304
+ inst.isSuspended ? "suspended" : ""
305
+ ].filter(Boolean).join(" \xB7 ") || void 0
306
+ }));
307
+ if (canInstallNew) {
308
+ options.push({ value: "install_new", label: "Install on a new account" });
309
+ }
310
+ const selected = await p.select({
311
+ message: "Select a GitHub installation",
312
+ options
313
+ });
314
+ if (p.isCancel(selected)) return null;
315
+ if (selected === "install_new") return "install_new";
316
+ return Number(selected);
317
+ }
9
318
 
10
319
  // src/utils/api.ts
11
320
  function isLimitErrorResponse(value) {
@@ -255,85 +564,329 @@ var VocoderAPI = class {
255
564
  }
256
565
  return payload;
257
566
  }
567
+ // ── CLI Auth endpoints (no project API key needed) ──────────────────────────
258
568
  /**
259
- * Look up whether a project already exists for a given repo + scope.
260
- * Returns { projectId, projectName, organizationName } or null if not found.
569
+ * Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
570
+ * `sessionId` is the raw poll token keep it secret, used for polling.
261
571
  */
262
- async lookupProjectByRepo(params) {
263
- try {
264
- const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
265
- method: "POST",
266
- headers: { "Content-Type": "application/json" },
267
- body: JSON.stringify({
268
- repo: params.repoCanonical,
269
- scopePath: params.scopePath
270
- })
572
+ async startCliAuthSession(callbackPort, repoCanonical) {
573
+ const response = await fetch(`${this.apiUrl}/api/cli/auth/start`, {
574
+ method: "POST",
575
+ headers: { "Content-Type": "application/json" },
576
+ body: JSON.stringify({
577
+ ...callbackPort != null ? { callbackPort } : {},
578
+ ...repoCanonical ? { repoCanonical } : {}
579
+ })
580
+ });
581
+ const payload = await readPayload(response);
582
+ if (!response.ok) {
583
+ throw new VocoderAPIError({
584
+ message: extractErrorMessage(payload, `Failed to start auth session (${response.status})`),
585
+ status: response.status,
586
+ payload
271
587
  });
272
- if (response.status === 404) return null;
273
- if (!response.ok) return null;
274
- return await response.json();
275
- } catch {
276
- return null;
277
588
  }
589
+ return payload;
278
590
  }
279
- };
280
-
281
- // src/utils/git-identity.ts
282
- import { execSync } from "child_process";
283
- import { relative, resolve } from "path";
284
- function safeExec(command) {
285
- try {
286
- const output = execSync(command, {
287
- encoding: "utf-8",
288
- stdio: ["pipe", "pipe", "ignore"]
289
- }).trim();
290
- return output.length > 0 ? output : null;
291
- } catch {
292
- return null;
293
- }
294
- }
295
- function normalizePath(pathname) {
296
- const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
297
- if (!cleaned || !cleaned.includes("/")) {
298
- return null;
299
- }
300
- return cleaned;
301
- }
302
- function parseRemoteUrl(remoteUrl) {
303
- const trimmed = remoteUrl.trim();
304
- if (!trimmed) {
305
- return null;
591
+ /**
592
+ * Poll for CLI auth session completion.
593
+ * Returns `{ token }` on success, throws on failure/expiry.
594
+ * The server returns HTTP 202 while still pending.
595
+ */
596
+ async pollCliAuthSession(pollToken) {
597
+ const response = await fetch(
598
+ `${this.apiUrl}/api/cli/auth/session?session=${encodeURIComponent(pollToken)}`
599
+ );
600
+ const payload = await readPayload(response);
601
+ if (response.status === 202) {
602
+ return { status: "pending" };
603
+ }
604
+ if (response.status === 410) {
605
+ return {
606
+ status: "failed",
607
+ reason: extractErrorMessage(payload, "Auth session expired or failed")
608
+ };
609
+ }
610
+ if (!response.ok) {
611
+ return {
612
+ status: "failed",
613
+ reason: extractErrorMessage(payload, `Auth session error (${response.status})`)
614
+ };
615
+ }
616
+ const result = payload;
617
+ if (!result.token) {
618
+ return { status: "failed", reason: "No token in response" };
619
+ }
620
+ return {
621
+ status: "complete",
622
+ token: result.token,
623
+ ...result.organizationId ? { organizationId: result.organizationId } : {}
624
+ };
306
625
  }
307
- if (!trimmed.includes("://")) {
308
- const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
309
- if (scpMatch) {
310
- const host = (scpMatch[1] || "").toLowerCase();
311
- const ownerRepoPath = normalizePath(scpMatch[2] || "");
312
- if (!host || !ownerRepoPath) {
313
- return null;
314
- }
315
- return { host, ownerRepoPath };
626
+ /**
627
+ * Validate a CLI user token and return the authenticated user's info.
628
+ * Used by the CLI to verify stored credentials on startup.
629
+ */
630
+ async getCliUserInfo(userToken) {
631
+ const response = await fetch(`${this.apiUrl}/api/cli/auth/me`, {
632
+ headers: { Authorization: `Bearer ${userToken}` }
633
+ });
634
+ const payload = await readPayload(response);
635
+ if (!response.ok) {
636
+ throw new VocoderAPIError({
637
+ message: extractErrorMessage(payload, `Token validation failed (${response.status})`),
638
+ status: response.status,
639
+ payload
640
+ });
316
641
  }
317
- return null;
642
+ return payload;
318
643
  }
319
- try {
320
- const parsed = new URL(trimmed);
321
- const host = parsed.hostname.toLowerCase();
322
- const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
323
- if (!host || !ownerRepoPath) {
324
- return null;
644
+ /**
645
+ * Revoke the given CLI user token server-side.
646
+ */
647
+ async revokeCliToken(userToken) {
648
+ const response = await fetch(`${this.apiUrl}/api/cli/auth/token`, {
649
+ method: "DELETE",
650
+ headers: { Authorization: `Bearer ${userToken}` }
651
+ });
652
+ if (!response.ok) {
653
+ const payload = await readPayload(response);
654
+ throw new VocoderAPIError({
655
+ message: extractErrorMessage(payload, `Token revocation failed (${response.status})`),
656
+ status: response.status,
657
+ payload
658
+ });
325
659
  }
326
- return { host, ownerRepoPath };
327
- } catch {
328
- return null;
329
660
  }
330
- }
331
- function toCanonical(host, ownerRepoPath) {
332
- if (host.includes("github.com")) {
333
- return `github:${ownerRepoPath.toLowerCase()}`;
661
+ // ── Workspaces ────────────────────────────────────────────────────────────────
662
+ async listWorkspaces(userToken) {
663
+ const response = await fetch(`${this.apiUrl}/api/cli/workspaces`, {
664
+ headers: { Authorization: `Bearer ${userToken}` }
665
+ });
666
+ const payload = await readPayload(response);
667
+ if (!response.ok) {
668
+ throw new VocoderAPIError({
669
+ message: extractErrorMessage(payload, `Failed to list workspaces (${response.status})`),
670
+ status: response.status,
671
+ payload
672
+ });
673
+ }
674
+ return payload;
334
675
  }
335
- if (host.includes("gitlab.com")) {
336
- return `gitlab:${ownerRepoPath.toLowerCase()}`;
676
+ // ── CLI GitHub endpoints ──────────────────────────────────────────────────────
677
+ async startCliGitHubInstall(userToken, params) {
678
+ const response = await fetch(`${this.apiUrl}/api/cli/github/install/start`, {
679
+ method: "POST",
680
+ headers: {
681
+ Authorization: `Bearer ${userToken}`,
682
+ "Content-Type": "application/json"
683
+ },
684
+ body: JSON.stringify(params)
685
+ });
686
+ const payload = await readPayload(response);
687
+ if (!response.ok) {
688
+ throw new VocoderAPIError({
689
+ message: extractErrorMessage(payload, `Failed to start GitHub install (${response.status})`),
690
+ status: response.status,
691
+ payload
692
+ });
693
+ }
694
+ return payload;
695
+ }
696
+ /**
697
+ * Start the "link existing installation" discovery flow.
698
+ * Unlike startCliGitHubOAuth, this requires no bearer token — the Vocoder
699
+ * account is created from the OAuth code in the callback.
700
+ */
701
+ async startCliGitHubLinkSession(sessionId, callbackPort) {
702
+ const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/link-start`, {
703
+ method: "POST",
704
+ headers: { "Content-Type": "application/json" },
705
+ body: JSON.stringify({ sessionId, ...callbackPort != null ? { callbackPort } : {} })
706
+ });
707
+ const payload = await readPayload(response);
708
+ if (!response.ok) {
709
+ throw new VocoderAPIError({
710
+ message: extractErrorMessage(payload, `Failed to start GitHub link session (${response.status})`),
711
+ status: response.status,
712
+ payload
713
+ });
714
+ }
715
+ return payload;
716
+ }
717
+ async startCliGitHubOAuth(userToken, params) {
718
+ const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/start`, {
719
+ method: "POST",
720
+ headers: {
721
+ Authorization: `Bearer ${userToken}`,
722
+ "Content-Type": "application/json"
723
+ },
724
+ body: JSON.stringify(params)
725
+ });
726
+ const payload = await readPayload(response);
727
+ if (!response.ok) {
728
+ throw new VocoderAPIError({
729
+ message: extractErrorMessage(payload, `Failed to start GitHub OAuth (${response.status})`),
730
+ status: response.status,
731
+ payload
732
+ });
733
+ }
734
+ return payload;
735
+ }
736
+ async getCliGitHubDiscovery(userToken) {
737
+ const response = await fetch(`${this.apiUrl}/api/cli/github/discovery`, {
738
+ headers: { Authorization: `Bearer ${userToken}` }
739
+ });
740
+ const payload = await readPayload(response);
741
+ if (!response.ok) {
742
+ throw new VocoderAPIError({
743
+ message: extractErrorMessage(payload, `Failed to fetch GitHub discovery (${response.status})`),
744
+ status: response.status,
745
+ payload
746
+ });
747
+ }
748
+ return payload;
749
+ }
750
+ async claimCliGitHubInstallation(userToken, params) {
751
+ const response = await fetch(`${this.apiUrl}/api/cli/github/claim`, {
752
+ method: "POST",
753
+ headers: {
754
+ Authorization: `Bearer ${userToken}`,
755
+ "Content-Type": "application/json"
756
+ },
757
+ body: JSON.stringify(params)
758
+ });
759
+ const payload = await readPayload(response);
760
+ if (!response.ok) {
761
+ throw new VocoderAPIError({
762
+ message: extractErrorMessage(payload, `Failed to claim GitHub installation (${response.status})`),
763
+ status: response.status,
764
+ payload
765
+ });
766
+ }
767
+ return payload;
768
+ }
769
+ // ── Locales ───────────────────────────────────────────────────────────────────
770
+ async listLocales(userToken) {
771
+ const response = await fetch(`${this.apiUrl}/api/cli/locales`, {
772
+ headers: { Authorization: `Bearer ${userToken}` }
773
+ });
774
+ const payload = await readPayload(response);
775
+ if (!response.ok) {
776
+ throw new VocoderAPIError({
777
+ message: extractErrorMessage(payload, `Failed to list locales (${response.status})`),
778
+ status: response.status,
779
+ payload
780
+ });
781
+ }
782
+ const result = payload;
783
+ return result.locales;
784
+ }
785
+ // ── Project creation ──────────────────────────────────────────────────────────
786
+ async createProject(userToken, params) {
787
+ const response = await fetch(`${this.apiUrl}/api/cli/projects`, {
788
+ method: "POST",
789
+ headers: {
790
+ "Content-Type": "application/json",
791
+ Authorization: `Bearer ${userToken}`
792
+ },
793
+ body: JSON.stringify(params)
794
+ });
795
+ const payload = await readPayload(response);
796
+ if (!response.ok) {
797
+ throw new VocoderAPIError({
798
+ message: extractErrorMessage(payload, `Failed to create project (${response.status})`),
799
+ status: response.status,
800
+ payload
801
+ });
802
+ }
803
+ return payload;
804
+ }
805
+ // ── Project lookup ────────────────────────────────────────────────────────────
806
+ /**
807
+ * Look up whether a project already exists for a given repo + scope.
808
+ * Returns { projectId, projectName, organizationName } or null if not found.
809
+ */
810
+ async lookupProjectByRepo(params) {
811
+ try {
812
+ const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
813
+ method: "POST",
814
+ headers: { "Content-Type": "application/json" },
815
+ body: JSON.stringify({
816
+ repo: params.repoCanonical,
817
+ scopePath: params.scopePath
818
+ })
819
+ });
820
+ if (response.status === 404) return null;
821
+ if (!response.ok) return null;
822
+ return await response.json();
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+ };
828
+
829
+ // src/commands/init.ts
830
+ import chalk6 from "chalk";
831
+ import { execSync as execSync3 } from "child_process";
832
+ import { config as loadEnv } from "dotenv";
833
+
834
+ // src/utils/git-identity.ts
835
+ import { execSync } from "child_process";
836
+ import { relative, resolve } from "path";
837
+ function safeExec(command) {
838
+ try {
839
+ const output = execSync(command, {
840
+ encoding: "utf-8",
841
+ stdio: ["pipe", "pipe", "ignore"]
842
+ }).trim();
843
+ return output.length > 0 ? output : null;
844
+ } catch {
845
+ return null;
846
+ }
847
+ }
848
+ function normalizePath(pathname) {
849
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
850
+ if (!cleaned || !cleaned.includes("/")) {
851
+ return null;
852
+ }
853
+ return cleaned;
854
+ }
855
+ function parseRemoteUrl(remoteUrl) {
856
+ const trimmed = remoteUrl.trim();
857
+ if (!trimmed) {
858
+ return null;
859
+ }
860
+ if (!trimmed.includes("://")) {
861
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
862
+ if (scpMatch) {
863
+ const host = (scpMatch[1] || "").toLowerCase();
864
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
865
+ if (!host || !ownerRepoPath) {
866
+ return null;
867
+ }
868
+ return { host, ownerRepoPath };
869
+ }
870
+ return null;
871
+ }
872
+ try {
873
+ const parsed = new URL(trimmed);
874
+ const host = parsed.hostname.toLowerCase();
875
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
876
+ if (!host || !ownerRepoPath) {
877
+ return null;
878
+ }
879
+ return { host, ownerRepoPath };
880
+ } catch {
881
+ return null;
882
+ }
883
+ }
884
+ function toCanonical(host, ownerRepoPath) {
885
+ if (host.includes("github.com")) {
886
+ return `github:${ownerRepoPath.toLowerCase()}`;
887
+ }
888
+ if (host.includes("gitlab.com")) {
889
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
337
890
  }
338
891
  if (host.includes("bitbucket.org")) {
339
892
  return `bitbucket:${ownerRepoPath.toLowerCase()}`;
@@ -374,18 +927,563 @@ function resolveGitContext() {
374
927
  return { identity, warnings };
375
928
  }
376
929
 
930
+ // src/utils/project-create.ts
931
+ import * as p3 from "@clack/prompts";
932
+ import chalk4 from "chalk";
933
+
934
+ // src/utils/locale-search.ts
935
+ import { Prompt, isCancel as isCancel2 } from "@clack/core";
936
+ import * as p2 from "@clack/prompts";
937
+ import chalk2 from "chalk";
938
+ var S_BAR = "\u2502";
939
+ var S_BAR_END = "\u2514";
940
+ var S_ACTIVE = "\u25C6";
941
+ var S_SUBMIT = "\u25C6";
942
+ var S_CANCEL = "\u25A0";
943
+ var S_ERROR = "\u25B2";
944
+ var noColor = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
945
+ var dim = (s) => noColor ? s : chalk2.gray(s);
946
+ var cyan = (s) => noColor ? s : chalk2.cyan(s);
947
+ var grn = (s) => noColor ? s : chalk2.green(s);
948
+ var ylw = (s) => noColor ? s : chalk2.yellow(s);
949
+ var red = (s) => noColor ? s : chalk2.red(s);
950
+ var bld = (s) => noColor ? s : chalk2.bold(s);
951
+ function symbol(state) {
952
+ switch (state) {
953
+ case "submit":
954
+ return grn(S_SUBMIT);
955
+ case "cancel":
956
+ return red(S_CANCEL);
957
+ case "error":
958
+ return ylw(S_ERROR);
959
+ default:
960
+ return cyan(S_ACTIVE);
961
+ }
962
+ }
963
+ var MAX_VISIBLE = 12;
964
+ function filterLocales(options, query) {
965
+ if (!query.trim()) return options;
966
+ const lower = query.toLowerCase();
967
+ return options.filter(
968
+ (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
969
+ );
970
+ }
971
+ function buildList(filtered, cursor, scrollOffset, selected) {
972
+ const isMulti = selected !== null;
973
+ const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
974
+ const visibleLines = [];
975
+ for (let i = scrollOffset; i < end; i++) {
976
+ const opt = filtered[i];
977
+ const isCursor = i === cursor;
978
+ const isChecked = isMulti && selected.has(opt.bcp47);
979
+ const icon = isMulti ? isChecked ? isCursor ? grn("\u25FC") : "\u25FC" : isCursor ? grn("\u25FB") : dim("\u25FB") : isCursor ? grn("\u25CF") : dim("\u25CB");
980
+ visibleLines.push(`${cyan(S_BAR)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`);
981
+ }
982
+ const hidden = filtered.length - (end - scrollOffset);
983
+ if (hidden > 0) visibleLines.push(dim(`${S_BAR} ${hidden} more \u2014 keep typing to narrow`));
984
+ if (filtered.length === 0) visibleLines.push(dim(`${S_BAR} No matches`));
985
+ if (isMulti && selected.size > 0) {
986
+ visibleLines.push(dim(`${S_BAR} ${selected.size} selected \u2014 Enter to confirm`));
987
+ }
988
+ return visibleLines.join("\n");
989
+ }
990
+ async function runFilterablePrompt(opts) {
991
+ const { message, options, multi } = opts;
992
+ let filter = "";
993
+ let cursor = 0;
994
+ let scrollOffset = 0;
995
+ const selected = new Set(multi ? opts.initialValues ?? [] : []);
996
+ if (!multi && opts.initialValue) {
997
+ const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
998
+ if (idx >= 0) cursor = idx;
999
+ }
1000
+ const getFiltered = () => filterLocales(options, filter);
1001
+ const clampCursor = (filtered) => {
1002
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
1003
+ if (cursor < scrollOffset) scrollOffset = cursor;
1004
+ if (cursor >= scrollOffset + MAX_VISIBLE) scrollOffset = cursor - MAX_VISIBLE + 1;
1005
+ if (scrollOffset < 0) scrollOffset = 0;
1006
+ };
1007
+ const prompt = new Prompt(
1008
+ {
1009
+ initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
1010
+ validate() {
1011
+ const f = getFiltered();
1012
+ if (multi && selected.size === 0) return "At least one target language is required.";
1013
+ if (!multi && !f[cursor]) return "Please select a language.";
1014
+ return void 0;
1015
+ },
1016
+ render() {
1017
+ const filtered = getFiltered();
1018
+ clampCursor(filtered);
1019
+ const hdr = `${dim(S_BAR)}
1020
+ ${symbol(this.state)} ${message}
1021
+ `;
1022
+ const hint = filter.length > 0 ? filter : dim("type to filter, \u2191\u2193 navigate" + (multi ? ", space select" : ""));
1023
+ switch (this.state) {
1024
+ case "submit": {
1025
+ const val = multi ? Array.from(selected).map((id) => options.find((o) => o.bcp47 === id)?.label ?? id).join(", ") : options.find((o) => o.bcp47 === this.value)?.label ?? "";
1026
+ return `${hdr}${dim(S_BAR)} ${bld(val || dim("none"))}`;
1027
+ }
1028
+ case "cancel":
1029
+ return `${hdr}${dim(S_BAR)}`;
1030
+ case "error":
1031
+ return [
1032
+ hdr.trimEnd(),
1033
+ `${ylw(S_BAR)} ${dim("/")} ${hint}`,
1034
+ buildList(filtered, cursor, scrollOffset, multi ? selected : null),
1035
+ `${ylw(S_BAR_END)} ${ylw(this.error)}`,
1036
+ ""
1037
+ ].join("\n");
1038
+ default:
1039
+ return [
1040
+ hdr.trimEnd(),
1041
+ `${cyan(S_BAR)} ${dim("/")} ${hint}`,
1042
+ buildList(filtered, cursor, scrollOffset, multi ? selected : null),
1043
+ `${cyan(S_BAR_END)}`,
1044
+ ""
1045
+ ].join("\n");
1046
+ }
1047
+ }
1048
+ },
1049
+ false
1050
+ // trackValue=false — we manage value manually
1051
+ );
1052
+ prompt.on("key", (key) => {
1053
+ if (!key || key === " ") return;
1054
+ const cp = key.codePointAt(0) ?? 0;
1055
+ if (cp === 127 || cp === 8) {
1056
+ filter = filter.slice(0, -1);
1057
+ cursor = 0;
1058
+ scrollOffset = 0;
1059
+ } else if (cp >= 32 && cp !== 127) {
1060
+ filter += key;
1061
+ cursor = 0;
1062
+ scrollOffset = 0;
1063
+ }
1064
+ });
1065
+ prompt.on("cursor", (action) => {
1066
+ const filtered = getFiltered();
1067
+ switch (action) {
1068
+ case "up":
1069
+ cursor = Math.max(0, cursor - 1);
1070
+ break;
1071
+ case "down":
1072
+ cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
1073
+ break;
1074
+ case "space":
1075
+ if (multi) {
1076
+ const opt = filtered[cursor];
1077
+ if (opt) {
1078
+ if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
1079
+ else selected.add(opt.bcp47);
1080
+ }
1081
+ }
1082
+ break;
1083
+ }
1084
+ if (!multi) {
1085
+ const opt = getFiltered()[cursor];
1086
+ prompt.value = opt?.bcp47 ?? null;
1087
+ }
1088
+ });
1089
+ prompt.on("finalize", () => {
1090
+ if (prompt.state === "submit") {
1091
+ if (multi) {
1092
+ prompt.value = Array.from(selected);
1093
+ } else {
1094
+ const f = getFiltered();
1095
+ prompt.value = f[cursor]?.bcp47 ?? null;
1096
+ }
1097
+ }
1098
+ });
1099
+ const result = await prompt.prompt();
1100
+ if (isCancel2(result)) return null;
1101
+ return result;
1102
+ }
1103
+ async function searchSelectLocale(options, message, initialValue) {
1104
+ const result = await runFilterablePrompt({ message, options, multi: false, initialValue });
1105
+ return typeof result === "string" ? result : null;
1106
+ }
1107
+ async function searchMultiSelectLocales(options, message, initialValues) {
1108
+ const result = await runFilterablePrompt({ message, options, multi: true, initialValues });
1109
+ if (result === null) return null;
1110
+ const picks = result;
1111
+ if (picks.length === 0) {
1112
+ p2.log.warn("At least one target language is required. Please select at least one.");
1113
+ return searchMultiSelectLocales(options, message, initialValues);
1114
+ }
1115
+ return picks;
1116
+ }
1117
+
1118
+ // src/utils/branch-select.ts
1119
+ import { Prompt as Prompt2, isCancel as isCancel3 } from "@clack/core";
1120
+ import chalk3 from "chalk";
1121
+ import { execSync as execSync2 } from "child_process";
1122
+ var S_BAR2 = "\u2502";
1123
+ var S_BAR_END2 = "\u2514";
1124
+ var S_ACTIVE2 = "\u25C6";
1125
+ var S_SUBMIT2 = "\u25C6";
1126
+ var S_CANCEL2 = "\u25A0";
1127
+ var S_ERROR2 = "\u25B2";
1128
+ var noColor2 = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
1129
+ var dim2 = (s) => noColor2 ? s : chalk3.gray(s);
1130
+ var cyan2 = (s) => noColor2 ? s : chalk3.cyan(s);
1131
+ var grn2 = (s) => noColor2 ? s : chalk3.green(s);
1132
+ var ylw2 = (s) => noColor2 ? s : chalk3.yellow(s);
1133
+ var red2 = (s) => noColor2 ? s : chalk3.red(s);
1134
+ var bld2 = (s) => noColor2 ? s : chalk3.bold(s);
1135
+ function symbol2(state) {
1136
+ switch (state) {
1137
+ case "submit":
1138
+ return grn2(S_SUBMIT2);
1139
+ case "cancel":
1140
+ return red2(S_CANCEL2);
1141
+ case "error":
1142
+ return ylw2(S_ERROR2);
1143
+ default:
1144
+ return cyan2(S_ACTIVE2);
1145
+ }
1146
+ }
1147
+ function detectGitBranches(cwd) {
1148
+ const workDir = cwd ?? process.cwd();
1149
+ try {
1150
+ const localOut = execSync2("git branch", { cwd: workDir, stdio: "pipe" }).toString();
1151
+ const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1152
+ let remoteBranches = [];
1153
+ try {
1154
+ const remoteOut = execSync2("git branch -r", { cwd: workDir, stdio: "pipe" }).toString();
1155
+ remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
1156
+ } catch {
1157
+ }
1158
+ const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1159
+ let defaultBranch = "main";
1160
+ try {
1161
+ const ref = execSync2("git symbolic-ref refs/remotes/origin/HEAD", { cwd: workDir, stdio: "pipe" }).toString().trim();
1162
+ defaultBranch = ref.split("/").pop() ?? "main";
1163
+ } catch {
1164
+ }
1165
+ return {
1166
+ branches: branches.length > 0 ? branches : [defaultBranch],
1167
+ defaultBranch
1168
+ };
1169
+ } catch {
1170
+ return { branches: ["main"], defaultBranch: "main" };
1171
+ }
1172
+ }
1173
+ var INVALID_CHARS = /[\s?^~:[\]\\]/;
1174
+ function validateBranchPattern(pattern) {
1175
+ const t = pattern.trim();
1176
+ if (!t) return "Pattern cannot be empty";
1177
+ if (INVALID_CHARS.test(t)) return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
1178
+ if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
1179
+ if (t.includes("//")) return "Cannot contain //";
1180
+ return null;
1181
+ }
1182
+ var MAX_VISIBLE2 = 10;
1183
+ function buildItems(branches, defaultBranch, customPatterns) {
1184
+ const items = branches.map((b) => ({
1185
+ value: b,
1186
+ label: b === defaultBranch ? `${b} (default branch)` : b
1187
+ }));
1188
+ for (const pt of customPatterns) {
1189
+ if (!branches.includes(pt)) {
1190
+ items.push({ value: pt, label: pt, isCustom: true });
1191
+ }
1192
+ }
1193
+ return items;
1194
+ }
1195
+ function filterItems(items, query) {
1196
+ if (!query.trim()) return items;
1197
+ const lower = query.toLowerCase();
1198
+ return items.filter((i) => i.value.toLowerCase().includes(lower));
1199
+ }
1200
+ function buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor) {
1201
+ const lines = [];
1202
+ const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
1203
+ for (let i = scrollOffset; i < end; i++) {
1204
+ const item = filtered[i];
1205
+ const isCursor = i === cursor && !addCursor;
1206
+ const isChecked = selected.has(item.value);
1207
+ const icon = isChecked ? isCursor ? grn2("\u25FC") : "\u25FC" : isCursor ? grn2("\u25FB") : dim2("\u25FB");
1208
+ let label = item.isCustom ? `${item.label} ${dim2("(custom)")}` : item.label;
1209
+ if (isCursor) label = bld2(label);
1210
+ lines.push(`${cyan2(S_BAR2)} ${icon} ${label}`);
1211
+ }
1212
+ const trimmed = filter.trim();
1213
+ const allItems = [...filtered];
1214
+ const isNewPattern = trimmed.length > 0 && !allItems.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
1215
+ if (isNewPattern) {
1216
+ const err = validateBranchPattern(trimmed);
1217
+ const icon = addCursor ? grn2("\u25FB") : dim2("\u25FB");
1218
+ const label = err ? `${ylw2("+")} ${dim2(`"${trimmed}" \u2014 ${err}`)}` : `${grn2("+")} Add "${trimmed}" as branch pattern`;
1219
+ lines.push(`${cyan2(S_BAR2)} ${icon} ${label}`);
1220
+ } else if (filtered.length === 0 && trimmed.length === 0) {
1221
+ lines.push(dim2(`${S_BAR2} No branches detected`));
1222
+ }
1223
+ const hidden = filtered.length - (end - scrollOffset);
1224
+ if (hidden > 0) lines.push(dim2(`${S_BAR2} ${hidden} more`));
1225
+ if (selected.size > 0) lines.push(dim2(`${S_BAR2} ${selected.size} selected \u2014 Enter to confirm`));
1226
+ return lines.join("\n");
1227
+ }
1228
+ async function filterableBranchSelect(params) {
1229
+ const { message, branches, defaultBranch } = params;
1230
+ let filter = "";
1231
+ let cursor = 0;
1232
+ let scrollOffset = 0;
1233
+ let addCursor = false;
1234
+ const customPatterns = [];
1235
+ const selected = new Set(params.initialValues ?? [defaultBranch]);
1236
+ const getItems = () => buildItems(branches, defaultBranch, customPatterns);
1237
+ const getFiltered = () => filterItems(getItems(), filter);
1238
+ const isNewPattern = () => {
1239
+ const t = filter.trim();
1240
+ if (!t) return false;
1241
+ return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
1242
+ };
1243
+ const clampCursor = (filtered) => {
1244
+ const hasAdd = isNewPattern();
1245
+ const max = filtered.length - 1 + (hasAdd ? 1 : 0);
1246
+ if (cursor > max && !addCursor) cursor = Math.max(0, max);
1247
+ if (!addCursor) {
1248
+ if (cursor < scrollOffset) scrollOffset = cursor;
1249
+ if (cursor >= scrollOffset + MAX_VISIBLE2) scrollOffset = cursor - MAX_VISIBLE2 + 1;
1250
+ if (scrollOffset < 0) scrollOffset = 0;
1251
+ }
1252
+ };
1253
+ const prompt = new Prompt2(
1254
+ {
1255
+ validate() {
1256
+ if (selected.size === 0) return "At least one branch is required.";
1257
+ return void 0;
1258
+ },
1259
+ render() {
1260
+ const filtered = getFiltered();
1261
+ clampCursor(filtered);
1262
+ const hdr = `${dim2(S_BAR2)}
1263
+ ${symbol2(this.state)} ${message}
1264
+ `;
1265
+ const hint = filter.length > 0 ? filter : dim2("type to filter or add pattern, \u2191\u2193 navigate, space select");
1266
+ switch (this.state) {
1267
+ case "submit": {
1268
+ const summary = selected.size > 0 ? bld2(Array.from(selected).join(", ")) : dim2("none");
1269
+ return `${hdr}${dim2(S_BAR2)} ${summary}`;
1270
+ }
1271
+ case "cancel":
1272
+ return `${hdr}${dim2(S_BAR2)}`;
1273
+ case "error":
1274
+ return [
1275
+ hdr.trimEnd(),
1276
+ `${ylw2(S_BAR2)} ${dim2("/")} ${hint}`,
1277
+ buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor),
1278
+ `${ylw2(S_BAR_END2)} ${ylw2(this.error)}`,
1279
+ ""
1280
+ ].join("\n");
1281
+ default:
1282
+ return [
1283
+ hdr.trimEnd(),
1284
+ `${cyan2(S_BAR2)} ${dim2("/")} ${hint}`,
1285
+ buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor),
1286
+ `${cyan2(S_BAR_END2)}`,
1287
+ ""
1288
+ ].join("\n");
1289
+ }
1290
+ }
1291
+ },
1292
+ false
1293
+ );
1294
+ prompt.on("key", (key) => {
1295
+ if (!key || key === " ") return;
1296
+ const cp = key.codePointAt(0) ?? 0;
1297
+ if (cp === 127 || cp === 8) {
1298
+ filter = filter.slice(0, -1);
1299
+ cursor = 0;
1300
+ scrollOffset = 0;
1301
+ addCursor = false;
1302
+ } else if (cp >= 32 && cp !== 127) {
1303
+ filter += key;
1304
+ cursor = 0;
1305
+ scrollOffset = 0;
1306
+ addCursor = false;
1307
+ }
1308
+ });
1309
+ prompt.on("cursor", (action) => {
1310
+ const filtered = getFiltered();
1311
+ const hasAdd = isNewPattern();
1312
+ switch (action) {
1313
+ case "up":
1314
+ if (addCursor) {
1315
+ addCursor = false;
1316
+ cursor = Math.max(0, filtered.length - 1);
1317
+ } else cursor = Math.max(0, cursor - 1);
1318
+ break;
1319
+ case "down":
1320
+ if (!addCursor && cursor >= filtered.length - 1 && hasAdd) addCursor = true;
1321
+ else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
1322
+ break;
1323
+ case "space":
1324
+ if (addCursor) {
1325
+ const t = filter.trim();
1326
+ const err = validateBranchPattern(t);
1327
+ if (!err) {
1328
+ customPatterns.push(t);
1329
+ selected.add(t);
1330
+ filter = "";
1331
+ cursor = 0;
1332
+ scrollOffset = 0;
1333
+ addCursor = false;
1334
+ }
1335
+ } else {
1336
+ const item = filtered[cursor];
1337
+ if (item) {
1338
+ if (selected.has(item.value)) selected.delete(item.value);
1339
+ else selected.add(item.value);
1340
+ }
1341
+ }
1342
+ break;
1343
+ }
1344
+ });
1345
+ prompt.on("finalize", () => {
1346
+ if (prompt.state === "submit") {
1347
+ prompt.value = Array.from(selected);
1348
+ }
1349
+ });
1350
+ const result = await prompt.prompt();
1351
+ if (isCancel3(result)) return null;
1352
+ return result;
1353
+ }
1354
+
1355
+ // src/utils/project-create.ts
1356
+ function buildLocaleOptions(locales) {
1357
+ return locales.map((l) => ({
1358
+ bcp47: l.code,
1359
+ label: `${l.name} \u2014 ${l.code}`
1360
+ }));
1361
+ }
1362
+ function buildLanguageOptions(locales) {
1363
+ const byFamily = /* @__PURE__ */ new Map();
1364
+ for (const l of locales) {
1365
+ const family = l.code.split("-")[0].toLowerCase();
1366
+ const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
1367
+ const existing = byFamily.get(family);
1368
+ if (!existing || l.code.length < existing.bcp47.length) {
1369
+ byFamily.set(family, opt);
1370
+ }
1371
+ }
1372
+ return Array.from(byFamily.values());
1373
+ }
1374
+ async function runProjectCreate(params) {
1375
+ const { api, userToken, organizationId, repoCanonical } = params;
1376
+ const projectName = (params.defaultName ?? "my-project").trim();
1377
+ p3.log.success(`Project: ${chalk4.bold(projectName)}`);
1378
+ let rawLocales;
1379
+ try {
1380
+ rawLocales = await api.listLocales(userToken);
1381
+ } catch {
1382
+ p3.log.error("Failed to fetch supported locales. Check your connection and try again.");
1383
+ return null;
1384
+ }
1385
+ const languageOptions = buildLanguageOptions(rawLocales);
1386
+ const localeOptions = buildLocaleOptions(rawLocales);
1387
+ const sourceLocale = await searchSelectLocale(
1388
+ languageOptions,
1389
+ "Source language (the language your code is written in)",
1390
+ params.defaultSourceLocale ?? "en"
1391
+ );
1392
+ if (sourceLocale === null) return null;
1393
+ const targetOptions = localeOptions.filter((opt) => opt.bcp47 !== sourceLocale);
1394
+ const targetLocales = await searchMultiSelectLocales(
1395
+ targetOptions,
1396
+ "Target languages (languages to translate into)"
1397
+ );
1398
+ if (targetLocales === null) return null;
1399
+ if (targetLocales.length === 0) {
1400
+ p3.log.warn("No target languages selected \u2014 you can add them later from the dashboard.");
1401
+ }
1402
+ const detected = detectGitBranches();
1403
+ const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
1404
+ let targetBranches = [];
1405
+ {
1406
+ let initial = initialBranches;
1407
+ while (targetBranches.length === 0) {
1408
+ const result = await filterableBranchSelect({
1409
+ message: "Target branches (translations will run when you push to these)",
1410
+ branches: detected.branches,
1411
+ defaultBranch: detected.defaultBranch,
1412
+ initialValues: initial
1413
+ });
1414
+ if (result === null) return null;
1415
+ if (result.length === 0) {
1416
+ p3.log.warn("At least one branch is required. Please select at least one.");
1417
+ initial = [detected.defaultBranch];
1418
+ } else {
1419
+ targetBranches = result;
1420
+ }
1421
+ }
1422
+ }
1423
+ try {
1424
+ const result = await api.createProject(userToken, {
1425
+ organizationId,
1426
+ name: projectName,
1427
+ sourceLocale,
1428
+ targetLocales,
1429
+ targetBranches,
1430
+ translationTriggers: ["push"],
1431
+ scopePaths: [],
1432
+ repoCanonical
1433
+ });
1434
+ p3.log.success(`Project ${chalk4.bold(result.projectName)} created!`);
1435
+ return result;
1436
+ } catch (error) {
1437
+ const message = error instanceof Error ? error.message : "Unknown error";
1438
+ p3.log.error(`Failed to create project: ${message}`);
1439
+ return null;
1440
+ }
1441
+ }
1442
+
1443
+ // src/utils/workspace.ts
1444
+ import * as p4 from "@clack/prompts";
1445
+ import chalk5 from "chalk";
1446
+ async function selectWorkspace(result) {
1447
+ const { workspaces, canCreateWorkspace } = result;
1448
+ if (workspaces.length === 0) {
1449
+ return { action: "create" };
1450
+ }
1451
+ const options = workspaces.map((ws) => ({
1452
+ value: ws.id,
1453
+ label: ws.name,
1454
+ hint: [
1455
+ ws.projectCount > 0 ? `${ws.projectCount} project${ws.projectCount !== 1 ? "s" : ""}` : "",
1456
+ ws.connectionLabel ? `GitHub: ${ws.connectionLabel}` : ""
1457
+ ].filter(Boolean).join(" \xB7 ") || void 0
1458
+ }));
1459
+ if (canCreateWorkspace) {
1460
+ options.push({ value: "create", label: "Create new workspace" });
1461
+ }
1462
+ const selected = await p4.select({
1463
+ message: "Select workspace",
1464
+ options
1465
+ });
1466
+ if (p4.isCancel(selected)) {
1467
+ return { action: "cancelled" };
1468
+ }
1469
+ if (selected === "create") {
1470
+ return { action: "create" };
1471
+ }
1472
+ const workspace = workspaces.find((ws) => ws.id === selected);
1473
+ if (!workspace) {
1474
+ return { action: "cancelled" };
1475
+ }
1476
+ return { action: "use", workspace };
1477
+ }
1478
+
377
1479
  // src/commands/init.ts
378
- import { spawn } from "child_process";
1480
+ import { spawn as spawn2 } from "child_process";
1481
+ loadEnv();
379
1482
  var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
380
- function parseTargetLocales(value) {
381
- if (!value) return void 0;
382
- const locales = value.split(",").map((locale) => locale.trim()).filter(Boolean);
383
- return locales.length > 0 ? locales : void 0;
384
- }
385
1483
  async function sleep(ms) {
386
1484
  await new Promise((resolve2) => setTimeout(resolve2, ms));
387
1485
  }
388
- async function tryOpenBrowser(url) {
1486
+ async function tryOpenBrowser2(url) {
389
1487
  if (!process.stdout.isTTY || process.env.CI === "true") {
390
1488
  return false;
391
1489
  }
@@ -403,7 +1501,7 @@ async function tryOpenBrowser(url) {
403
1501
  }
404
1502
  return await new Promise((resolve2) => {
405
1503
  try {
406
- const child = spawn(command, args, {
1504
+ const child = spawn2(command, args, {
407
1505
  detached: true,
408
1506
  stdio: "ignore",
409
1507
  windowsHide: true
@@ -438,132 +1536,555 @@ function getSubscriptionSettingsUrl(apiUrl) {
438
1536
  return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
439
1537
  }
440
1538
  function printPlanLimitMessage(apiUrl, message) {
441
- p.log.error(`You are over your plan limits.
1539
+ p5.log.error(`You are over your plan limits.
442
1540
  ${message}`);
443
- p.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
1541
+ p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
444
1542
  }
445
- function printNextSteps(projectName, organizationName) {
446
- p.log.info(`Project: ${chalk.bold(projectName)}`);
447
- p.log.info(`Workspace: ${chalk.bold(organizationName)}`);
448
- p.log.info("");
449
- p.log.info("Next steps:");
450
- p.log.info(` 1. Add ${chalk.cyan("@vocoder/unplugin")} to your build config`);
451
- p.log.info(` 2. Wrap translatable strings with ${chalk.green("<T>")}...${chalk.green("</T>")}`);
452
- p.log.info(" 3. Push to trigger extraction + translation");
1543
+ function runScaffold(params) {
1544
+ const { projectName, organizationName, sourceLocale, translationTriggers } = params;
1545
+ p5.log.info(`Project: ${chalk6.bold(projectName)}`);
1546
+ p5.log.info(`Workspace: ${chalk6.bold(organizationName)}`);
1547
+ const detection = detectLocalEcosystem();
1548
+ if (detection.ecosystem) {
1549
+ const frameworkLabel = detection.framework ?? detection.ecosystem;
1550
+ const pmLabel = detection.packageManager;
1551
+ p5.log.info(`Detected: ${chalk6.bold(frameworkLabel)} (${pmLabel})`);
1552
+ }
1553
+ const packagesToInstall = getPackagesToInstall(detection);
1554
+ if (packagesToInstall.length > 0) {
1555
+ const installCmd = buildInstallCommand(detection.packageManager, packagesToInstall);
1556
+ p5.log.info("");
1557
+ const installSpinner = p5.spinner();
1558
+ installSpinner.start(`Installing ${packagesToInstall.join(", ")}...`);
1559
+ try {
1560
+ execSync3(installCmd, { stdio: "pipe", cwd: process.cwd() });
1561
+ installSpinner.stop(`Installed ${packagesToInstall.join(", ")}`);
1562
+ } catch {
1563
+ installSpinner.stop("Package installation failed");
1564
+ p5.log.warn(`Run manually: ${chalk6.cyan(installCmd)}`);
1565
+ }
1566
+ } else if (detection.ecosystem) {
1567
+ p5.log.info(`Packages: ${chalk6.green("already installed")}`);
1568
+ }
1569
+ const snippets = getSetupSnippets({
1570
+ framework: detection.framework,
1571
+ ecosystem: detection.ecosystem,
1572
+ sourceLocale,
1573
+ translationTriggers
1574
+ });
1575
+ let stepNum = 1;
1576
+ if (snippets.pluginStep) {
1577
+ p5.log.message("");
1578
+ p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the plugin to ${chalk6.cyan(snippets.pluginStep.file)}`);
1579
+ printCodeBlock(snippets.pluginStep.code);
1580
+ stepNum++;
1581
+ }
1582
+ if (snippets.providerStep) {
1583
+ p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the provider to ${chalk6.cyan(snippets.providerStep.file)}`);
1584
+ printCodeBlock(snippets.providerStep.code);
1585
+ stepNum++;
1586
+ }
1587
+ p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Wrap translatable strings`);
1588
+ printCodeBlock(snippets.wrapStep.code);
1589
+ p5.log.message("");
1590
+ for (const line of snippets.whatsNext.split("\n")) {
1591
+ p5.log.success(line);
1592
+ }
1593
+ }
1594
+ function printMcpSetup(apiKey) {
1595
+ const addCommand = `claude mcp add --scope project --transport stdio \\
1596
+ --env VOCODER_API_KEY=${apiKey} \\
1597
+ vocoder -- npx -y @vocoder/mcp`;
1598
+ const teamConfig = JSON.stringify(
1599
+ {
1600
+ mcpServers: {
1601
+ vocoder: {
1602
+ type: "stdio",
1603
+ command: "npx",
1604
+ args: ["-y", "@vocoder/mcp"],
1605
+ env: { VOCODER_API_KEY: "${env:VOCODER_API_KEY}" }
1606
+ }
1607
+ }
1608
+ },
1609
+ null,
1610
+ 2
1611
+ );
1612
+ p5.log.message("");
1613
+ p5.log.message(chalk6.bold("Use Vocoder with Claude Code"));
1614
+ p5.log.message("Run this to add the MCP server to your project:");
1615
+ p5.log.message("");
1616
+ printCodeBlock(addCommand);
1617
+ p5.log.message("");
1618
+ p5.log.message("To share with your team, commit " + chalk6.cyan(".mcp.json") + " with an env var reference");
1619
+ p5.log.message("so each developer supplies their own key:");
1620
+ p5.log.message("");
1621
+ printCodeBlock(teamConfig);
1622
+ p5.log.message("");
1623
+ p5.log.message(chalk6.gray("Setup instructions: https://vocoder.app/docs/mcp"));
1624
+ }
1625
+ function printCodeBlock(code) {
1626
+ const lines = code.split("\n");
1627
+ const maxLen = lines.reduce((max, line) => Math.max(max, line.length), 0);
1628
+ const bar = chalk6.gray("\u2502");
1629
+ const pad = (s) => s + " ".repeat(maxLen - s.length);
1630
+ process.stdout.write(`${chalk6.gray("\u2502")}
1631
+ `);
1632
+ process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u250C" + "\u2500".repeat(maxLen + 2) + "\u2510")}
1633
+ `);
1634
+ for (const line of lines) {
1635
+ process.stdout.write(`${chalk6.gray("\u2502")} ${bar} ${pad(line)} ${bar}
1636
+ `);
1637
+ }
1638
+ process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u2514" + "\u2500".repeat(maxLen + 2) + "\u2518")}
1639
+ `);
1640
+ }
1641
+ async function verifyStoredToken(api, token) {
1642
+ try {
1643
+ return await api.getCliUserInfo(token);
1644
+ } catch {
1645
+ clearAuthData();
1646
+ return null;
1647
+ }
1648
+ }
1649
+ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1650
+ let server = null;
1651
+ if (!options.ci) {
1652
+ try {
1653
+ server = await startCallbackServer();
1654
+ } catch {
1655
+ }
1656
+ }
1657
+ const session = await api.startCliAuthSession(server?.port, repoCanonical);
1658
+ const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
1659
+ const expiresAt = new Date(session.expiresAt).getTime();
1660
+ if (options.ci) {
1661
+ process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
1662
+ `);
1663
+ process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
1664
+ `);
1665
+ } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1666
+ if (reauth) {
1667
+ if (!options.yes) {
1668
+ const shouldOpen = await p5.confirm({ message: "Open your browser to sign in again?" });
1669
+ if (p5.isCancel(shouldOpen)) {
1670
+ server?.close();
1671
+ p5.cancel("Setup cancelled.");
1672
+ return null;
1673
+ }
1674
+ if (!shouldOpen) {
1675
+ p5.log.info("Open the URL above manually in your browser to continue.");
1676
+ } else {
1677
+ const opened = await tryOpenBrowser2(browserUrl);
1678
+ if (!opened) {
1679
+ p5.note(browserUrl, "Sign In");
1680
+ p5.log.info("Open the URL above manually to continue.");
1681
+ }
1682
+ }
1683
+ } else {
1684
+ await tryOpenBrowser2(browserUrl);
1685
+ }
1686
+ } else {
1687
+ let isLinkFlow = false;
1688
+ if (!options.yes) {
1689
+ const connectChoice = await p5.select({
1690
+ message: "Vocoder needs to be installed on your GitHub account to get started",
1691
+ options: [
1692
+ { value: "install", label: "Install GitHub App", hint: "recommended" },
1693
+ { value: "link", label: "Already installed? Link your account" }
1694
+ ]
1695
+ });
1696
+ if (p5.isCancel(connectChoice)) {
1697
+ server?.close();
1698
+ p5.cancel("Setup cancelled.");
1699
+ return null;
1700
+ }
1701
+ isLinkFlow = connectChoice === "link";
1702
+ }
1703
+ let urlToOpen = browserUrl;
1704
+ if (isLinkFlow) {
1705
+ try {
1706
+ const linkSession = await api.startCliGitHubLinkSession(
1707
+ session.sessionId,
1708
+ server?.port
1709
+ );
1710
+ urlToOpen = linkSession.oauthUrl;
1711
+ } catch {
1712
+ urlToOpen = browserUrl;
1713
+ }
1714
+ }
1715
+ const opened = await tryOpenBrowser2(urlToOpen);
1716
+ if (!opened) {
1717
+ p5.log.warn("Could not open your browser automatically.");
1718
+ p5.note(urlToOpen, "GitHub");
1719
+ p5.log.info("Open the URL above to continue.");
1720
+ }
1721
+ }
1722
+ }
1723
+ const authSpinner = p5.spinner();
1724
+ authSpinner.start("Waiting for GitHub authorization...");
1725
+ let rawToken = null;
1726
+ let callbackOrganizationId;
1727
+ let callbackDiscoveryReady = false;
1728
+ if (server) {
1729
+ try {
1730
+ const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
1731
+ const timeoutMs = deadline - Date.now();
1732
+ const params = await Promise.race([
1733
+ server.waitForCallback(),
1734
+ new Promise((resolve2) => setTimeout(() => resolve2(null), timeoutMs))
1735
+ ]);
1736
+ if (params && typeof params.token === "string") {
1737
+ rawToken = params.token;
1738
+ if (typeof params.organizationId === "string" && params.organizationId) {
1739
+ callbackOrganizationId = params.organizationId;
1740
+ }
1741
+ if (params.discovery_ready === "1") {
1742
+ callbackDiscoveryReady = true;
1743
+ }
1744
+ }
1745
+ } catch {
1746
+ } finally {
1747
+ server.close();
1748
+ }
1749
+ }
1750
+ if (!rawToken) {
1751
+ while (Date.now() < expiresAt) {
1752
+ const result = await api.pollCliAuthSession(session.sessionId);
1753
+ if (result.status === "complete") {
1754
+ rawToken = result.token;
1755
+ if (result.organizationId) {
1756
+ callbackOrganizationId = result.organizationId;
1757
+ }
1758
+ break;
1759
+ }
1760
+ if (result.status === "failed") {
1761
+ authSpinner.stop();
1762
+ p5.log.error(result.reason);
1763
+ return null;
1764
+ }
1765
+ await sleep(2e3);
1766
+ }
1767
+ }
1768
+ if (!rawToken) {
1769
+ authSpinner.stop();
1770
+ p5.log.error("The authentication link expired. Run `vocoder init` again.");
1771
+ return null;
1772
+ }
1773
+ const userInfo = await api.getCliUserInfo(rawToken);
1774
+ authSpinner.stop();
1775
+ p5.log.success(`Authenticated as ${chalk6.bold(userInfo.email)}`);
1776
+ return { token: rawToken, ...userInfo, organizationId: callbackOrganizationId, discoveryReady: callbackDiscoveryReady };
453
1777
  }
454
1778
  async function init(options = {}) {
455
1779
  const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
456
- p.intro("Vocoder Setup");
457
- const spinner4 = p.spinner();
1780
+ p5.intro("Vocoder Setup");
458
1781
  try {
459
1782
  const gitContext = resolveGitContext();
460
1783
  const identity = gitContext.identity;
461
1784
  if (gitContext.warnings.length > 0) {
462
1785
  for (const warning of gitContext.warnings) {
463
- p.log.warn(warning);
1786
+ p5.log.warn(warning);
464
1787
  }
465
1788
  }
466
1789
  if (identity) {
467
- spinner4.start("Checking for existing project...");
468
- const api2 = new VocoderAPI({ apiUrl, apiKey: "" });
469
- const existing = await api2.lookupProjectByRepo({
1790
+ const anonApi = new VocoderAPI({ apiUrl, apiKey: "" });
1791
+ const existing = await anonApi.lookupProjectByRepo({
470
1792
  repoCanonical: identity.repoCanonical,
471
1793
  scopePath: identity.repoScopePath
472
1794
  });
473
1795
  if (existing) {
474
- spinner4.stop("Found existing project!");
475
- p.outro("Vocoder is already set up for this repository.");
476
- printNextSteps(existing.projectName, existing.organizationName);
1796
+ runScaffold({
1797
+ projectName: existing.projectName,
1798
+ organizationName: existing.organizationName,
1799
+ sourceLocale: existing.sourceLocale ?? "en",
1800
+ translationTriggers: existing.translationTriggers ?? ["push"]
1801
+ });
1802
+ p5.outro("Vocoder is already set up for this repository.");
477
1803
  return 0;
478
1804
  }
479
- spinner4.stop("No existing project found for this repo.");
480
1805
  }
481
- spinner4.start("Creating setup session");
482
1806
  const api = new VocoderAPI({ apiUrl, apiKey: "" });
483
- const start = await api.startInitSession({
484
- projectName: options.projectName,
485
- sourceLocale: options.sourceLocale,
486
- targetLocales: parseTargetLocales(options.targetLocales),
487
- ...identity?.repoCanonical ? { repoCanonical: identity.repoCanonical } : {},
488
- ...identity ? { repoScopePath: identity.repoScopePath } : {}
489
- });
490
- spinner4.stop("Setup session created");
491
- const verificationUrlString = start.verificationUrl;
492
- p.log.info("Create a project in your browser to continue.");
493
- p.note(verificationUrlString, "Setup URL");
494
- if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
495
- const shouldOpen = options.yes ? true : await p.confirm({ message: "Open this URL in your browser?" });
496
- if (p.isCancel(shouldOpen)) {
497
- p.cancel("Setup cancelled.");
498
- return 1;
1807
+ let userToken;
1808
+ let userEmail;
1809
+ let userName;
1810
+ let authOrganizationId;
1811
+ let authDiscoveryReady = false;
1812
+ const stored = readAuthData();
1813
+ if (stored && stored.apiUrl === apiUrl) {
1814
+ const verified = await verifyStoredToken(api, stored.token);
1815
+ if (verified) {
1816
+ p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
1817
+ userToken = stored.token;
1818
+ userEmail = verified.email;
1819
+ userName = verified.name;
1820
+ } else {
1821
+ p5.log.warn("Stored credentials expired \u2014 signing in again");
1822
+ const authResult = await runAuthFlow(
1823
+ api,
1824
+ options,
1825
+ /* reauth */
1826
+ true
1827
+ );
1828
+ if (!authResult) return 1;
1829
+ userToken = authResult.token;
1830
+ userEmail = authResult.email;
1831
+ userName = authResult.name;
1832
+ authOrganizationId = authResult.organizationId;
1833
+ authDiscoveryReady = authResult.discoveryReady ?? false;
1834
+ writeAuthData({
1835
+ token: userToken,
1836
+ apiUrl,
1837
+ userId: authResult.userId,
1838
+ email: userEmail,
1839
+ name: userName,
1840
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1841
+ });
499
1842
  }
500
- if (shouldOpen) {
501
- const opened = await tryOpenBrowser(verificationUrlString);
502
- if (opened) {
503
- p.log.info("Opened your browser.");
1843
+ } else {
1844
+ const authResult = await runAuthFlow(api, options, false, identity?.repoCanonical);
1845
+ if (!authResult) return 1;
1846
+ userToken = authResult.token;
1847
+ userEmail = authResult.email;
1848
+ userName = authResult.name;
1849
+ authOrganizationId = authResult.organizationId;
1850
+ writeAuthData({
1851
+ token: userToken,
1852
+ apiUrl,
1853
+ userId: authResult.userId,
1854
+ email: userEmail,
1855
+ name: userName,
1856
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1857
+ });
1858
+ }
1859
+ let selectedWorkspaceId;
1860
+ let selectedWorkspaceName;
1861
+ if (authOrganizationId) {
1862
+ const workspaceData = await api.listWorkspaces(userToken);
1863
+ const ws = workspaceData.workspaces.find((w) => w.id === authOrganizationId);
1864
+ selectedWorkspaceId = authOrganizationId;
1865
+ selectedWorkspaceName = ws?.name ?? userEmail;
1866
+ p5.log.success(`Connected as ${chalk6.bold(userEmail)} \u2014 workspace: ${chalk6.bold(selectedWorkspaceName)}`);
1867
+ } else {
1868
+ const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
1869
+ const cachedInstallations = discoveryResult?.installations ?? [];
1870
+ if (cachedInstallations.length > 0) {
1871
+ if (identity?.repoCanonical) {
1872
+ const repoOwner = identity.repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
1873
+ if (repoOwner) {
1874
+ const hasMatchingAccount = cachedInstallations.some(
1875
+ (i) => i.accountLogin.toLowerCase() === repoOwner
1876
+ );
1877
+ if (!hasMatchingAccount) {
1878
+ p5.log.warn(
1879
+ `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
1880
+ The project will be created but translations won't trigger automatically.
1881
+ To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
1882
+ );
1883
+ }
1884
+ }
1885
+ }
1886
+ const validInstallations = cachedInstallations.filter(
1887
+ (i) => !i.isSuspended && !i.conflictLabel
1888
+ );
1889
+ let selectedInstallationId = null;
1890
+ if (validInstallations.length === 1 && cachedInstallations.length === 1) {
1891
+ selectedInstallationId = validInstallations[0].installationId;
504
1892
  } else {
505
- p.log.info("Could not open a browser automatically. Use the URL above.");
1893
+ selectedInstallationId = await selectGitHubInstallation(
1894
+ cachedInstallations.map((inst) => ({
1895
+ installationId: inst.installationId,
1896
+ accountLogin: inst.accountLogin,
1897
+ accountType: inst.accountType,
1898
+ isSuspended: inst.isSuspended,
1899
+ conflictLabel: inst.conflictLabel
1900
+ })),
1901
+ false
1902
+ );
506
1903
  }
507
- }
508
- }
509
- const expiresAt = new Date(start.expiresAt).getTime();
510
- spinner4.start("Waiting for setup to complete...");
511
- while (Date.now() < expiresAt) {
512
- const status = await api.getInitSessionStatus({
513
- sessionId: start.sessionId,
514
- pollToken: start.poll.token
515
- });
516
- if (status.status === "pending") {
517
- const pendingMessage = status.message?.trim();
518
- if (pendingMessage) {
519
- spinner4.message(`Waiting for setup to complete... (${pendingMessage})`);
1904
+ if (selectedInstallationId === null || selectedInstallationId === "install_new") {
1905
+ p5.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
1906
+ return 1;
520
1907
  }
521
- await sleep((status.pollIntervalSeconds || start.poll.intervalSeconds) * 1e3);
522
- continue;
523
- }
524
- if (status.status === "failed") {
525
- spinner4.stop("Setup failed");
526
- if (isPlanLimitFailure(status.message)) {
527
- printPlanLimitMessage(apiUrl, status.message);
1908
+ const claimResult = await api.claimCliGitHubInstallation(userToken, {
1909
+ installationId: String(selectedInstallationId),
1910
+ organizationId: null
1911
+ });
1912
+ selectedWorkspaceId = claimResult.organizationId;
1913
+ selectedWorkspaceName = claimResult.organizationName;
1914
+ p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
1915
+ } else {
1916
+ const workspaceData = await api.listWorkspaces(userToken);
1917
+ if (workspaceData.workspaces.length === 1 && !workspaceData.canCreateWorkspace) {
1918
+ const ws = workspaceData.workspaces[0];
1919
+ selectedWorkspaceId = ws.id;
1920
+ selectedWorkspaceName = ws.name;
1921
+ p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
528
1922
  } else {
529
- p.log.error(status.message);
1923
+ const workspaceResult = await selectWorkspace(workspaceData);
1924
+ if (workspaceResult.action === "cancelled") {
1925
+ p5.cancel("Setup cancelled.");
1926
+ return 1;
1927
+ }
1928
+ if (workspaceResult.action === "use") {
1929
+ selectedWorkspaceId = workspaceResult.workspace.id;
1930
+ selectedWorkspaceName = workspaceResult.workspace.name;
1931
+ p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
1932
+ } else {
1933
+ const connectChoice = await p5.select({
1934
+ message: "Connect your new workspace to GitHub",
1935
+ options: [
1936
+ { value: "install", label: "Install the Vocoder GitHub App" },
1937
+ { value: "link", label: "Link an existing installation" }
1938
+ ]
1939
+ });
1940
+ if (p5.isCancel(connectChoice)) {
1941
+ p5.cancel("Setup cancelled.");
1942
+ return 1;
1943
+ }
1944
+ if (connectChoice === "install") {
1945
+ const connectResult = await runGitHubInstallFlow({
1946
+ api,
1947
+ userToken,
1948
+ yes: options.yes
1949
+ });
1950
+ if (!connectResult) {
1951
+ p5.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1952
+ return 1;
1953
+ }
1954
+ selectedWorkspaceId = connectResult.organizationId;
1955
+ selectedWorkspaceName = connectResult.organizationName;
1956
+ p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
1957
+ } else {
1958
+ const installations = await runGitHubDiscoveryFlow({
1959
+ api,
1960
+ userToken,
1961
+ yes: options.yes
1962
+ });
1963
+ if (!installations) return 1;
1964
+ if (installations.length === 0) {
1965
+ p5.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
1966
+ const installNow = await p5.confirm({ message: "Open GitHub to install the App?" });
1967
+ if (p5.isCancel(installNow) || !installNow) return 1;
1968
+ const connectResult = await runGitHubInstallFlow({
1969
+ api,
1970
+ userToken,
1971
+ yes: options.yes
1972
+ });
1973
+ if (!connectResult) return 1;
1974
+ selectedWorkspaceId = connectResult.organizationId;
1975
+ selectedWorkspaceName = connectResult.organizationName;
1976
+ } else {
1977
+ const selectedInstallationId = await selectGitHubInstallation(
1978
+ installations.map((inst) => ({
1979
+ installationId: inst.installationId,
1980
+ accountLogin: inst.accountLogin,
1981
+ accountType: inst.accountType,
1982
+ isSuspended: inst.isSuspended,
1983
+ conflictLabel: inst.conflictLabel
1984
+ })),
1985
+ true
1986
+ );
1987
+ if (selectedInstallationId === null) {
1988
+ p5.cancel("Setup cancelled.");
1989
+ return 1;
1990
+ }
1991
+ if (selectedInstallationId === "install_new") {
1992
+ const connectResult = await runGitHubInstallFlow({
1993
+ api,
1994
+ userToken,
1995
+ yes: options.yes
1996
+ });
1997
+ if (!connectResult) return 1;
1998
+ selectedWorkspaceId = connectResult.organizationId;
1999
+ selectedWorkspaceName = connectResult.organizationName;
2000
+ } else {
2001
+ const claimResult = await api.claimCliGitHubInstallation(userToken, {
2002
+ installationId: String(selectedInstallationId),
2003
+ organizationId: null
2004
+ });
2005
+ selectedWorkspaceId = claimResult.organizationId;
2006
+ selectedWorkspaceName = claimResult.organizationName;
2007
+ }
2008
+ }
2009
+ p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
2010
+ }
2011
+ }
530
2012
  }
531
- p.cancel("Setup could not be completed.");
532
- return 1;
533
- }
534
- if (status.status === "completed") {
535
- spinner4.stop("Setup complete!");
536
- const { credentials } = status;
537
- p.outro("Vocoder initialized successfully!");
538
- printNextSteps(credentials.projectName, credentials.organizationName);
539
- return 0;
540
2013
  }
541
2014
  }
542
- spinner4.stop("Setup timed out");
543
- p.log.error("Setup timed out. Run `vocoder init` again.");
544
- p.cancel("Setup could not be completed.");
545
- return 1;
2015
+ const projectResult = await runProjectCreate({
2016
+ api,
2017
+ userToken,
2018
+ organizationId: selectedWorkspaceId,
2019
+ defaultName: identity?.repoCanonical ? identity.repoCanonical.split("/").pop() : void 0,
2020
+ defaultSourceLocale: "en",
2021
+ repoCanonical: identity?.repoCanonical,
2022
+ defaultBranches: ["main"]
2023
+ });
2024
+ if (!projectResult) {
2025
+ p5.log.error("Project creation failed. Run `vocoder init` again.");
2026
+ return 1;
2027
+ }
2028
+ if (!projectResult.repositoryBound && identity?.repoCanonical) {
2029
+ p5.log.warn(
2030
+ `This repository isn't accessible to your GitHub App installation.
2031
+ Translations won't run automatically until you grant access.
2032
+
2033
+ To fix: go to your GitHub App installation settings and add this
2034
+ repository to the allowed list, or switch to "All repositories".
2035
+ ` + (projectResult.configureUrl ? `
2036
+ ${chalk6.dim(projectResult.configureUrl)}
2037
+ ` : "")
2038
+ );
2039
+ }
2040
+ runScaffold({
2041
+ projectName: projectResult.projectName,
2042
+ organizationName: selectedWorkspaceName,
2043
+ sourceLocale: projectResult.sourceLocale,
2044
+ translationTriggers: projectResult.translationTriggers
2045
+ });
2046
+ printMcpSetup(projectResult.apiKey);
2047
+ p5.outro("You're all set.");
2048
+ return 0;
546
2049
  } catch (error) {
547
- spinner4.stop();
548
2050
  if (error instanceof Error) {
549
2051
  if (isPlanLimitFailure(error.message)) {
550
2052
  printPlanLimitMessage(apiUrl, error.message);
551
2053
  return 1;
552
2054
  }
553
- p.log.error(`Error: ${error.message}`);
2055
+ p5.log.error(`Error: ${error.message}`);
554
2056
  } else {
555
- p.log.error("Unknown setup error");
2057
+ p5.log.error("Unknown setup error");
556
2058
  }
557
2059
  return 1;
558
2060
  }
559
2061
  }
560
2062
 
2063
+ // src/commands/logout.ts
2064
+ import * as p6 from "@clack/prompts";
2065
+ async function logout(options = {}) {
2066
+ const stored = readAuthData();
2067
+ if (!stored) {
2068
+ p6.log.info("Not currently authenticated.");
2069
+ return 0;
2070
+ }
2071
+ const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
2072
+ const api = new VocoderAPI({ apiUrl, apiKey: "" });
2073
+ try {
2074
+ await api.revokeCliToken(stored.token);
2075
+ } catch {
2076
+ }
2077
+ clearAuthData();
2078
+ p6.log.success(`Logged out (was ${stored.email})`);
2079
+ return 0;
2080
+ }
2081
+
561
2082
  // src/commands/sync.ts
562
- import * as p2 from "@clack/prompts";
563
- import { createHash as createHash2, randomUUID } from "crypto";
2083
+ import * as p7 from "@clack/prompts";
2084
+ import { createHash, randomUUID } from "crypto";
564
2085
 
565
2086
  // src/utils/branch.ts
566
- import { execSync as execSync2 } from "child_process";
2087
+ import { execSync as execSync4 } from "child_process";
567
2088
  var REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/g;
568
2089
  function escapeRegexChar(value) {
569
2090
  return value.replace(REGEX_SPECIAL_CHARS, "\\$&");
@@ -585,7 +2106,7 @@ function detectBranch(override) {
585
2106
  return envBranch;
586
2107
  }
587
2108
  try {
588
- const branch = execSync2("git rev-parse --abbrev-ref HEAD", {
2109
+ const branch = execSync4("git rev-parse --abbrev-ref HEAD", {
589
2110
  encoding: "utf-8",
590
2111
  stdio: ["pipe", "pipe", "ignore"]
591
2112
  }).trim();
@@ -629,12 +2150,12 @@ function matchBranchPattern(branch, pattern) {
629
2150
  }
630
2151
 
631
2152
  // src/commands/sync.ts
632
- import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
2153
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
633
2154
 
634
2155
  // src/utils/config.ts
635
- import chalk2 from "chalk";
636
- import { config as loadEnv } from "dotenv";
637
- loadEnv();
2156
+ import chalk7 from "chalk";
2157
+ import { config as loadEnv2 } from "dotenv";
2158
+ loadEnv2();
638
2159
  function validateLocalConfig(config) {
639
2160
  if (!config.apiKey || config.apiKey.length === 0) {
640
2161
  throw new Error("VOCODER_API_KEY is required. Set it in your .env file.");
@@ -715,317 +2236,45 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
715
2236
  configSources.maxWaitMs = "environment";
716
2237
  }
717
2238
  }
718
- let noFallback = false;
719
- if (typeof cliOptions.noFallback === "boolean") {
720
- noFallback = cliOptions.noFallback;
721
- configSources.noFallback = "CLI flag";
722
- } else if (envSyncNoFallback) {
723
- noFallback = ["1", "true", "yes", "on"].includes(envSyncNoFallback.toLowerCase());
724
- configSources.noFallback = "environment";
725
- }
726
- if (verbose) {
727
- console.log(chalk2.dim("\n Configuration sources:"));
728
- console.log(chalk2.dim(` Include patterns: ${configSources.extractionPattern}`));
729
- if (excludePattern.length > 0) {
730
- console.log(chalk2.dim(` Exclude patterns: ${configSources.excludePattern}`));
731
- }
732
- console.log(chalk2.dim(` API key: ${configSources.apiKey}`));
733
- console.log(chalk2.dim(` API URL: ${configSources.apiUrl}
734
- `));
735
- console.log(chalk2.dim(` Sync mode: ${configSources.mode}`));
736
- if (maxWaitMs) {
737
- console.log(chalk2.dim(` Max wait: ${configSources.maxWaitMs}`));
738
- }
739
- console.log(chalk2.dim(` No fallback: ${configSources.noFallback}
740
- `));
741
- }
742
- return {
743
- extractionPattern,
744
- excludePattern,
745
- apiKey,
746
- apiUrl,
747
- mode,
748
- maxWaitMs,
749
- noFallback,
750
- configSources
751
- };
752
- }
753
-
754
- // src/utils/extract.ts
755
- import { createHash } from "crypto";
756
- import { readFileSync } from "fs";
757
- import { parse } from "@babel/parser";
758
- import babelTraverse from "@babel/traverse";
759
- import { glob } from "glob";
760
- import { relative as pathRelative } from "path";
761
- var traverse = babelTraverse.default || babelTraverse;
762
- var StringExtractor = class {
763
- /**
764
- * Extract strings from all files matching the pattern(s)
765
- *
766
- * @param pattern - Glob pattern(s) to include
767
- * @param projectRoot - Project root directory
768
- * @param excludePattern - Glob pattern(s) to exclude (optional)
769
- */
770
- async extractFromProject(pattern, projectRoot = process.cwd(), excludePattern) {
771
- const includePatterns = Array.isArray(pattern) ? pattern : [pattern];
772
- const defaultIgnore = ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"];
773
- const ignorePatterns = excludePattern ? [...defaultIgnore, ...Array.isArray(excludePattern) ? excludePattern : [excludePattern]] : defaultIgnore;
774
- const allFiles = /* @__PURE__ */ new Set();
775
- for (const includePattern of includePatterns) {
776
- const files = await glob(includePattern, {
777
- cwd: projectRoot,
778
- absolute: true,
779
- ignore: ignorePatterns
780
- });
781
- files.forEach((file) => allFiles.add(file));
782
- }
783
- const allStrings = [];
784
- const sortedFiles = Array.from(allFiles).sort();
785
- for (const file of sortedFiles) {
786
- try {
787
- const strings = await this.extractFromFile(file, projectRoot);
788
- allStrings.push(...strings);
789
- } catch (error) {
790
- console.warn(`Warning: Failed to extract from ${file}:`, error);
791
- }
792
- }
793
- const unique = this.deduplicateStrings(allStrings);
794
- return unique;
795
- }
796
- /**
797
- * Extract strings from a single file
798
- */
799
- async extractFromFile(filePath, projectRoot) {
800
- const code = readFileSync(filePath, "utf-8");
801
- const strings = [];
802
- const relativeFilePath = pathRelative(projectRoot, filePath).split("\\").join("/");
803
- try {
804
- const ast = parse(code, {
805
- sourceType: "module",
806
- plugins: ["jsx", "typescript"]
807
- });
808
- const vocoderImports = /* @__PURE__ */ new Map();
809
- const tFunctionNames = /* @__PURE__ */ new Set();
810
- traverse(ast, {
811
- // Track imports of <T> component and t function
812
- ImportDeclaration: (path) => {
813
- const source = path.node.source.value;
814
- if (source === "@vocoder/react") {
815
- path.node.specifiers.forEach((spec) => {
816
- if (spec.type === "ImportSpecifier") {
817
- const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
818
- const local = spec.local.name;
819
- if (imported === "T") {
820
- vocoderImports.set(local, "T");
821
- }
822
- if (imported === "t") {
823
- tFunctionNames.add(local);
824
- }
825
- if (imported === "useVocoder") {
826
- }
827
- }
828
- });
829
- }
830
- },
831
- // Track destructured 't' from useVocoder hook
832
- VariableDeclarator: (path) => {
833
- const init2 = path.node.init;
834
- if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === "useVocoder" && path.node.id.type === "ObjectPattern") {
835
- path.node.id.properties.forEach((prop) => {
836
- if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "t") {
837
- const localName = prop.value.type === "Identifier" ? prop.value.name : "t";
838
- tFunctionNames.add(localName);
839
- }
840
- });
841
- }
842
- },
843
- // Extract from t() function calls
844
- CallExpression: (path) => {
845
- const callee = path.node.callee;
846
- const isTFunction = callee.type === "Identifier" && tFunctionNames.has(callee.name);
847
- if (!isTFunction) return;
848
- const firstArg = path.node.arguments[0];
849
- if (!firstArg) return;
850
- let text = null;
851
- if (firstArg.type === "StringLiteral") {
852
- text = firstArg.value;
853
- } else if (firstArg.type === "TemplateLiteral") {
854
- text = this.extractTemplateText(firstArg);
855
- }
856
- if (!text || text.trim().length === 0) return;
857
- const secondArg = path.node.arguments[1];
858
- let context;
859
- let formality;
860
- let explicitKey;
861
- if (secondArg && secondArg.type === "ObjectExpression") {
862
- secondArg.properties.forEach((prop) => {
863
- if (prop.type === "ObjectProperty" && prop.key.type === "Identifier") {
864
- if (prop.key.name === "context" && prop.value.type === "StringLiteral") {
865
- context = prop.value.value;
866
- }
867
- if (prop.key.name === "formality" && prop.value.type === "StringLiteral") {
868
- formality = prop.value.value;
869
- }
870
- if (prop.key.name === "id" && prop.value.type === "StringLiteral") {
871
- explicitKey = prop.value.value.trim();
872
- }
873
- }
874
- });
875
- }
876
- const line = path.node.loc?.start.line || 0;
877
- const column = path.node.loc?.start.column || 0;
878
- const key = explicitKey && explicitKey.length > 0 ? explicitKey : this.generateStableKey({
879
- filePath: relativeFilePath,
880
- kind: "t-call",
881
- line,
882
- column
883
- });
884
- strings.push({
885
- key,
886
- text: text.trim(),
887
- file: filePath,
888
- line,
889
- context,
890
- formality
891
- });
892
- },
893
- // Extract from JSX elements
894
- JSXElement: (path) => {
895
- const opening = path.node.openingElement;
896
- const tagName = opening.name.type === "JSXIdentifier" ? opening.name.name : null;
897
- if (!tagName) return;
898
- const isTranslationComponent = vocoderImports.has(tagName);
899
- if (!isTranslationComponent) return;
900
- const msgAttribute = this.getStringAttribute(opening.attributes, "msg");
901
- const text = msgAttribute || this.extractTextContent(path.node.children);
902
- if (!text || text.trim().length === 0) return;
903
- const id = this.getStringAttribute(opening.attributes, "id");
904
- const context = this.getStringAttribute(opening.attributes, "context");
905
- const formality = this.getStringAttribute(
906
- opening.attributes,
907
- "formality"
908
- );
909
- const line = path.node.loc?.start.line || 0;
910
- const column = path.node.loc?.start.column || 0;
911
- const key = id && id.trim().length > 0 ? id.trim() : this.generateStableKey({
912
- filePath: relativeFilePath,
913
- kind: "jsx",
914
- line,
915
- column
916
- });
917
- strings.push({
918
- key,
919
- text: text.trim(),
920
- file: filePath,
921
- line,
922
- context,
923
- formality
924
- });
925
- }
926
- });
927
- } catch (error) {
928
- throw new Error(
929
- `Failed to parse ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`
930
- );
931
- }
932
- return strings;
933
- }
934
- /**
935
- * Extract text from template literal
936
- * Converts template literals like `Hello ${name}` to `Hello {name}`
937
- */
938
- extractTemplateText(node) {
939
- let text = "";
940
- for (let i = 0; i < node.quasis.length; i++) {
941
- const quasi = node.quasis[i];
942
- text += quasi.value.raw;
943
- if (i < node.expressions.length) {
944
- const expr = node.expressions[i];
945
- if (expr.type === "Identifier") {
946
- text += `{${expr.name}}`;
947
- } else {
948
- text += "{value}";
949
- }
950
- }
951
- }
952
- return text;
953
- }
954
- /**
955
- * Extract text content from JSX children
956
- */
957
- extractTextContent(children) {
958
- let text = "";
959
- for (const child of children) {
960
- if (child.type === "JSXText") {
961
- text += child.value;
962
- } else if (child.type === "JSXExpressionContainer") {
963
- const expr = child.expression;
964
- if (expr.type === "Identifier") {
965
- text += `{${expr.name}}`;
966
- } else if (expr.type === "StringLiteral") {
967
- text += expr.value;
968
- } else if (expr.type === "TemplateLiteral") {
969
- text += this.extractTemplateText(expr);
970
- }
971
- }
972
- }
973
- return text;
974
- }
975
- /**
976
- * Get string value from JSX attribute
977
- * Handles both string literals and template literals
978
- */
979
- getStringAttribute(attributes, name) {
980
- const attr = attributes.find(
981
- (a) => a.type === "JSXAttribute" && a.name.name === name
982
- );
983
- if (!attr || !attr.value) return void 0;
984
- if (attr.value.type === "StringLiteral") {
985
- return attr.value.value;
986
- }
987
- if (attr.value.type === "JSXExpressionContainer") {
988
- const expr = attr.value.expression;
989
- if (expr.type === "TemplateLiteral") {
990
- return this.extractTemplateText(expr);
991
- }
992
- if (expr.type === "StringLiteral") {
993
- return expr.value;
994
- }
995
- }
996
- return void 0;
997
- }
998
- /**
999
- * Deduplicate strings (keep first occurrence)
1000
- */
1001
- deduplicateStrings(strings) {
1002
- const seen = /* @__PURE__ */ new Map();
1003
- const unique = [];
1004
- for (const str of strings) {
1005
- const dedupeKey = `${str.text}|${str.context || ""}|${str.formality || ""}`;
1006
- const existingIndex = seen.get(dedupeKey);
1007
- if (existingIndex === void 0) {
1008
- seen.set(dedupeKey, unique.length);
1009
- unique.push(str);
1010
- continue;
1011
- }
1012
- const existing = unique[existingIndex];
1013
- if (existing && str.key < existing.key) {
1014
- existing.key = str.key;
1015
- }
1016
- }
1017
- return unique;
1018
- }
1019
- generateStableKey(params) {
1020
- const payload = `${params.filePath}|${params.kind}|${params.line}:${params.column}`;
1021
- const digest = createHash("sha1").update(payload).digest("hex");
1022
- return `SK_${digest.slice(0, 24).toUpperCase()}`;
2239
+ let noFallback = false;
2240
+ if (typeof cliOptions.noFallback === "boolean") {
2241
+ noFallback = cliOptions.noFallback;
2242
+ configSources.noFallback = "CLI flag";
2243
+ } else if (envSyncNoFallback) {
2244
+ noFallback = ["1", "true", "yes", "on"].includes(envSyncNoFallback.toLowerCase());
2245
+ configSources.noFallback = "environment";
1023
2246
  }
1024
- };
2247
+ if (verbose) {
2248
+ console.log(chalk7.dim("\n Configuration sources:"));
2249
+ console.log(chalk7.dim(` Include patterns: ${configSources.extractionPattern}`));
2250
+ if (excludePattern.length > 0) {
2251
+ console.log(chalk7.dim(` Exclude patterns: ${configSources.excludePattern}`));
2252
+ }
2253
+ console.log(chalk7.dim(` API key: ${configSources.apiKey}`));
2254
+ console.log(chalk7.dim(` API URL: ${configSources.apiUrl}
2255
+ `));
2256
+ console.log(chalk7.dim(` Sync mode: ${configSources.mode}`));
2257
+ if (maxWaitMs) {
2258
+ console.log(chalk7.dim(` Max wait: ${configSources.maxWaitMs}`));
2259
+ }
2260
+ console.log(chalk7.dim(` No fallback: ${configSources.noFallback}
2261
+ `));
2262
+ }
2263
+ return {
2264
+ extractionPattern,
2265
+ excludePattern,
2266
+ apiKey,
2267
+ apiUrl,
2268
+ mode,
2269
+ maxWaitMs,
2270
+ noFallback,
2271
+ configSources
2272
+ };
2273
+ }
1025
2274
 
1026
2275
  // src/commands/sync.ts
1027
- import chalk3 from "chalk";
1028
- import { join } from "path";
2276
+ import chalk8 from "chalk";
2277
+ import { join as join2 } from "path";
1029
2278
  function isRecord(value) {
1030
2279
  return typeof value === "object" && value !== null && !Array.isArray(value);
1031
2280
  }
@@ -1071,9 +2320,9 @@ function parseTranslations(value) {
1071
2320
  }
1072
2321
  function getCacheFilePath(projectRoot, branch) {
1073
2322
  const slug = branch.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
1074
- const branchHash = createHash2("sha1").update(branch).digest("hex").slice(0, 12);
2323
+ const branchHash = createHash("sha1").update(branch).digest("hex").slice(0, 12);
1075
2324
  const filename = `${slug || "branch"}-${branchHash}.json`;
1076
- return join(projectRoot, ".vocoder", "cache", "sync", filename);
2325
+ return join2(projectRoot, "node_modules", ".vocoder", "cache", "sync", filename);
1077
2326
  }
1078
2327
  function readLocalSnapshotCache(params) {
1079
2328
  const candidateBranches = params.branch === "main" ? ["main"] : [params.branch, "main"];
@@ -1109,7 +2358,7 @@ function readLocalSnapshotCache(params) {
1109
2358
  }
1110
2359
  function writeLocalSnapshotCache(params) {
1111
2360
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.branch);
1112
- mkdirSync(join(params.projectRoot, ".vocoder", "cache", "sync"), {
2361
+ mkdirSync2(join2(params.projectRoot, "node_modules", ".vocoder", "cache", "sync"), {
1113
2362
  recursive: true
1114
2363
  });
1115
2364
  const payload = {
@@ -1123,7 +2372,7 @@ function writeLocalSnapshotCache(params) {
1123
2372
  ...params.localeMetadata ? { localeMetadata: params.localeMetadata } : {},
1124
2373
  translations: params.translations
1125
2374
  };
1126
- writeFileSync(cacheFilePath, JSON.stringify(payload, null, 2), "utf-8");
2375
+ writeFileSync2(cacheFilePath, JSON.stringify(payload, null, 2), "utf-8");
1127
2376
  return cacheFilePath;
1128
2377
  }
1129
2378
  function resolveEffectiveModeFromPolicy(params) {
@@ -1278,12 +2527,12 @@ async function fetchApiSnapshot(api, params) {
1278
2527
  async function sync(options = {}) {
1279
2528
  const startTime = Date.now();
1280
2529
  const projectRoot = process.cwd();
1281
- p2.intro("Vocoder Sync");
1282
- const spinner4 = p2.spinner();
2530
+ p7.intro("Vocoder Sync");
2531
+ const spinner4 = p7.spinner();
1283
2532
  try {
1284
2533
  spinner4.start("Detecting branch");
1285
2534
  const branch = detectBranch(options.branch);
1286
- spinner4.stop(`Branch: ${chalk3.cyan(branch)}`);
2535
+ spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
1287
2536
  spinner4.start("Loading project configuration");
1288
2537
  const mergedConfig = await getMergedConfig(options, options.verbose);
1289
2538
  const localConfig = {
@@ -1308,12 +2557,12 @@ async function sync(options = {}) {
1308
2557
  };
1309
2558
  spinner4.stop("Project configuration loaded");
1310
2559
  if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
1311
- p2.log.warn(
1312
- `Skipping translations (${chalk3.cyan(branch)} is not a target branch)`
2560
+ p7.log.warn(
2561
+ `Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
1313
2562
  );
1314
- p2.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
1315
- p2.log.info("Use --force to translate anyway");
1316
- p2.outro("");
2563
+ p7.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
2564
+ p7.log.info("Use --force to translate anyway");
2565
+ p7.outro("");
1317
2566
  return 0;
1318
2567
  }
1319
2568
  const patternsDisplay = Array.isArray(config.extractionPattern) ? config.extractionPattern.join(", ") : config.extractionPattern;
@@ -1326,22 +2575,22 @@ async function sync(options = {}) {
1326
2575
  );
1327
2576
  if (extractedStrings.length === 0) {
1328
2577
  spinner4.stop("No translatable strings found");
1329
- p2.log.warn("Make sure you are using <T> components from @vocoder/react");
1330
- p2.outro("");
2578
+ p7.log.warn("Make sure you are wrapping translatable strings with Vocoder");
2579
+ p7.outro("");
1331
2580
  return 0;
1332
2581
  }
1333
2582
  spinner4.stop(
1334
- `Extracted ${chalk3.cyan(extractedStrings.length)} strings from ${chalk3.cyan(patternsDisplay)}`
2583
+ `Extracted ${chalk8.cyan(extractedStrings.length)} strings from ${chalk8.cyan(patternsDisplay)}`
1335
2584
  );
1336
2585
  if (options.verbose) {
1337
2586
  const sampleLines = extractedStrings.slice(0, 5).map((s) => ` "${s.text}" (${s.file}:${s.line})`);
1338
2587
  if (extractedStrings.length > 5) {
1339
2588
  sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
1340
2589
  }
1341
- p2.note(sampleLines.join("\n"), "Sample strings");
2590
+ p7.note(sampleLines.join("\n"), "Sample strings");
1342
2591
  }
1343
2592
  if (options.dryRun) {
1344
- p2.note(
2593
+ p7.note(
1345
2594
  [
1346
2595
  `Strings: ${extractedStrings.length}`,
1347
2596
  `Branch: ${branch}`,
@@ -1352,19 +2601,19 @@ async function sync(options = {}) {
1352
2601
  ].join("\n"),
1353
2602
  "Dry run - would translate"
1354
2603
  );
1355
- p2.outro("No API calls made.");
2604
+ p7.outro("No API calls made.");
1356
2605
  return 0;
1357
2606
  }
1358
2607
  const repoIdentity = resolveGitRepositoryIdentity();
1359
2608
  if (!repoIdentity && options.verbose) {
1360
- p2.log.warn(
2609
+ p7.log.warn(
1361
2610
  "Could not detect git remote origin. Sync will continue without repo metadata."
1362
2611
  );
1363
2612
  }
1364
2613
  const stringEntries = buildStringEntries(extractedStrings);
1365
2614
  const sourceStrings = stringEntries.map((entry) => entry.text);
1366
2615
  if (options.verbose && stringEntries.length !== extractedStrings.length) {
1367
- p2.log.info(
2616
+ p7.log.info(
1368
2617
  `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
1369
2618
  );
1370
2619
  }
@@ -1380,38 +2629,38 @@ async function sync(options = {}) {
1380
2629
  },
1381
2630
  repoIdentity ?? void 0
1382
2631
  );
1383
- spinner4.stop(`Submitted to API - Batch ${chalk3.cyan(batchResponse.batchId)}`);
2632
+ spinner4.stop(`Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`);
1384
2633
  const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
1385
2634
  branch,
1386
2635
  requestedMode,
1387
2636
  policy: config.syncPolicy
1388
2637
  });
1389
2638
  if (options.verbose) {
1390
- p2.log.info(`Requested mode: ${requestedMode}`);
1391
- p2.log.info(`Effective mode: ${effectiveMode}`);
1392
- p2.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2639
+ p7.log.info(`Requested mode: ${requestedMode}`);
2640
+ p7.log.info(`Effective mode: ${effectiveMode}`);
2641
+ p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
1393
2642
  if (batchResponse.queueStatus) {
1394
- p2.log.info(`Queue status: ${batchResponse.queueStatus}`);
2643
+ p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
1395
2644
  }
1396
2645
  }
1397
2646
  if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
1398
- p2.log.success("No changes detected - strings are up to date");
2647
+ p7.log.success("No changes detected - strings are up to date");
1399
2648
  }
1400
- p2.log.info(`New strings: ${chalk3.cyan(batchResponse.newStrings)}`);
2649
+ p7.log.info(`New strings: ${chalk8.cyan(batchResponse.newStrings)}`);
1401
2650
  if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
1402
- p2.log.info(
1403
- `Deleted strings: ${chalk3.yellow(batchResponse.deletedStrings)} (archived)`
2651
+ p7.log.info(
2652
+ `Deleted strings: ${chalk8.yellow(batchResponse.deletedStrings)} (archived)`
1404
2653
  );
1405
2654
  }
1406
- p2.log.info(`Total strings: ${chalk3.cyan(batchResponse.totalStrings)}`);
2655
+ p7.log.info(`Total strings: ${chalk8.cyan(batchResponse.totalStrings)}`);
1407
2656
  if (batchResponse.newStrings === 0) {
1408
- p2.log.success("No new strings - using existing translations");
2657
+ p7.log.success("No new strings - using existing translations");
1409
2658
  } else {
1410
- p2.log.info(
2659
+ p7.log.info(
1411
2660
  `Syncing to ${config.targetLocales.length} locales (${config.targetLocales.join(", ")})`
1412
2661
  );
1413
2662
  if (batchResponse.estimatedTime) {
1414
- p2.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
2663
+ p7.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
1415
2664
  }
1416
2665
  }
1417
2666
  let artifacts = null;
@@ -1449,7 +2698,7 @@ async function sync(options = {}) {
1449
2698
  if (effectiveMode === "required") {
1450
2699
  throw waitError;
1451
2700
  }
1452
- p2.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2701
+ p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
1453
2702
  }
1454
2703
  }
1455
2704
  if (!artifacts) {
@@ -1483,7 +2732,7 @@ async function sync(options = {}) {
1483
2732
  spinner4.stop("Failed to fetch API snapshot");
1484
2733
  if (options.verbose) {
1485
2734
  const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
1486
- p2.log.warn(`Snapshot fetch error: ${message}`);
2735
+ p7.log.warn(`Snapshot fetch error: ${message}`);
1487
2736
  }
1488
2737
  }
1489
2738
  }
@@ -1516,1217 +2765,89 @@ async function sync(options = {}) {
1516
2765
  completedAt: artifacts.completedAt ?? (artifacts.source === "fresh" ? (/* @__PURE__ */ new Date()).toISOString() : null)
1517
2766
  });
1518
2767
  if (options.verbose) {
1519
- p2.log.info(`Cached snapshot: ${cachePath}`);
2768
+ p7.log.info(`Cached snapshot: ${cachePath}`);
1520
2769
  }
1521
2770
  } catch (error) {
1522
2771
  if (options.verbose) {
1523
2772
  const message = error instanceof Error ? error.message : "Unknown cache write error";
1524
- p2.log.warn(`Failed to write local snapshot cache: ${message}`);
2773
+ p7.log.warn(`Failed to write local snapshot cache: ${message}`);
1525
2774
  }
1526
2775
  }
1527
2776
  if (artifacts.source !== "fresh") {
1528
2777
  const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
1529
- p2.log.warn(
2778
+ p7.log.warn(
1530
2779
  `Using ${sourceLabel}. New strings may appear after the background sync completes.`
1531
2780
  );
1532
2781
  }
1533
2782
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
1534
- p2.outro(`Sync complete! (${duration}s)`);
1535
- p2.log.info("Translations will be injected at build time by @vocoder/unplugin.");
1536
- p2.log.info("Just use <VocoderProvider> and <T> \u2014 no manual imports needed.");
2783
+ p7.outro(`Sync complete! (${duration}s)`);
2784
+ p7.log.info("Translations will be injected at build time by @vocoder/unplugin.");
2785
+ p7.log.info("Just use <VocoderProvider> and <T> \u2014 no manual imports needed.");
1537
2786
  return 0;
1538
2787
  } catch (error) {
1539
2788
  spinner4.stop();
1540
2789
  if (error instanceof VocoderAPIError && error.syncPolicyError) {
1541
- p2.log.error(error.syncPolicyError.message);
2790
+ p7.log.error(error.syncPolicyError.message);
1542
2791
  const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
1543
2792
  for (const line of guidance) {
1544
- p2.log.info(line);
2793
+ p7.log.info(line);
1545
2794
  }
1546
2795
  return 1;
1547
2796
  }
1548
2797
  if (error instanceof VocoderAPIError && error.limitError) {
1549
2798
  const { limitError } = error;
1550
- p2.log.error(limitError.message);
2799
+ p7.log.error(limitError.message);
1551
2800
  const guidance = getLimitErrorGuidance(limitError);
1552
2801
  for (const line of guidance) {
1553
- p2.log.info(line);
2802
+ p7.log.info(line);
1554
2803
  }
1555
2804
  return 1;
1556
2805
  }
1557
2806
  if (error instanceof Error) {
1558
- p2.log.error(error.message);
2807
+ p7.log.error(error.message);
1559
2808
  if (error.message.includes("VOCODER_API_KEY")) {
1560
- p2.log.warn("VOCODER_API_KEY is only needed for `vocoder sync` (CLI push).");
1561
- p2.log.info(" Create one at: https://vocoder.app/dashboard");
1562
- p2.log.info(' Then: export VOCODER_API_KEY="vc_..." or add it to .env');
1563
- p2.log.info("");
1564
- p2.log.info(" Note: If you use @vocoder/unplugin, `vocoder sync` is optional.");
1565
- p2.log.info(" Translations are fetched automatically at build time.");
2809
+ p7.log.warn("VOCODER_API_KEY is only needed for `vocoder sync` (CLI push).");
2810
+ p7.log.info(" Create one at: https://vocoder.app/dashboard");
2811
+ p7.log.info(' Then: export VOCODER_API_KEY="vc_..." or add it to .env');
2812
+ p7.log.info("");
2813
+ p7.log.info(" Note: If you use @vocoder/unplugin, `vocoder sync` is optional.");
2814
+ p7.log.info(" Translations are fetched automatically at build time.");
1566
2815
  } else if (error.message.includes("git branch")) {
1567
- p2.log.warn("Run from a git repository, or use:");
1568
- p2.log.info(" vocoder sync --branch main");
2816
+ p7.log.warn("Run from a git repository, or use:");
2817
+ p7.log.info(" vocoder sync --branch main");
1569
2818
  }
1570
2819
  if (options.verbose) {
1571
- p2.log.info(`Full error: ${error.stack ?? error}`);
2820
+ p7.log.info(`Full error: ${error.stack ?? error}`);
1572
2821
  }
1573
2822
  }
1574
2823
  return 1;
1575
2824
  }
1576
2825
  }
1577
2826
 
1578
- // src/commands/wrap.ts
1579
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
1580
- import { relative as relative2 } from "path";
1581
- import * as p3 from "@clack/prompts";
1582
- import chalk4 from "chalk";
1583
-
1584
- // src/utils/wrap/analyzer.ts
1585
- import { readFileSync as readFileSync3 } from "fs";
1586
- import { parse as parse2 } from "@babel/parser";
1587
- import babelTraverse2 from "@babel/traverse";
1588
- import { glob as glob2 } from "glob";
1589
-
1590
- // src/utils/wrap/heuristics.ts
1591
- var URL_REGEX = /^(https?:\/\/|\/\/|mailto:|tel:|ftp:\/\/)/i;
1592
- var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1593
- var FILE_PATH_REGEX = /^(\.{0,2}\/|[a-zA-Z]:\\)/;
1594
- var COLOR_HEX_REGEX = /^#([0-9a-fA-F]{3,8})$/;
1595
- var COLOR_FUNC_REGEX = /^(rgb|rgba|hsl|hsla)\s*\(/i;
1596
- var CAMEL_CASE_REGEX = /^[a-z][a-zA-Z0-9]*$/;
1597
- var PASCAL_CASE_REGEX = /^[A-Z][a-zA-Z0-9]*$/;
1598
- var SCREAMING_SNAKE_REGEX = /^[A-Z][A-Z0-9_]+$/;
1599
- var KEBAB_CASE_REGEX = /^[a-z][a-z0-9-]+$/;
1600
- var MIME_TYPE_REGEX = /^(application|text|image|audio|video|font|multipart)\//;
1601
- var DATE_FORMAT_REGEX = /^[YMDHhmsaAZz\-\/\.\s:,]+$/;
1602
- var CSS_UNIT_REGEX = /^\d+(\.\d+)?(px|em|rem|vh|vw|%|ch|ex|pt|pc|in|cm|mm)$/;
1603
- var TAILWIND_REGEX = /^[a-z][\w-]*(\s+[a-z][\w-]*)*$/;
1604
- var TAILWIND_PREFIXES = [
1605
- "flex",
1606
- "grid",
1607
- "block",
1608
- "inline",
1609
- "hidden",
1610
- "absolute",
1611
- "relative",
1612
- "fixed",
1613
- "sticky",
1614
- "top",
1615
- "bottom",
1616
- "left",
1617
- "right",
1618
- "inset",
1619
- "w-",
1620
- "h-",
1621
- "min-",
1622
- "max-",
1623
- "p-",
1624
- "px-",
1625
- "py-",
1626
- "pt-",
1627
- "pb-",
1628
- "pl-",
1629
- "pr-",
1630
- "m-",
1631
- "mx-",
1632
- "my-",
1633
- "mt-",
1634
- "mb-",
1635
- "ml-",
1636
- "mr-",
1637
- "text-",
1638
- "font-",
1639
- "leading-",
1640
- "tracking-",
1641
- "bg-",
1642
- "border-",
1643
- "rounded-",
1644
- "shadow-",
1645
- "opacity-",
1646
- "z-",
1647
- "gap-",
1648
- "space-",
1649
- "items-",
1650
- "justify-",
1651
- "self-",
1652
- "place-",
1653
- "overflow-",
1654
- "cursor-",
1655
- "transition-",
1656
- "duration-",
1657
- "ease-",
1658
- "sm:",
1659
- "md:",
1660
- "lg:",
1661
- "xl:",
1662
- "2xl:",
1663
- "dark:",
1664
- "hover:",
1665
- "focus:",
1666
- "active:",
1667
- "group-",
1668
- "peer-"
1669
- ];
1670
- var NON_TRANSLATABLE_ATTRIBUTES = /* @__PURE__ */ new Set([
1671
- "className",
1672
- "class",
1673
- "href",
1674
- "src",
1675
- "id",
1676
- "key",
1677
- "ref",
1678
- "style",
1679
- "data-testid",
1680
- "data-cy",
1681
- "data-test",
1682
- "type",
1683
- "name",
1684
- "value",
1685
- "action",
1686
- "method",
1687
- "encType",
1688
- "target",
1689
- "rel",
1690
- "role",
1691
- "tabIndex",
1692
- "htmlFor",
1693
- "for",
1694
- "width",
1695
- "height",
1696
- "viewBox",
1697
- "xmlns",
1698
- "fill",
1699
- "stroke",
1700
- "onClick",
1701
- "onChange",
1702
- "onSubmit",
1703
- "onBlur",
1704
- "onFocus",
1705
- "onKeyDown",
1706
- "onKeyUp",
1707
- "onKeyPress",
1708
- "onMouseEnter",
1709
- "onMouseLeave"
1710
- ]);
1711
- var TRANSLATABLE_ATTRIBUTES = /* @__PURE__ */ new Set([
1712
- "title",
1713
- "placeholder",
1714
- "alt",
1715
- "aria-label",
1716
- "aria-description",
1717
- "aria-placeholder",
1718
- "aria-roledescription",
1719
- "aria-valuetext",
1720
- "label",
1721
- "description",
1722
- "message",
1723
- "heading",
1724
- "caption",
1725
- "helperText",
1726
- "errorMessage",
1727
- "successMessage",
1728
- "tooltip"
1729
- ]);
1730
- var NON_TRANSLATABLE_CALLS = /* @__PURE__ */ new Set([
1731
- "console.log",
1732
- "console.warn",
1733
- "console.error",
1734
- "console.info",
1735
- "console.debug",
1736
- "require",
1737
- "import",
1738
- "addEventListener",
1739
- "removeEventListener",
1740
- "querySelector",
1741
- "querySelectorAll",
1742
- "getElementById",
1743
- "getAttribute",
1744
- "setAttribute",
1745
- "createElement",
1746
- "JSON.parse",
1747
- "JSON.stringify",
1748
- "parseInt",
1749
- "parseFloat",
1750
- "encodeURIComponent",
1751
- "decodeURIComponent",
1752
- "encodeURI",
1753
- "decodeURI",
1754
- "RegExp"
1755
- ]);
1756
- var TRANSLATABLE_VAR_NAMES = /* @__PURE__ */ new Set([
1757
- "label",
1758
- "message",
1759
- "title",
1760
- "description",
1761
- "heading",
1762
- "text",
1763
- "caption",
1764
- "subtitle",
1765
- "tooltip",
1766
- "errorMessage",
1767
- "successMessage",
1768
- "warningMessage",
1769
- "infoMessage",
1770
- "placeholder",
1771
- "helperText",
1772
- "hint",
1773
- "buttonText",
1774
- "linkText",
1775
- "headerText",
1776
- "footerText",
1777
- "confirmText",
1778
- "cancelText",
1779
- "submitText",
1780
- "greeting",
1781
- "welcome",
1782
- "instructions"
1783
- ]);
1784
- function classifyString(text, context, metadata = {}) {
1785
- const trimmed = text.trim();
1786
- if (trimmed.length === 0) {
1787
- return { translatable: false, confidence: "high", reason: "Empty or whitespace-only" };
1788
- }
1789
- if (trimmed.length === 1) {
1790
- return { translatable: false, confidence: "high", reason: "Single character" };
1791
- }
1792
- if (!/[a-zA-Z]/.test(trimmed)) {
1793
- return { translatable: false, confidence: "high", reason: "No alphabetic characters" };
1794
- }
1795
- if (URL_REGEX.test(trimmed)) {
1796
- return { translatable: false, confidence: "high", reason: "URL" };
1797
- }
1798
- if (EMAIL_REGEX.test(trimmed)) {
1799
- return { translatable: false, confidence: "high", reason: "Email address" };
1800
- }
1801
- if (FILE_PATH_REGEX.test(trimmed) && !trimmed.includes(" ")) {
1802
- return { translatable: false, confidence: "high", reason: "File path" };
1803
- }
1804
- if (COLOR_HEX_REGEX.test(trimmed) || COLOR_FUNC_REGEX.test(trimmed)) {
1805
- return { translatable: false, confidence: "high", reason: "Color code" };
1806
- }
1807
- if (CSS_UNIT_REGEX.test(trimmed)) {
1808
- return { translatable: false, confidence: "high", reason: "CSS unit value" };
1809
- }
1810
- if (MIME_TYPE_REGEX.test(trimmed)) {
1811
- return { translatable: false, confidence: "high", reason: "MIME type" };
1812
- }
1813
- if (DATE_FORMAT_REGEX.test(trimmed) && trimmed.length > 1) {
1814
- return { translatable: false, confidence: "high", reason: "Date format string" };
1815
- }
1816
- if (context === "jsx-attribute" && metadata.attributeName) {
1817
- if (NON_TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
1818
- return { translatable: false, confidence: "high", reason: `Non-translatable attribute: ${metadata.attributeName}` };
1819
- }
1820
- if (metadata.attributeName.startsWith("data-") && !TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
1821
- return { translatable: false, confidence: "high", reason: "data-* attribute" };
1822
- }
1823
- if (metadata.attributeName.startsWith("on") && metadata.attributeName.length > 2) {
1824
- const thirdChar = metadata.attributeName[2];
1825
- if (thirdChar && thirdChar === thirdChar.toUpperCase()) {
1826
- return { translatable: false, confidence: "high", reason: "Event handler attribute" };
1827
- }
1828
- }
1829
- if (TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
1830
- return { translatable: true, confidence: "high", reason: `Translatable attribute: ${metadata.attributeName}` };
1831
- }
1832
- }
1833
- if (context === "jsx-text") {
1834
- const hasWords = /[a-zA-Z]{2,}/.test(trimmed);
1835
- if (hasWords) {
1836
- return { translatable: true, confidence: "high", reason: "JSX text with words" };
1837
- }
1838
- }
1839
- if (!trimmed.includes(" ") && (CAMEL_CASE_REGEX.test(trimmed) || PASCAL_CASE_REGEX.test(trimmed) || SCREAMING_SNAKE_REGEX.test(trimmed) || KEBAB_CASE_REGEX.test(trimmed))) {
1840
- return { translatable: false, confidence: "high", reason: "Code identifier" };
1841
- }
1842
- if (isTailwindClasses(trimmed)) {
1843
- return { translatable: false, confidence: "high", reason: "CSS/Tailwind classes" };
1844
- }
1845
- if (metadata.isInsideCallExpression) {
1846
- if (NON_TRANSLATABLE_CALLS.has(metadata.isInsideCallExpression)) {
1847
- return { translatable: false, confidence: "high", reason: `Inside ${metadata.isInsideCallExpression}()` };
1848
- }
1849
- }
1850
- if (metadata.parentType === "ThrowStatement" || metadata.isInsideCallExpression === "Error") {
1851
- return { translatable: false, confidence: "high", reason: "Error message" };
1852
- }
1853
- if ((context === "string-literal" || context === "template-literal") && metadata.parentType === "VariableDeclarator") {
1854
- return { translatable: true, confidence: "medium", reason: "String in variable declaration" };
1855
- }
1856
- const wordCount = trimmed.split(/\s+/).length;
1857
- if (wordCount >= 3) {
1858
- return { translatable: true, confidence: "medium", reason: `Multi-word string (${wordCount} words)` };
1859
- }
1860
- if (wordCount === 2 && /[a-zA-Z]{2,}/.test(trimmed)) {
1861
- return { translatable: true, confidence: "low", reason: "Short phrase (2 words)" };
1862
- }
1863
- if (/^[A-Z][a-z]/.test(trimmed) && context !== "string-literal") {
1864
- return { translatable: true, confidence: "low", reason: "Capitalized word, possibly UI text" };
1865
- }
1866
- return { translatable: false, confidence: "low", reason: "Ambiguous single-word string" };
1867
- }
1868
- function isTranslatableVarName(name) {
1869
- const lower = name.toLowerCase();
1870
- for (const varName of TRANSLATABLE_VAR_NAMES) {
1871
- if (lower === varName.toLowerCase() || lower.endsWith(varName.toLowerCase())) {
1872
- return true;
1873
- }
1874
- }
1875
- return false;
1876
- }
1877
- function isTailwindClasses(text) {
1878
- if (!TAILWIND_REGEX.test(text)) return false;
1879
- const parts = text.split(/\s+/);
1880
- let tailwindCount = 0;
1881
- for (const part of parts) {
1882
- if (TAILWIND_PREFIXES.some((prefix) => part.startsWith(prefix))) {
1883
- tailwindCount++;
1884
- }
1885
- }
1886
- return tailwindCount > parts.length / 2;
1887
- }
1888
-
1889
- // src/utils/wrap/analyzer.ts
1890
- var traverse2 = babelTraverse2.default || babelTraverse2;
1891
- var StringAnalyzer = class {
1892
- constructor(adapter) {
1893
- this.adapter = adapter;
1894
- }
1895
- /**
1896
- * Analyze all files matching the given patterns and return wrap candidates.
1897
- */
1898
- async analyzeProject(options, projectRoot = process.cwd()) {
1899
- const includePatterns = options.include?.length ? options.include : ["src/**/*.{tsx,jsx,ts,js}"];
1900
- const defaultIgnore = [
1901
- "**/node_modules/**",
1902
- "**/.next/**",
1903
- "**/dist/**",
1904
- "**/build/**",
1905
- "**/*.test.*",
1906
- "**/*.spec.*",
1907
- "**/*.stories.*",
1908
- "**/__tests__/**"
1909
- ];
1910
- const ignorePatterns = options.exclude ? [...defaultIgnore, ...options.exclude] : defaultIgnore;
1911
- const allFiles = /* @__PURE__ */ new Set();
1912
- for (const pattern of includePatterns) {
1913
- const files = await glob2(pattern, {
1914
- cwd: projectRoot,
1915
- absolute: true,
1916
- ignore: ignorePatterns
1917
- });
1918
- files.forEach((file) => allFiles.add(file));
1919
- }
1920
- const allCandidates = [];
1921
- for (const file of allFiles) {
1922
- try {
1923
- const candidates = this.analyzeFile(file);
1924
- allCandidates.push(...candidates);
1925
- } catch (error) {
1926
- if (options.verbose) {
1927
- const msg = error instanceof Error ? error.message : "Unknown error";
1928
- console.warn(`Warning: Failed to analyze ${file}: ${msg}`);
1929
- }
1930
- }
1931
- }
1932
- return allCandidates;
1933
- }
1934
- /**
1935
- * Analyze a single file and return wrap candidates.
1936
- */
1937
- analyzeFile(filePath) {
1938
- const code = readFileSync3(filePath, "utf-8");
1939
- return this.analyzeCode(code, filePath);
1940
- }
1941
- /**
1942
- * Analyze source code and return wrap candidates.
1943
- */
1944
- analyzeCode(code, filePath = "<input>") {
1945
- const candidates = [];
1946
- const ast = parse2(code, {
1947
- sourceType: "module",
1948
- plugins: ["jsx", "typescript"]
1949
- });
1950
- const vocoderImports = /* @__PURE__ */ new Map();
1951
- const tFunctionNames = /* @__PURE__ */ new Set();
1952
- traverse2(ast, {
1953
- // Track imports from @vocoder/react
1954
- ImportDeclaration: (path) => {
1955
- const source = path.node.source.value;
1956
- if (source === this.adapter.importSource) {
1957
- path.node.specifiers.forEach((spec) => {
1958
- if (spec.type === "ImportSpecifier") {
1959
- const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
1960
- const local = spec.local.name;
1961
- if (imported === this.adapter.componentName) {
1962
- vocoderImports.set(local, this.adapter.componentName);
1963
- }
1964
- if (imported === this.adapter.functionName) {
1965
- tFunctionNames.add(local);
1966
- }
1967
- if (imported === this.adapter.hookName) {
1968
- vocoderImports.set(local, this.adapter.hookName);
1969
- }
1970
- }
1971
- });
1972
- }
1973
- },
1974
- // Track destructured t from useVocoder()
1975
- VariableDeclarator: (path) => {
1976
- const init2 = path.node.init;
1977
- if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === this.adapter.hookName && path.node.id.type === "ObjectPattern") {
1978
- path.node.id.properties.forEach((prop) => {
1979
- if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === this.adapter.functionName) {
1980
- const localName = prop.value.type === "Identifier" ? prop.value.name : this.adapter.functionName;
1981
- tFunctionNames.add(localName);
1982
- }
1983
- });
1984
- }
1985
- },
1986
- // Find bare JSX text
1987
- JSXText: (path) => {
1988
- const text = path.node.value;
1989
- const trimmed = text.trim();
1990
- if (!trimmed) return;
1991
- const ancestors = path.getAncestry().map((a) => a.node);
1992
- if (this.adapter.isAlreadyWrapped(ancestors, vocoderImports)) return;
1993
- const classification = classifyString(trimmed, "jsx-text", {
1994
- isInsideComponent: true
1995
- });
1996
- if (classification.translatable) {
1997
- candidates.push({
1998
- file: filePath,
1999
- line: path.node.loc?.start.line || 0,
2000
- column: path.node.loc?.start.column || 0,
2001
- text: trimmed,
2002
- confidence: classification.confidence,
2003
- strategy: "T-component",
2004
- context: "jsx-text",
2005
- reason: classification.reason
2006
- });
2007
- }
2008
- },
2009
- // Find translatable JSX attributes
2010
- JSXAttribute: (path) => {
2011
- const attrName = path.node.name?.name;
2012
- if (!attrName) return;
2013
- const value = path.node.value;
2014
- if (!value) return;
2015
- let text = null;
2016
- let context = "jsx-attribute";
2017
- if (value.type === "StringLiteral") {
2018
- text = value.value;
2019
- } else if (value.type === "JSXExpressionContainer" && value.expression.type === "StringLiteral") {
2020
- text = value.expression.value;
2021
- }
2022
- if (!text || !text.trim()) return;
2023
- if (value.type === "JSXExpressionContainer" && value.expression.type === "CallExpression") {
2024
- if (this.adapter.isAlreadyWrappedCall(value.expression, tFunctionNames)) return;
2025
- }
2026
- const classification = classifyString(text.trim(), context, {
2027
- attributeName: attrName,
2028
- isInsideComponent: true
2029
- });
2030
- if (classification.translatable) {
2031
- candidates.push({
2032
- file: filePath,
2033
- line: path.node.loc?.start.line || 0,
2034
- column: path.node.loc?.start.column || 0,
2035
- text: text.trim(),
2036
- confidence: classification.confidence,
2037
- strategy: "t-function",
2038
- context,
2039
- reason: classification.reason
2040
- });
2041
- }
2042
- },
2043
- // Find string literals in non-JSX contexts
2044
- StringLiteral: (path) => {
2045
- if (path.parent.type === "ImportDeclaration") return;
2046
- if (path.parent.type === "ExportDeclaration") return;
2047
- if (path.parent.type === "JSXAttribute") return;
2048
- if (path.parent.type === "JSXExpressionContainer" && path.parentPath?.parent?.type === "JSXAttribute") return;
2049
- if (path.parent.type === "JSXExpressionContainer") return;
2050
- if (path.parent.type === "ObjectProperty" && path.parent.key === path.node) return;
2051
- if (path.parent.type === "TSLiteralType") return;
2052
- if (isInsideTCall(path, tFunctionNames)) return;
2053
- const text = path.node.value;
2054
- if (!text.trim()) return;
2055
- const callExpr = getEnclosingCallExpression(path);
2056
- const parentType = path.parent.type;
2057
- const classification = classifyString(text.trim(), "string-literal", {
2058
- parentType,
2059
- isInsideCallExpression: callExpr,
2060
- isInsideComponent: false
2061
- });
2062
- let { confidence } = classification;
2063
- if (parentType === "VariableDeclarator" && path.parent.id?.type === "Identifier") {
2064
- const varName = path.parent.id.name;
2065
- if (isTranslatableVarName(varName) && classification.translatable) {
2066
- confidence = "high";
2067
- }
2068
- }
2069
- if (classification.translatable) {
2070
- candidates.push({
2071
- file: filePath,
2072
- line: path.node.loc?.start.line || 0,
2073
- column: path.node.loc?.start.column || 0,
2074
- text: text.trim(),
2075
- confidence,
2076
- strategy: "t-function",
2077
- context: "string-literal",
2078
- reason: classification.reason
2079
- });
2080
- }
2081
- },
2082
- // Find template literals
2083
- TemplateLiteral: (path) => {
2084
- if (path.parent.type === "ImportDeclaration") return;
2085
- if (path.parent.type === "TaggedTemplateExpression") return;
2086
- if (isInsideTCall(path, tFunctionNames)) return;
2087
- const quasis = path.node.quasis;
2088
- if (quasis.length === 0) return;
2089
- const parts = [];
2090
- for (let i = 0; i < quasis.length; i++) {
2091
- const quasi = quasis[i];
2092
- parts.push(quasi.value.raw);
2093
- if (i < path.node.expressions.length) {
2094
- const expr = path.node.expressions[i];
2095
- if (expr.type === "Identifier") {
2096
- parts.push(`{${expr.name}}`);
2097
- } else {
2098
- parts.push("{value}");
2099
- }
2100
- }
2101
- }
2102
- const text = parts.join("").trim();
2103
- if (!text) return;
2104
- const callExpr = getEnclosingCallExpression(path);
2105
- const parentType = path.parent.type;
2106
- const classification = classifyString(text, "template-literal", {
2107
- parentType,
2108
- isInsideCallExpression: callExpr,
2109
- isInsideComponent: false
2110
- });
2111
- if (classification.translatable) {
2112
- candidates.push({
2113
- file: filePath,
2114
- line: path.node.loc?.start.line || 0,
2115
- column: path.node.loc?.start.column || 0,
2116
- text,
2117
- confidence: classification.confidence,
2118
- strategy: "t-function",
2119
- context: "template-literal",
2120
- reason: classification.reason
2121
- });
2122
- }
2123
- }
2124
- });
2125
- return candidates;
2126
- }
2127
- };
2128
- function isInsideTCall(path, tNames) {
2129
- let current = path.parentPath;
2130
- while (current) {
2131
- if (current.node.type === "CallExpression") {
2132
- const callee = current.node.callee;
2133
- if (callee.type === "Identifier" && tNames.has(callee.name)) {
2134
- return true;
2135
- }
2136
- }
2137
- current = current.parentPath;
2138
- }
2139
- return false;
2140
- }
2141
- function getEnclosingCallExpression(path) {
2142
- let current = path.parentPath;
2143
- while (current) {
2144
- if (current.node.type === "CallExpression") {
2145
- const callee = current.node.callee;
2146
- if (callee.type === "Identifier") {
2147
- return callee.name;
2148
- }
2149
- if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
2150
- return `${callee.object.name}.${callee.property.name}`;
2151
- }
2152
- }
2153
- if (current.node.type === "NewExpression") {
2154
- const callee = current.node.callee;
2155
- if (callee.type === "Identifier") {
2156
- return callee.name;
2157
- }
2158
- }
2159
- current = current.parentPath;
2160
- }
2161
- return void 0;
2162
- }
2163
-
2164
- // src/utils/wrap/transformer.ts
2165
- import * as recast from "recast";
2166
- import { parse as babelParse } from "@babel/parser";
2167
- var babelParser = {
2168
- parse(source) {
2169
- return babelParse(source, {
2170
- sourceType: "module",
2171
- plugins: ["jsx", "typescript"],
2172
- tokens: true
2173
- });
2174
- }
2175
- };
2176
- var StringTransformer = class {
2177
- constructor(adapter) {
2178
- this.adapter = adapter;
2179
- }
2180
- /**
2181
- * Transform a file by wrapping the given candidates.
2182
- * Returns the transformed source code.
2183
- */
2184
- transform(code, candidates, filePath = "<input>") {
2185
- const ast = recast.parse(code, { parser: babelParser });
2186
- const b = recast.types.builders;
2187
- const wrapped = [];
2188
- const skipped = [];
2189
- const usedStrategies = /* @__PURE__ */ new Set();
2190
- const componentsNeedingHook = /* @__PURE__ */ new Set();
2191
- const candidatesByLocation = /* @__PURE__ */ new Map();
2192
- for (const c of candidates) {
2193
- candidatesByLocation.set(`${c.line}:${c.column}`, c);
2194
- }
2195
- let existingImportDecl = null;
2196
- const existingSpecifiers = /* @__PURE__ */ new Set();
2197
- const adapter = this.adapter;
2198
- recast.visit(ast, {
2199
- visitImportDeclaration(path) {
2200
- const source = path.node.source.value;
2201
- if (source === adapter.importSource) {
2202
- existingImportDecl = path;
2203
- for (const spec of path.node.specifiers || []) {
2204
- if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") {
2205
- existingSpecifiers.add(spec.imported.name);
2206
- }
2207
- }
2208
- }
2209
- this.traverse(path);
2210
- },
2211
- visitJSXText(path) {
2212
- const loc = path.node.loc;
2213
- if (!loc) {
2214
- this.traverse(path);
2215
- return;
2216
- }
2217
- const key = `${loc.start.line}:${loc.start.column}`;
2218
- const candidate = candidatesByLocation.get(key);
2219
- if (!candidate || candidate.strategy !== "T-component") {
2220
- this.traverse(path);
2221
- return;
2222
- }
2223
- const tOpen = b.jsxOpeningElement(
2224
- b.jsxIdentifier(adapter.componentName),
2225
- []
2226
- );
2227
- const tClose = b.jsxClosingElement(
2228
- b.jsxIdentifier(adapter.componentName)
2229
- );
2230
- const tElement = b.jsxElement(
2231
- tOpen,
2232
- tClose,
2233
- [b.jsxText(candidate.text)]
2234
- );
2235
- path.replace(tElement);
2236
- wrapped.push(candidate);
2237
- usedStrategies.add("T-component");
2238
- candidatesByLocation.delete(key);
2239
- return false;
2240
- },
2241
- visitJSXAttribute(path) {
2242
- const loc = path.node.loc;
2243
- if (!loc) {
2244
- this.traverse(path);
2245
- return;
2246
- }
2247
- const key = `${loc.start.line}:${loc.start.column}`;
2248
- const candidate = candidatesByLocation.get(key);
2249
- if (!candidate || candidate.strategy !== "t-function") {
2250
- this.traverse(path);
2251
- return;
2252
- }
2253
- const value = path.node.value;
2254
- if (!value) {
2255
- this.traverse(path);
2256
- return;
2257
- }
2258
- const tCall = b.callExpression(
2259
- b.identifier(adapter.functionName),
2260
- [b.stringLiteral(candidate.text)]
2261
- );
2262
- const exprContainer = b.jsxExpressionContainer(tCall);
2263
- path.node.value = exprContainer;
2264
- const componentFunc = findEnclosingComponent(path);
2265
- if (componentFunc) {
2266
- componentsNeedingHook.add(componentFunc);
2267
- }
2268
- wrapped.push(candidate);
2269
- usedStrategies.add("t-function");
2270
- candidatesByLocation.delete(key);
2271
- this.traverse(path);
2272
- },
2273
- visitStringLiteral(path) {
2274
- const loc = path.node.loc;
2275
- if (!loc) {
2276
- this.traverse(path);
2277
- return;
2278
- }
2279
- const key = `${loc.start.line}:${loc.start.column}`;
2280
- const candidate = candidatesByLocation.get(key);
2281
- if (!candidate || candidate.strategy !== "t-function") {
2282
- this.traverse(path);
2283
- return;
2284
- }
2285
- if (path.parent.node.type === "JSXAttribute") {
2286
- this.traverse(path);
2287
- return;
2288
- }
2289
- const tCall = b.callExpression(
2290
- b.identifier(adapter.functionName),
2291
- [b.stringLiteral(candidate.text)]
2292
- );
2293
- path.replace(tCall);
2294
- const componentFunc = findEnclosingComponent(path);
2295
- if (componentFunc) {
2296
- componentsNeedingHook.add(componentFunc);
2297
- }
2298
- wrapped.push(candidate);
2299
- usedStrategies.add("t-function");
2300
- candidatesByLocation.delete(key);
2301
- return false;
2302
- }
2303
- });
2304
- for (const candidate of candidatesByLocation.values()) {
2305
- skipped.push(candidate);
2306
- }
2307
- if (componentsNeedingHook.size > 0) {
2308
- this.injectUseVocoderHooks(ast, componentsNeedingHook, b);
2309
- }
2310
- this.manageImports(ast, usedStrategies, existingImportDecl, existingSpecifiers, componentsNeedingHook.size > 0, b);
2311
- const output = recast.print(ast).code;
2312
- return {
2313
- file: filePath,
2314
- output,
2315
- wrappedCount: wrapped.length,
2316
- wrapped,
2317
- skipped
2318
- };
2319
- }
2320
- /**
2321
- * Inject `const { t } = useVocoder();` at the top of component functions.
2322
- */
2323
- injectUseVocoderHooks(ast, componentFuncs, b) {
2324
- const adapterFunctionName = this.adapter.functionName;
2325
- const adapterHookName = this.adapter.hookName;
2326
- const buildHookDecl = () => b.variableDeclaration("const", [
2327
- b.variableDeclarator(
2328
- b.objectPattern([
2329
- b.property.from({
2330
- kind: "init",
2331
- key: b.identifier(adapterFunctionName),
2332
- value: b.identifier(adapterFunctionName),
2333
- shorthand: true
2334
- })
2335
- ]),
2336
- b.callExpression(b.identifier(adapterHookName), [])
2337
- )
2338
- ]);
2339
- recast.visit(ast, {
2340
- visitFunction(path) {
2341
- if (componentFuncs.has(path.node)) {
2342
- const body = path.node.body;
2343
- if (body.type === "BlockStatement") {
2344
- const alreadyHasHook = body.body.some((stmt) => {
2345
- if (stmt.type !== "VariableDeclaration") return false;
2346
- return stmt.declarations.some(
2347
- (decl) => decl.init?.type === "CallExpression" && decl.init.callee?.type === "Identifier" && decl.init.callee.name === "useVocoder"
2348
- );
2349
- });
2350
- if (!alreadyHasHook) {
2351
- body.body.unshift(buildHookDecl());
2352
- }
2353
- }
2354
- }
2355
- this.traverse(path);
2356
- },
2357
- visitArrowFunctionExpression(path) {
2358
- if (componentFuncs.has(path.node)) {
2359
- const body = path.node.body;
2360
- if (body.type === "BlockStatement") {
2361
- const alreadyHasHook = body.body.some((stmt) => {
2362
- if (stmt.type !== "VariableDeclaration") return false;
2363
- return stmt.declarations.some(
2364
- (decl) => decl.init?.type === "CallExpression" && decl.init.callee?.type === "Identifier" && decl.init.callee.name === "useVocoder"
2365
- );
2366
- });
2367
- if (!alreadyHasHook) {
2368
- body.body.unshift(buildHookDecl());
2369
- }
2370
- }
2371
- }
2372
- this.traverse(path);
2373
- }
2374
- });
2375
- }
2376
- /**
2377
- * Add or update @vocoder/react imports.
2378
- */
2379
- manageImports(ast, usedStrategies, existingImportPath, existingSpecifiers, needsHook, b) {
2380
- if (usedStrategies.size === 0) return;
2381
- const neededSpecifiers = /* @__PURE__ */ new Set();
2382
- if (usedStrategies.has("T-component")) {
2383
- neededSpecifiers.add(this.adapter.componentName);
2384
- }
2385
- if (usedStrategies.has("t-function") && needsHook) {
2386
- neededSpecifiers.add(this.adapter.hookName);
2387
- }
2388
- const missingSpecifiers = [];
2389
- for (const spec of neededSpecifiers) {
2390
- if (!existingSpecifiers.has(spec)) {
2391
- missingSpecifiers.push(spec);
2392
- }
2393
- }
2394
- if (missingSpecifiers.length === 0) return;
2395
- if (existingImportPath) {
2396
- for (const name of missingSpecifiers) {
2397
- const specifier = b.importSpecifier(b.identifier(name), b.identifier(name));
2398
- existingImportPath.node.specifiers.push(specifier);
2399
- }
2400
- } else {
2401
- const specifiers = missingSpecifiers.map(
2402
- (name) => b.importSpecifier(b.identifier(name), b.identifier(name))
2403
- );
2404
- const importDecl = b.importDeclaration(
2405
- specifiers,
2406
- b.stringLiteral(this.adapter.importSource)
2407
- );
2408
- const body = ast.program.body;
2409
- let lastImportIndex = -1;
2410
- for (let i = 0; i < body.length; i++) {
2411
- if (body[i].type === "ImportDeclaration") {
2412
- lastImportIndex = i;
2413
- }
2414
- }
2415
- if (lastImportIndex >= 0) {
2416
- body.splice(lastImportIndex + 1, 0, importDecl);
2417
- } else {
2418
- body.unshift(importDecl);
2419
- }
2420
- }
2421
- }
2422
- };
2423
- function findEnclosingComponent(path) {
2424
- let current = path.parent;
2425
- while (current) {
2426
- const node = current.node;
2427
- if (node.type === "FunctionDeclaration" && node.id?.name) {
2428
- const name = node.id.name;
2429
- if (/^[A-Z]/.test(name)) return node;
2430
- }
2431
- if (node.type === "ArrowFunctionExpression") {
2432
- const parent = current.parent?.node;
2433
- if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
2434
- const name = parent.id.name;
2435
- if (/^[A-Z]/.test(name)) return node;
2436
- }
2437
- }
2438
- if (node.type === "FunctionExpression") {
2439
- const parent = current.parent?.node;
2440
- if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
2441
- const name = parent.id.name;
2442
- if (/^[A-Z]/.test(name)) return node;
2443
- }
2444
- }
2445
- current = current.parent;
2446
- }
2447
- return null;
2448
- }
2449
-
2450
- // src/utils/wrap/adapters/react.ts
2451
- var reactAdapter = {
2452
- name: "react",
2453
- extensions: [".tsx", ".jsx", ".ts", ".js"],
2454
- importSource: "@vocoder/react",
2455
- componentName: "T",
2456
- functionName: "t",
2457
- hookName: "useVocoder",
2458
- translatableAttributes: [
2459
- "title",
2460
- "placeholder",
2461
- "alt",
2462
- "aria-label",
2463
- "aria-description",
2464
- "aria-placeholder",
2465
- "aria-roledescription",
2466
- "aria-valuetext",
2467
- "label",
2468
- "description",
2469
- "message",
2470
- "heading",
2471
- "caption",
2472
- "helperText",
2473
- "errorMessage",
2474
- "successMessage",
2475
- "tooltip"
2476
- ],
2477
- nonTranslatableAttributes: [
2478
- "className",
2479
- "class",
2480
- "href",
2481
- "src",
2482
- "id",
2483
- "key",
2484
- "ref",
2485
- "style",
2486
- "data-testid",
2487
- "data-cy",
2488
- "data-test",
2489
- "type",
2490
- "name",
2491
- "value",
2492
- "action",
2493
- "method",
2494
- "encType",
2495
- "target",
2496
- "rel",
2497
- "role",
2498
- "tabIndex",
2499
- "htmlFor",
2500
- "for",
2501
- "width",
2502
- "height",
2503
- "viewBox",
2504
- "xmlns",
2505
- "fill",
2506
- "stroke"
2507
- ],
2508
- isAlreadyWrapped(ancestors, imports) {
2509
- for (const ancestor of ancestors) {
2510
- if (ancestor.type === "JSXElement") {
2511
- const opening = ancestor.openingElement;
2512
- if (opening && opening.name && opening.name.type === "JSXIdentifier") {
2513
- const tagName = opening.name.name;
2514
- if (imports.has(tagName) && imports.get(tagName) === "T") {
2515
- return true;
2516
- }
2517
- }
2518
- }
2519
- }
2520
- return false;
2521
- },
2522
- isAlreadyWrappedCall(node, tNames) {
2523
- if (node.type === "CallExpression") {
2524
- const callee = node.callee;
2525
- if (callee.type === "Identifier" && tNames.has(callee.name)) {
2526
- return true;
2527
- }
2528
- }
2529
- return false;
2530
- },
2531
- getRequiredImports(strategies) {
2532
- const specifiers = [];
2533
- if (strategies.has("T-component")) {
2534
- specifiers.push("T");
2535
- }
2536
- if (strategies.has("t-function")) {
2537
- specifiers.push("useVocoder");
2538
- }
2539
- return { specifiers, source: "@vocoder/react" };
2827
+ // src/commands/whoami.ts
2828
+ import * as p8 from "@clack/prompts";
2829
+ import chalk9 from "chalk";
2830
+ async function whoami(options = {}) {
2831
+ const stored = readAuthData();
2832
+ if (!stored) {
2833
+ p8.log.info("Not logged in. Run `vocoder init` to authenticate.");
2834
+ return 1;
2540
2835
  }
2541
- };
2542
-
2543
- // src/commands/wrap.ts
2544
- var CONFIDENCE_ORDER = ["high", "medium", "low"];
2545
- function meetsConfidenceThreshold(candidate, threshold) {
2546
- return CONFIDENCE_ORDER.indexOf(candidate) <= CONFIDENCE_ORDER.indexOf(threshold);
2547
- }
2548
- async function wrap(options = {}) {
2549
- const startTime = Date.now();
2550
- const projectRoot = process.cwd();
2551
- const confidenceThreshold = options.confidence || "high";
2552
- p3.intro("Vocoder Wrap");
2553
- const spinner4 = p3.spinner();
2836
+ const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
2837
+ const api = new VocoderAPI({ apiUrl, apiKey: "" });
2554
2838
  try {
2555
- spinner4.start("Scanning files for unwrapped strings");
2556
- const analyzer = new StringAnalyzer(reactAdapter);
2557
- const allCandidates = await analyzer.analyzeProject(options, projectRoot);
2558
- if (allCandidates.length === 0) {
2559
- spinner4.stop("No unwrapped strings found");
2560
- p3.log.info("All user-facing strings appear to be wrapped already.");
2561
- p3.outro("");
2562
- return 0;
2563
- }
2564
- spinner4.stop(
2565
- `Found ${chalk4.cyan(allCandidates.length)} candidate strings`
2566
- );
2567
- const filtered = allCandidates.filter(
2568
- (c) => meetsConfidenceThreshold(c.confidence, confidenceThreshold)
2569
- );
2570
- if (filtered.length === 0) {
2571
- p3.log.warn(
2572
- `No strings meet the ${chalk4.bold(confidenceThreshold)} confidence threshold.`
2573
- );
2574
- p3.log.info("Try --confidence medium or --confidence low to see more candidates.");
2575
- p3.outro("");
2576
- return 0;
2577
- }
2578
- p3.log.info(
2579
- `${filtered.length} strings meet ${chalk4.bold(confidenceThreshold)} confidence threshold`
2580
- );
2581
- const byFile = /* @__PURE__ */ new Map();
2582
- for (const c of filtered) {
2583
- const existing = byFile.get(c.file) || [];
2584
- existing.push(c);
2585
- byFile.set(c.file, existing);
2586
- }
2587
- if (options.dryRun) {
2588
- const lines = [];
2589
- for (const [file, candidates] of byFile) {
2590
- const relPath = relative2(projectRoot, file);
2591
- lines.push(chalk4.bold(relPath));
2592
- for (const c of candidates) {
2593
- const confidenceColor = c.confidence === "high" ? chalk4.green : c.confidence === "medium" ? chalk4.yellow : chalk4.red;
2594
- const strategyLabel = c.strategy === "T-component" ? "<T>" : "t()";
2595
- lines.push(
2596
- ` ${chalk4.dim(`L${c.line}`)} ${confidenceColor(`[${c.confidence}]`)} ${chalk4.cyan(strategyLabel)} "${truncate(c.text, 50)}"`
2597
- );
2598
- if (options.verbose) {
2599
- lines.push(chalk4.dim(` ${c.reason}`));
2600
- }
2601
- }
2602
- lines.push("");
2603
- }
2604
- lines.push(summarizeCandidates(filtered));
2605
- p3.note(lines.join("\n"), "Dry run \u2014 would wrap");
2606
- p3.outro("Run without --dry-run to apply changes.");
2607
- return 0;
2608
- }
2609
- let accepted;
2610
- if (options.interactive) {
2611
- accepted = await interactiveConfirm(byFile, projectRoot);
2612
- if (accepted.length === 0) {
2613
- p3.log.warn("No strings selected for wrapping.");
2614
- p3.outro("");
2615
- return 0;
2616
- }
2617
- } else {
2618
- accepted = filtered;
2619
- }
2620
- spinner4.start("Wrapping strings");
2621
- const transformer = new StringTransformer(reactAdapter);
2622
- let totalWrapped = 0;
2623
- let filesModified = 0;
2624
- const acceptedByFile = /* @__PURE__ */ new Map();
2625
- for (const c of accepted) {
2626
- const existing = acceptedByFile.get(c.file) || [];
2627
- existing.push(c);
2628
- acceptedByFile.set(c.file, existing);
2629
- }
2630
- for (const [file, candidates] of acceptedByFile) {
2631
- const code = readFileSync4(file, "utf-8");
2632
- const result = transformer.transform(code, candidates, file);
2633
- if (result.wrappedCount > 0) {
2634
- writeFileSync2(file, result.output, "utf-8");
2635
- totalWrapped += result.wrappedCount;
2636
- filesModified++;
2637
- }
2638
- if (options.verbose && result.skipped.length > 0) {
2639
- const relPath = relative2(projectRoot, file);
2640
- p3.log.info(`Skipped ${result.skipped.length} strings in ${relPath}`);
2641
- }
2839
+ const info = await api.getCliUserInfo(stored.token);
2840
+ p8.log.info(`Logged in as ${chalk9.bold(info.email)}`);
2841
+ if (info.name) {
2842
+ p8.log.info(`Name: ${info.name}`);
2642
2843
  }
2643
- spinner4.stop(
2644
- `Wrapped ${chalk4.cyan(totalWrapped)} strings across ${chalk4.cyan(filesModified)} files`
2645
- );
2646
- const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
2647
- p3.outro(`Done! (${duration}s)`);
2648
- p3.log.info("Next steps:");
2649
- p3.log.info(" 1. Review the changes (git diff)");
2650
- p3.log.info(" 2. Run your tests to verify nothing broke");
2651
- p3.log.info(' 3. Run "vocoder sync" to extract and translate');
2844
+ p8.log.info(`API: ${apiUrl}`);
2652
2845
  return 0;
2653
- } catch (error) {
2654
- spinner4.stop();
2655
- if (error instanceof Error) {
2656
- p3.log.error(error.message);
2657
- if (options.verbose) {
2658
- p3.log.info(`Full error: ${error.stack ?? error}`);
2659
- }
2660
- }
2846
+ } catch {
2847
+ p8.log.error("Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate.");
2661
2848
  return 1;
2662
2849
  }
2663
2850
  }
2664
- async function interactiveConfirm(byFile, projectRoot) {
2665
- const accepted = [];
2666
- p3.log.info("Interactive mode \u2014 confirm each string:");
2667
- for (const [file, candidates] of byFile) {
2668
- const relPath = relative2(projectRoot, file);
2669
- p3.log.step(chalk4.bold(relPath));
2670
- let skipFile = false;
2671
- for (const c of candidates) {
2672
- if (skipFile) break;
2673
- const strategyLabel = c.strategy === "T-component" ? "<T>" : "t()";
2674
- const label = `L${c.line} ${strategyLabel} "${truncate(c.text, 50)}"`;
2675
- const action = await p3.select({
2676
- message: label,
2677
- options: [
2678
- { value: "yes", label: "Yes, wrap this string" },
2679
- { value: "no", label: "No, skip" },
2680
- { value: "all", label: "Accept all remaining" },
2681
- { value: "skip", label: "Skip this file" },
2682
- { value: "quit", label: "Quit" }
2683
- ]
2684
- });
2685
- if (p3.isCancel(action) || action === "quit") {
2686
- return accepted;
2687
- }
2688
- if (action === "yes") {
2689
- accepted.push(c);
2690
- } else if (action === "all") {
2691
- accepted.push(c);
2692
- const remaining = candidates.slice(candidates.indexOf(c) + 1);
2693
- accepted.push(...remaining);
2694
- for (const [, moreCandidates] of byFile) {
2695
- if (moreCandidates !== candidates) {
2696
- accepted.push(...moreCandidates);
2697
- }
2698
- }
2699
- return accepted;
2700
- } else if (action === "skip") {
2701
- skipFile = true;
2702
- }
2703
- }
2704
- }
2705
- return accepted;
2706
- }
2707
- function truncate(text, maxLen) {
2708
- if (text.length <= maxLen) return text;
2709
- return text.slice(0, maxLen - 3) + "...";
2710
- }
2711
- function summarizeCandidates(candidates) {
2712
- let high = 0;
2713
- let medium = 0;
2714
- let low = 0;
2715
- let tComponent = 0;
2716
- let tFunction = 0;
2717
- for (const c of candidates) {
2718
- if (c.confidence === "high") high++;
2719
- else if (c.confidence === "medium") medium++;
2720
- else low++;
2721
- if (c.strategy === "T-component") tComponent++;
2722
- else tFunction++;
2723
- }
2724
- const parts = [];
2725
- if (high > 0) parts.push(chalk4.green(`${high} high`));
2726
- if (medium > 0) parts.push(chalk4.yellow(`${medium} medium`));
2727
- if (low > 0) parts.push(chalk4.red(`${low} low`));
2728
- return `${candidates.length} total (${parts.join(", ")}) | ${tComponent} <T>, ${tFunction} t()`;
2729
- }
2730
2851
 
2731
2852
  // src/bin.ts
2732
2853
  function collect(value, previous = []) {
@@ -2734,15 +2855,18 @@ function collect(value, previous = []) {
2734
2855
  }
2735
2856
  async function runCommand(command, options) {
2736
2857
  const exitCode = await command(options);
2737
- process.exitCode = exitCode;
2858
+ process.exit(exitCode);
2738
2859
  }
2739
2860
  var program = new Command();
2740
- program.name("vocoder").description("Vocoder CLI - Sync translations for your application").version("0.1.2");
2741
- program.command("sync").description("Extract strings and sync translations").option("--include <pattern>", "Glob pattern(s) to include (can be used multiple times)", collect, []).option("--exclude <pattern>", "Glob pattern(s) to exclude (can be used multiple times)", collect, []).option("--branch <name>", "Override branch detection").option("--force", "Sync even if not a target branch").option("--mode <mode>", "Sync mode: auto|required|best-effort").option("--max-wait-ms <ms>", "Max wait time before fallback (ms)", (value) => Number.parseInt(value, 10)).option("--no-fallback", "Fail instead of using fallback artifacts").option("--dry-run", "Show what would be synced without doing it").option("--verbose", "Show detailed progress").action((options) => runCommand(sync, {
2742
- ...options,
2743
- noFallback: options.noFallback ? true : void 0
2744
- }));
2745
- program.command("wrap").description("Auto-wrap strings with <T> and t() for translation").option("--include <pattern>", "Glob pattern(s) to include (can be used multiple times)", collect, []).option("--exclude <pattern>", "Glob pattern(s) to exclude (can be used multiple times)", collect, []).option("--dry-run", "Preview changes without modifying files").option("--interactive", "Confirm each string interactively").option("--confidence <level>", "Minimum confidence: high, medium, low", "high").option("--verbose", "Detailed output").action((options) => runCommand(wrap, options));
2746
- 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));
2861
+ program.name("vocoder").description("Vocoder CLI - Project setup and string extraction").version("0.1.5");
2862
+ program.command("init").description("Authenticate and provision Vocoder for this project").option("--api-url <url>", "Override Vocoder API URL").option("--yes", "Allow overwriting existing local config values").option("--ci", "Non-interactive mode: print auth URL to stdout, skip browser open").option("--project-name <name>", "Starter project name to create").option("--source-locale <locale>", "Source locale for the starter project").option("--target-locales <list>", "Comma-separated target locales (e.g. es,fr,de)").action((options) => runCommand(init, options));
2863
+ program.command("sync").description("Extract strings and sync translations").option("--branch <branch>", "Override detected branch").option("--mode <mode>", "Sync mode: auto, required, best-effort", "auto").option("--max-wait <ms>", "Max wait for translations (ms)").option("--force", "Force re-extraction even if no changes").option("--dry-run", "Preview without syncing").option("--no-fallback", "Disable fallback to cached translations").option("--include <pattern>", "Include glob pattern", collect, []).option("--exclude <pattern>", "Exclude glob pattern", collect, []).option("--verbose", "Detailed output").action((options) => {
2864
+ const translated = { ...options };
2865
+ if (options.maxWait) translated.maxWaitMs = Number(options.maxWait);
2866
+ if (options.fallback === false) translated.noFallback = true;
2867
+ return runCommand(sync, translated);
2868
+ });
2869
+ program.command("logout").description("Log out and remove stored credentials").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(logout, options));
2870
+ program.command("whoami").description("Show the currently authenticated user").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(whoami, options));
2747
2871
  program.parse(process.argv);
2748
2872
  //# sourceMappingURL=bin.mjs.map