@vocoder/cli 0.1.16 → 0.1.18

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
@@ -5,319 +5,16 @@ import {
5
5
  detectLocalEcosystem,
6
6
  getPackagesToInstall,
7
7
  getSetupSnippets
8
- } from "./chunk-KPIT5ETY.mjs";
8
+ } from "./chunk-OFQLREXF.mjs";
9
9
 
10
10
  // src/bin.ts
11
11
  import { Command } from "commander";
12
12
 
13
13
  // src/commands/init.ts
14
+ import { execSync as execSync3, spawn as spawn2 } from "child_process";
14
15
  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
60
- import * as p from "@clack/prompts";
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({
309
- value: "install_new",
310
- label: `Install on a new account ${chalk.dim("(creates a new personal workspace)")}`
311
- });
312
- }
313
- const selected = await p.select({
314
- message: "Select a GitHub installation",
315
- options
316
- });
317
- if (p.isCancel(selected)) return null;
318
- if (selected === "install_new") return "install_new";
319
- return Number(selected);
320
- }
16
+ import chalk6 from "chalk";
17
+ import { config as loadEnv } from "dotenv";
321
18
 
322
19
  // src/utils/api.ts
323
20
  function isLimitErrorResponse(value) {
@@ -353,7 +50,9 @@ function parsePayload(raw) {
353
50
  }
354
51
  const trimmed = raw.trimStart();
355
52
  if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
356
- return { message: "Unexpected response from server (received HTML). Check your network connection or try again." };
53
+ return {
54
+ message: "Unexpected response from server (received HTML). Check your network connection or try again."
55
+ };
357
56
  }
358
57
  try {
359
58
  return JSON.parse(raw);
@@ -398,7 +97,10 @@ var VocoderAPI = class {
398
97
  if (!response.ok) {
399
98
  const limitError = isLimitErrorResponse(payload) ? payload : null;
400
99
  const syncPolicyError = isSyncPolicyErrorResponse(payload) ? payload : null;
401
- const baseMessage = extractErrorMessage(payload, `Request failed with status ${response.status}`);
100
+ const baseMessage = extractErrorMessage(
101
+ payload,
102
+ `Request failed with status ${response.status}`
103
+ );
402
104
  throw new VocoderAPIError({
403
105
  message: errorPrefix ? `${errorPrefix}: ${baseMessage}` : baseMessage,
404
106
  status: response.status,
@@ -420,10 +122,13 @@ var VocoderAPI = class {
420
122
  organizationName: data.organizationName,
421
123
  sourceLocale: data.sourceLocale,
422
124
  targetLocales: data.targetLocales,
423
- branchTriggers: data.branchTriggers ?? [],
125
+ targetBranches: data.targetBranches ?? ["main"],
424
126
  primaryBranch: data.primaryBranch,
425
127
  syncPolicy: {
426
- blockingBranches: data.syncPolicy?.blockingBranches ?? ["main", "master"],
128
+ blockingBranches: data.syncPolicy?.blockingBranches ?? [
129
+ "main",
130
+ "master"
131
+ ],
427
132
  blockingMode: data.syncPolicy?.blockingMode ?? "required",
428
133
  nonBlockingMode: data.syncPolicy?.nonBlockingMode ?? "best-effort",
429
134
  defaultMaxWaitMs: data.syncPolicy?.defaultMaxWaitMs ?? 6e4
@@ -466,23 +171,28 @@ var VocoderAPI = class {
466
171
  const crypto = await import("crypto");
467
172
  const sortedStrings = [...strings].sort();
468
173
  const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
469
- return this.request("/api/cli/sync", {
470
- method: "POST",
471
- headers: {
472
- "Content-Type": "application/json"
174
+ return this.request(
175
+ "/api/cli/sync",
176
+ {
177
+ method: "POST",
178
+ headers: {
179
+ "Content-Type": "application/json"
180
+ },
181
+ body: JSON.stringify({
182
+ branch,
183
+ stringEntries,
184
+ targetLocales,
185
+ stringsHash,
186
+ ...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
187
+ ...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
188
+ ...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
189
+ ...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
190
+ ...repoIdentity?.repoAppDir !== void 0 ? { repoAppDir: repoIdentity.repoAppDir } : {},
191
+ ...repoIdentity?.commitSha ? { commitSha: repoIdentity.commitSha } : {}
192
+ })
473
193
  },
474
- body: JSON.stringify({
475
- branch,
476
- stringEntries,
477
- targetLocales,
478
- stringsHash,
479
- ...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
480
- ...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
481
- ...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
482
- ...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
483
- ...repoIdentity?.repoScopePath !== void 0 ? { repoScopePath: repoIdentity.repoScopePath } : {}
484
- })
485
- }, "Translation submission failed");
194
+ "Translation submission failed"
195
+ );
486
196
  }
487
197
  /**
488
198
  * Check translation status
@@ -546,7 +256,10 @@ var VocoderAPI = class {
546
256
  const payload = await readPayload(response);
547
257
  if (!response.ok) {
548
258
  throw new VocoderAPIError({
549
- message: extractErrorMessage(payload, `Failed to start init session (${response.status})`),
259
+ message: extractErrorMessage(
260
+ payload,
261
+ `Failed to start init session (${response.status})`
262
+ ),
550
263
  status: response.status,
551
264
  payload
552
265
  });
@@ -565,7 +278,10 @@ var VocoderAPI = class {
565
278
  const payload = await readPayload(response);
566
279
  if (!response.ok) {
567
280
  throw new VocoderAPIError({
568
- message: extractErrorMessage(payload, `Failed to get init status (${response.status})`),
281
+ message: extractErrorMessage(
282
+ payload,
283
+ `Failed to get init status (${response.status})`
284
+ ),
569
285
  status: response.status,
570
286
  payload
571
287
  });
@@ -589,7 +305,10 @@ var VocoderAPI = class {
589
305
  const payload = await readPayload(response);
590
306
  if (!response.ok) {
591
307
  throw new VocoderAPIError({
592
- message: extractErrorMessage(payload, `Failed to start auth session (${response.status})`),
308
+ message: extractErrorMessage(
309
+ payload,
310
+ `Failed to start auth session (${response.status})`
311
+ ),
593
312
  status: response.status,
594
313
  payload
595
314
  });
@@ -618,7 +337,10 @@ var VocoderAPI = class {
618
337
  if (!response.ok) {
619
338
  return {
620
339
  status: "failed",
621
- reason: extractErrorMessage(payload, `Auth session error (${response.status})`)
340
+ reason: extractErrorMessage(
341
+ payload,
342
+ `Auth session error (${response.status})`
343
+ )
622
344
  };
623
345
  }
624
346
  const result = payload;
@@ -642,7 +364,10 @@ var VocoderAPI = class {
642
364
  const payload = await readPayload(response);
643
365
  if (!response.ok) {
644
366
  throw new VocoderAPIError({
645
- message: extractErrorMessage(payload, `Token validation failed (${response.status})`),
367
+ message: extractErrorMessage(
368
+ payload,
369
+ `Token validation failed (${response.status})`
370
+ ),
646
371
  status: response.status,
647
372
  payload
648
373
  });
@@ -660,7 +385,10 @@ var VocoderAPI = class {
660
385
  if (!response.ok) {
661
386
  const payload = await readPayload(response);
662
387
  throw new VocoderAPIError({
663
- message: extractErrorMessage(payload, `Token revocation failed (${response.status})`),
388
+ message: extractErrorMessage(
389
+ payload,
390
+ `Token revocation failed (${response.status})`
391
+ ),
664
392
  status: response.status,
665
393
  payload
666
394
  });
@@ -676,7 +404,10 @@ var VocoderAPI = class {
676
404
  const payload = await readPayload(response);
677
405
  if (!response.ok) {
678
406
  throw new VocoderAPIError({
679
- message: extractErrorMessage(payload, `Failed to list workspaces (${response.status})`),
407
+ message: extractErrorMessage(
408
+ payload,
409
+ `Failed to list workspaces (${response.status})`
410
+ ),
680
411
  status: response.status,
681
412
  payload
682
413
  });
@@ -692,7 +423,10 @@ var VocoderAPI = class {
692
423
  const payload = await readPayload(response);
693
424
  if (!response.ok) {
694
425
  throw new VocoderAPIError({
695
- message: extractErrorMessage(payload, `Failed to list projects (${response.status})`),
426
+ message: extractErrorMessage(
427
+ payload,
428
+ `Failed to list projects (${response.status})`
429
+ ),
696
430
  status: response.status,
697
431
  payload
698
432
  });
@@ -700,20 +434,51 @@ var VocoderAPI = class {
700
434
  const result = payload;
701
435
  return result.projects;
702
436
  }
437
+ async regenerateProjectApiKey(userToken, projectId) {
438
+ const response = await fetch(
439
+ `${this.apiUrl}/api/cli/project/regenerate-key`,
440
+ {
441
+ method: "POST",
442
+ headers: {
443
+ "Content-Type": "application/json",
444
+ Authorization: `Bearer ${userToken}`
445
+ },
446
+ body: JSON.stringify({ projectId })
447
+ }
448
+ );
449
+ const payload = await readPayload(response);
450
+ if (!response.ok) {
451
+ throw new VocoderAPIError({
452
+ message: extractErrorMessage(
453
+ payload,
454
+ `Failed to regenerate API key (${response.status})`
455
+ ),
456
+ status: response.status,
457
+ payload
458
+ });
459
+ }
460
+ return payload;
461
+ }
703
462
  // ── CLI GitHub endpoints ──────────────────────────────────────────────────────
704
463
  async startCliGitHubInstall(userToken, params) {
705
- const response = await fetch(`${this.apiUrl}/api/cli/github/install/start`, {
706
- method: "POST",
707
- headers: {
708
- Authorization: `Bearer ${userToken}`,
709
- "Content-Type": "application/json"
710
- },
711
- body: JSON.stringify(params)
712
- });
464
+ const response = await fetch(
465
+ `${this.apiUrl}/api/cli/github/install/start`,
466
+ {
467
+ method: "POST",
468
+ headers: {
469
+ Authorization: `Bearer ${userToken}`,
470
+ "Content-Type": "application/json"
471
+ },
472
+ body: JSON.stringify(params)
473
+ }
474
+ );
713
475
  const payload = await readPayload(response);
714
476
  if (!response.ok) {
715
477
  throw new VocoderAPIError({
716
- message: extractErrorMessage(payload, `Failed to start GitHub install (${response.status})`),
478
+ message: extractErrorMessage(
479
+ payload,
480
+ `Failed to start GitHub install (${response.status})`
481
+ ),
717
482
  status: response.status,
718
483
  payload
719
484
  });
@@ -726,15 +491,24 @@ var VocoderAPI = class {
726
491
  * account is created from the OAuth code in the callback.
727
492
  */
728
493
  async startCliGitHubLinkSession(sessionId, callbackPort) {
729
- const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/link-start`, {
730
- method: "POST",
731
- headers: { "Content-Type": "application/json" },
732
- body: JSON.stringify({ sessionId, ...callbackPort != null ? { callbackPort } : {} })
733
- });
494
+ const response = await fetch(
495
+ `${this.apiUrl}/api/cli/github/oauth/link-start`,
496
+ {
497
+ method: "POST",
498
+ headers: { "Content-Type": "application/json" },
499
+ body: JSON.stringify({
500
+ sessionId,
501
+ ...callbackPort != null ? { callbackPort } : {}
502
+ })
503
+ }
504
+ );
734
505
  const payload = await readPayload(response);
735
506
  if (!response.ok) {
736
507
  throw new VocoderAPIError({
737
- message: extractErrorMessage(payload, `Failed to start GitHub link session (${response.status})`),
508
+ message: extractErrorMessage(
509
+ payload,
510
+ `Failed to start GitHub link session (${response.status})`
511
+ ),
738
512
  status: response.status,
739
513
  payload
740
514
  });
@@ -753,7 +527,10 @@ var VocoderAPI = class {
753
527
  const payload = await readPayload(response);
754
528
  if (!response.ok) {
755
529
  throw new VocoderAPIError({
756
- message: extractErrorMessage(payload, `Failed to start GitHub OAuth (${response.status})`),
530
+ message: extractErrorMessage(
531
+ payload,
532
+ `Failed to start GitHub OAuth (${response.status})`
533
+ ),
757
534
  status: response.status,
758
535
  payload
759
536
  });
@@ -767,7 +544,10 @@ var VocoderAPI = class {
767
544
  const payload = await readPayload(response);
768
545
  if (!response.ok) {
769
546
  throw new VocoderAPIError({
770
- message: extractErrorMessage(payload, `Failed to fetch GitHub discovery (${response.status})`),
547
+ message: extractErrorMessage(
548
+ payload,
549
+ `Failed to fetch GitHub discovery (${response.status})`
550
+ ),
771
551
  status: response.status,
772
552
  payload
773
553
  });
@@ -786,7 +566,10 @@ var VocoderAPI = class {
786
566
  const payload = await readPayload(response);
787
567
  if (!response.ok) {
788
568
  throw new VocoderAPIError({
789
- message: extractErrorMessage(payload, `Failed to claim GitHub installation (${response.status})`),
569
+ message: extractErrorMessage(
570
+ payload,
571
+ `Failed to claim GitHub installation (${response.status})`
572
+ ),
790
573
  status: response.status,
791
574
  payload
792
575
  });
@@ -801,7 +584,10 @@ var VocoderAPI = class {
801
584
  const payload = await readPayload(response);
802
585
  if (!response.ok) {
803
586
  throw new VocoderAPIError({
804
- message: extractErrorMessage(payload, `Failed to list locales (${response.status})`),
587
+ message: extractErrorMessage(
588
+ payload,
589
+ `Failed to list locales (${response.status})`
590
+ ),
805
591
  status: response.status,
806
592
  payload
807
593
  });
@@ -822,7 +608,10 @@ var VocoderAPI = class {
822
608
  const payload = await readPayload(response);
823
609
  if (!response.ok) {
824
610
  throw new VocoderAPIError({
825
- message: extractErrorMessage(payload, `Failed to create project (${response.status})`),
611
+ message: extractErrorMessage(
612
+ payload,
613
+ `Failed to create project (${response.status})`
614
+ ),
826
615
  status: response.status,
827
616
  payload
828
617
  });
@@ -842,7 +631,7 @@ var VocoderAPI = class {
842
631
  headers: { "Content-Type": "application/json" },
843
632
  body: JSON.stringify({
844
633
  repo: params.repoCanonical,
845
- scopePath: params.scopePath
634
+ appDir: params.appDir
846
635
  })
847
636
  });
848
637
  if (!response.ok) {
@@ -869,23 +658,73 @@ var VocoderAPI = class {
869
658
  const payload = await readPayload(response);
870
659
  if (!response.ok) {
871
660
  throw new VocoderAPIError({
872
- message: extractErrorMessage(payload, `Failed to create project app (${response.status})`),
661
+ message: extractErrorMessage(
662
+ payload,
663
+ `Failed to create project app (${response.status})`
664
+ ),
873
665
  status: response.status,
874
666
  payload
875
667
  });
876
668
  }
877
669
  return payload;
878
670
  }
879
- };
880
-
881
- // src/commands/init.ts
882
- import chalk6 from "chalk";
883
- import { execSync as execSync3 } from "child_process";
884
- import { config as loadEnv } from "dotenv";
671
+ };
672
+
673
+ // src/utils/auth-store.ts
674
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
675
+ import { homedir } from "os";
676
+ import { dirname, join } from "path";
677
+ function getAuthFilePath() {
678
+ return join(homedir(), ".config", "vocoder", "auth.json");
679
+ }
680
+ function readAuthData() {
681
+ const filePath = getAuthFilePath();
682
+ try {
683
+ const raw = readFileSync(filePath, "utf8");
684
+ const parsed = JSON.parse(raw);
685
+ if (!parsed || typeof parsed !== "object") return null;
686
+ const data = parsed;
687
+ if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
688
+ return null;
689
+ }
690
+ return {
691
+ token: data.token,
692
+ apiUrl: data.apiUrl,
693
+ userId: data.userId,
694
+ email: data.email,
695
+ name: typeof data.name === "string" ? data.name : null,
696
+ createdAt: data.createdAt
697
+ };
698
+ } catch {
699
+ return null;
700
+ }
701
+ }
702
+ function writeAuthData(data) {
703
+ const filePath = getAuthFilePath();
704
+ const dir = dirname(filePath);
705
+ mkdirSync(dir, { recursive: true, mode: 448 });
706
+ writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
707
+ }
708
+ function clearAuthData() {
709
+ const filePath = getAuthFilePath();
710
+ try {
711
+ unlinkSync(filePath);
712
+ } catch {
713
+ }
714
+ }
885
715
 
886
716
  // src/utils/git-identity.ts
887
717
  import { execSync } from "child_process";
888
718
  import { relative, resolve } from "path";
719
+ var SHA_REGEX = /^[0-9a-f]{40}$/i;
720
+ function detectCommitSha() {
721
+ if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
722
+ return process.env.VOCODER_COMMIT_SHA;
723
+ }
724
+ const knownSha = process.env.GITHUB_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.CI_COMMIT_SHA || process.env.BITBUCKET_COMMIT || process.env.CIRCLE_SHA1 || process.env.RENDER_GIT_COMMIT;
725
+ if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
726
+ return safeExec("git rev-parse HEAD");
727
+ }
889
728
  function safeExec(command) {
890
729
  try {
891
730
  const output = execSync(command, {
@@ -956,16 +795,19 @@ function resolveGitRepositoryIdentity() {
956
795
  }
957
796
  const repositoryRoot = safeExec("git rev-parse --show-toplevel");
958
797
  const currentDirectory = process.cwd();
959
- let repoScopePath = "";
798
+ let repoAppDir = "";
960
799
  if (repositoryRoot) {
961
- const relativePath = relative(resolve(repositoryRoot), resolve(currentDirectory)).replace(/\\/g, "/").trim();
800
+ const relativePath = relative(
801
+ resolve(repositoryRoot),
802
+ resolve(currentDirectory)
803
+ ).replace(/\\/g, "/").trim();
962
804
  if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
963
- repoScopePath = relativePath;
805
+ repoAppDir = relativePath;
964
806
  }
965
807
  }
966
808
  return {
967
809
  repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
968
- repoScopePath
810
+ repoAppDir
969
811
  };
970
812
  }
971
813
  function resolveGitContext() {
@@ -976,16 +818,296 @@ function resolveGitContext() {
976
818
  "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
977
819
  );
978
820
  }
979
- return { identity, warnings };
821
+ return { identity, warnings };
822
+ }
823
+
824
+ // src/utils/github-connect.ts
825
+ import { spawn } from "child_process";
826
+ import * as p from "@clack/prompts";
827
+ import chalk from "chalk";
828
+
829
+ // src/utils/local-server.ts
830
+ import { createServer } from "http";
831
+ import { URL as URL2 } from "url";
832
+ function startCallbackServer() {
833
+ return new Promise((resolve2, reject) => {
834
+ let settled = false;
835
+ let callbackResolve = null;
836
+ let callbackReject = null;
837
+ const callbackPromise = new Promise((res, rej) => {
838
+ callbackResolve = res;
839
+ callbackReject = rej;
840
+ });
841
+ const server = createServer((req, res) => {
842
+ if (!req.url) {
843
+ res.writeHead(400);
844
+ res.end();
845
+ return;
846
+ }
847
+ let pathname;
848
+ let params;
849
+ try {
850
+ const parsed = new URL2(req.url, "http://localhost");
851
+ pathname = parsed.pathname;
852
+ params = Object.fromEntries(parsed.searchParams.entries());
853
+ } catch {
854
+ res.writeHead(400);
855
+ res.end("Bad request");
856
+ return;
857
+ }
858
+ if (pathname !== "/callback") {
859
+ res.writeHead(404);
860
+ res.end("Not found");
861
+ return;
862
+ }
863
+ res.writeHead(200, { "Content-Type": "text/html" });
864
+ res.end(
865
+ '<!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>'
866
+ );
867
+ if (callbackResolve) {
868
+ callbackResolve(params);
869
+ callbackResolve = null;
870
+ }
871
+ setImmediate(() => server.close());
872
+ });
873
+ server.on("error", (err) => {
874
+ if (!settled) {
875
+ settled = true;
876
+ if (callbackReject) callbackReject(err);
877
+ reject(err);
878
+ }
879
+ });
880
+ server.listen(0, "127.0.0.1", () => {
881
+ if (settled) return;
882
+ settled = true;
883
+ const port = server.address().port;
884
+ resolve2({
885
+ port,
886
+ waitForCallback: () => callbackPromise,
887
+ close: () => server.close()
888
+ });
889
+ });
890
+ });
891
+ }
892
+
893
+ // src/utils/github-connect.ts
894
+ async function tryOpenBrowser(url) {
895
+ if (!process.stdout.isTTY || process.env.CI === "true") {
896
+ return false;
897
+ }
898
+ const platform = process.platform;
899
+ let command;
900
+ let args;
901
+ if (platform === "darwin") {
902
+ command = "open";
903
+ args = [url];
904
+ } else if (platform === "win32") {
905
+ command = "rundll32";
906
+ args = ["url.dll,FileProtocolHandler", url];
907
+ } else {
908
+ command = "xdg-open";
909
+ args = [url];
910
+ }
911
+ return new Promise((resolve2) => {
912
+ try {
913
+ const child = spawn(command, args, {
914
+ detached: true,
915
+ stdio: "ignore",
916
+ windowsHide: true
917
+ });
918
+ let settled = false;
919
+ child.once("spawn", () => {
920
+ if (settled) return;
921
+ settled = true;
922
+ child.unref();
923
+ resolve2(true);
924
+ });
925
+ child.once("error", () => {
926
+ if (settled) return;
927
+ settled = true;
928
+ resolve2(false);
929
+ });
930
+ setTimeout(() => {
931
+ if (settled) return;
932
+ settled = true;
933
+ resolve2(false);
934
+ }, 300);
935
+ } catch {
936
+ resolve2(false);
937
+ }
938
+ });
939
+ }
940
+ async function runGitHubInstallFlow(params) {
941
+ let server = null;
942
+ try {
943
+ server = await startCallbackServer();
944
+ } catch {
945
+ }
946
+ const { installUrl } = await params.api.startCliGitHubInstall(
947
+ params.userToken,
948
+ {
949
+ organizationId: params.organizationId,
950
+ callbackPort: server?.port
951
+ }
952
+ );
953
+ p.log.info("Opening GitHub to install the Vocoder App...");
954
+ p.note(installUrl, "Install URL");
955
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
956
+ const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
957
+ if (p.isCancel(shouldOpen)) {
958
+ server?.close();
959
+ return null;
960
+ }
961
+ if (shouldOpen) {
962
+ const opened = await tryOpenBrowser(installUrl);
963
+ if (!opened) {
964
+ p.log.info(
965
+ "Could not open a browser automatically. Use the URL above."
966
+ );
967
+ }
968
+ }
969
+ }
970
+ const connectSpinner = p.spinner();
971
+ connectSpinner.start("Waiting for GitHub App installation...");
972
+ if (server) {
973
+ try {
974
+ const params_timeout = 15 * 60 * 1e3;
975
+ const callbackParams = await Promise.race([
976
+ server.waitForCallback(),
977
+ new Promise(
978
+ (resolve2) => setTimeout(() => resolve2(null), params_timeout)
979
+ )
980
+ ]);
981
+ server.close();
982
+ if (!callbackParams) {
983
+ connectSpinner.stop("GitHub App installation timed out");
984
+ p.log.error(
985
+ "The installation flow timed out. Run `vocoder init` again."
986
+ );
987
+ return null;
988
+ }
989
+ if (callbackParams.error) {
990
+ connectSpinner.stop("GitHub App installation failed");
991
+ p.log.error(callbackParams.error);
992
+ return null;
993
+ }
994
+ const { organizationId, connectionLabel, workspace_created } = callbackParams;
995
+ if (!organizationId || !connectionLabel) {
996
+ connectSpinner.stop("GitHub App installation incomplete");
997
+ p.log.error("Missing organization or connection data from callback.");
998
+ return null;
999
+ }
1000
+ connectSpinner.stop(
1001
+ `Connected to GitHub as ${chalk.bold(connectionLabel)}`
1002
+ );
1003
+ const orgName = workspace_created ? connectionLabel : organizationId;
1004
+ return {
1005
+ organizationId,
1006
+ organizationName: orgName,
1007
+ connectionLabel
1008
+ };
1009
+ } catch {
1010
+ server.close();
1011
+ connectSpinner.stop("GitHub App installation failed");
1012
+ return null;
1013
+ }
1014
+ }
1015
+ connectSpinner.stop("Could not detect GitHub App installation automatically");
1016
+ p.log.warn(
1017
+ "Complete the installation in your browser, then run `vocoder init` again."
1018
+ );
1019
+ return null;
1020
+ }
1021
+ async function runGitHubDiscoveryFlow(params) {
1022
+ let server = null;
1023
+ try {
1024
+ server = await startCallbackServer();
1025
+ } catch {
1026
+ }
1027
+ const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
1028
+ organizationId: params.organizationId,
1029
+ callbackPort: server?.port
1030
+ });
1031
+ p.log.info("Opening GitHub to authorize your account...");
1032
+ p.note("Complete authorization in your browser.");
1033
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1034
+ const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
1035
+ if (p.isCancel(shouldOpen)) {
1036
+ server?.close();
1037
+ return null;
1038
+ }
1039
+ if (shouldOpen) {
1040
+ const opened = await tryOpenBrowser(oauthUrl);
1041
+ if (!opened) {
1042
+ p.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
1043
+ }
1044
+ }
1045
+ }
1046
+ const oauthSpinner = p.spinner();
1047
+ oauthSpinner.start("Waiting for GitHub authorization...");
1048
+ if (server) {
1049
+ try {
1050
+ const timeoutMs = 10 * 60 * 1e3;
1051
+ const callbackParams = await Promise.race([
1052
+ server.waitForCallback(),
1053
+ new Promise(
1054
+ (resolve2) => setTimeout(() => resolve2(null), timeoutMs)
1055
+ )
1056
+ ]);
1057
+ server.close();
1058
+ if (!callbackParams) {
1059
+ oauthSpinner.stop("GitHub authorization timed out");
1060
+ return null;
1061
+ }
1062
+ if (callbackParams.error) {
1063
+ oauthSpinner.stop("GitHub authorization failed");
1064
+ p.log.error(callbackParams.error);
1065
+ return null;
1066
+ }
1067
+ } catch {
1068
+ server.close();
1069
+ oauthSpinner.stop("GitHub authorization failed");
1070
+ return null;
1071
+ }
1072
+ }
1073
+ oauthSpinner.stop("GitHub account authorized");
1074
+ const discoveryResult = await params.api.getCliGitHubDiscovery(
1075
+ params.userToken
1076
+ );
1077
+ return discoveryResult.installations;
1078
+ }
1079
+ async function selectGitHubInstallation(installations, canInstallNew) {
1080
+ const options = installations.map((inst) => ({
1081
+ value: String(inst.installationId),
1082
+ label: inst.accountLogin,
1083
+ hint: [
1084
+ inst.accountType === "Organization" ? "organization" : "personal",
1085
+ inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
1086
+ inst.isSuspended ? "suspended" : ""
1087
+ ].filter(Boolean).join(" \xB7 ") || void 0
1088
+ }));
1089
+ if (canInstallNew) {
1090
+ options.push({
1091
+ value: "install_new",
1092
+ label: `Install on a new account ${chalk.dim("(creates a new personal workspace)")}`
1093
+ });
1094
+ }
1095
+ const selected = await p.select({
1096
+ message: "Select a GitHub installation",
1097
+ options
1098
+ });
1099
+ if (p.isCancel(selected)) return null;
1100
+ if (selected === "install_new") return "install_new";
1101
+ return Number(selected);
980
1102
  }
981
1103
 
982
1104
  // src/utils/project-create.ts
983
1105
  import * as p3 from "@clack/prompts";
984
1106
  import chalk4 from "chalk";
985
1107
 
986
- // src/utils/locale-search.ts
987
- import { Prompt, isCancel as isCancel2 } from "@clack/core";
988
- import * as p2 from "@clack/prompts";
1108
+ // src/utils/branch-select.ts
1109
+ import { execSync as execSync2 } from "child_process";
1110
+ import { isCancel as isCancel2, Prompt } from "@clack/core";
989
1111
  import chalk2 from "chalk";
990
1112
  var S_BAR = "\u2502";
991
1113
  var S_BAR_END = "\u2514";
@@ -1012,57 +1134,134 @@ function symbol(state) {
1012
1134
  return cyan(S_ACTIVE);
1013
1135
  }
1014
1136
  }
1015
- var MAX_VISIBLE = 12;
1016
- function filterLocales(options, query) {
1017
- if (!query.trim()) return options;
1137
+ function detectGitBranches(cwd) {
1138
+ const workDir = cwd ?? process.cwd();
1139
+ try {
1140
+ const localOut = execSync2("git branch", {
1141
+ cwd: workDir,
1142
+ stdio: "pipe"
1143
+ }).toString();
1144
+ const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1145
+ let remoteBranches = [];
1146
+ try {
1147
+ const remoteOut = execSync2("git branch -r", {
1148
+ cwd: workDir,
1149
+ stdio: "pipe"
1150
+ }).toString();
1151
+ remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
1152
+ } catch {
1153
+ }
1154
+ const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1155
+ let defaultBranch = "main";
1156
+ try {
1157
+ const ref = execSync2("git symbolic-ref refs/remotes/origin/HEAD", {
1158
+ cwd: workDir,
1159
+ stdio: "pipe"
1160
+ }).toString().trim();
1161
+ defaultBranch = ref.split("/").pop() ?? "main";
1162
+ } catch {
1163
+ }
1164
+ return {
1165
+ branches: branches.length > 0 ? branches : [defaultBranch],
1166
+ defaultBranch
1167
+ };
1168
+ } catch {
1169
+ return { branches: ["main"], defaultBranch: "main" };
1170
+ }
1171
+ }
1172
+ var INVALID_CHARS = /[\s?^~:[\]\\]/;
1173
+ function validateBranchPattern(pattern) {
1174
+ const t = pattern.trim();
1175
+ if (!t) return "Pattern cannot be empty";
1176
+ if (INVALID_CHARS.test(t))
1177
+ 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_VISIBLE = 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;
1018
1197
  const lower = query.toLowerCase();
1019
- return options.filter(
1020
- (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
1021
- );
1198
+ return items.filter((i) => i.value.toLowerCase().includes(lower));
1022
1199
  }
1023
- function buildList(filtered, cursor, scrollOffset, selected) {
1024
- const isMulti = selected !== null;
1200
+ function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, optional = false, excludedPatterns = /* @__PURE__ */ new Set()) {
1201
+ const lines = [];
1025
1202
  const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
1026
- const visibleLines = [];
1027
1203
  for (let i = scrollOffset; i < end; i++) {
1028
- const opt = filtered[i];
1029
- const isCursor = i === cursor;
1030
- const isChecked = isMulti && selected.has(opt.bcp47);
1031
- const icon = isMulti ? isChecked ? isCursor ? grn("\u25FC") : "\u25FC" : isCursor ? grn("\u25FB") : dim("\u25FB") : isCursor ? grn("\u25CF") : dim("\u25CB");
1032
- visibleLines.push(`${cyan(S_BAR)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`);
1204
+ const item = filtered[i];
1205
+ const isCursor = i === cursor && !addCursor;
1206
+ const isChecked = selected.has(item.value);
1207
+ const icon = isChecked ? isCursor ? grn("\u25FC") : "\u25FC" : isCursor ? grn("\u25FB") : dim("\u25FB");
1208
+ let label = item.isCustom ? `${item.label} ${dim("(custom)")}` : item.label;
1209
+ if (isCursor) label = bld(label);
1210
+ lines.push(`${cyan(S_BAR)} ${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) ?? (excludedPatterns.has(trimmed) ? "Already used for automatic translation" : null);
1217
+ const icon = addCursor ? grn("\u25FB") : dim("\u25FB");
1218
+ const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}" as branch pattern`;
1219
+ lines.push(`${cyan(S_BAR)} ${icon} ${label}`);
1220
+ } else if (filtered.length === 0 && trimmed.length === 0) {
1221
+ lines.push(dim(`${S_BAR} No branches detected`));
1033
1222
  }
1034
1223
  const hidden = filtered.length - (end - scrollOffset);
1035
- if (hidden > 0) visibleLines.push(dim(`${S_BAR} ${hidden} more \u2014 keep typing to narrow`));
1036
- if (filtered.length === 0) visibleLines.push(dim(`${S_BAR} No matches`));
1037
- if (isMulti && selected.size > 0) {
1038
- visibleLines.push(dim(`${S_BAR} ${selected.size} selected \u2014 Enter to confirm`));
1224
+ if (hidden > 0) lines.push(dim(`${S_BAR} ${hidden} more`));
1225
+ if (selected.size > 0) {
1226
+ lines.push(dim(`${S_BAR} ${selected.size} selected \u2014 Enter to confirm`));
1227
+ } else if (optional) {
1228
+ lines.push(dim(`${S_BAR} Enter to skip`));
1039
1229
  }
1040
- return visibleLines.join("\n");
1230
+ return lines.join("\n");
1041
1231
  }
1042
- async function runFilterablePrompt(opts) {
1043
- const { message, options, multi } = opts;
1232
+ async function filterableBranchSelect(params) {
1233
+ const { message, branches, defaultBranch } = params;
1234
+ const optional = params.optional ?? false;
1235
+ const excludedSet = new Set(params.excludedPatterns ?? []);
1044
1236
  let filter = "";
1045
1237
  let cursor = 0;
1046
1238
  let scrollOffset = 0;
1047
- const selected = new Set(multi ? opts.initialValues ?? [] : []);
1048
- if (!multi && opts.initialValue) {
1049
- const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
1050
- if (idx >= 0) cursor = idx;
1051
- }
1052
- const getFiltered = () => filterLocales(options, filter);
1239
+ let addCursor = false;
1240
+ const customPatterns = [];
1241
+ const selected = new Set(params.initialValues ?? [defaultBranch]);
1242
+ const getItems = () => buildItems(branches, defaultBranch, customPatterns);
1243
+ const getFiltered = () => filterItems(getItems(), filter);
1244
+ const isNewPattern = () => {
1245
+ const t = filter.trim();
1246
+ if (!t) return false;
1247
+ return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
1248
+ };
1053
1249
  const clampCursor = (filtered) => {
1054
- if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
1055
- if (cursor < scrollOffset) scrollOffset = cursor;
1056
- if (cursor >= scrollOffset + MAX_VISIBLE) scrollOffset = cursor - MAX_VISIBLE + 1;
1057
- if (scrollOffset < 0) scrollOffset = 0;
1250
+ const hasAdd = isNewPattern();
1251
+ const max = filtered.length - 1 + (hasAdd ? 1 : 0);
1252
+ if (cursor > max && !addCursor) cursor = Math.max(0, max);
1253
+ if (!addCursor) {
1254
+ if (cursor < scrollOffset) scrollOffset = cursor;
1255
+ if (cursor >= scrollOffset + MAX_VISIBLE)
1256
+ scrollOffset = cursor - MAX_VISIBLE + 1;
1257
+ if (scrollOffset < 0) scrollOffset = 0;
1258
+ }
1058
1259
  };
1059
1260
  const prompt = new Prompt(
1060
1261
  {
1061
- initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
1062
1262
  validate() {
1063
- const f = getFiltered();
1064
- if (multi && selected.size === 0) return "At least one target language is required.";
1065
- if (!multi && !f[cursor]) return "Please select a language.";
1263
+ if (!optional && selected.size === 0)
1264
+ return "At least one branch is required.";
1066
1265
  return void 0;
1067
1266
  },
1068
1267
  render() {
@@ -1071,11 +1270,11 @@ async function runFilterablePrompt(opts) {
1071
1270
  const hdr = `${dim(S_BAR)}
1072
1271
  ${symbol(this.state)} ${message}
1073
1272
  `;
1074
- const hint = filter.length > 0 ? filter : dim("type to filter, \u2191\u2193 navigate" + (multi ? ", space select" : ""));
1273
+ const hint = filter.length > 0 ? filter : dim("type to filter or add pattern, \u2191\u2193 navigate, space select");
1075
1274
  switch (this.state) {
1076
1275
  case "submit": {
1077
- 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 ?? "";
1078
- return `${hdr}${dim(S_BAR)} ${bld(val || dim("none"))}`;
1276
+ const summary = selected.size > 0 ? bld(Array.from(selected).join(", ")) : dim("none");
1277
+ return `${hdr}${dim(S_BAR)} ${summary}`;
1079
1278
  }
1080
1279
  case "cancel":
1081
1280
  return `${hdr}${dim(S_BAR)}`;
@@ -1083,7 +1282,17 @@ ${symbol(this.state)} ${message}
1083
1282
  return [
1084
1283
  hdr.trimEnd(),
1085
1284
  `${ylw(S_BAR)} ${dim("/")} ${hint}`,
1086
- buildList(filtered, cursor, scrollOffset, multi ? selected : null),
1285
+ buildList(
1286
+ filtered,
1287
+ cursor,
1288
+ scrollOffset,
1289
+ selected,
1290
+ filter,
1291
+ customPatterns,
1292
+ addCursor,
1293
+ optional,
1294
+ excludedSet
1295
+ ),
1087
1296
  `${ylw(S_BAR_END)} ${ylw(this.error)}`,
1088
1297
  ""
1089
1298
  ].join("\n");
@@ -1091,7 +1300,17 @@ ${symbol(this.state)} ${message}
1091
1300
  return [
1092
1301
  hdr.trimEnd(),
1093
1302
  `${cyan(S_BAR)} ${dim("/")} ${hint}`,
1094
- buildList(filtered, cursor, scrollOffset, multi ? selected : null),
1303
+ buildList(
1304
+ filtered,
1305
+ cursor,
1306
+ scrollOffset,
1307
+ selected,
1308
+ filter,
1309
+ customPatterns,
1310
+ addCursor,
1311
+ optional,
1312
+ excludedSet
1313
+ ),
1095
1314
  `${cyan(S_BAR_END)}`,
1096
1315
  ""
1097
1316
  ].join("\n");
@@ -1099,7 +1318,6 @@ ${symbol(this.state)} ${message}
1099
1318
  }
1100
1319
  },
1101
1320
  false
1102
- // trackValue=false — we manage value manually
1103
1321
  );
1104
1322
  prompt.on("key", (key) => {
1105
1323
  if (!key || key === " ") return;
@@ -1108,209 +1326,148 @@ ${symbol(this.state)} ${message}
1108
1326
  filter = filter.slice(0, -1);
1109
1327
  cursor = 0;
1110
1328
  scrollOffset = 0;
1329
+ addCursor = false;
1111
1330
  } else if (cp >= 32 && cp !== 127) {
1112
1331
  filter += key;
1113
1332
  cursor = 0;
1114
1333
  scrollOffset = 0;
1334
+ addCursor = false;
1115
1335
  }
1116
1336
  });
1117
1337
  prompt.on("cursor", (action) => {
1118
1338
  const filtered = getFiltered();
1339
+ const hasAdd = isNewPattern();
1119
1340
  switch (action) {
1120
1341
  case "up":
1121
- cursor = Math.max(0, cursor - 1);
1342
+ if (addCursor) {
1343
+ addCursor = false;
1344
+ cursor = Math.max(0, filtered.length - 1);
1345
+ } else cursor = Math.max(0, cursor - 1);
1122
1346
  break;
1123
1347
  case "down":
1124
- cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
1348
+ if (!addCursor && cursor >= filtered.length - 1 && hasAdd)
1349
+ addCursor = true;
1350
+ else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
1125
1351
  break;
1126
1352
  case "space":
1127
- if (multi) {
1128
- const opt = filtered[cursor];
1129
- if (opt) {
1130
- if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
1131
- else selected.add(opt.bcp47);
1353
+ if (addCursor) {
1354
+ const t = filter.trim();
1355
+ const err = validateBranchPattern(t) ?? (excludedSet.has(t) ? "Already used for automatic translation" : null);
1356
+ if (!err) {
1357
+ customPatterns.push(t);
1358
+ selected.add(t);
1359
+ filter = "";
1360
+ cursor = 0;
1361
+ scrollOffset = 0;
1362
+ addCursor = false;
1363
+ }
1364
+ } else {
1365
+ const item = filtered[cursor];
1366
+ if (item) {
1367
+ if (selected.has(item.value)) selected.delete(item.value);
1368
+ else selected.add(item.value);
1132
1369
  }
1133
1370
  }
1134
1371
  break;
1135
1372
  }
1136
- if (!multi) {
1137
- const opt = getFiltered()[cursor];
1138
- prompt.value = opt?.bcp47 ?? null;
1139
- }
1140
1373
  });
1141
1374
  prompt.on("finalize", () => {
1142
1375
  if (prompt.state === "submit") {
1143
- if (multi) {
1144
- prompt.value = Array.from(selected);
1145
- } else {
1146
- const f = getFiltered();
1147
- prompt.value = f[cursor]?.bcp47 ?? null;
1148
- }
1376
+ prompt.value = Array.from(selected);
1149
1377
  }
1150
1378
  });
1151
1379
  const result = await prompt.prompt();
1152
1380
  if (isCancel2(result)) return null;
1153
1381
  return result;
1154
1382
  }
1155
- async function searchSelectLocale(options, message, initialValue) {
1156
- const result = await runFilterablePrompt({ message, options, multi: false, initialValue });
1157
- return typeof result === "string" ? result : null;
1158
- }
1159
- async function searchMultiSelectLocales(options, message, initialValues) {
1160
- const result = await runFilterablePrompt({ message, options, multi: true, initialValues });
1161
- if (result === null) return null;
1162
- const picks = result;
1163
- if (picks.length === 0) {
1164
- p2.log.warn("At least one target language is required. Please select at least one.");
1165
- return searchMultiSelectLocales(options, message, initialValues);
1166
- }
1167
- return picks;
1168
- }
1169
-
1170
- // src/utils/branch-select.ts
1171
- import { Prompt as Prompt2, isCancel as isCancel3 } from "@clack/core";
1172
- import chalk3 from "chalk";
1173
- import { execSync as execSync2 } from "child_process";
1174
- var S_BAR2 = "\u2502";
1175
- var S_BAR_END2 = "\u2514";
1176
- var S_ACTIVE2 = "\u25C6";
1177
- var S_SUBMIT2 = "\u25C6";
1178
- var S_CANCEL2 = "\u25A0";
1179
- var S_ERROR2 = "\u25B2";
1180
- var noColor2 = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
1181
- var dim2 = (s) => noColor2 ? s : chalk3.gray(s);
1182
- var cyan2 = (s) => noColor2 ? s : chalk3.cyan(s);
1183
- var grn2 = (s) => noColor2 ? s : chalk3.green(s);
1184
- var ylw2 = (s) => noColor2 ? s : chalk3.yellow(s);
1185
- var red2 = (s) => noColor2 ? s : chalk3.red(s);
1186
- var bld2 = (s) => noColor2 ? s : chalk3.bold(s);
1187
- function symbol2(state) {
1188
- switch (state) {
1189
- case "submit":
1190
- return grn2(S_SUBMIT2);
1191
- case "cancel":
1192
- return red2(S_CANCEL2);
1193
- case "error":
1194
- return ylw2(S_ERROR2);
1195
- default:
1196
- return cyan2(S_ACTIVE2);
1197
- }
1198
- }
1199
- function detectGitBranches(cwd) {
1200
- const workDir = cwd ?? process.cwd();
1201
- try {
1202
- const localOut = execSync2("git branch", { cwd: workDir, stdio: "pipe" }).toString();
1203
- const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1204
- let remoteBranches = [];
1205
- try {
1206
- const remoteOut = execSync2("git branch -r", { cwd: workDir, stdio: "pipe" }).toString();
1207
- remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
1208
- } catch {
1209
- }
1210
- const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1211
- let defaultBranch = "main";
1212
- try {
1213
- const ref = execSync2("git symbolic-ref refs/remotes/origin/HEAD", { cwd: workDir, stdio: "pipe" }).toString().trim();
1214
- defaultBranch = ref.split("/").pop() ?? "main";
1215
- } catch {
1216
- }
1217
- return {
1218
- branches: branches.length > 0 ? branches : [defaultBranch],
1219
- defaultBranch
1220
- };
1221
- } catch {
1222
- return { branches: ["main"], defaultBranch: "main" };
1223
- }
1224
- }
1225
- var INVALID_CHARS = /[\s?^~:[\]\\]/;
1226
- function validateBranchPattern(pattern) {
1227
- const t = pattern.trim();
1228
- if (!t) return "Pattern cannot be empty";
1229
- if (INVALID_CHARS.test(t)) return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
1230
- if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
1231
- if (t.includes("//")) return "Cannot contain //";
1232
- return null;
1233
- }
1234
- var MAX_VISIBLE2 = 10;
1235
- function buildItems(branches, defaultBranch, customPatterns) {
1236
- const items = branches.map((b) => ({
1237
- value: b,
1238
- label: b === defaultBranch ? `${b} (default branch)` : b
1239
- }));
1240
- for (const pt of customPatterns) {
1241
- if (!branches.includes(pt)) {
1242
- items.push({ value: pt, label: pt, isCustom: true });
1243
- }
1383
+
1384
+ // src/utils/locale-search.ts
1385
+ import { isCancel as isCancel3, Prompt as Prompt2 } from "@clack/core";
1386
+ import * as p2 from "@clack/prompts";
1387
+ import chalk3 from "chalk";
1388
+ var S_BAR2 = "\u2502";
1389
+ var S_BAR_END2 = "\u2514";
1390
+ var S_ACTIVE2 = "\u25C6";
1391
+ var S_SUBMIT2 = "\u25C6";
1392
+ var S_CANCEL2 = "\u25A0";
1393
+ var S_ERROR2 = "\u25B2";
1394
+ var noColor2 = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
1395
+ var dim2 = (s) => noColor2 ? s : chalk3.gray(s);
1396
+ var cyan2 = (s) => noColor2 ? s : chalk3.cyan(s);
1397
+ var grn2 = (s) => noColor2 ? s : chalk3.green(s);
1398
+ var ylw2 = (s) => noColor2 ? s : chalk3.yellow(s);
1399
+ var red2 = (s) => noColor2 ? s : chalk3.red(s);
1400
+ var bld2 = (s) => noColor2 ? s : chalk3.bold(s);
1401
+ function symbol2(state) {
1402
+ switch (state) {
1403
+ case "submit":
1404
+ return grn2(S_SUBMIT2);
1405
+ case "cancel":
1406
+ return red2(S_CANCEL2);
1407
+ case "error":
1408
+ return ylw2(S_ERROR2);
1409
+ default:
1410
+ return cyan2(S_ACTIVE2);
1244
1411
  }
1245
- return items;
1246
1412
  }
1247
- function filterItems(items, query) {
1248
- if (!query.trim()) return items;
1413
+ var MAX_VISIBLE2 = 12;
1414
+ function filterLocales(options, query) {
1415
+ if (!query.trim()) return options;
1249
1416
  const lower = query.toLowerCase();
1250
- return items.filter((i) => i.value.toLowerCase().includes(lower));
1417
+ return options.filter(
1418
+ (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
1419
+ );
1251
1420
  }
1252
- function buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, optional = false) {
1253
- const lines = [];
1421
+ function buildList2(filtered, cursor, scrollOffset, selected) {
1422
+ const isMulti = selected !== null;
1254
1423
  const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
1424
+ const visibleLines = [];
1255
1425
  for (let i = scrollOffset; i < end; i++) {
1256
- const item = filtered[i];
1257
- const isCursor = i === cursor && !addCursor;
1258
- const isChecked = selected.has(item.value);
1259
- const icon = isChecked ? isCursor ? grn2("\u25FC") : "\u25FC" : isCursor ? grn2("\u25FB") : dim2("\u25FB");
1260
- let label = item.isCustom ? `${item.label} ${dim2("(custom)")}` : item.label;
1261
- if (isCursor) label = bld2(label);
1262
- lines.push(`${cyan2(S_BAR2)} ${icon} ${label}`);
1263
- }
1264
- const trimmed = filter.trim();
1265
- const allItems = [...filtered];
1266
- const isNewPattern = trimmed.length > 0 && !allItems.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
1267
- if (isNewPattern) {
1268
- const err = validateBranchPattern(trimmed);
1269
- const icon = addCursor ? grn2("\u25FB") : dim2("\u25FB");
1270
- const label = err ? `${ylw2("+")} ${dim2(`"${trimmed}" \u2014 ${err}`)}` : `${grn2("+")} Add "${trimmed}" as branch pattern`;
1271
- lines.push(`${cyan2(S_BAR2)} ${icon} ${label}`);
1272
- } else if (filtered.length === 0 && trimmed.length === 0) {
1273
- lines.push(dim2(`${S_BAR2} No branches detected`));
1426
+ const opt = filtered[i];
1427
+ const isCursor = i === cursor;
1428
+ const isChecked = isMulti && selected.has(opt.bcp47);
1429
+ const icon = isMulti ? isChecked ? isCursor ? grn2("\u25FC") : "\u25FC" : isCursor ? grn2("\u25FB") : dim2("\u25FB") : isCursor ? grn2("\u25CF") : dim2("\u25CB");
1430
+ visibleLines.push(
1431
+ `${cyan2(S_BAR2)} ${icon} ${isCursor ? bld2(opt.label) : opt.label}`
1432
+ );
1274
1433
  }
1275
1434
  const hidden = filtered.length - (end - scrollOffset);
1276
- if (hidden > 0) lines.push(dim2(`${S_BAR2} ${hidden} more`));
1277
- if (selected.size > 0) {
1278
- lines.push(dim2(`${S_BAR2} ${selected.size} selected \u2014 Enter to confirm`));
1279
- } else if (optional) {
1280
- lines.push(dim2(`${S_BAR2} Enter to skip`));
1435
+ if (hidden > 0)
1436
+ visibleLines.push(dim2(`${S_BAR2} ${hidden} more \u2014 keep typing to narrow`));
1437
+ if (filtered.length === 0) visibleLines.push(dim2(`${S_BAR2} No matches`));
1438
+ if (isMulti && selected.size > 0) {
1439
+ visibleLines.push(
1440
+ dim2(`${S_BAR2} ${selected.size} selected \u2014 Enter to confirm`)
1441
+ );
1281
1442
  }
1282
- return lines.join("\n");
1443
+ return visibleLines.join("\n");
1283
1444
  }
1284
- async function filterableBranchSelect(params) {
1285
- const { message, branches, defaultBranch } = params;
1286
- const optional = params.optional ?? false;
1445
+ async function runFilterablePrompt(opts) {
1446
+ const { message, options, multi } = opts;
1287
1447
  let filter = "";
1288
1448
  let cursor = 0;
1289
1449
  let scrollOffset = 0;
1290
- let addCursor = false;
1291
- const customPatterns = [];
1292
- const selected = new Set(params.initialValues ?? [defaultBranch]);
1293
- const getItems = () => buildItems(branches, defaultBranch, customPatterns);
1294
- const getFiltered = () => filterItems(getItems(), filter);
1295
- const isNewPattern = () => {
1296
- const t = filter.trim();
1297
- if (!t) return false;
1298
- return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
1299
- };
1450
+ const selected = new Set(multi ? opts.initialValues ?? [] : []);
1451
+ if (!multi && opts.initialValue) {
1452
+ const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
1453
+ if (idx >= 0) cursor = idx;
1454
+ }
1455
+ const getFiltered = () => filterLocales(options, filter);
1300
1456
  const clampCursor = (filtered) => {
1301
- const hasAdd = isNewPattern();
1302
- const max = filtered.length - 1 + (hasAdd ? 1 : 0);
1303
- if (cursor > max && !addCursor) cursor = Math.max(0, max);
1304
- if (!addCursor) {
1305
- if (cursor < scrollOffset) scrollOffset = cursor;
1306
- if (cursor >= scrollOffset + MAX_VISIBLE2) scrollOffset = cursor - MAX_VISIBLE2 + 1;
1307
- if (scrollOffset < 0) scrollOffset = 0;
1308
- }
1457
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
1458
+ if (cursor < scrollOffset) scrollOffset = cursor;
1459
+ if (cursor >= scrollOffset + MAX_VISIBLE2)
1460
+ scrollOffset = cursor - MAX_VISIBLE2 + 1;
1461
+ if (scrollOffset < 0) scrollOffset = 0;
1309
1462
  };
1310
1463
  const prompt = new Prompt2(
1311
1464
  {
1465
+ initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
1312
1466
  validate() {
1313
- if (!optional && selected.size === 0) return "At least one branch is required.";
1467
+ const f = getFiltered();
1468
+ if (multi && selected.size === 0)
1469
+ return "At least one target language is required.";
1470
+ if (!multi && !f[cursor]) return "Please select a language.";
1314
1471
  return void 0;
1315
1472
  },
1316
1473
  render() {
@@ -1319,11 +1476,13 @@ async function filterableBranchSelect(params) {
1319
1476
  const hdr = `${dim2(S_BAR2)}
1320
1477
  ${symbol2(this.state)} ${message}
1321
1478
  `;
1322
- const hint = filter.length > 0 ? filter : dim2("type to filter or add pattern, \u2191\u2193 navigate, space select");
1479
+ const hint = filter.length > 0 ? filter : dim2(
1480
+ `type to filter, \u2191\u2193 navigate${multi ? ", space select" : ""}`
1481
+ );
1323
1482
  switch (this.state) {
1324
1483
  case "submit": {
1325
- const summary = selected.size > 0 ? bld2(Array.from(selected).join(", ")) : dim2("none");
1326
- return `${hdr}${dim2(S_BAR2)} ${summary}`;
1484
+ 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 ?? "";
1485
+ return `${hdr}${dim2(S_BAR2)} ${bld2(val || dim2("none"))}`;
1327
1486
  }
1328
1487
  case "cancel":
1329
1488
  return `${hdr}${dim2(S_BAR2)}`;
@@ -1331,7 +1490,12 @@ ${symbol2(this.state)} ${message}
1331
1490
  return [
1332
1491
  hdr.trimEnd(),
1333
1492
  `${ylw2(S_BAR2)} ${dim2("/")} ${hint}`,
1334
- buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, optional),
1493
+ buildList2(
1494
+ filtered,
1495
+ cursor,
1496
+ scrollOffset,
1497
+ multi ? selected : null
1498
+ ),
1335
1499
  `${ylw2(S_BAR_END2)} ${ylw2(this.error)}`,
1336
1500
  ""
1337
1501
  ].join("\n");
@@ -1339,7 +1503,12 @@ ${symbol2(this.state)} ${message}
1339
1503
  return [
1340
1504
  hdr.trimEnd(),
1341
1505
  `${cyan2(S_BAR2)} ${dim2("/")} ${hint}`,
1342
- buildList2(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, optional),
1506
+ buildList2(
1507
+ filtered,
1508
+ cursor,
1509
+ scrollOffset,
1510
+ multi ? selected : null
1511
+ ),
1343
1512
  `${cyan2(S_BAR_END2)}`,
1344
1513
  ""
1345
1514
  ].join("\n");
@@ -1347,6 +1516,7 @@ ${symbol2(this.state)} ${message}
1347
1516
  }
1348
1517
  },
1349
1518
  false
1519
+ // trackValue=false — we manage value manually
1350
1520
  );
1351
1521
  prompt.on("key", (key) => {
1352
1522
  if (!key || key === " ") return;
@@ -1355,59 +1525,76 @@ ${symbol2(this.state)} ${message}
1355
1525
  filter = filter.slice(0, -1);
1356
1526
  cursor = 0;
1357
1527
  scrollOffset = 0;
1358
- addCursor = false;
1359
1528
  } else if (cp >= 32 && cp !== 127) {
1360
1529
  filter += key;
1361
1530
  cursor = 0;
1362
1531
  scrollOffset = 0;
1363
- addCursor = false;
1364
1532
  }
1365
1533
  });
1366
1534
  prompt.on("cursor", (action) => {
1367
1535
  const filtered = getFiltered();
1368
- const hasAdd = isNewPattern();
1369
1536
  switch (action) {
1370
1537
  case "up":
1371
- if (addCursor) {
1372
- addCursor = false;
1373
- cursor = Math.max(0, filtered.length - 1);
1374
- } else cursor = Math.max(0, cursor - 1);
1538
+ cursor = Math.max(0, cursor - 1);
1375
1539
  break;
1376
1540
  case "down":
1377
- if (!addCursor && cursor >= filtered.length - 1 && hasAdd) addCursor = true;
1378
- else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
1541
+ cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
1379
1542
  break;
1380
1543
  case "space":
1381
- if (addCursor) {
1382
- const t = filter.trim();
1383
- const err = validateBranchPattern(t);
1384
- if (!err) {
1385
- customPatterns.push(t);
1386
- selected.add(t);
1387
- filter = "";
1388
- cursor = 0;
1389
- scrollOffset = 0;
1390
- addCursor = false;
1391
- }
1392
- } else {
1393
- const item = filtered[cursor];
1394
- if (item) {
1395
- if (selected.has(item.value)) selected.delete(item.value);
1396
- else selected.add(item.value);
1544
+ if (multi) {
1545
+ const opt = filtered[cursor];
1546
+ if (opt) {
1547
+ if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
1548
+ else selected.add(opt.bcp47);
1397
1549
  }
1398
1550
  }
1399
1551
  break;
1400
1552
  }
1553
+ if (!multi) {
1554
+ const opt = getFiltered()[cursor];
1555
+ prompt.value = opt?.bcp47 ?? null;
1556
+ }
1401
1557
  });
1402
1558
  prompt.on("finalize", () => {
1403
1559
  if (prompt.state === "submit") {
1404
- prompt.value = Array.from(selected);
1560
+ if (multi) {
1561
+ prompt.value = Array.from(selected);
1562
+ } else {
1563
+ const f = getFiltered();
1564
+ prompt.value = f[cursor]?.bcp47 ?? null;
1565
+ }
1405
1566
  }
1406
1567
  });
1407
1568
  const result = await prompt.prompt();
1408
1569
  if (isCancel3(result)) return null;
1409
1570
  return result;
1410
1571
  }
1572
+ async function searchSelectLocale(options, message, initialValue) {
1573
+ const result = await runFilterablePrompt({
1574
+ message,
1575
+ options,
1576
+ multi: false,
1577
+ initialValue
1578
+ });
1579
+ return typeof result === "string" ? result : null;
1580
+ }
1581
+ async function searchMultiSelectLocales(options, message, initialValues) {
1582
+ const result = await runFilterablePrompt({
1583
+ message,
1584
+ options,
1585
+ multi: true,
1586
+ initialValues
1587
+ });
1588
+ if (result === null) return null;
1589
+ const picks = result;
1590
+ if (picks.length === 0) {
1591
+ p2.log.warn(
1592
+ "At least one target language is required. Please select at least one."
1593
+ );
1594
+ return searchMultiSelectLocales(options, message, initialValues);
1595
+ }
1596
+ return picks;
1597
+ }
1411
1598
 
1412
1599
  // src/utils/project-create.ts
1413
1600
  function buildLocaleOptions(locales) {
@@ -1436,15 +1623,17 @@ async function runProjectCreate(params) {
1436
1623
  try {
1437
1624
  rawLocales = await api.listLocales(userToken);
1438
1625
  } catch {
1439
- p3.log.error("Failed to fetch supported locales. Check your connection and try again.");
1626
+ p3.log.error(
1627
+ "Failed to fetch supported locales. Check your connection and try again."
1628
+ );
1440
1629
  return null;
1441
1630
  }
1442
1631
  const languageOptions = buildLanguageOptions(rawLocales);
1443
1632
  const localeOptions = buildLocaleOptions(rawLocales);
1444
- let scopePath;
1445
- if (params.defaultScopePath) {
1446
- scopePath = params.defaultScopePath;
1447
- p3.log.success(`App directory: ${chalk4.bold(scopePath)}`);
1633
+ let appDir;
1634
+ if (params.defaultAppDir) {
1635
+ appDir = params.defaultAppDir;
1636
+ p3.log.success(`App directory: ${chalk4.bold(appDir)}`);
1448
1637
  } else {
1449
1638
  const rawScope = await p3.text({
1450
1639
  message: "App directory (leave blank for the entire repo)",
@@ -1453,12 +1642,13 @@ async function runProjectCreate(params) {
1453
1642
  validate(value) {
1454
1643
  const v = value.trim();
1455
1644
  if (!v) return;
1456
- if (v.startsWith("/")) return "Use a relative path, not an absolute path";
1645
+ if (v.startsWith("/"))
1646
+ return "Use a relative path, not an absolute path";
1457
1647
  if (v.includes("..")) return 'Path must not contain ".."';
1458
1648
  }
1459
1649
  });
1460
1650
  if (p3.isCancel(rawScope)) return null;
1461
- scopePath = (rawScope ?? "").trim();
1651
+ appDir = (rawScope ?? "").trim();
1462
1652
  }
1463
1653
  const sourceLocale = await searchSelectLocale(
1464
1654
  languageOptions,
@@ -1466,14 +1656,18 @@ async function runProjectCreate(params) {
1466
1656
  params.defaultSourceLocale ?? "en"
1467
1657
  );
1468
1658
  if (sourceLocale === null) return null;
1469
- const targetOptions = localeOptions.filter((opt) => opt.bcp47 !== sourceLocale);
1659
+ const targetOptions = localeOptions.filter(
1660
+ (opt) => opt.bcp47 !== sourceLocale
1661
+ );
1470
1662
  const targetLocales = await searchMultiSelectLocales(
1471
1663
  targetOptions,
1472
1664
  "Target languages (languages to translate into)"
1473
1665
  );
1474
1666
  if (targetLocales === null) return null;
1475
1667
  if (targetLocales.length === 0) {
1476
- p3.log.warn("No target languages selected \u2014 you can add them later from the dashboard.");
1668
+ p3.log.warn(
1669
+ "No target languages selected \u2014 you can add them later from the dashboard."
1670
+ );
1477
1671
  }
1478
1672
  const detected = detectGitBranches();
1479
1673
  const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
@@ -1482,73 +1676,31 @@ async function runProjectCreate(params) {
1482
1676
  let initial = initialBranches;
1483
1677
  while (pushBranches.length === 0) {
1484
1678
  const result = await filterableBranchSelect({
1485
- message: "Translate on push \u2014 which branches?",
1679
+ message: "Which branches should trigger translations?",
1486
1680
  branches: detected.branches,
1487
1681
  defaultBranch: detected.defaultBranch,
1488
1682
  initialValues: initial
1489
1683
  });
1490
1684
  if (result === null) return null;
1491
1685
  if (result.length === 0) {
1492
- p3.log.warn("At least one branch is required. Please select at least one.");
1686
+ p3.log.warn(
1687
+ "At least one branch is required. Please select at least one."
1688
+ );
1493
1689
  initial = [detected.defaultBranch];
1494
1690
  } else {
1495
1691
  pushBranches = result;
1496
1692
  }
1497
1693
  }
1498
1694
  }
1499
- const prResult = await filterableBranchSelect({
1500
- message: "Translate on pull requests \u2014 which branches? (optional)",
1501
- branches: detected.branches,
1502
- defaultBranch: detected.defaultBranch,
1503
- initialValues: [],
1504
- optional: true
1505
- });
1506
- if (prResult === null) return null;
1507
- const prBranches = prResult;
1508
- const autoSet = /* @__PURE__ */ new Set([...pushBranches, ...prBranches]);
1509
- const manualResult = await filterableBranchSelect({
1510
- message: `Manual-only branches \u2014 translate via \`vocoder sync\` only (optional)`,
1511
- branches: detected.branches.filter((b) => !autoSet.has(b)),
1512
- defaultBranch: detected.defaultBranch,
1513
- initialValues: [],
1514
- optional: true
1515
- });
1516
- if (manualResult === null) return null;
1517
- const manualBranches = manualResult.filter((b) => {
1518
- if (autoSet.has(b)) {
1519
- p3.log.warn(`"${b}" is already configured for automatic translation \u2014 skipping from manual.`);
1520
- return false;
1521
- }
1522
- return true;
1523
- });
1524
- if (pushBranches.length === 0 && prBranches.length === 0 && manualBranches.length === 0) {
1525
- p3.log.error("At least one branch must be configured.");
1526
- return null;
1527
- }
1528
- const triggerMap = /* @__PURE__ */ new Map();
1529
- for (const b of pushBranches) {
1530
- if (!triggerMap.has(b)) triggerMap.set(b, /* @__PURE__ */ new Set());
1531
- triggerMap.get(b).add("push");
1532
- }
1533
- for (const b of prBranches) {
1534
- if (!triggerMap.has(b)) triggerMap.set(b, /* @__PURE__ */ new Set());
1535
- triggerMap.get(b).add("pull_request");
1536
- }
1537
- for (const b of manualBranches) {
1538
- triggerMap.set(b, /* @__PURE__ */ new Set(["manual"]));
1539
- }
1540
- const branchTriggers = Array.from(triggerMap.entries()).map(([pattern, triggers]) => ({
1541
- pattern,
1542
- triggers: Array.from(triggers)
1543
- }));
1695
+ const targetBranches = pushBranches;
1544
1696
  try {
1545
1697
  const result = await api.createProject(userToken, {
1546
1698
  organizationId,
1547
1699
  name: projectName,
1548
1700
  sourceLocale,
1549
1701
  targetLocales,
1550
- branchTriggers,
1551
- scopePaths: scopePath ? [scopePath] : [],
1702
+ targetBranches,
1703
+ appDirs: appDir ? [appDir] : [],
1552
1704
  repoCanonical
1553
1705
  });
1554
1706
  p3.log.success(`Project ${chalk4.bold(result.projectName)} created!`);
@@ -1561,41 +1713,47 @@ async function runProjectCreate(params) {
1561
1713
  }
1562
1714
  async function runProjectAppCreate(params) {
1563
1715
  const { api, userToken, projectId, projectName, repoCanonical } = params;
1564
- const existingScopes = new Set(params.existingApps.map((a) => a.scopePath));
1716
+ const existingScopes = new Set(params.existingApps.map((a) => a.appDir));
1565
1717
  let rawLocales;
1566
1718
  try {
1567
1719
  rawLocales = await api.listLocales(userToken);
1568
1720
  } catch {
1569
- p3.log.error("Failed to fetch supported locales. Check your connection and try again.");
1721
+ p3.log.error(
1722
+ "Failed to fetch supported locales. Check your connection and try again."
1723
+ );
1570
1724
  return null;
1571
1725
  }
1572
1726
  const languageOptions = buildLanguageOptions(rawLocales);
1573
1727
  const localeOptions = buildLocaleOptions(rawLocales);
1574
- let scopePath;
1575
- if (params.defaultScopePath && !existingScopes.has(params.defaultScopePath)) {
1576
- scopePath = params.defaultScopePath;
1577
- p3.log.success(`App directory: ${chalk4.bold(scopePath)}`);
1728
+ let appDir;
1729
+ if (params.defaultAppDir && !existingScopes.has(params.defaultAppDir)) {
1730
+ appDir = params.defaultAppDir;
1731
+ p3.log.success(`App directory: ${chalk4.bold(appDir)}`);
1578
1732
  } else {
1579
1733
  if (params.existingApps.length > 0) {
1580
- const configuredList = params.existingApps.map((a) => chalk4.dim(a.scopePath || "(entire repo)")).join(", ");
1734
+ const configuredList = params.existingApps.map((a) => chalk4.dim(a.appDir || "(entire repo)")).join(", ");
1581
1735
  p3.log.info(`Already configured: ${configuredList}`);
1582
1736
  }
1583
1737
  const hasWholeRepoApp = existingScopes.has("");
1584
1738
  const rawScope = await p3.text({
1585
1739
  message: "App directory for this new app",
1586
1740
  placeholder: "e.g. apps/backend",
1587
- initialValue: params.defaultScopePath ?? "",
1741
+ initialValue: params.defaultAppDir ?? "",
1588
1742
  validate(value) {
1589
1743
  const v = value.trim();
1590
- if (!v && hasWholeRepoApp) return "This project already covers the entire repo.";
1591
- if (!v) return "App directory is required when other apps already exist.";
1592
- if (v.startsWith("/")) return "Use a relative path, not an absolute path.";
1744
+ if (!v && hasWholeRepoApp)
1745
+ return "This project already covers the entire repo.";
1746
+ if (!v)
1747
+ return "App directory is required when other apps already exist.";
1748
+ if (v.startsWith("/"))
1749
+ return "Use a relative path, not an absolute path.";
1593
1750
  if (v.includes("..")) return 'Path must not contain "..".';
1594
- if (existingScopes.has(v)) return `"${v}" is already configured. Choose a different directory.`;
1751
+ if (existingScopes.has(v))
1752
+ return `"${v}" is already configured. Choose a different directory.`;
1595
1753
  }
1596
1754
  });
1597
1755
  if (p3.isCancel(rawScope)) return null;
1598
- scopePath = (rawScope ?? "").trim();
1756
+ appDir = (rawScope ?? "").trim();
1599
1757
  }
1600
1758
  const sourceLocale = await searchSelectLocale(
1601
1759
  languageOptions,
@@ -1603,14 +1761,18 @@ async function runProjectAppCreate(params) {
1603
1761
  "en"
1604
1762
  );
1605
1763
  if (sourceLocale === null) return null;
1606
- const targetOptions = localeOptions.filter((opt) => opt.bcp47 !== sourceLocale);
1764
+ const targetOptions = localeOptions.filter(
1765
+ (opt) => opt.bcp47 !== sourceLocale
1766
+ );
1607
1767
  const targetLocales = await searchMultiSelectLocales(
1608
1768
  targetOptions,
1609
1769
  "Target languages"
1610
1770
  );
1611
1771
  if (targetLocales === null) return null;
1612
1772
  if (targetLocales.length === 0) {
1613
- p3.log.warn("No target languages selected \u2014 you can add them later from the dashboard.");
1773
+ p3.log.warn(
1774
+ "No target languages selected \u2014 you can add them later from the dashboard."
1775
+ );
1614
1776
  }
1615
1777
  const detectedApp = detectGitBranches();
1616
1778
  let appPushBranches = [];
@@ -1618,7 +1780,7 @@ async function runProjectAppCreate(params) {
1618
1780
  let initial = [detectedApp.defaultBranch];
1619
1781
  while (appPushBranches.length === 0) {
1620
1782
  const result = await filterableBranchSelect({
1621
- message: "Translate on push \u2014 which branches?",
1783
+ message: "Which branches should trigger translations?",
1622
1784
  branches: detectedApp.branches,
1623
1785
  defaultBranch: detectedApp.defaultBranch,
1624
1786
  initialValues: initial
@@ -1632,64 +1794,27 @@ async function runProjectAppCreate(params) {
1632
1794
  }
1633
1795
  }
1634
1796
  }
1635
- const appPrResult = await filterableBranchSelect({
1636
- message: "Translate on pull requests \u2014 which branches? (optional)",
1637
- branches: detectedApp.branches,
1638
- defaultBranch: detectedApp.defaultBranch,
1639
- initialValues: [],
1640
- optional: true
1641
- });
1642
- if (appPrResult === null) return null;
1643
- const appAutoSet = /* @__PURE__ */ new Set([...appPushBranches, ...appPrResult]);
1644
- const appManualResult = await filterableBranchSelect({
1645
- message: "Manual-only branches (optional)",
1646
- branches: detectedApp.branches.filter((b) => !appAutoSet.has(b)),
1647
- defaultBranch: detectedApp.defaultBranch,
1648
- initialValues: [],
1649
- optional: true
1650
- });
1651
- if (appManualResult === null) return null;
1652
- const appManualBranches = appManualResult.filter((b) => {
1653
- if (appAutoSet.has(b)) {
1654
- p3.log.warn(`"${b}" is already configured for automatic translation \u2014 skipping from manual.`);
1655
- return false;
1656
- }
1657
- return true;
1658
- });
1659
- const appTriggerMap = /* @__PURE__ */ new Map();
1660
- for (const b of appPushBranches) {
1661
- if (!appTriggerMap.has(b)) appTriggerMap.set(b, /* @__PURE__ */ new Set());
1662
- appTriggerMap.get(b).add("push");
1663
- }
1664
- for (const b of appPrResult) {
1665
- if (!appTriggerMap.has(b)) appTriggerMap.set(b, /* @__PURE__ */ new Set());
1666
- appTriggerMap.get(b).add("pull_request");
1667
- }
1668
- for (const b of appManualBranches) {
1669
- appTriggerMap.set(b, /* @__PURE__ */ new Set(["manual"]));
1670
- }
1671
- const branchTriggers = Array.from(appTriggerMap.entries()).map(([pattern, triggers]) => ({
1672
- pattern,
1673
- triggers: Array.from(triggers)
1674
- }));
1797
+ const targetBranches = appPushBranches;
1675
1798
  try {
1676
1799
  const result = await api.createProjectApp(userToken, {
1677
1800
  projectId,
1678
- scopePath,
1801
+ appDir,
1679
1802
  sourceLocale,
1680
1803
  targetLocales,
1681
- branchTriggers,
1804
+ targetBranches,
1682
1805
  repoCanonical: repoCanonical ?? ""
1683
1806
  });
1684
- p3.log.success(`App ${chalk4.bold(scopePath)} added to ${chalk4.bold(projectName)}!`);
1807
+ p3.log.success(
1808
+ `App ${chalk4.bold(appDir)} added to ${chalk4.bold(projectName)}!`
1809
+ );
1685
1810
  return {
1686
1811
  projectId: result.projectId,
1687
1812
  projectName: result.projectName,
1688
1813
  apiKey: result.apiKey,
1689
- scopePath: result.scopePath,
1814
+ appDir: result.appDir,
1690
1815
  sourceLocale,
1691
1816
  targetLocales,
1692
- branchTriggers
1817
+ targetBranches
1693
1818
  };
1694
1819
  } catch (error) {
1695
1820
  const message = error instanceof Error ? error.message : "Unknown error";
@@ -1735,7 +1860,6 @@ async function selectWorkspace(result) {
1735
1860
  }
1736
1861
 
1737
1862
  // src/commands/init.ts
1738
- import { spawn as spawn2 } from "child_process";
1739
1863
  loadEnv();
1740
1864
  var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
1741
1865
  async function sleep(ms) {
@@ -1799,9 +1923,7 @@ function printPlanLimitMessage(apiUrl, message) {
1799
1923
  p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
1800
1924
  }
1801
1925
  function runScaffold(params) {
1802
- const { projectName, organizationName, sourceLocale, branchTriggers } = params;
1803
- p5.log.info(`Project: ${chalk6.bold(projectName)}`);
1804
- p5.log.info(`Workspace: ${chalk6.bold(organizationName)}`);
1926
+ const { sourceLocale, targetBranches } = params;
1805
1927
  const detection = detectLocalEcosystem();
1806
1928
  if (detection.ecosystem) {
1807
1929
  const frameworkLabel = detection.framework ?? detection.ecosystem;
@@ -1810,7 +1932,10 @@ function runScaffold(params) {
1810
1932
  }
1811
1933
  const packagesToInstall = getPackagesToInstall(detection);
1812
1934
  if (packagesToInstall.length > 0) {
1813
- const installCmd = buildInstallCommand(detection.packageManager, packagesToInstall);
1935
+ const installCmd = buildInstallCommand(
1936
+ detection.packageManager,
1937
+ packagesToInstall
1938
+ );
1814
1939
  p5.log.info("");
1815
1940
  const installSpinner = p5.spinner();
1816
1941
  installSpinner.start(`Installing ${packagesToInstall.join(", ")}...`);
@@ -1828,17 +1953,21 @@ function runScaffold(params) {
1828
1953
  framework: detection.framework,
1829
1954
  ecosystem: detection.ecosystem,
1830
1955
  sourceLocale,
1831
- branchTriggers
1956
+ targetBranches
1832
1957
  });
1833
1958
  let stepNum = 1;
1834
1959
  if (snippets.pluginStep) {
1835
1960
  p5.log.message("");
1836
- p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the plugin to ${chalk6.cyan(snippets.pluginStep.file)}`);
1961
+ p5.log.step(
1962
+ `${chalk6.bold(`Step ${stepNum}:`)} Add the plugin to ${chalk6.cyan(snippets.pluginStep.file)}`
1963
+ );
1837
1964
  printCodeBlock(snippets.pluginStep.code);
1838
1965
  stepNum++;
1839
1966
  }
1840
1967
  if (snippets.providerStep) {
1841
- p5.log.step(`${chalk6.bold(`Step ${stepNum}:`)} Add the provider to ${chalk6.cyan(snippets.providerStep.file)}`);
1968
+ p5.log.step(
1969
+ `${chalk6.bold(`Step ${stepNum}:`)} Add the provider to ${chalk6.cyan(snippets.providerStep.file)}`
1970
+ );
1842
1971
  printCodeBlock(snippets.providerStep.code);
1843
1972
  stepNum++;
1844
1973
  }
@@ -1860,6 +1989,7 @@ function printMcpSetup(apiKey) {
1860
1989
  type: "stdio",
1861
1990
  command: "npx",
1862
1991
  args: ["-y", "@vocoder/mcp"],
1992
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: MCP config template, not a JS template literal
1863
1993
  env: { VOCODER_API_KEY: "${env:VOCODER_API_KEY}" }
1864
1994
  }
1865
1995
  }
@@ -1873,7 +2003,9 @@ function printMcpSetup(apiKey) {
1873
2003
  p5.log.message("");
1874
2004
  printCodeBlock(addCommand);
1875
2005
  p5.log.message("");
1876
- p5.log.message("To share with your team, commit " + chalk6.cyan(".mcp.json") + " with an env var reference");
2006
+ p5.log.message(
2007
+ "To share with your team, commit " + chalk6.cyan(".mcp.json") + " with an env var reference"
2008
+ );
1877
2009
  p5.log.message("so each developer supplies their own key:");
1878
2010
  p5.log.message("");
1879
2011
  printCodeBlock(teamConfig);
@@ -1882,19 +2014,26 @@ function printMcpSetup(apiKey) {
1882
2014
  }
1883
2015
  function printCodeBlock(code) {
1884
2016
  const lines = code.split("\n");
1885
- const maxLen = lines.reduce((max, line) => Math.max(max, line.length), 0);
2017
+ const maxLen = lines.reduce(
2018
+ (max, line) => Math.max(max, line.length),
2019
+ 0
2020
+ );
1886
2021
  const bar = chalk6.gray("\u2502");
1887
2022
  const pad = (s) => s + " ".repeat(maxLen - s.length);
1888
2023
  process.stdout.write(`${chalk6.gray("\u2502")}
1889
2024
  `);
1890
- process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u250C" + "\u2500".repeat(maxLen + 2) + "\u2510")}
1891
- `);
2025
+ process.stdout.write(
2026
+ `${chalk6.gray("\u2502")} ${chalk6.gray(`\u250C${"\u2500".repeat(maxLen + 2)}\u2510`)}
2027
+ `
2028
+ );
1892
2029
  for (const line of lines) {
1893
2030
  process.stdout.write(`${chalk6.gray("\u2502")} ${bar} ${pad(line)} ${bar}
1894
2031
  `);
1895
2032
  }
1896
- process.stdout.write(`${chalk6.gray("\u2502")} ${chalk6.gray("\u2514" + "\u2500".repeat(maxLen + 2) + "\u2518")}
1897
- `);
2033
+ process.stdout.write(
2034
+ `${chalk6.gray("\u2502")} ${chalk6.gray(`\u2514${"\u2500".repeat(maxLen + 2)}\u2518`)}
2035
+ `
2036
+ );
1898
2037
  }
1899
2038
  async function verifyStoredToken(api, token) {
1900
2039
  try {
@@ -1926,14 +2065,18 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1926
2065
  } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1927
2066
  if (reauth) {
1928
2067
  if (!options.yes) {
1929
- const shouldOpen = await p5.confirm({ message: "Open your browser to sign in again?" });
2068
+ const shouldOpen = await p5.confirm({
2069
+ message: "Open your browser to sign in again?"
2070
+ });
1930
2071
  if (p5.isCancel(shouldOpen)) {
1931
2072
  server?.close();
1932
2073
  p5.cancel("Setup cancelled.");
1933
2074
  return null;
1934
2075
  }
1935
2076
  if (!shouldOpen) {
1936
- p5.log.info("Open the URL above manually in your browser to continue.");
2077
+ p5.log.info(
2078
+ "Open the URL above manually in your browser to continue."
2079
+ );
1937
2080
  } else {
1938
2081
  const opened = await tryOpenBrowser2(browserUrl);
1939
2082
  if (!opened) {
@@ -1950,7 +2093,11 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1950
2093
  const connectChoice = await p5.select({
1951
2094
  message: "Vocoder needs to be installed on your GitHub account to get started",
1952
2095
  options: [
1953
- { value: "install", label: "Install GitHub App", hint: "recommended" },
2096
+ {
2097
+ value: "install",
2098
+ label: "Install GitHub App",
2099
+ hint: "recommended"
2100
+ },
1954
2101
  { value: "link", label: "Already installed? Link your account" }
1955
2102
  ]
1956
2103
  });
@@ -1992,7 +2139,9 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1992
2139
  const timeoutMs = deadline - Date.now();
1993
2140
  const params = await Promise.race([
1994
2141
  server.waitForCallback(),
1995
- new Promise((resolve2) => setTimeout(() => resolve2(null), timeoutMs))
2142
+ new Promise(
2143
+ (resolve2) => setTimeout(() => resolve2(null), timeoutMs)
2144
+ )
1996
2145
  ]);
1997
2146
  if (params && typeof params.token === "string") {
1998
2147
  rawToken = params.token;
@@ -2033,11 +2182,16 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
2033
2182
  }
2034
2183
  const userInfo = await api.getCliUserInfo(rawToken);
2035
2184
  authSpinner.stop(`Authenticated as ${chalk6.bold(userInfo.email)}`);
2036
- return { token: rawToken, ...userInfo, organizationId: callbackOrganizationId, discoveryReady: callbackDiscoveryReady };
2185
+ return {
2186
+ token: rawToken,
2187
+ ...userInfo,
2188
+ organizationId: callbackOrganizationId,
2189
+ discoveryReady: callbackDiscoveryReady
2190
+ };
2037
2191
  }
2038
2192
  async function init(options = {}) {
2039
2193
  const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
2040
- p5.intro("Vocoder Setup");
2194
+ p5.intro(chalk6.bold("Vocoder Setup"));
2041
2195
  try {
2042
2196
  const gitContext = resolveGitContext();
2043
2197
  const identity = gitContext.identity;
@@ -2053,28 +2207,50 @@ async function init(options = {}) {
2053
2207
  const anonApi = new VocoderAPI({ apiUrl, apiKey: "" });
2054
2208
  const lookup = await anonApi.lookupProjectByRepo({
2055
2209
  repoCanonical: identity.repoCanonical,
2056
- scopePath: identity.repoScopePath
2210
+ appDir: identity.repoAppDir
2057
2211
  });
2058
2212
  if (lookup.exactMatch) {
2059
2213
  const { exactMatch } = lookup;
2060
- runScaffold({
2061
- projectName: exactMatch.projectName,
2062
- organizationName: exactMatch.organizationName,
2063
- sourceLocale: exactMatch.sourceLocale ?? "en",
2064
- branchTriggers: exactMatch.branchTriggers ?? [{ pattern: "main", triggers: ["push"] }]
2214
+ p5.log.success(`Project: ${chalk6.bold(exactMatch.projectName)}`);
2215
+ p5.log.info(
2216
+ `Branches: ${chalk6.cyan((exactMatch.targetBranches ?? ["main"]).join(", "))}`
2217
+ );
2218
+ const needsKey = await p5.confirm({
2219
+ message: "Need to regenerate your API key?"
2065
2220
  });
2221
+ if (!p5.isCancel(needsKey) && needsKey) {
2222
+ const anonApi2 = new VocoderAPI({ apiUrl, apiKey: "" });
2223
+ const authResult = await runAuthFlow(
2224
+ anonApi2,
2225
+ options,
2226
+ /* reauth */
2227
+ true
2228
+ );
2229
+ if (!authResult) return 1;
2230
+ const spinner4 = p5.spinner();
2231
+ spinner4.start("Generating new API key...");
2232
+ try {
2233
+ const { apiKey } = await anonApi2.regenerateProjectApiKey(
2234
+ authResult.token,
2235
+ exactMatch.projectId
2236
+ );
2237
+ spinner4.stop("New API key generated");
2238
+ printMcpSetup(apiKey);
2239
+ } catch {
2240
+ spinner4.stop("Failed to generate key");
2241
+ p5.log.error(
2242
+ "Could not generate API key. Try again or generate one from the dashboard."
2243
+ );
2244
+ return 1;
2245
+ }
2246
+ }
2066
2247
  p5.outro("Vocoder is already set up for this repository.");
2067
2248
  return 0;
2068
2249
  }
2069
2250
  if (lookup.hasWholeRepoApp) {
2070
- const wholeRepo = lookup.existingApps.find((a) => a.scopePath === "");
2251
+ const wholeRepo = lookup.existingApps.find((a) => a.appDir === "");
2071
2252
  if (wholeRepo) {
2072
- runScaffold({
2073
- projectName: wholeRepo.projectName,
2074
- organizationName: wholeRepo.organizationName,
2075
- sourceLocale: "en",
2076
- branchTriggers: [{ pattern: "main", triggers: ["push"] }]
2077
- });
2253
+ p5.log.success(`Project: ${chalk6.bold(wholeRepo.projectName)}`);
2078
2254
  p5.outro("Vocoder is already set up for this repository.");
2079
2255
  return 0;
2080
2256
  }
@@ -2090,7 +2266,6 @@ async function init(options = {}) {
2090
2266
  let userEmail;
2091
2267
  let userName;
2092
2268
  let authOrganizationId;
2093
- let authDiscoveryReady = false;
2094
2269
  const stored = readAuthData();
2095
2270
  if (stored && stored.apiUrl === apiUrl) {
2096
2271
  const verified = await verifyStoredToken(api, stored.token);
@@ -2118,7 +2293,6 @@ async function init(options = {}) {
2118
2293
  userEmail = authResult.email;
2119
2294
  userName = authResult.name;
2120
2295
  authOrganizationId = authResult.organizationId;
2121
- authDiscoveryReady = authResult.discoveryReady ?? false;
2122
2296
  writeAuthData({
2123
2297
  token: userToken,
2124
2298
  apiUrl,
@@ -2129,7 +2303,12 @@ async function init(options = {}) {
2129
2303
  });
2130
2304
  }
2131
2305
  } else {
2132
- const authResult = await runAuthFlow(api, options, false, identity?.repoCanonical);
2306
+ const authResult = await runAuthFlow(
2307
+ api,
2308
+ options,
2309
+ false,
2310
+ identity?.repoCanonical
2311
+ );
2133
2312
  if (!authResult) return 1;
2134
2313
  userToken = authResult.token;
2135
2314
  userEmail = authResult.email;
@@ -2148,10 +2327,14 @@ async function init(options = {}) {
2148
2327
  let selectedWorkspaceName;
2149
2328
  if (authOrganizationId) {
2150
2329
  const workspaceData = await api.listWorkspaces(userToken);
2151
- const ws = workspaceData.workspaces.find((w) => w.id === authOrganizationId);
2330
+ const ws = workspaceData.workspaces.find(
2331
+ (w) => w.id === authOrganizationId
2332
+ );
2152
2333
  selectedWorkspaceId = authOrganizationId;
2153
2334
  selectedWorkspaceName = ws?.name ?? userEmail;
2154
- p5.log.success(`Connected as ${chalk6.bold(userEmail)} \u2014 workspace: ${chalk6.bold(selectedWorkspaceName)}`);
2335
+ p5.log.success(
2336
+ `Connected as ${chalk6.bold(userEmail)} \u2014 workspace: ${chalk6.bold(selectedWorkspaceName)}`
2337
+ );
2155
2338
  } else {
2156
2339
  const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
2157
2340
  const cachedInstallations = discoveryResult?.installations ?? [];
@@ -2190,7 +2373,9 @@ async function init(options = {}) {
2190
2373
  );
2191
2374
  }
2192
2375
  if (selectedInstallationId === null || selectedInstallationId === "install_new") {
2193
- p5.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
2376
+ p5.cancel(
2377
+ "Setup cancelled. Re-run `vocoder init` and choose Install GitHub App."
2378
+ );
2194
2379
  return 1;
2195
2380
  }
2196
2381
  const claimResult = await api.claimCliGitHubInstallation(userToken, {
@@ -2206,7 +2391,9 @@ async function init(options = {}) {
2206
2391
  });
2207
2392
  const repoCanonical = identity?.repoCanonical ?? null;
2208
2393
  const covering = repoCanonical ? workspaceData.workspaces.filter((w) => w.coversRepo === true) : [];
2209
- const connected = workspaceData.workspaces.filter((w) => w.hasGitHubConnection);
2394
+ const connected = workspaceData.workspaces.filter(
2395
+ (w) => w.hasGitHubConnection
2396
+ );
2210
2397
  if (repoCanonical && covering.length === 1) {
2211
2398
  const ws = covering[0];
2212
2399
  selectedWorkspaceId = ws.id;
@@ -2265,9 +2452,15 @@ async function init(options = {}) {
2265
2452
  );
2266
2453
  return 1;
2267
2454
  }
2268
- const connectResult = await runGitHubInstallFlow({ api, userToken, yes: options.yes });
2455
+ const connectResult = await runGitHubInstallFlow({
2456
+ api,
2457
+ userToken,
2458
+ yes: options.yes
2459
+ });
2269
2460
  if (!connectResult) {
2270
- p5.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
2461
+ p5.log.error(
2462
+ "GitHub App installation did not complete. Run `vocoder init` again."
2463
+ );
2271
2464
  return 1;
2272
2465
  }
2273
2466
  selectedWorkspaceId = connectResult.organizationId;
@@ -2302,22 +2495,42 @@ async function init(options = {}) {
2302
2495
  return 1;
2303
2496
  }
2304
2497
  if (connectChoice === "install") {
2305
- const connectResult = await runGitHubInstallFlow({ api, userToken, yes: options.yes });
2498
+ const connectResult = await runGitHubInstallFlow({
2499
+ api,
2500
+ userToken,
2501
+ yes: options.yes
2502
+ });
2306
2503
  if (!connectResult) {
2307
- p5.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
2504
+ p5.log.error(
2505
+ "GitHub App installation did not complete. Run `vocoder init` again."
2506
+ );
2308
2507
  return 1;
2309
2508
  }
2310
2509
  selectedWorkspaceId = connectResult.organizationId;
2311
2510
  selectedWorkspaceName = connectResult.organizationName;
2312
- p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
2511
+ p5.log.success(
2512
+ `Workspace: ${chalk6.bold(selectedWorkspaceName)}`
2513
+ );
2313
2514
  } else {
2314
- const installations = await runGitHubDiscoveryFlow({ api, userToken, yes: options.yes });
2515
+ const installations = await runGitHubDiscoveryFlow({
2516
+ api,
2517
+ userToken,
2518
+ yes: options.yes
2519
+ });
2315
2520
  if (!installations) return 1;
2316
2521
  if (installations.length === 0) {
2317
- p5.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
2318
- const installNow = await p5.confirm({ message: "Open GitHub to install the App?" });
2522
+ p5.log.warn(
2523
+ "No GitHub installations found. Install the Vocoder GitHub App first."
2524
+ );
2525
+ const installNow = await p5.confirm({
2526
+ message: "Open GitHub to install the App?"
2527
+ });
2319
2528
  if (p5.isCancel(installNow) || !installNow) return 1;
2320
- const connectResult = await runGitHubInstallFlow({ api, userToken, yes: options.yes });
2529
+ const connectResult = await runGitHubInstallFlow({
2530
+ api,
2531
+ userToken,
2532
+ yes: options.yes
2533
+ });
2321
2534
  if (!connectResult) return 1;
2322
2535
  selectedWorkspaceId = connectResult.organizationId;
2323
2536
  selectedWorkspaceName = connectResult.organizationName;
@@ -2337,20 +2550,29 @@ async function init(options = {}) {
2337
2550
  return 1;
2338
2551
  }
2339
2552
  if (selectedInstallationId === "install_new") {
2340
- const connectResult = await runGitHubInstallFlow({ api, userToken, yes: options.yes });
2553
+ const connectResult = await runGitHubInstallFlow({
2554
+ api,
2555
+ userToken,
2556
+ yes: options.yes
2557
+ });
2341
2558
  if (!connectResult) return 1;
2342
2559
  selectedWorkspaceId = connectResult.organizationId;
2343
2560
  selectedWorkspaceName = connectResult.organizationName;
2344
2561
  } else {
2345
- const claimResult = await api.claimCliGitHubInstallation(userToken, {
2346
- installationId: String(selectedInstallationId),
2347
- organizationId: null
2348
- });
2562
+ const claimResult = await api.claimCliGitHubInstallation(
2563
+ userToken,
2564
+ {
2565
+ installationId: String(selectedInstallationId),
2566
+ organizationId: null
2567
+ }
2568
+ );
2349
2569
  selectedWorkspaceId = claimResult.organizationId;
2350
2570
  selectedWorkspaceName = claimResult.organizationName;
2351
2571
  }
2352
2572
  }
2353
- p5.log.success(`Workspace: ${chalk6.bold(selectedWorkspaceName)}`);
2573
+ p5.log.success(
2574
+ `Workspace: ${chalk6.bold(selectedWorkspaceName)}`
2575
+ );
2354
2576
  }
2355
2577
  }
2356
2578
  }
@@ -2360,7 +2582,7 @@ async function init(options = {}) {
2360
2582
  if (repoProjectId && repoProjectName && existingAppsForRepo.length > 0) {
2361
2583
  p5.log.info(
2362
2584
  `${chalk6.bold(repoProjectName)} is already set up for this repo.
2363
- Configured apps: ${existingAppsForRepo.map((a) => chalk6.cyan(a.scopePath || "(entire repo)")).join(", ")}`
2585
+ Configured apps: ${existingAppsForRepo.map((a) => chalk6.cyan(a.appDir || "(entire repo)")).join(", ")}`
2364
2586
  );
2365
2587
  const appResult = await runProjectAppCreate({
2366
2588
  api,
@@ -2369,7 +2591,7 @@ async function init(options = {}) {
2369
2591
  projectName: repoProjectName,
2370
2592
  organizationName: selectedWorkspaceName,
2371
2593
  repoCanonical: identity?.repoCanonical,
2372
- defaultScopePath: identity?.repoScopePath,
2594
+ defaultAppDir: identity?.repoAppDir,
2373
2595
  existingApps: existingAppsForRepo
2374
2596
  });
2375
2597
  if (!appResult) {
@@ -2377,10 +2599,8 @@ async function init(options = {}) {
2377
2599
  return 1;
2378
2600
  }
2379
2601
  runScaffold({
2380
- projectName: appResult.projectName,
2381
- organizationName: selectedWorkspaceName,
2382
2602
  sourceLocale: appResult.sourceLocale,
2383
- branchTriggers: appResult.branchTriggers
2603
+ targetBranches: appResult.targetBranches
2384
2604
  });
2385
2605
  p5.outro("You're all set.");
2386
2606
  return 0;
@@ -2412,10 +2632,15 @@ async function init(options = {}) {
2412
2632
  }
2413
2633
  if (limitAction === "upgrade") {
2414
2634
  await tryOpenBrowser2(`${apiUrl}${SUBSCRIPTION_SETTINGS_PATH}`);
2415
- p5.cancel("Upgrade your plan in the browser, then re-run `vocoder init`.");
2635
+ p5.cancel(
2636
+ "Upgrade your plan in the browser, then re-run `vocoder init`."
2637
+ );
2416
2638
  return 1;
2417
2639
  }
2418
- const existingProjects = await api.listProjects(userToken, selectedWorkspaceId);
2640
+ const existingProjects = await api.listProjects(
2641
+ userToken,
2642
+ selectedWorkspaceId
2643
+ );
2419
2644
  if (existingProjects.length === 0) {
2420
2645
  p5.log.error("No projects found in this workspace.");
2421
2646
  return 1;
@@ -2439,7 +2664,7 @@ async function init(options = {}) {
2439
2664
  projectName: chosen.name,
2440
2665
  organizationName: selectedWorkspaceName,
2441
2666
  repoCanonical: identity?.repoCanonical,
2442
- defaultScopePath: identity?.repoScopePath,
2667
+ defaultAppDir: identity?.repoAppDir,
2443
2668
  existingApps: []
2444
2669
  });
2445
2670
  if (!appResult) {
@@ -2447,10 +2672,8 @@ async function init(options = {}) {
2447
2672
  return 1;
2448
2673
  }
2449
2674
  runScaffold({
2450
- projectName: appResult.projectName,
2451
- organizationName: selectedWorkspaceName,
2452
2675
  sourceLocale: appResult.sourceLocale,
2453
- branchTriggers: appResult.branchTriggers
2676
+ targetBranches: appResult.targetBranches
2454
2677
  });
2455
2678
  p5.outro("You're all set.");
2456
2679
  return 0;
@@ -2465,7 +2688,7 @@ async function init(options = {}) {
2465
2688
  defaultSourceLocale: "en",
2466
2689
  repoCanonical: identity?.repoCanonical,
2467
2690
  defaultBranches: ["main"],
2468
- defaultScopePath: identity?.repoScopePath
2691
+ defaultAppDir: identity?.repoAppDir
2469
2692
  });
2470
2693
  if (!projectResult) {
2471
2694
  p5.log.error("Project creation failed. Run `vocoder init` again.");
@@ -2484,10 +2707,8 @@ Translations won't run automatically until you grant access.
2484
2707
  );
2485
2708
  }
2486
2709
  runScaffold({
2487
- projectName: projectResult.projectName,
2488
- organizationName: selectedWorkspaceName,
2489
2710
  sourceLocale: projectResult.sourceLocale,
2490
- branchTriggers: projectResult.branchTriggers
2711
+ targetBranches: projectResult.targetBranches
2491
2712
  });
2492
2713
  printMcpSetup(projectResult.apiKey);
2493
2714
  p5.outro("You're all set.");
@@ -2526,8 +2747,11 @@ async function logout(options = {}) {
2526
2747
  }
2527
2748
 
2528
2749
  // src/commands/sync.ts
2529
- import * as p7 from "@clack/prompts";
2530
2750
  import { createHash, randomUUID } from "crypto";
2751
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2752
+ import { join as join2 } from "path";
2753
+ import * as p7 from "@clack/prompts";
2754
+ import chalk8 from "chalk";
2531
2755
 
2532
2756
  // src/utils/branch.ts
2533
2757
  import { execSync as execSync4 } from "child_process";
@@ -2557,7 +2781,7 @@ function detectBranch(override) {
2557
2781
  stdio: ["pipe", "pipe", "ignore"]
2558
2782
  }).trim();
2559
2783
  return branch;
2560
- } catch (error) {
2784
+ } catch (_error) {
2561
2785
  throw new Error(
2562
2786
  "Failed to detect git branch. Make sure you are in a git repository or set the --branch flag."
2563
2787
  );
@@ -2595,9 +2819,6 @@ function matchBranchPattern(branch, pattern) {
2595
2819
  return new RegExp(regexSource).test(branch);
2596
2820
  }
2597
2821
 
2598
- // src/commands/sync.ts
2599
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2600
-
2601
2822
  // src/utils/config.ts
2602
2823
  import chalk7 from "chalk";
2603
2824
  import { config as loadEnv2 } from "dotenv";
@@ -2606,8 +2827,15 @@ function validateLocalConfig(config) {
2606
2827
  if (!config.apiKey || config.apiKey.length === 0) {
2607
2828
  throw new Error("VOCODER_API_KEY is required. Set it in your .env file.");
2608
2829
  }
2609
- if (!config.apiKey.startsWith("vc_")) {
2610
- throw new Error("Invalid API key format. Expected format: vc_...");
2830
+ if (!config.apiKey.startsWith("vcp_")) {
2831
+ if (config.apiKey.startsWith("vco_") || config.apiKey.startsWith("vcu_")) {
2832
+ throw new Error(
2833
+ "VOCODER_API_KEY must be a project-scoped key (starts with vcp_). Got an org or user key."
2834
+ );
2835
+ }
2836
+ throw new Error(
2837
+ "Invalid API key format. Expected a project API key starting with vcp_."
2838
+ );
2611
2839
  }
2612
2840
  if (!config.apiUrl || !config.apiUrl.startsWith("http")) {
2613
2841
  throw new Error("Invalid API URL");
@@ -2615,7 +2843,7 @@ function validateLocalConfig(config) {
2615
2843
  }
2616
2844
  async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2617
2845
  const configSources = {
2618
- extractionPattern: "default",
2846
+ includePattern: "default",
2619
2847
  excludePattern: "default",
2620
2848
  apiKey: "environment",
2621
2849
  apiUrl: "default",
@@ -2624,29 +2852,53 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2624
2852
  noFallback: "default"
2625
2853
  };
2626
2854
  const defaults = {
2627
- extractionPattern: ["src/**/*.{tsx,jsx,ts,js}"],
2628
- excludePattern: [],
2855
+ includePattern: ["**/*.{tsx,jsx,ts,js}"],
2856
+ excludePattern: [
2857
+ "**/node_modules/**",
2858
+ "**/.next/**",
2859
+ "**/.nuxt/**",
2860
+ "**/.svelte-kit/**",
2861
+ "**/.output/**",
2862
+ "**/dist/**",
2863
+ "**/build/**",
2864
+ "**/out/**",
2865
+ "**/.vite/**",
2866
+ "**/.turbo/**",
2867
+ "**/coverage/**",
2868
+ "**/.cache/**",
2869
+ "**/*.min.js",
2870
+ "**/*.min.ts",
2871
+ "**/__generated__/**",
2872
+ "**/*.test.*",
2873
+ "**/*.spec.*",
2874
+ "**/*.stories.*",
2875
+ "**/__tests__/**"
2876
+ ],
2629
2877
  apiUrl: "https://vocoder.app"
2630
2878
  };
2631
- const envExtractionPattern = process.env.VOCODER_EXTRACTION_PATTERN;
2879
+ const envExtractionPattern = process.env.VOCODER_INCLUDE_PATTERN;
2880
+ const envExcludePattern = process.env.VOCODER_EXCLUDE_PATTERN;
2632
2881
  const envApiUrl = process.env.VOCODER_API_URL;
2633
2882
  const envSyncMode = process.env.VOCODER_SYNC_MODE;
2634
2883
  const envSyncMaxWaitMs = process.env.VOCODER_SYNC_MAX_WAIT_MS;
2635
2884
  const envSyncNoFallback = process.env.VOCODER_SYNC_NO_FALLBACK;
2636
- let extractionPattern;
2885
+ let includePattern;
2637
2886
  if (cliOptions.include && cliOptions.include.length > 0) {
2638
- extractionPattern = cliOptions.include;
2639
- configSources.extractionPattern = "CLI flag";
2887
+ includePattern = cliOptions.include;
2888
+ configSources.includePattern = "CLI flag";
2640
2889
  } else if (envExtractionPattern) {
2641
- extractionPattern = [envExtractionPattern];
2642
- configSources.extractionPattern = "environment";
2890
+ includePattern = [envExtractionPattern];
2891
+ configSources.includePattern = "environment";
2643
2892
  } else {
2644
- extractionPattern = defaults.extractionPattern;
2893
+ includePattern = defaults.includePattern;
2645
2894
  }
2646
2895
  let excludePattern;
2647
2896
  if (cliOptions.exclude && cliOptions.exclude.length > 0) {
2648
2897
  excludePattern = cliOptions.exclude;
2649
2898
  configSources.excludePattern = "CLI flag";
2899
+ } else if (envExcludePattern) {
2900
+ excludePattern = envExcludePattern.split(",").map((p9) => p9.trim()).filter(Boolean);
2901
+ configSources.excludePattern = "environment";
2650
2902
  } else {
2651
2903
  excludePattern = defaults.excludePattern;
2652
2904
  }
@@ -2687,14 +2939,20 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2687
2939
  noFallback = cliOptions.noFallback;
2688
2940
  configSources.noFallback = "CLI flag";
2689
2941
  } else if (envSyncNoFallback) {
2690
- noFallback = ["1", "true", "yes", "on"].includes(envSyncNoFallback.toLowerCase());
2942
+ noFallback = ["1", "true", "yes", "on"].includes(
2943
+ envSyncNoFallback.toLowerCase()
2944
+ );
2691
2945
  configSources.noFallback = "environment";
2692
2946
  }
2693
2947
  if (verbose) {
2694
2948
  console.log(chalk7.dim("\n Configuration sources:"));
2695
- console.log(chalk7.dim(` Include patterns: ${configSources.extractionPattern}`));
2949
+ console.log(
2950
+ chalk7.dim(` Include patterns: ${configSources.includePattern}`)
2951
+ );
2696
2952
  if (excludePattern.length > 0) {
2697
- console.log(chalk7.dim(` Exclude patterns: ${configSources.excludePattern}`));
2953
+ console.log(
2954
+ chalk7.dim(` Exclude patterns: ${configSources.excludePattern}`)
2955
+ );
2698
2956
  }
2699
2957
  console.log(chalk7.dim(` API key: ${configSources.apiKey}`));
2700
2958
  console.log(chalk7.dim(` API URL: ${configSources.apiUrl}
@@ -2707,7 +2965,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2707
2965
  `));
2708
2966
  }
2709
2967
  return {
2710
- extractionPattern,
2968
+ includePattern,
2711
2969
  excludePattern,
2712
2970
  apiKey,
2713
2971
  apiUrl,
@@ -2719,8 +2977,21 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2719
2977
  }
2720
2978
 
2721
2979
  // src/commands/sync.ts
2722
- import chalk8 from "chalk";
2723
- import { join as join2 } from "path";
2980
+ function computeStringsHash(texts) {
2981
+ const sorted = [...texts].sort();
2982
+ return createHash("sha256").update(sorted.join("\0")).digest("hex").slice(0, 16);
2983
+ }
2984
+ function readCachedStringsHash(projectRoot, branch) {
2985
+ const filePath = getCacheFilePath(projectRoot, branch);
2986
+ if (!existsSync(filePath)) return null;
2987
+ try {
2988
+ const raw = JSON.parse(readFileSync2(filePath, "utf-8"));
2989
+ if (isRecord(raw) && typeof raw.stringsHash === "string")
2990
+ return raw.stringsHash;
2991
+ } catch {
2992
+ }
2993
+ return null;
2994
+ }
2724
2995
  function isRecord(value) {
2725
2996
  return typeof value === "object" && value !== null && !Array.isArray(value);
2726
2997
  }
@@ -2765,10 +3036,15 @@ function parseTranslations(value) {
2765
3036
  return Object.keys(translations).length > 0 ? translations : null;
2766
3037
  }
2767
3038
  function getCacheFilePath(projectRoot, branch) {
2768
- const slug = branch.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
2769
3039
  const branchHash = createHash("sha1").update(branch).digest("hex").slice(0, 12);
2770
- const filename = `${slug || "branch"}-${branchHash}.json`;
2771
- return join2(projectRoot, "node_modules", ".vocoder", "cache", "sync", filename);
3040
+ return join2(
3041
+ projectRoot,
3042
+ "node_modules",
3043
+ ".vocoder",
3044
+ "cache",
3045
+ "sync",
3046
+ `${branchHash}.json`
3047
+ );
2772
3048
  }
2773
3049
  function readLocalSnapshotCache(params) {
2774
3050
  const candidateBranches = params.branch === "main" ? ["main"] : [params.branch, "main"];
@@ -2797,22 +3073,25 @@ function readLocalSnapshotCache(params) {
2797
3073
  cacheBranch: candidateBranch
2798
3074
  };
2799
3075
  } catch {
2800
- continue;
2801
3076
  }
2802
3077
  }
2803
3078
  return null;
2804
3079
  }
2805
3080
  function writeLocalSnapshotCache(params) {
2806
3081
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.branch);
2807
- mkdirSync2(join2(params.projectRoot, "node_modules", ".vocoder", "cache", "sync"), {
2808
- recursive: true
2809
- });
3082
+ mkdirSync2(
3083
+ join2(params.projectRoot, "node_modules", ".vocoder", "cache", "sync"),
3084
+ {
3085
+ recursive: true
3086
+ }
3087
+ );
2810
3088
  const payload = {
2811
3089
  version: 1,
2812
3090
  branch: params.branch,
2813
3091
  sourceLocale: params.sourceLocale,
2814
3092
  targetLocales: params.targetLocales,
2815
3093
  savedAt: (/* @__PURE__ */ new Date()).toISOString(),
3094
+ ...params.stringsHash ? { stringsHash: params.stringsHash } : {},
2816
3095
  ...params.snapshotBatchId ? { snapshotBatchId: params.snapshotBatchId } : {},
2817
3096
  ...params.completedAt ? { completedAt: params.completedAt } : {},
2818
3097
  ...params.localeMetadata ? { localeMetadata: params.localeMetadata } : {},
@@ -2902,7 +3181,9 @@ function getSyncPolicyErrorGuidance(error) {
2902
3181
  if (error.branch) {
2903
3182
  lines2.push(`Current branch: ${error.branch}`);
2904
3183
  }
2905
- lines2.push("Update your project target branches in the dashboard if needed.");
3184
+ lines2.push(
3185
+ "Update your project target branches in the dashboard if needed."
3186
+ );
2906
3187
  return lines2;
2907
3188
  }
2908
3189
  const lines = ["This project is bound to a different repository."];
@@ -2994,7 +3275,7 @@ async function sync(options = {}) {
2994
3275
  const config = {
2995
3276
  ...localConfig,
2996
3277
  ...apiConfig,
2997
- extractionPattern: mergedConfig.extractionPattern,
3278
+ includePattern: mergedConfig.includePattern,
2998
3279
  excludePattern: mergedConfig.excludePattern,
2999
3280
  timeout: waitTimeoutMs
3000
3281
  };
@@ -3008,17 +3289,19 @@ async function sync(options = {}) {
3008
3289
  p7.outro("");
3009
3290
  return 0;
3010
3291
  }
3011
- const patternsDisplay = Array.isArray(config.extractionPattern) ? config.extractionPattern.join(", ") : config.extractionPattern;
3292
+ const patternsDisplay = Array.isArray(config.includePattern) ? config.includePattern.join(", ") : config.includePattern;
3012
3293
  spinner4.start(`Extracting strings from ${patternsDisplay}`);
3013
3294
  const extractor = new StringExtractor();
3014
3295
  const extractedStrings = await extractor.extractFromProject(
3015
- config.extractionPattern,
3296
+ config.includePattern,
3016
3297
  projectRoot,
3017
3298
  config.excludePattern
3018
3299
  );
3019
3300
  if (extractedStrings.length === 0) {
3020
3301
  spinner4.stop("No translatable strings found");
3021
- p7.log.warn("Make sure you are wrapping translatable strings with Vocoder");
3302
+ p7.log.warn(
3303
+ "Make sure you are wrapping translatable strings with Vocoder"
3304
+ );
3022
3305
  p7.outro("");
3023
3306
  return 0;
3024
3307
  }
@@ -3053,6 +3336,7 @@ async function sync(options = {}) {
3053
3336
  "Could not detect git remote origin. Sync will continue without repo metadata."
3054
3337
  );
3055
3338
  }
3339
+ const commitSha = detectCommitSha() ?? void 0;
3056
3340
  const stringEntries = buildStringEntries(extractedStrings);
3057
3341
  const sourceStrings = stringEntries.map((entry) => entry.text);
3058
3342
  if (options.verbose && stringEntries.length !== extractedStrings.length) {
@@ -3060,6 +3344,15 @@ async function sync(options = {}) {
3060
3344
  `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
3061
3345
  );
3062
3346
  }
3347
+ const currentHash = computeStringsHash(sourceStrings);
3348
+ if (!options.force) {
3349
+ const cachedHash = readCachedStringsHash(projectRoot, branch);
3350
+ if (cachedHash && cachedHash === currentHash) {
3351
+ const duration2 = ((Date.now() - startTime) / 1e3).toFixed(1);
3352
+ p7.outro(`Up to date (${duration2}s)`);
3353
+ return 0;
3354
+ }
3355
+ }
3063
3356
  spinner4.start("Submitting strings to Vocoder API");
3064
3357
  const batchResponse = await api.submitTranslation(
3065
3358
  branch,
@@ -3070,9 +3363,11 @@ async function sync(options = {}) {
3070
3363
  requestedMaxWaitMs: waitTimeoutMs,
3071
3364
  clientRunId: randomUUID()
3072
3365
  },
3073
- repoIdentity ?? void 0
3366
+ repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
3367
+ );
3368
+ spinner4.stop(
3369
+ `Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`
3074
3370
  );
3075
- spinner4.stop(`Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`);
3076
3371
  const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
3077
3372
  branch,
3078
3373
  requestedMode,
@@ -3204,6 +3499,7 @@ async function sync(options = {}) {
3204
3499
  targetLocales: config.targetLocales,
3205
3500
  translations: finalTranslations,
3206
3501
  localeMetadata: artifacts.localeMetadata,
3502
+ stringsHash: currentHash,
3207
3503
  snapshotBatchId: artifacts.snapshotBatchId ?? (artifacts.source === "fresh" ? batchResponse.batchId : batchResponse.latestCompletedBatchId),
3208
3504
  completedAt: artifacts.completedAt ?? (artifacts.source === "fresh" ? (/* @__PURE__ */ new Date()).toISOString() : null)
3209
3505
  });
@@ -3224,8 +3520,6 @@ async function sync(options = {}) {
3224
3520
  }
3225
3521
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
3226
3522
  p7.outro(`Sync complete! (${duration}s)`);
3227
- p7.log.info("Translations will be injected at build time by @vocoder/unplugin.");
3228
- p7.log.info("Just use <VocoderProvider> and <T> \u2014 no manual imports needed.");
3229
3523
  return 0;
3230
3524
  } catch (error) {
3231
3525
  spinner4.stop();
@@ -3249,11 +3543,15 @@ async function sync(options = {}) {
3249
3543
  if (error instanceof Error) {
3250
3544
  p7.log.error(error.message);
3251
3545
  if (error.message.includes("VOCODER_API_KEY")) {
3252
- p7.log.warn("VOCODER_API_KEY is only needed for `vocoder sync` (CLI push).");
3546
+ p7.log.warn(
3547
+ "VOCODER_API_KEY is only needed for `vocoder sync` (CLI push)."
3548
+ );
3253
3549
  p7.log.info(" Create one at: https://vocoder.app/dashboard");
3254
3550
  p7.log.info(' Then: export VOCODER_API_KEY="vc_..." or add it to .env');
3255
3551
  p7.log.info("");
3256
- p7.log.info(" Note: If you use @vocoder/unplugin, `vocoder sync` is optional.");
3552
+ p7.log.info(
3553
+ " Note: If you use @vocoder/unplugin, `vocoder sync` is optional."
3554
+ );
3257
3555
  p7.log.info(" Translations are fetched automatically at build time.");
3258
3556
  } else if (error.message.includes("git branch")) {
3259
3557
  p7.log.warn("Run from a git repository, or use:");
@@ -3287,7 +3585,9 @@ async function whoami(options = {}) {
3287
3585
  p8.log.info(`API: ${apiUrl}`);
3288
3586
  return 0;
3289
3587
  } catch {
3290
- p8.log.error("Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate.");
3588
+ p8.log.error(
3589
+ "Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate."
3590
+ );
3291
3591
  return 1;
3292
3592
  }
3293
3593
  }
@@ -3302,7 +3602,13 @@ async function runCommand(command, options) {
3302
3602
  }
3303
3603
  var program = new Command();
3304
3604
  program.name("vocoder").description("Vocoder CLI - Project setup and string extraction").version("0.1.5");
3305
- 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));
3605
+ 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(
3606
+ "--ci",
3607
+ "Non-interactive mode: print auth URL to stdout, skip browser open"
3608
+ ).option("--project-name <name>", "Starter project name to create").option("--source-locale <locale>", "Source locale for the starter project").option(
3609
+ "--target-locales <list>",
3610
+ "Comma-separated target locales (e.g. es,fr,de)"
3611
+ ).action((options) => runCommand(init, options));
3306
3612
  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) => {
3307
3613
  const translated = { ...options };
3308
3614
  if (options.maxWait) translated.maxWaitMs = Number(options.maxWait);