@vocoder/cli 0.1.6 → 0.1.7

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