@vocoder/cli 0.10.0 → 0.12.0

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
@@ -2,759 +2,37 @@
2
2
  import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  StringExtractor,
5
+ VocoderAPI,
6
+ VocoderAPIError,
5
7
  buildInstallCommand,
8
+ clearAuthData,
6
9
  detectLocalEcosystem,
7
10
  getPackagesToInstall,
8
11
  getSetupSnippets,
9
- loadVocoderConfig
10
- } from "./chunk-73U4VZYP.mjs";
12
+ loadVocoderConfig,
13
+ readAuthData,
14
+ verifyStoredAuth,
15
+ writeAuthData
16
+ } from "./chunk-XUCVAFBG.mjs";
11
17
 
12
18
  // src/bin.ts
13
19
  import { Command } from "commander";
14
20
 
15
21
  // src/commands/init.ts
16
- import { execSync as execSync3, spawn as spawn2 } from "child_process";
17
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
18
- import { join as join3 } from "path";
19
22
  import * as p5 from "@clack/prompts";
20
- import chalk6 from "chalk";
21
- import { config as loadEnv } from "dotenv";
22
-
23
- // src/utils/api.ts
24
- function isLimitErrorResponse(value) {
25
- if (!value || typeof value !== "object") {
26
- return false;
27
- }
28
- const candidate = value;
29
- return typeof candidate.errorCode === "string" && typeof candidate.limitType === "string" && typeof candidate.planId === "string" && typeof candidate.current === "number" && typeof candidate.required === "number" && typeof candidate.upgradeUrl === "string" && typeof candidate.message === "string";
30
- }
31
- function isSyncPolicyErrorResponse(value) {
32
- if (!value || typeof value !== "object") {
33
- return false;
34
- }
35
- const candidate = value;
36
- return (candidate.errorCode === "BRANCH_NOT_ALLOWED" || candidate.errorCode === "PROJECT_REPOSITORY_MISMATCH") && typeof candidate.message === "string";
37
- }
38
- function extractErrorMessage(payload, fallback) {
39
- if (!payload || typeof payload !== "object") {
40
- return fallback;
41
- }
42
- const candidate = payload;
43
- if (typeof candidate.message === "string") {
44
- return candidate.message;
45
- }
46
- if (typeof candidate.error === "string") {
47
- return candidate.error;
48
- }
49
- return fallback;
50
- }
51
- function parsePayload(raw) {
52
- if (raw.length === 0) {
53
- return null;
54
- }
55
- const trimmed = raw.trimStart();
56
- if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
57
- return {
58
- message: "Unexpected response from server (received HTML). Check your network connection or try again."
59
- };
60
- }
61
- try {
62
- return JSON.parse(raw);
63
- } catch {
64
- return { message: raw };
65
- }
66
- }
67
- async function readPayload(response) {
68
- if (typeof response.text === "function") {
69
- const raw = await response.text();
70
- return parsePayload(raw);
71
- }
72
- if (typeof response.json === "function") {
73
- return response.json();
74
- }
75
- return null;
76
- }
77
- var VocoderAPIError = class extends Error {
78
- constructor(params) {
79
- super(params.message);
80
- this.name = "VocoderAPIError";
81
- this.status = params.status;
82
- this.payload = params.payload;
83
- this.limitError = params.limitError ?? null;
84
- this.syncPolicyError = params.syncPolicyError ?? null;
85
- }
86
- };
87
- var VocoderAPI = class {
88
- constructor(config) {
89
- this.apiUrl = config.apiUrl;
90
- this.apiKey = config.apiKey;
91
- }
92
- async request(path, init2 = {}, errorPrefix) {
93
- const response = await fetch(`${this.apiUrl}${path}`, {
94
- ...init2,
95
- headers: {
96
- Authorization: `Bearer ${this.apiKey}`,
97
- ...init2.headers ?? {}
98
- }
99
- });
100
- const payload = await readPayload(response);
101
- if (!response.ok) {
102
- const limitError = isLimitErrorResponse(payload) ? payload : null;
103
- const syncPolicyError = isSyncPolicyErrorResponse(payload) ? payload : null;
104
- const baseMessage = extractErrorMessage(
105
- payload,
106
- `Request failed with status ${response.status}`
107
- );
108
- throw new VocoderAPIError({
109
- message: errorPrefix ? `${errorPrefix}: ${baseMessage}` : baseMessage,
110
- status: response.status,
111
- payload,
112
- limitError,
113
- syncPolicyError
114
- });
115
- }
116
- return payload;
117
- }
118
- /**
119
- * Fetch project configuration from API
120
- * Project is determined from the API key
121
- */
122
- async getProjectConfig() {
123
- const data = await this.request("/api/cli/config", {}, "Failed to fetch project config");
124
- return {
125
- projectName: data.projectName,
126
- organizationName: data.organizationName,
127
- shortCode: data.shortCode,
128
- sourceLocale: data.sourceLocale,
129
- targetLocales: data.targetLocales,
130
- targetBranches: data.targetBranches ?? ["main"],
131
- primaryBranch: data.primaryBranch,
132
- syncPolicy: {
133
- blockingBranches: data.syncPolicy?.blockingBranches ?? [
134
- "main",
135
- "master"
136
- ],
137
- blockingMode: data.syncPolicy?.blockingMode ?? "required",
138
- nonBlockingMode: data.syncPolicy?.nonBlockingMode ?? "best-effort",
139
- defaultMaxWaitMs: data.syncPolicy?.defaultMaxWaitMs ?? 6e4
140
- }
141
- };
142
- }
143
- /**
144
- * Submit strings for translation
145
- * Project is determined from the API key
146
- */
147
- stableTextKey(text2) {
148
- let hash = 2166136261;
149
- for (let i = 0; i < text2.length; i++) {
150
- hash ^= text2.charCodeAt(i);
151
- hash = Math.imul(hash, 16777619);
152
- }
153
- return `SK_TEXT_${(hash >>> 0).toString(16).toUpperCase().padStart(8, "0")}`;
154
- }
155
- normalizeStringEntries(entries) {
156
- if (entries.length === 0) {
157
- return [];
158
- }
159
- const first = entries[0];
160
- if (typeof first === "string") {
161
- return entries.map((text2) => ({
162
- key: this.stableTextKey(text2),
163
- text: text2
164
- }));
165
- }
166
- return entries.map((entry, index) => ({
167
- key: entry.key || this.stableTextKey(`${entry.text}:${index}`),
168
- text: entry.text,
169
- ...entry.context ? { context: entry.context } : {},
170
- ...entry.formality ? { formality: entry.formality } : {},
171
- ...entry.uiRole ? { uiRole: entry.uiRole } : {},
172
- ...entry.featureArea ? { featureArea: entry.featureArea } : {}
173
- }));
174
- }
175
- async submitTranslation(branch, entries, targetLocales, options, repoIdentity) {
176
- const stringEntries = this.normalizeStringEntries(entries);
177
- const strings = stringEntries.map((entry) => entry.text);
178
- const crypto = await import("crypto");
179
- const sortedStrings = [...strings].sort();
180
- const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
181
- return this.request(
182
- "/api/cli/sync",
183
- {
184
- method: "POST",
185
- headers: {
186
- "Content-Type": "application/json"
187
- },
188
- body: JSON.stringify({
189
- branch,
190
- stringEntries,
191
- targetLocales,
192
- ...options?.force ? {} : { stringsHash },
193
- ...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
194
- ...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
195
- ...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
196
- ...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
197
- ...repoIdentity?.repoAppDir !== void 0 ? { repoAppDir: repoIdentity.repoAppDir } : {},
198
- ...repoIdentity?.commitSha ? { commitSha: repoIdentity.commitSha } : {},
199
- ...options?.appIndustry ? { appIndustry: options.appIndustry } : {}
200
- })
201
- },
202
- "Translation submission failed"
203
- );
204
- }
205
- /**
206
- * Check translation status
207
- */
208
- async getTranslationStatus(batchId) {
209
- return this.request(
210
- `/api/cli/sync/status/${batchId}`,
211
- {},
212
- "Failed to check translation status"
213
- );
214
- }
215
- async getTranslationSnapshot(params) {
216
- const search = new URLSearchParams();
217
- search.set("branch", params.branch);
218
- for (const locale of params.targetLocales) {
219
- search.append("targetLocale", locale);
220
- }
221
- return this.request(
222
- `/api/cli/sync/snapshot?${search.toString()}`,
223
- {},
224
- "Failed to fetch translation snapshot"
225
- );
226
- }
227
- /**
228
- * Wait for translation to complete with polling
229
- */
230
- async waitForCompletion(batchId, timeout = 6e4, onProgress) {
231
- const startTime = Date.now();
232
- const pollInterval = 1e3;
233
- while (Date.now() - startTime < timeout) {
234
- const status = await this.getTranslationStatus(batchId);
235
- if (onProgress) {
236
- onProgress(status.progress);
237
- }
238
- if (status.status === "COMPLETED") {
239
- if (!status.translations) {
240
- throw new Error("Translation completed but no translations returned");
241
- }
242
- return {
243
- translations: status.translations,
244
- localeMetadata: status.localeMetadata
245
- };
246
- }
247
- if (status.status === "FAILED") {
248
- throw new Error(
249
- `Translation failed: ${status.errorMessage || "Unknown error"}`
250
- );
251
- }
252
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
253
- }
254
- throw new Error(`Translation timeout after ${timeout}ms`);
255
- }
256
- async startInitSession(input) {
257
- const response = await fetch(`${this.apiUrl}/api/cli/init/start`, {
258
- method: "POST",
259
- headers: {
260
- "Content-Type": "application/json"
261
- },
262
- body: JSON.stringify(input)
263
- });
264
- const payload = await readPayload(response);
265
- if (!response.ok) {
266
- throw new VocoderAPIError({
267
- message: extractErrorMessage(
268
- payload,
269
- `Failed to start init session (${response.status})`
270
- ),
271
- status: response.status,
272
- payload
273
- });
274
- }
275
- return payload;
276
- }
277
- async getInitSessionStatus(params) {
278
- const response = await fetch(
279
- `${this.apiUrl}/api/cli/init/status/${params.sessionId}`,
280
- {
281
- headers: {
282
- Authorization: `Bearer ${params.pollToken}`
283
- }
284
- }
285
- );
286
- const payload = await readPayload(response);
287
- if (!response.ok) {
288
- throw new VocoderAPIError({
289
- message: extractErrorMessage(
290
- payload,
291
- `Failed to get init status (${response.status})`
292
- ),
293
- status: response.status,
294
- payload
295
- });
296
- }
297
- return payload;
298
- }
299
- // ── CLI Auth endpoints (no project API key needed) ──────────────────────────
300
- /**
301
- * Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
302
- * `sessionId` is the raw poll token — keep it secret, used for polling.
303
- */
304
- async startCliAuthSession(callbackPort, repoCanonical) {
305
- const response = await fetch(`${this.apiUrl}/api/cli/auth/start`, {
306
- method: "POST",
307
- headers: { "Content-Type": "application/json" },
308
- body: JSON.stringify({
309
- ...callbackPort != null ? { callbackPort } : {},
310
- ...repoCanonical ? { repoCanonical } : {}
311
- })
312
- });
313
- const payload = await readPayload(response);
314
- if (!response.ok) {
315
- throw new VocoderAPIError({
316
- message: extractErrorMessage(
317
- payload,
318
- `Failed to start auth session (${response.status})`
319
- ),
320
- status: response.status,
321
- payload
322
- });
323
- }
324
- return payload;
325
- }
326
- /**
327
- * Poll for CLI auth session completion.
328
- * Returns `{ token }` on success, throws on failure/expiry.
329
- * The server returns HTTP 202 while still pending.
330
- */
331
- async pollCliAuthSession(pollToken) {
332
- const response = await fetch(
333
- `${this.apiUrl}/api/cli/auth/session?session=${encodeURIComponent(pollToken)}`
334
- );
335
- const payload = await readPayload(response);
336
- if (response.status === 202) {
337
- return { status: "pending" };
338
- }
339
- if (response.status === 410) {
340
- return {
341
- status: "failed",
342
- reason: extractErrorMessage(payload, "Auth session expired or failed")
343
- };
344
- }
345
- if (!response.ok) {
346
- return {
347
- status: "failed",
348
- reason: extractErrorMessage(
349
- payload,
350
- `Auth session error (${response.status})`
351
- )
352
- };
353
- }
354
- const result = payload;
355
- if (!result.token) {
356
- return { status: "failed", reason: "No token in response" };
357
- }
358
- return {
359
- status: "complete",
360
- token: result.token,
361
- ...result.organizationId ? { organizationId: result.organizationId } : {}
362
- };
363
- }
364
- /**
365
- * Validate a CLI user token and return the authenticated user's info.
366
- * Used by the CLI to verify stored credentials on startup.
367
- */
368
- async getCliUserInfo(userToken) {
369
- const response = await fetch(`${this.apiUrl}/api/cli/auth/me`, {
370
- headers: { Authorization: `Bearer ${userToken}` }
371
- });
372
- const payload = await readPayload(response);
373
- if (!response.ok) {
374
- throw new VocoderAPIError({
375
- message: extractErrorMessage(
376
- payload,
377
- `Token validation failed (${response.status})`
378
- ),
379
- status: response.status,
380
- payload
381
- });
382
- }
383
- return payload;
384
- }
385
- /**
386
- * Revoke the given CLI user token server-side.
387
- */
388
- async revokeCliToken(userToken) {
389
- const response = await fetch(`${this.apiUrl}/api/cli/auth/token`, {
390
- method: "DELETE",
391
- headers: { Authorization: `Bearer ${userToken}` }
392
- });
393
- if (!response.ok) {
394
- const payload = await readPayload(response);
395
- throw new VocoderAPIError({
396
- message: extractErrorMessage(
397
- payload,
398
- `Token revocation failed (${response.status})`
399
- ),
400
- status: response.status,
401
- payload
402
- });
403
- }
404
- }
405
- // ── Workspaces ────────────────────────────────────────────────────────────────
406
- async listWorkspaces(userToken, params) {
407
- const url = new URL(`${this.apiUrl}/api/cli/workspaces`);
408
- if (params?.repo) url.searchParams.set("repo", params.repo);
409
- const response = await fetch(url.toString(), {
410
- headers: { Authorization: `Bearer ${userToken}` }
411
- });
412
- const payload = await readPayload(response);
413
- if (!response.ok) {
414
- throw new VocoderAPIError({
415
- message: extractErrorMessage(
416
- payload,
417
- `Failed to list workspaces (${response.status})`
418
- ),
419
- status: response.status,
420
- payload
421
- });
422
- }
423
- return payload;
424
- }
425
- async listProjects(userToken, organizationId) {
426
- const url = new URL(`${this.apiUrl}/api/cli/projects`);
427
- url.searchParams.set("organizationId", organizationId);
428
- const response = await fetch(url.toString(), {
429
- headers: { Authorization: `Bearer ${userToken}` }
430
- });
431
- const payload = await readPayload(response);
432
- if (!response.ok) {
433
- throw new VocoderAPIError({
434
- message: extractErrorMessage(
435
- payload,
436
- `Failed to list projects (${response.status})`
437
- ),
438
- status: response.status,
439
- payload
440
- });
441
- }
442
- const result = payload;
443
- return result.projects;
444
- }
445
- async regenerateProjectApiKey(userToken, projectId) {
446
- const response = await fetch(
447
- `${this.apiUrl}/api/cli/project/regenerate-key`,
448
- {
449
- method: "POST",
450
- headers: {
451
- "Content-Type": "application/json",
452
- Authorization: `Bearer ${userToken}`
453
- },
454
- body: JSON.stringify({ projectId })
455
- }
456
- );
457
- const payload = await readPayload(response);
458
- if (!response.ok) {
459
- throw new VocoderAPIError({
460
- message: extractErrorMessage(
461
- payload,
462
- `Failed to regenerate API key (${response.status})`
463
- ),
464
- status: response.status,
465
- payload
466
- });
467
- }
468
- return payload;
469
- }
470
- // ── CLI GitHub endpoints ──────────────────────────────────────────────────────
471
- async startCliGitHubInstall(userToken, params) {
472
- const response = await fetch(
473
- `${this.apiUrl}/api/cli/github/install/start`,
474
- {
475
- method: "POST",
476
- headers: {
477
- Authorization: `Bearer ${userToken}`,
478
- "Content-Type": "application/json"
479
- },
480
- body: JSON.stringify(params)
481
- }
482
- );
483
- const payload = await readPayload(response);
484
- if (!response.ok) {
485
- throw new VocoderAPIError({
486
- message: extractErrorMessage(
487
- payload,
488
- `Failed to start GitHub install (${response.status})`
489
- ),
490
- status: response.status,
491
- payload
492
- });
493
- }
494
- return payload;
495
- }
496
- /**
497
- * Start the "link existing installation" discovery flow.
498
- * Unlike startCliGitHubOAuth, this requires no bearer token — the Vocoder
499
- * account is created from the OAuth code in the callback.
500
- */
501
- async startCliGitHubLinkSession(sessionId, callbackPort) {
502
- const response = await fetch(
503
- `${this.apiUrl}/api/cli/github/oauth/link-start`,
504
- {
505
- method: "POST",
506
- headers: { "Content-Type": "application/json" },
507
- body: JSON.stringify({
508
- sessionId,
509
- ...callbackPort != null ? { callbackPort } : {}
510
- })
511
- }
512
- );
513
- const payload = await readPayload(response);
514
- if (!response.ok) {
515
- throw new VocoderAPIError({
516
- message: extractErrorMessage(
517
- payload,
518
- `Failed to start GitHub link session (${response.status})`
519
- ),
520
- status: response.status,
521
- payload
522
- });
523
- }
524
- return payload;
525
- }
526
- async startCliGitHubOAuth(userToken, params) {
527
- const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/start`, {
528
- method: "POST",
529
- headers: {
530
- Authorization: `Bearer ${userToken}`,
531
- "Content-Type": "application/json"
532
- },
533
- body: JSON.stringify(params)
534
- });
535
- const payload = await readPayload(response);
536
- if (!response.ok) {
537
- throw new VocoderAPIError({
538
- message: extractErrorMessage(
539
- payload,
540
- `Failed to start GitHub OAuth (${response.status})`
541
- ),
542
- status: response.status,
543
- payload
544
- });
545
- }
546
- return payload;
547
- }
548
- async getCliGitHubDiscovery(userToken) {
549
- const response = await fetch(`${this.apiUrl}/api/cli/github/discovery`, {
550
- headers: { Authorization: `Bearer ${userToken}` }
551
- });
552
- const payload = await readPayload(response);
553
- if (!response.ok) {
554
- throw new VocoderAPIError({
555
- message: extractErrorMessage(
556
- payload,
557
- `Failed to fetch GitHub discovery (${response.status})`
558
- ),
559
- status: response.status,
560
- payload
561
- });
562
- }
563
- return payload;
564
- }
565
- async claimCliGitHubInstallation(userToken, params) {
566
- const response = await fetch(`${this.apiUrl}/api/cli/github/claim`, {
567
- method: "POST",
568
- headers: {
569
- Authorization: `Bearer ${userToken}`,
570
- "Content-Type": "application/json"
571
- },
572
- body: JSON.stringify(params)
573
- });
574
- const payload = await readPayload(response);
575
- if (!response.ok) {
576
- throw new VocoderAPIError({
577
- message: extractErrorMessage(
578
- payload,
579
- `Failed to claim GitHub installation (${response.status})`
580
- ),
581
- status: response.status,
582
- payload
583
- });
584
- }
585
- return payload;
586
- }
587
- // ── Locales ───────────────────────────────────────────────────────────────────
588
- async listLocales(userToken) {
589
- const response = await fetch(`${this.apiUrl}/api/cli/locales`, {
590
- headers: { Authorization: `Bearer ${userToken}` }
591
- });
592
- const payload = await readPayload(response);
593
- if (!response.ok) {
594
- throw new VocoderAPIError({
595
- message: extractErrorMessage(
596
- payload,
597
- `Failed to list locales (${response.status})`
598
- ),
599
- status: response.status,
600
- payload
601
- });
602
- }
603
- const result = payload;
604
- return result;
605
- }
606
- async listCompatibleLocales(userToken, sourceLocale) {
607
- const url = `${this.apiUrl}/api/cli/locales/compatible?source=${encodeURIComponent(sourceLocale)}`;
608
- const response = await fetch(url, {
609
- headers: { Authorization: `Bearer ${userToken}` }
610
- });
611
- const payload = await readPayload(response);
612
- if (!response.ok) {
613
- throw new VocoderAPIError({
614
- message: extractErrorMessage(
615
- payload,
616
- `Failed to list compatible locales (${response.status})`
617
- ),
618
- status: response.status,
619
- payload
620
- });
621
- }
622
- const result = payload;
623
- return result.locales;
624
- }
625
- // ── Project creation ──────────────────────────────────────────────────────────
626
- async createProject(userToken, params) {
627
- const response = await fetch(`${this.apiUrl}/api/cli/projects`, {
628
- method: "POST",
629
- headers: {
630
- "Content-Type": "application/json",
631
- Authorization: `Bearer ${userToken}`
632
- },
633
- body: JSON.stringify(params)
634
- });
635
- const payload = await readPayload(response);
636
- if (!response.ok) {
637
- throw new VocoderAPIError({
638
- message: extractErrorMessage(
639
- payload,
640
- `Failed to create project (${response.status})`
641
- ),
642
- status: response.status,
643
- payload
644
- });
645
- }
646
- return payload;
647
- }
648
- // ── Project lookup ────────────────────────────────────────────────────────────
649
- /**
650
- * Look up all project apps for a given repo. Returns info about exact matches,
651
- * existing apps in other scopes, and whether a whole-repo app exists.
652
- * No auth required.
653
- */
654
- async lookupProjectByRepo(params) {
655
- try {
656
- const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
657
- method: "POST",
658
- headers: { "Content-Type": "application/json" },
659
- body: JSON.stringify({
660
- repo: params.repoCanonical,
661
- appDir: params.appDir
662
- })
663
- });
664
- if (!response.ok) {
665
- return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
666
- }
667
- const data = await response.json();
668
- return {
669
- exactMatch: data.exactMatch ?? null,
670
- existingApps: data.existingApps ?? [],
671
- hasWholeRepoApp: data.hasWholeRepoApp ?? false
672
- };
673
- } catch {
674
- return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
675
- }
676
- }
677
- /**
678
- * Add a new ProjectApp to an existing project (monorepo: new app directory).
679
- * Does not check plan limits — no new project is created.
680
- */
681
- async createProjectApp(userToken, params) {
682
- const response = await fetch(`${this.apiUrl}/api/cli/project/apps`, {
683
- method: "POST",
684
- headers: {
685
- "Content-Type": "application/json",
686
- Authorization: `Bearer ${userToken}`
687
- },
688
- body: JSON.stringify(params)
689
- });
690
- const payload = await readPayload(response);
691
- if (!response.ok) {
692
- throw new VocoderAPIError({
693
- message: extractErrorMessage(
694
- payload,
695
- `Failed to create project app (${response.status})`
696
- ),
697
- status: response.status,
698
- payload
699
- });
700
- }
701
- return payload;
702
- }
703
- };
704
-
705
- // src/utils/auth-store.ts
706
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
707
- import { homedir } from "os";
708
- import { dirname, join } from "path";
709
- function getAuthFilePath() {
710
- return join(homedir(), ".config", "vocoder", "auth.json");
711
- }
712
- function readAuthData() {
713
- const filePath = getAuthFilePath();
714
- try {
715
- const raw = readFileSync(filePath, "utf8");
716
- const parsed = JSON.parse(raw);
717
- if (!parsed || typeof parsed !== "object") return null;
718
- const data = parsed;
719
- if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
720
- return null;
721
- }
722
- return {
723
- token: data.token,
724
- apiUrl: data.apiUrl,
725
- userId: data.userId,
726
- email: data.email,
727
- name: typeof data.name === "string" ? data.name : null,
728
- createdAt: data.createdAt
729
- };
730
- } catch {
731
- return null;
732
- }
733
- }
734
- function writeAuthData(data) {
735
- const filePath = getAuthFilePath();
736
- const dir = dirname(filePath);
737
- mkdirSync(dir, { recursive: true, mode: 448 });
738
- writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
739
- }
740
- function clearAuthData() {
741
- const filePath = getAuthFilePath();
742
- try {
743
- unlinkSync(filePath);
744
- } catch {
745
- }
746
- }
23
+ import { execSync as execSync3, spawn as spawn2 } from "child_process";
24
+ import { existsSync as existsSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
747
25
 
748
26
  // src/utils/write-config.ts
749
- import { existsSync, writeFileSync as writeFileSync2 } from "fs";
750
- import { join as join2 } from "path";
27
+ import { existsSync, writeFileSync } from "fs";
28
+ import { join } from "path";
751
29
  function findExistingConfig(cwd = process.cwd()) {
752
30
  for (const name of [
753
31
  "vocoder.config.ts",
754
32
  "vocoder.config.js",
755
33
  "vocoder.config.json"
756
34
  ]) {
757
- const candidate = join2(cwd, name);
35
+ const candidate = join(cwd, name);
758
36
  if (existsSync(candidate)) return candidate;
759
37
  }
760
38
  return null;
@@ -767,7 +45,7 @@ function writeVocoderConfig(options) {
767
45
  } = options;
768
46
  if (findExistingConfig(cwd)) return null;
769
47
  const ext = useTypeScript ? "ts" : "js";
770
- const configPath = join2(cwd, `vocoder.config.${ext}`);
48
+ const configPath = join(cwd, `vocoder.config.${ext}`);
771
49
  const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
772
50
  const content = `import { defineConfig } from '@vocoder/config'
773
51
 
@@ -785,121 +63,13 @@ export default defineConfig({
785
63
  })
786
64
  `;
787
65
  try {
788
- writeFileSync2(configPath, content, "utf-8");
66
+ writeFileSync(configPath, content, "utf-8");
789
67
  return `vocoder.config.${ext}`;
790
68
  } catch {
791
69
  return null;
792
70
  }
793
71
  }
794
72
 
795
- // src/utils/git-identity.ts
796
- import { execSync } from "child_process";
797
- import { relative, resolve } from "path";
798
- var SHA_REGEX = /^[0-9a-f]{40}$/i;
799
- function detectCommitSha() {
800
- if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
801
- return process.env.VOCODER_COMMIT_SHA;
802
- }
803
- 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;
804
- if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
805
- return safeExec("git rev-parse HEAD");
806
- }
807
- function safeExec(command) {
808
- try {
809
- const output = execSync(command, {
810
- encoding: "utf-8",
811
- stdio: ["pipe", "pipe", "ignore"]
812
- }).trim();
813
- return output.length > 0 ? output : null;
814
- } catch {
815
- return null;
816
- }
817
- }
818
- function normalizePath(pathname) {
819
- const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
820
- if (!cleaned || !cleaned.includes("/")) {
821
- return null;
822
- }
823
- return cleaned;
824
- }
825
- function parseRemoteUrl(remoteUrl) {
826
- const trimmed = remoteUrl.trim();
827
- if (!trimmed) {
828
- return null;
829
- }
830
- if (!trimmed.includes("://")) {
831
- const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
832
- if (scpMatch) {
833
- const host = (scpMatch[1] || "").toLowerCase();
834
- const ownerRepoPath = normalizePath(scpMatch[2] || "");
835
- if (!host || !ownerRepoPath) {
836
- return null;
837
- }
838
- return { host, ownerRepoPath };
839
- }
840
- return null;
841
- }
842
- try {
843
- const parsed = new URL(trimmed);
844
- const host = parsed.hostname.toLowerCase();
845
- const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
846
- if (!host || !ownerRepoPath) {
847
- return null;
848
- }
849
- return { host, ownerRepoPath };
850
- } catch {
851
- return null;
852
- }
853
- }
854
- function toCanonical(host, ownerRepoPath) {
855
- if (host.includes("github.com")) {
856
- return `github:${ownerRepoPath.toLowerCase()}`;
857
- }
858
- if (host.includes("gitlab.com")) {
859
- return `gitlab:${ownerRepoPath.toLowerCase()}`;
860
- }
861
- if (host.includes("bitbucket.org")) {
862
- return `bitbucket:${ownerRepoPath.toLowerCase()}`;
863
- }
864
- return `git:${host}/${ownerRepoPath.toLowerCase()}`;
865
- }
866
- function resolveGitRepositoryIdentity() {
867
- const remoteUrl = safeExec("git config --get remote.origin.url");
868
- if (!remoteUrl) {
869
- return null;
870
- }
871
- const parsed = parseRemoteUrl(remoteUrl);
872
- if (!parsed) {
873
- return null;
874
- }
875
- const repositoryRoot = safeExec("git rev-parse --show-toplevel");
876
- const currentDirectory = process.cwd();
877
- let repoAppDir = "";
878
- if (repositoryRoot) {
879
- const relativePath = relative(
880
- resolve(repositoryRoot),
881
- resolve(currentDirectory)
882
- ).replace(/\\/g, "/").trim();
883
- if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
884
- repoAppDir = relativePath;
885
- }
886
- }
887
- return {
888
- repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
889
- repoAppDir
890
- };
891
- }
892
- function resolveGitContext() {
893
- const warnings = [];
894
- const identity = resolveGitRepositoryIdentity();
895
- if (!identity) {
896
- warnings.push(
897
- "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
898
- );
899
- }
900
- return { identity, warnings };
901
- }
902
-
903
73
  // src/utils/github-connect.ts
904
74
  import { spawn } from "child_process";
905
75
  import * as p from "@clack/prompts";
@@ -1185,7 +355,7 @@ import * as p3 from "@clack/prompts";
1185
355
  import chalk4 from "chalk";
1186
356
 
1187
357
  // src/utils/branch-select.ts
1188
- import { execSync as execSync2 } from "child_process";
358
+ import { execSync } from "child_process";
1189
359
  import { isCancel as isCancel2, Prompt } from "@clack/core";
1190
360
  import chalk2 from "chalk";
1191
361
  var S_BAR = "\u2502";
@@ -1216,14 +386,14 @@ function symbol(state) {
1216
386
  function detectGitBranches(cwd) {
1217
387
  const workDir = cwd ?? process.cwd();
1218
388
  try {
1219
- const localOut = execSync2("git branch", {
389
+ const localOut = execSync("git branch", {
1220
390
  cwd: workDir,
1221
391
  stdio: "pipe"
1222
392
  }).toString();
1223
393
  const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1224
394
  let remoteBranches = [];
1225
395
  try {
1226
- const remoteOut = execSync2("git branch -r", {
396
+ const remoteOut = execSync("git branch -r", {
1227
397
  cwd: workDir,
1228
398
  stdio: "pipe"
1229
399
  }).toString();
@@ -1233,7 +403,7 @@ function detectGitBranches(cwd) {
1233
403
  const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1234
404
  let defaultBranch = "main";
1235
405
  try {
1236
- const ref = execSync2("git symbolic-ref refs/remotes/origin/HEAD", {
406
+ const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1237
407
  cwd: workDir,
1238
408
  stdio: "pipe"
1239
409
  }).toString().trim();
@@ -1870,53 +1040,166 @@ async function runProjectAppCreate(params) {
1870
1040
  "No target languages selected \u2014 you can add them later from the dashboard."
1871
1041
  );
1872
1042
  }
1873
- const detectedApp = detectGitBranches();
1874
- let appPushBranches = [];
1875
- {
1876
- let initial = [detectedApp.defaultBranch];
1877
- while (appPushBranches.length === 0) {
1878
- const result = await filterableBranchSelect({
1879
- message: "Which branches should trigger translations?",
1880
- branches: detectedApp.branches,
1881
- defaultBranch: detectedApp.defaultBranch,
1882
- initialValues: initial
1883
- });
1884
- if (result === null) return null;
1885
- if (result.length === 0) {
1886
- p3.log.warn("At least one branch is required.");
1887
- initial = [detectedApp.defaultBranch];
1888
- } else {
1889
- appPushBranches = result;
1890
- }
1043
+ const detectedApp = detectGitBranches();
1044
+ let appPushBranches = [];
1045
+ {
1046
+ let initial = [detectedApp.defaultBranch];
1047
+ while (appPushBranches.length === 0) {
1048
+ const result = await filterableBranchSelect({
1049
+ message: "Which branches should trigger translations?",
1050
+ branches: detectedApp.branches,
1051
+ defaultBranch: detectedApp.defaultBranch,
1052
+ initialValues: initial
1053
+ });
1054
+ if (result === null) return null;
1055
+ if (result.length === 0) {
1056
+ p3.log.warn("At least one branch is required.");
1057
+ initial = [detectedApp.defaultBranch];
1058
+ } else {
1059
+ appPushBranches = result;
1060
+ }
1061
+ }
1062
+ }
1063
+ const targetBranches = appPushBranches;
1064
+ try {
1065
+ const result = await api.createProjectApp(userToken, {
1066
+ projectId,
1067
+ appDir,
1068
+ sourceLocale,
1069
+ targetLocales,
1070
+ targetBranches,
1071
+ repoCanonical: repoCanonical ?? ""
1072
+ });
1073
+ p3.log.success(
1074
+ `App ${chalk4.bold(appDir)} added to ${chalk4.bold(projectName)}!`
1075
+ );
1076
+ return {
1077
+ projectId: result.projectId,
1078
+ projectName: result.projectName,
1079
+ apiKey: result.apiKey,
1080
+ appDir: result.appDir,
1081
+ sourceLocale,
1082
+ targetLocales,
1083
+ targetBranches
1084
+ };
1085
+ } catch (error) {
1086
+ const message = error instanceof Error ? error.message : "Unknown error";
1087
+ p3.log.error(`Failed to add app: ${message}`);
1088
+ return null;
1089
+ }
1090
+ }
1091
+
1092
+ // src/commands/init.ts
1093
+ import chalk6 from "chalk";
1094
+ import { join as join2 } from "path";
1095
+ import { config as loadEnv } from "dotenv";
1096
+
1097
+ // src/utils/git-identity.ts
1098
+ import { execSync as execSync2 } from "child_process";
1099
+ import { relative, resolve } from "path";
1100
+ var SHA_REGEX = /^[0-9a-f]{40}$/i;
1101
+ function detectCommitSha() {
1102
+ if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1103
+ return process.env.VOCODER_COMMIT_SHA;
1104
+ }
1105
+ 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;
1106
+ if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1107
+ return safeExec("git rev-parse HEAD");
1108
+ }
1109
+ function safeExec(command) {
1110
+ try {
1111
+ const output = execSync2(command, {
1112
+ encoding: "utf-8",
1113
+ stdio: ["pipe", "pipe", "ignore"]
1114
+ }).trim();
1115
+ return output.length > 0 ? output : null;
1116
+ } catch {
1117
+ return null;
1118
+ }
1119
+ }
1120
+ function normalizePath(pathname) {
1121
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1122
+ if (!cleaned || !cleaned.includes("/")) {
1123
+ return null;
1124
+ }
1125
+ return cleaned;
1126
+ }
1127
+ function parseRemoteUrl(remoteUrl) {
1128
+ const trimmed = remoteUrl.trim();
1129
+ if (!trimmed) {
1130
+ return null;
1131
+ }
1132
+ if (!trimmed.includes("://")) {
1133
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1134
+ if (scpMatch) {
1135
+ const host = (scpMatch[1] || "").toLowerCase();
1136
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
1137
+ if (!host || !ownerRepoPath) {
1138
+ return null;
1139
+ }
1140
+ return { host, ownerRepoPath };
1141
+ }
1142
+ return null;
1143
+ }
1144
+ try {
1145
+ const parsed = new URL(trimmed);
1146
+ const host = parsed.hostname.toLowerCase();
1147
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
1148
+ if (!host || !ownerRepoPath) {
1149
+ return null;
1150
+ }
1151
+ return { host, ownerRepoPath };
1152
+ } catch {
1153
+ return null;
1154
+ }
1155
+ }
1156
+ function toCanonical(host, ownerRepoPath) {
1157
+ if (host.includes("github.com")) {
1158
+ return `github:${ownerRepoPath.toLowerCase()}`;
1159
+ }
1160
+ if (host.includes("gitlab.com")) {
1161
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
1162
+ }
1163
+ if (host.includes("bitbucket.org")) {
1164
+ return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1165
+ }
1166
+ return `git:${host}/${ownerRepoPath.toLowerCase()}`;
1167
+ }
1168
+ function resolveGitRepositoryIdentity() {
1169
+ const remoteUrl = safeExec("git config --get remote.origin.url");
1170
+ if (!remoteUrl) {
1171
+ return null;
1172
+ }
1173
+ const parsed = parseRemoteUrl(remoteUrl);
1174
+ if (!parsed) {
1175
+ return null;
1176
+ }
1177
+ const repositoryRoot = safeExec("git rev-parse --show-toplevel");
1178
+ const currentDirectory = process.cwd();
1179
+ let repoAppDir = "";
1180
+ if (repositoryRoot) {
1181
+ const relativePath = relative(
1182
+ resolve(repositoryRoot),
1183
+ resolve(currentDirectory)
1184
+ ).replace(/\\/g, "/").trim();
1185
+ if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
1186
+ repoAppDir = relativePath;
1891
1187
  }
1892
1188
  }
1893
- const targetBranches = appPushBranches;
1894
- try {
1895
- const result = await api.createProjectApp(userToken, {
1896
- projectId,
1897
- appDir,
1898
- sourceLocale,
1899
- targetLocales,
1900
- targetBranches,
1901
- repoCanonical: repoCanonical ?? ""
1902
- });
1903
- p3.log.success(
1904
- `App ${chalk4.bold(appDir)} added to ${chalk4.bold(projectName)}!`
1189
+ return {
1190
+ repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
1191
+ repoAppDir
1192
+ };
1193
+ }
1194
+ function resolveGitContext() {
1195
+ const warnings = [];
1196
+ const identity = resolveGitRepositoryIdentity();
1197
+ if (!identity) {
1198
+ warnings.push(
1199
+ "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
1905
1200
  );
1906
- return {
1907
- projectId: result.projectId,
1908
- projectName: result.projectName,
1909
- apiKey: result.apiKey,
1910
- appDir: result.appDir,
1911
- sourceLocale,
1912
- targetLocales,
1913
- targetBranches
1914
- };
1915
- } catch (error) {
1916
- const message = error instanceof Error ? error.message : "Unknown error";
1917
- p3.log.error(`Failed to add app: ${message}`);
1918
- return null;
1919
1201
  }
1202
+ return { identity, warnings };
1920
1203
  }
1921
1204
 
1922
1205
  // src/utils/workspace.ts
@@ -2112,10 +1395,10 @@ function runScaffold(params) {
2112
1395
  p5.log.message(chalk6.gray(" Docs: https://vocoder.app/docs/getting-started"));
2113
1396
  }
2114
1397
  function writeApiKeyToEnv(apiKey) {
2115
- const envPath = join3(process.cwd(), ".env");
1398
+ const envPath = join2(process.cwd(), ".env");
2116
1399
  if (!existsSync2(envPath)) return false;
2117
1400
  try {
2118
- const content = readFileSync2(envPath, "utf-8");
1401
+ const content = readFileSync(envPath, "utf-8");
2119
1402
  const keyLine = `VOCODER_API_KEY=${apiKey}`;
2120
1403
  let updated;
2121
1404
  if (/^VOCODER_API_KEY=/m.test(content)) {
@@ -2125,7 +1408,7 @@ function writeApiKeyToEnv(apiKey) {
2125
1408
  updated = `${content}${sep}${keyLine}
2126
1409
  `;
2127
1410
  }
2128
- writeFileSync3(envPath, updated);
1411
+ writeFileSync2(envPath, updated);
2129
1412
  return true;
2130
1413
  } catch {
2131
1414
  return false;
@@ -2165,17 +1448,6 @@ function printCodeBlock(code) {
2165
1448
  `
2166
1449
  );
2167
1450
  }
2168
- async function verifyStoredToken(api, token) {
2169
- try {
2170
- return await api.getCliUserInfo(token);
2171
- } catch (err) {
2172
- clearAuthData();
2173
- if (err instanceof VocoderAPIError && err.status === 404) {
2174
- return { userGone: true };
2175
- }
2176
- return null;
2177
- }
2178
- }
2179
1451
  async function runAuthFlow(api, options, reauth = false, repoCanonical) {
2180
1452
  let server = null;
2181
1453
  if (!options.ci) {
@@ -2356,17 +1628,17 @@ async function init(options = {}) {
2356
1628
  true
2357
1629
  );
2358
1630
  if (!authResult) return 1;
2359
- const spinner4 = p5.spinner();
2360
- spinner4.start("Generating new API key...");
1631
+ const spinner7 = p5.spinner();
1632
+ spinner7.start("Generating new API key...");
2361
1633
  try {
2362
1634
  const { apiKey } = await anonApi2.regenerateProjectApiKey(
2363
1635
  authResult.token,
2364
1636
  exactMatch.projectId
2365
1637
  );
2366
- spinner4.stop("New API key generated");
1638
+ spinner7.stop("New API key generated");
2367
1639
  printApiKey(apiKey);
2368
1640
  } catch (err) {
2369
- spinner4.stop("Failed to generate key");
1641
+ spinner7.stop("Failed to generate key");
2370
1642
  const msg = err instanceof Error ? err.message : String(err);
2371
1643
  p5.log.error(`Could not generate API key: ${msg}`);
2372
1644
  p5.log.info("Try again or generate one from the dashboard.");
@@ -2401,47 +1673,23 @@ async function init(options = {}) {
2401
1673
  let userEmail;
2402
1674
  let userName;
2403
1675
  let authOrganizationId;
2404
- const stored = readAuthData();
2405
- if (stored && stored.apiUrl === apiUrl) {
2406
- const verified = await verifyStoredToken(api, stored.token);
2407
- if (verified && !("userGone" in verified)) {
2408
- p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
2409
- userToken = stored.token;
2410
- userEmail = verified.email;
2411
- userName = verified.name;
2412
- } else {
2413
- const isFirstTime = verified !== null && "userGone" in verified;
2414
- if (isFirstTime) {
2415
- p5.log.warn("Account not found \u2014 starting fresh setup");
2416
- } else {
2417
- p5.log.warn("Stored credentials expired \u2014 signing in again");
2418
- }
2419
- const authResult = await runAuthFlow(
2420
- api,
2421
- options,
2422
- /* reauth */
2423
- !isFirstTime,
2424
- identity?.repoCanonical
2425
- );
2426
- if (!authResult) return 1;
2427
- userToken = authResult.token;
2428
- userEmail = authResult.email;
2429
- userName = authResult.name;
2430
- authOrganizationId = authResult.organizationId;
2431
- writeAuthData({
2432
- token: userToken,
2433
- apiUrl,
2434
- userId: authResult.userId,
2435
- email: userEmail,
2436
- name: userName,
2437
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
2438
- });
2439
- }
1676
+ const storedAuth = await verifyStoredAuth(api);
1677
+ if (storedAuth.status === "valid") {
1678
+ p5.log.success(`Authenticated as ${chalk6.bold(storedAuth.email)}`);
1679
+ userToken = storedAuth.token;
1680
+ userEmail = storedAuth.email;
1681
+ userName = storedAuth.name;
2440
1682
  } else {
1683
+ const reauth = storedAuth.status === "expired";
1684
+ if (reauth) {
1685
+ p5.log.warn("Stored credentials expired \u2014 signing in again");
1686
+ } else if (storedAuth.status === "gone") {
1687
+ p5.log.warn("Account not found \u2014 starting fresh setup");
1688
+ }
2441
1689
  const authResult = await runAuthFlow(
2442
1690
  api,
2443
1691
  options,
2444
- false,
1692
+ reauth,
2445
1693
  identity?.repoCanonical
2446
1694
  );
2447
1695
  if (!authResult) return 1;
@@ -2451,7 +1699,6 @@ async function init(options = {}) {
2451
1699
  authOrganizationId = authResult.organizationId;
2452
1700
  writeAuthData({
2453
1701
  token: userToken,
2454
- apiUrl,
2455
1702
  userId: authResult.userId,
2456
1703
  email: userEmail,
2457
1704
  name: userName,
@@ -2862,30 +2109,16 @@ Translations won't run automatically until you grant access.
2862
2109
  }
2863
2110
  }
2864
2111
 
2865
- // src/commands/logout.ts
2866
- import * as p6 from "@clack/prompts";
2867
- async function logout(options = {}) {
2868
- const stored = readAuthData();
2869
- if (!stored) {
2870
- p6.log.info("Not currently authenticated.");
2871
- return 0;
2872
- }
2873
- const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
2874
- const api = new VocoderAPI({ apiUrl, apiKey: "" });
2875
- try {
2876
- await api.revokeCliToken(stored.token);
2877
- } catch {
2878
- }
2879
- clearAuthData();
2880
- p6.log.success(`Logged out (was ${stored.email})`);
2881
- return 0;
2882
- }
2112
+ // src/commands/locales.ts
2113
+ import * as p8 from "@clack/prompts";
2114
+ import chalk9 from "chalk";
2115
+ import { config as loadEnv3 } from "dotenv";
2883
2116
 
2884
2117
  // src/commands/sync.ts
2885
2118
  import { createHash, randomUUID } from "crypto";
2886
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
2887
- import { join as join4 } from "path";
2888
- import * as p8 from "@clack/prompts";
2119
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2120
+ import { join as join3 } from "path";
2121
+ import * as p7 from "@clack/prompts";
2889
2122
  import chalk8 from "chalk";
2890
2123
 
2891
2124
  // src/utils/branch.ts
@@ -2955,7 +2188,7 @@ function matchBranchPattern(branch, pattern) {
2955
2188
  }
2956
2189
 
2957
2190
  // src/utils/config.ts
2958
- import * as p7 from "@clack/prompts";
2191
+ import * as p6 from "@clack/prompts";
2959
2192
  import chalk7 from "chalk";
2960
2193
  import { config as loadEnv2 } from "dotenv";
2961
2194
  loadEnv2();
@@ -3014,7 +2247,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
3014
2247
  };
3015
2248
  const fileConfig = loadVocoderConfig(process.cwd());
3016
2249
  if (!fileConfig) {
3017
- p7.log.warn(
2250
+ p6.log.warn(
3018
2251
  `No ${chalk7.cyan("vocoder.config.ts")} found \u2014 run ${chalk7.cyan("npx @vocoder/cli init")} to generate one.`
3019
2252
  );
3020
2253
  }
@@ -3045,7 +2278,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
3045
2278
  excludePattern = fileConfig.exclude;
3046
2279
  configSources.excludePattern = "vocoder.config";
3047
2280
  } else if (envExcludePattern) {
3048
- excludePattern = envExcludePattern.split(",").map((p10) => p10.trim()).filter(Boolean);
2281
+ excludePattern = envExcludePattern.split(",").map((p14) => p14.trim()).filter(Boolean);
3049
2282
  configSources.excludePattern = "environment";
3050
2283
  } else {
3051
2284
  excludePattern = defaults.excludePattern;
@@ -3102,7 +2335,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
3102
2335
  ...maxWaitMs ? [`Max wait: ${chalk7.cyan(String(configSources.maxWaitMs))}`] : [],
3103
2336
  `No fallback: ${chalk7.cyan(String(configSources.noFallback))}`
3104
2337
  ];
3105
- p7.note(lines.join("\n"), "Configuration sources");
2338
+ p6.note(lines.join("\n"), "Configuration sources");
3106
2339
  }
3107
2340
  return {
3108
2341
  includePattern,
@@ -3165,7 +2398,7 @@ function parseTranslations(value) {
3165
2398
  return Object.keys(translations).length > 0 ? translations : null;
3166
2399
  }
3167
2400
  function getCacheFilePath(projectRoot, fingerprint) {
3168
- return join4(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
2401
+ return join3(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
3169
2402
  }
3170
2403
  function buildTranslationData(params) {
3171
2404
  const textToHash = new Map(params.stringEntries.map((e) => [e.text, e.key]));
@@ -3192,7 +2425,7 @@ function readLocalCache(params) {
3192
2425
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
3193
2426
  if (!existsSync3(cacheFilePath)) return null;
3194
2427
  try {
3195
- const raw = readFileSync3(cacheFilePath, "utf-8");
2428
+ const raw = readFileSync2(cacheFilePath, "utf-8");
3196
2429
  const parsed = JSON.parse(raw);
3197
2430
  if (!isRecord(parsed)) return null;
3198
2431
  const inner = isRecord(parsed.config) ? parsed : null;
@@ -3206,10 +2439,10 @@ function readLocalCache(params) {
3206
2439
  }
3207
2440
  }
3208
2441
  function writeCache(params) {
3209
- const cacheDir = join4(params.projectRoot, "node_modules", ".vocoder", "cache");
3210
- mkdirSync2(cacheDir, { recursive: true });
2442
+ const cacheDir = join3(params.projectRoot, "node_modules", ".vocoder", "cache");
2443
+ mkdirSync(cacheDir, { recursive: true });
3211
2444
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
3212
- writeFileSync4(cacheFilePath, JSON.stringify(params.data), "utf-8");
2445
+ writeFileSync3(cacheFilePath, JSON.stringify(params.data), "utf-8");
3213
2446
  return cacheFilePath;
3214
2447
  }
3215
2448
  function resolveEffectiveModeFromPolicy(params) {
@@ -3280,6 +2513,13 @@ function getLimitErrorGuidance(limitError) {
3280
2513
  `Upgrade plan: ${limitError.upgradeUrl}`
3281
2514
  ];
3282
2515
  }
2516
+ if (limitError.limitType === "target_locales") {
2517
+ return [
2518
+ `Current target locales: ${limitError.current}`,
2519
+ `Plan limit: ${limitError.current} (${limitError.planId})`,
2520
+ `Upgrade plan: ${limitError.upgradeUrl}`
2521
+ ];
2522
+ }
3283
2523
  return [
3284
2524
  `Plan: ${limitError.planId}`,
3285
2525
  `Current: ${limitError.current}`,
@@ -3329,8 +2569,7 @@ function buildStringEntries(extractedStrings) {
3329
2569
  text: str.text,
3330
2570
  ...str.context ? { context: str.context } : {},
3331
2571
  ...str.formality ? { formality: str.formality } : {},
3332
- ...str.uiRole ? { uiRole: str.uiRole } : {},
3333
- ...str.featureArea ? { featureArea: str.featureArea } : {}
2572
+ ...str.uiRole ? { uiRole: str.uiRole } : {}
3334
2573
  });
3335
2574
  continue;
3336
2575
  }
@@ -3365,22 +2604,22 @@ async function fetchApiSnapshot(api, params) {
3365
2604
  async function sync(options = {}) {
3366
2605
  const startTime = Date.now();
3367
2606
  const projectRoot = process.cwd();
3368
- p8.intro("Vocoder Sync");
2607
+ p7.intro("Vocoder Sync");
3369
2608
  const mergedConfig = await getMergedConfig(options, options.verbose);
3370
2609
  if (!mergedConfig.apiKey) {
3371
- p8.log.warn("No API key found. Run init to get started:");
3372
- p8.log.info(" npx @vocoder/cli init");
3373
- p8.log.info("");
3374
- p8.log.info(
2610
+ p7.log.warn("No API key found. Run init to get started:");
2611
+ p7.log.info(" npx @vocoder/cli init");
2612
+ p7.log.info("");
2613
+ p7.log.info(
3375
2614
  " Or add your key to .env: VOCODER_API_KEY=vcp_..."
3376
2615
  );
3377
- p8.outro("Run `npx @vocoder/cli init` to set up your project.");
2616
+ p7.outro("Run `npx @vocoder/cli init` to set up your project.");
3378
2617
  return 1;
3379
2618
  }
3380
- const spinner4 = p8.spinner();
2619
+ const spinner7 = p7.spinner();
3381
2620
  try {
3382
2621
  const branch = detectBranch(options.branch);
3383
- spinner4.start("Loading project configuration");
2622
+ spinner7.start("Loading project configuration");
3384
2623
  const localConfig = {
3385
2624
  apiKey: mergedConfig.apiKey,
3386
2625
  apiUrl: mergedConfig.apiUrl || "https://vocoder.app"
@@ -3404,18 +2643,18 @@ async function sync(options = {}) {
3404
2643
  ...fileConfig?.appIndustry ? { appIndustry: fileConfig.appIndustry } : {},
3405
2644
  ...fileConfig?.formality ? { formality: fileConfig.formality } : {}
3406
2645
  };
3407
- spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
2646
+ spinner7.stop(`Branch: ${chalk8.cyan(branch)}`);
3408
2647
  if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
3409
- p8.log.warn(
2648
+ p7.log.warn(
3410
2649
  `Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
3411
2650
  );
3412
- p8.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
3413
- p8.log.info("Use --force to translate anyway");
3414
- p8.outro("");
2651
+ p7.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
2652
+ p7.log.info("Use --force to translate anyway");
2653
+ p7.outro("");
3415
2654
  return 0;
3416
2655
  }
3417
2656
  const patternsDisplay = Array.isArray(config.includePattern) ? config.includePattern.join(", ") : config.includePattern;
3418
- spinner4.start(`Extracting strings from ${patternsDisplay}`);
2657
+ spinner7.start(`Extracting strings from ${patternsDisplay}`);
3419
2658
  const extractor = new StringExtractor();
3420
2659
  const extractedStrings = await extractor.extractFromProject(
3421
2660
  config.includePattern,
@@ -3423,14 +2662,14 @@ async function sync(options = {}) {
3423
2662
  config.excludePattern
3424
2663
  );
3425
2664
  if (extractedStrings.length === 0) {
3426
- spinner4.stop("No translatable strings found");
3427
- p8.log.warn(
2665
+ spinner7.stop("No translatable strings found");
2666
+ p7.log.warn(
3428
2667
  "Make sure you are wrapping translatable strings with Vocoder"
3429
2668
  );
3430
- p8.outro("");
2669
+ p7.outro("");
3431
2670
  return 0;
3432
2671
  }
3433
- spinner4.stop(
2672
+ spinner7.stop(
3434
2673
  `Extracted ${chalk8.cyan(extractedStrings.length)} strings from ${chalk8.cyan(patternsDisplay)}`
3435
2674
  );
3436
2675
  if (options.verbose) {
@@ -3438,10 +2677,10 @@ async function sync(options = {}) {
3438
2677
  if (extractedStrings.length > 5) {
3439
2678
  sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
3440
2679
  }
3441
- p8.note(sampleLines.join("\n"), "Sample strings");
2680
+ p7.note(sampleLines.join("\n"), "Sample strings");
3442
2681
  }
3443
2682
  if (options.dryRun) {
3444
- p8.note(
2683
+ p7.note(
3445
2684
  [
3446
2685
  `Strings: ${extractedStrings.length}`,
3447
2686
  `Branch: ${branch}`,
@@ -3452,12 +2691,12 @@ async function sync(options = {}) {
3452
2691
  ].join("\n"),
3453
2692
  "Dry run - would translate"
3454
2693
  );
3455
- p8.outro("No API calls made.");
2694
+ p7.outro("No API calls made.");
3456
2695
  return 0;
3457
2696
  }
3458
2697
  const repoIdentity = resolveGitRepositoryIdentity();
3459
2698
  if (!repoIdentity && options.verbose) {
3460
- p8.log.warn(
2699
+ p7.log.warn(
3461
2700
  "Could not detect git remote origin. Sync will continue without repo metadata."
3462
2701
  );
3463
2702
  }
@@ -3465,7 +2704,7 @@ async function sync(options = {}) {
3465
2704
  const stringEntries = buildStringEntries(extractedStrings);
3466
2705
  const sourceStrings = stringEntries.map((entry) => entry.text);
3467
2706
  if (options.verbose && stringEntries.length !== extractedStrings.length) {
3468
- p8.log.info(
2707
+ p7.log.info(
3469
2708
  `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
3470
2709
  );
3471
2710
  }
@@ -3474,17 +2713,17 @@ async function sync(options = {}) {
3474
2713
  const cacheFile = getCacheFilePath(projectRoot, fingerprint);
3475
2714
  if (existsSync3(cacheFile)) {
3476
2715
  if (options.verbose) {
3477
- p8.log.info(`Cache hit: ${chalk8.dim(cacheFile)} (fingerprint ${chalk8.cyan(fingerprint)})`);
2716
+ p7.log.info(`Cache hit: ${chalk8.dim(cacheFile)} (fingerprint ${chalk8.cyan(fingerprint)})`);
3478
2717
  }
3479
2718
  const duration2 = ((Date.now() - startTime) / 1e3).toFixed(1);
3480
- p8.outro(`Up to date (${duration2}s)`);
2719
+ p7.outro(`Up to date (${duration2}s)`);
3481
2720
  return 0;
3482
2721
  }
3483
2722
  if (options.verbose) {
3484
- p8.log.info(`No cache for fingerprint ${chalk8.cyan(fingerprint)} \u2014 will submit to API`);
2723
+ p7.log.info(`No cache for fingerprint ${chalk8.cyan(fingerprint)} \u2014 will submit to API`);
3485
2724
  }
3486
2725
  }
3487
- spinner4.start("Submitting strings to Vocoder API");
2726
+ spinner7.start("Submitting strings to Vocoder API");
3488
2727
  const batchResponse = await api.submitTranslation(
3489
2728
  branch,
3490
2729
  stringEntries,
@@ -3499,33 +2738,33 @@ async function sync(options = {}) {
3499
2738
  },
3500
2739
  repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
3501
2740
  );
3502
- spinner4.stop("Strings submitted");
2741
+ spinner7.stop("Strings submitted");
3503
2742
  const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
3504
2743
  branch,
3505
2744
  requestedMode,
3506
2745
  policy: config.syncPolicy
3507
2746
  });
3508
2747
  if (options.verbose) {
3509
- p8.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
3510
- p8.log.info(`Requested mode: ${requestedMode}`);
3511
- p8.log.info(`Effective mode: ${effectiveMode}`);
3512
- p8.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2748
+ p7.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
2749
+ p7.log.info(`Requested mode: ${requestedMode}`);
2750
+ p7.log.info(`Effective mode: ${effectiveMode}`);
2751
+ p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
3513
2752
  if (batchResponse.queueStatus) {
3514
- p8.log.info(`Queue status: ${batchResponse.queueStatus}`);
2753
+ p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
3515
2754
  }
3516
2755
  }
3517
2756
  if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
3518
- p8.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
2757
+ p7.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
3519
2758
  } else if (batchResponse.newStrings === 0) {
3520
2759
  const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk8.yellow(batchResponse.deletedStrings)} archived` : "";
3521
- p8.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2760
+ p7.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
3522
2761
  } else {
3523
2762
  const statParts = [`${chalk8.cyan(batchResponse.newStrings)} new, ${chalk8.cyan(batchResponse.totalStrings)} total`];
3524
2763
  if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
3525
2764
  statParts.push(`${chalk8.yellow(batchResponse.deletedStrings)} archived`);
3526
2765
  }
3527
2766
  const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
3528
- p8.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
2767
+ p7.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
3529
2768
  }
3530
2769
  let artifacts = null;
3531
2770
  if (batchResponse.translations) {
@@ -3537,7 +2776,7 @@ async function sync(options = {}) {
3537
2776
  let waitError = null;
3538
2777
  if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
3539
2778
  const waitTimeoutSecs = Math.round(waitTimeoutMs / 1e3);
3540
- spinner4.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
2779
+ spinner7.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
3541
2780
  let lastProgress = 0;
3542
2781
  try {
3543
2782
  const completion = await api.waitForCompletion(
@@ -3546,7 +2785,7 @@ async function sync(options = {}) {
3546
2785
  (progress) => {
3547
2786
  const percent = Math.round(progress * 100);
3548
2787
  if (percent > lastProgress) {
3549
- spinner4.message(`Translating... ${percent}%`);
2788
+ spinner7.message(`Translating... ${percent}%`);
3550
2789
  lastProgress = percent;
3551
2790
  }
3552
2791
  }
@@ -3556,14 +2795,14 @@ async function sync(options = {}) {
3556
2795
  translations: completion.translations,
3557
2796
  localeMetadata: completion.localeMetadata
3558
2797
  };
3559
- spinner4.stop("Translations complete");
2798
+ spinner7.stop("Translations complete");
3560
2799
  } catch (error) {
3561
- spinner4.stop("Translation wait incomplete");
2800
+ spinner7.stop("Translation wait incomplete");
3562
2801
  waitError = error instanceof Error ? error : new Error(String(error));
3563
2802
  if (effectiveMode === "required") {
3564
2803
  throw waitError;
3565
2804
  }
3566
- p8.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2805
+ p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
3567
2806
  }
3568
2807
  }
3569
2808
  if (!artifacts) {
@@ -3572,14 +2811,14 @@ async function sync(options = {}) {
3572
2811
  "Fresh translations are not available and fallback is disabled (--no-fallback)."
3573
2812
  );
3574
2813
  }
3575
- spinner4.start("Loading fallback translations");
2814
+ spinner7.start("Loading fallback translations");
3576
2815
  const localFallback = readLocalCache({
3577
2816
  projectRoot,
3578
2817
  fingerprint
3579
2818
  });
3580
2819
  if (localFallback) {
3581
2820
  artifacts = localFallback;
3582
- spinner4.stop(`Using local cached snapshot (${fingerprint})`);
2821
+ spinner7.stop(`Using local cached snapshot (${fingerprint})`);
3583
2822
  } else {
3584
2823
  try {
3585
2824
  const apiSnapshot = await fetchApiSnapshot(api, {
@@ -3588,15 +2827,15 @@ async function sync(options = {}) {
3588
2827
  });
3589
2828
  if (apiSnapshot) {
3590
2829
  artifacts = apiSnapshot;
3591
- spinner4.stop("Using latest completed API snapshot");
2830
+ spinner7.stop("Using latest completed API snapshot");
3592
2831
  } else {
3593
- spinner4.stop("No completed API snapshot available");
2832
+ spinner7.stop("No completed API snapshot available");
3594
2833
  }
3595
2834
  } catch (error) {
3596
- spinner4.stop("Failed to fetch API snapshot");
2835
+ spinner7.stop("Failed to fetch API snapshot");
3597
2836
  if (options.verbose) {
3598
2837
  const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
3599
- p8.log.warn(`Snapshot fetch error: ${message}`);
2838
+ p7.log.warn(`Snapshot fetch error: ${message}`);
3600
2839
  }
3601
2840
  }
3602
2841
  }
@@ -3628,85 +2867,433 @@ async function sync(options = {}) {
3628
2867
  });
3629
2868
  const cachePath = writeCache({ projectRoot, fingerprint, data });
3630
2869
  if (options.verbose) {
3631
- p8.log.info(`Cache written: ${cachePath}`);
2870
+ p7.log.info(`Cache written: ${cachePath}`);
3632
2871
  }
3633
2872
  } catch (error) {
3634
2873
  if (options.verbose) {
3635
2874
  const message = error instanceof Error ? error.message : "Unknown cache write error";
3636
- p8.log.warn(`Failed to write cache: ${message}`);
2875
+ p7.log.warn(`Failed to write cache: ${message}`);
3637
2876
  }
3638
2877
  }
3639
2878
  if (artifacts.source !== "fresh") {
3640
2879
  const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
3641
- p8.log.warn(
2880
+ p7.log.warn(
3642
2881
  `Using ${sourceLabel}. New strings may appear after the background sync completes.`
3643
2882
  );
3644
2883
  }
3645
2884
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
3646
- p8.outro(`Sync complete! (${duration}s)`);
2885
+ p7.outro(`Sync complete! (${duration}s)`);
3647
2886
  return 0;
3648
2887
  } catch (error) {
3649
- spinner4.stop();
2888
+ spinner7.stop();
3650
2889
  if (error instanceof VocoderAPIError && error.syncPolicyError) {
3651
- p8.log.error(error.syncPolicyError.message);
2890
+ p7.log.error(error.syncPolicyError.message);
3652
2891
  const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
3653
2892
  for (const line of guidance) {
3654
- p8.log.info(line);
2893
+ p7.log.info(line);
3655
2894
  }
3656
2895
  return 1;
3657
2896
  }
3658
2897
  if (error instanceof VocoderAPIError && error.limitError) {
3659
2898
  const { limitError } = error;
3660
- p8.log.error(limitError.message);
2899
+ p7.log.error(limitError.message);
3661
2900
  const guidance = getLimitErrorGuidance(limitError);
3662
2901
  for (const line of guidance) {
3663
- p8.log.info(line);
2902
+ p7.log.info(line);
3664
2903
  }
3665
2904
  return 1;
3666
2905
  }
3667
2906
  if (error instanceof Error) {
3668
- p8.log.error(error.message);
2907
+ p7.log.error(error.message);
3669
2908
  const isInvalidKey = error.message.toLowerCase().includes("invalid api key") || error instanceof VocoderAPIError && error.status === 401;
3670
2909
  if (isInvalidKey) {
3671
- p8.log.warn(
2910
+ p7.log.warn(
3672
2911
  "API key rejected \u2014 the project may have been deleted or the key revoked."
3673
2912
  );
3674
- p8.log.info(
2913
+ p7.log.info(
3675
2914
  " Run `npx @vocoder/cli init` to create a new project and key."
3676
2915
  );
3677
2916
  } else if (error.message.includes("git branch")) {
3678
- p8.log.warn("Run from a git repository, or use:");
3679
- p8.log.info(" vocoder sync --branch main");
2917
+ p7.log.warn("Run from a git repository, or use:");
2918
+ p7.log.info(" vocoder sync --branch main");
3680
2919
  }
3681
2920
  if (options.verbose) {
3682
- p8.log.info(`Full error: ${error.stack ?? error}`);
2921
+ p7.log.info(`Full error: ${error.stack ?? error}`);
3683
2922
  }
3684
2923
  }
3685
2924
  return 1;
3686
2925
  }
3687
2926
  }
3688
2927
 
3689
- // src/commands/whoami.ts
2928
+ // src/commands/locales.ts
2929
+ loadEnv3();
2930
+ function getApiConfig(options) {
2931
+ const apiKey = process.env.VOCODER_API_KEY;
2932
+ if (!apiKey) {
2933
+ p8.log.error(
2934
+ "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
2935
+ );
2936
+ return null;
2937
+ }
2938
+ return {
2939
+ apiKey,
2940
+ apiUrl: options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app"
2941
+ };
2942
+ }
2943
+ async function listProjectLocales(options = {}) {
2944
+ const config = getApiConfig(options);
2945
+ if (!config) return 1;
2946
+ const api = new VocoderAPI(config);
2947
+ try {
2948
+ const projectConfig2 = await api.getProjectConfig();
2949
+ p8.log.info(
2950
+ `Source locale: ${chalk9.cyan(projectConfig2.sourceLocale)}`
2951
+ );
2952
+ if (projectConfig2.targetLocales.length === 0) {
2953
+ p8.log.info("Target locales: (none configured)");
2954
+ } else {
2955
+ p8.log.info(
2956
+ `Target locales: ${projectConfig2.targetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
2957
+ );
2958
+ }
2959
+ return 0;
2960
+ } catch (error) {
2961
+ p8.log.error(
2962
+ error instanceof Error ? error.message : "Failed to fetch project locales."
2963
+ );
2964
+ return 1;
2965
+ }
2966
+ }
2967
+ async function addLocales(locales, options = {}) {
2968
+ if (locales.length === 0) {
2969
+ p8.log.error("No locale codes provided.");
2970
+ return 1;
2971
+ }
2972
+ const config = getApiConfig(options);
2973
+ if (!config) return 1;
2974
+ const api = new VocoderAPI(config);
2975
+ let lastTargetLocales = [];
2976
+ let hadError = false;
2977
+ for (const locale of locales) {
2978
+ const spinner7 = p8.spinner();
2979
+ spinner7.start(`Adding ${locale}\u2026`);
2980
+ try {
2981
+ const result = await api.addLocale(locale);
2982
+ lastTargetLocales = result.targetLocales;
2983
+ spinner7.stop(`Added ${chalk9.cyan(locale)}`);
2984
+ } catch (error) {
2985
+ spinner7.stop(`Failed to add ${chalk9.red(locale)}`);
2986
+ hadError = true;
2987
+ if (error instanceof VocoderAPIError && error.limitError) {
2988
+ const { limitError } = error;
2989
+ p8.log.error(limitError.message);
2990
+ for (const line of getLimitErrorGuidance(limitError)) {
2991
+ p8.log.info(line);
2992
+ }
2993
+ break;
2994
+ }
2995
+ p8.log.error(
2996
+ error instanceof Error ? error.message : "Unknown error"
2997
+ );
2998
+ }
2999
+ }
3000
+ if (lastTargetLocales.length > 0) {
3001
+ p8.log.info(
3002
+ `Target locales now: ${lastTargetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
3003
+ );
3004
+ }
3005
+ return hadError ? 1 : 0;
3006
+ }
3007
+ async function removeLocales(locales, options = {}) {
3008
+ if (locales.length === 0) {
3009
+ p8.log.error("No locale codes provided.");
3010
+ return 1;
3011
+ }
3012
+ const config = getApiConfig(options);
3013
+ if (!config) return 1;
3014
+ const api = new VocoderAPI(config);
3015
+ let lastTargetLocales = [];
3016
+ let hadError = false;
3017
+ for (const locale of locales) {
3018
+ const spinner7 = p8.spinner();
3019
+ spinner7.start(`Removing ${locale}\u2026`);
3020
+ try {
3021
+ const result = await api.removeLocale(locale);
3022
+ lastTargetLocales = result.targetLocales;
3023
+ spinner7.stop(`Removed ${chalk9.cyan(locale)}`);
3024
+ } catch (error) {
3025
+ spinner7.stop(`Failed to remove ${chalk9.red(locale)}`);
3026
+ hadError = true;
3027
+ p8.log.error(
3028
+ error instanceof Error ? error.message : "Unknown error"
3029
+ );
3030
+ }
3031
+ }
3032
+ if (lastTargetLocales.length > 0) {
3033
+ p8.log.info(
3034
+ `Target locales now: ${lastTargetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
3035
+ );
3036
+ } else if (!hadError) {
3037
+ p8.log.info("Target locales now: (none configured)");
3038
+ }
3039
+ return hadError ? 1 : 0;
3040
+ }
3041
+ async function listSupportedLocales(options = {}) {
3042
+ const config = getApiConfig(options);
3043
+ if (!config) return 1;
3044
+ const api = new VocoderAPI(config);
3045
+ try {
3046
+ const result = await api.listLocales(config.apiKey);
3047
+ p8.log.info(chalk9.bold("Source locales:"));
3048
+ printLocaleTable(result.sourceLocales);
3049
+ p8.log.info("");
3050
+ p8.log.info(chalk9.bold("Target locales:"));
3051
+ printLocaleTable(result.targetLocales);
3052
+ return 0;
3053
+ } catch (error) {
3054
+ p8.log.error(
3055
+ error instanceof Error ? error.message : "Failed to fetch supported locales."
3056
+ );
3057
+ return 1;
3058
+ }
3059
+ }
3060
+ function printLocaleTable(locales) {
3061
+ for (const locale of locales) {
3062
+ const native = locale.nativeName && locale.nativeName !== locale.name ? ` (${locale.nativeName})` : "";
3063
+ p8.log.info(` ${chalk9.cyan(locale.code.padEnd(10))} ${locale.name}${native}`);
3064
+ }
3065
+ }
3066
+
3067
+ // src/commands/logout.ts
3690
3068
  import * as p9 from "@clack/prompts";
3691
- import chalk9 from "chalk";
3069
+ async function logout(options = {}) {
3070
+ const stored = readAuthData();
3071
+ if (!stored) {
3072
+ p9.log.info("Not currently authenticated.");
3073
+ return 0;
3074
+ }
3075
+ const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
3076
+ const api = new VocoderAPI({ apiUrl, apiKey: "" });
3077
+ try {
3078
+ await api.revokeCliToken(stored.token);
3079
+ } catch {
3080
+ }
3081
+ clearAuthData();
3082
+ p9.log.success(`Logged out (was ${stored.email})`);
3083
+ return 0;
3084
+ }
3085
+
3086
+ // src/commands/project-config.ts
3087
+ import * as p10 from "@clack/prompts";
3088
+ import chalk10 from "chalk";
3089
+ import { config as loadEnv4 } from "dotenv";
3090
+ loadEnv4();
3091
+ async function projectConfig(options = {}) {
3092
+ const apiKey = process.env.VOCODER_API_KEY;
3093
+ if (!apiKey) {
3094
+ p10.log.error(
3095
+ "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3096
+ );
3097
+ return 1;
3098
+ }
3099
+ const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
3100
+ const api = new VocoderAPI({ apiKey, apiUrl });
3101
+ try {
3102
+ const config = await api.getProjectConfig();
3103
+ const lines = [
3104
+ `Project: ${chalk10.bold(config.projectName)}`,
3105
+ `Organization: ${config.organizationName}`,
3106
+ `Source locale: ${chalk10.cyan(config.sourceLocale)}`,
3107
+ `Target locales: ${config.targetLocales.length > 0 ? config.targetLocales.map((l) => chalk10.cyan(l)).join(", ") : chalk10.dim("(none)")}`,
3108
+ `Target branches: ${config.targetBranches.map((b) => chalk10.cyan(b)).join(", ")}`,
3109
+ ...config.primaryBranch ? [`Primary branch: ${chalk10.cyan(config.primaryBranch)}`] : [],
3110
+ `Sync policy:`,
3111
+ ` Blocking branches: ${config.syncPolicy.blockingBranches.map((b) => chalk10.cyan(b)).join(", ")}`,
3112
+ ` Blocking mode: ${chalk10.cyan(config.syncPolicy.blockingMode)}`,
3113
+ ` Non-blocking mode: ${chalk10.cyan(config.syncPolicy.nonBlockingMode)}`,
3114
+ ` Max wait: ${chalk10.cyan(String(config.syncPolicy.defaultMaxWaitMs))} ms`
3115
+ ];
3116
+ p10.note(lines.join("\n"), `${config.projectName} \u2014 project config`);
3117
+ return 0;
3118
+ } catch (error) {
3119
+ p10.log.error(
3120
+ error instanceof Error ? error.message : "Failed to fetch project config."
3121
+ );
3122
+ return 1;
3123
+ }
3124
+ }
3125
+
3126
+ // src/commands/translations.ts
3127
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
3128
+ import { join as join4 } from "path";
3129
+ import * as p11 from "@clack/prompts";
3130
+ import chalk11 from "chalk";
3131
+ import { config as loadEnv5 } from "dotenv";
3132
+ loadEnv5();
3133
+ async function getTranslations(options = {}) {
3134
+ const apiKey = process.env.VOCODER_API_KEY;
3135
+ if (!apiKey) {
3136
+ p11.log.error(
3137
+ "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3138
+ );
3139
+ return 1;
3140
+ }
3141
+ const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
3142
+ const api = new VocoderAPI({ apiKey, apiUrl });
3143
+ let branch;
3144
+ try {
3145
+ branch = detectBranch(options.branch);
3146
+ } catch (error) {
3147
+ p11.log.error(
3148
+ error instanceof Error ? error.message : "Failed to detect branch."
3149
+ );
3150
+ return 1;
3151
+ }
3152
+ const spinner7 = p11.spinner();
3153
+ spinner7.start(`Fetching translations for ${chalk11.cyan(branch)}\u2026`);
3154
+ try {
3155
+ const projectConfig2 = await api.getProjectConfig();
3156
+ const targetLocales = options.locale ? [options.locale] : projectConfig2.targetLocales;
3157
+ if (targetLocales.length === 0) {
3158
+ spinner7.stop("No target locales configured.");
3159
+ p11.log.info("Add target locales with `vocoder locales add <code>`.");
3160
+ return 1;
3161
+ }
3162
+ const snapshot = await api.getTranslationSnapshot({ branch, targetLocales });
3163
+ spinner7.stop(`Fetched translations for ${chalk11.cyan(branch)}`);
3164
+ if (snapshot.status === "NOT_FOUND") {
3165
+ p11.log.warn(
3166
+ `No translation snapshot found for branch "${branch}". Run \`vocoder sync\` to generate one.`
3167
+ );
3168
+ return 1;
3169
+ }
3170
+ const translations = snapshot.translations ?? {};
3171
+ if (options.output) {
3172
+ writeLocaleFiles(translations, options.output);
3173
+ } else {
3174
+ process.stdout.write(JSON.stringify(translations, null, 2));
3175
+ process.stdout.write("\n");
3176
+ }
3177
+ return 0;
3178
+ } catch (error) {
3179
+ spinner7.stop("Failed to fetch translations.");
3180
+ p11.log.error(
3181
+ error instanceof Error ? error.message : "Unknown error."
3182
+ );
3183
+ return 1;
3184
+ }
3185
+ }
3186
+ function writeLocaleFiles(translations, outputDir) {
3187
+ mkdirSync2(outputDir, { recursive: true });
3188
+ for (const [locale, strings] of Object.entries(translations)) {
3189
+ const filePath = join4(outputDir, `${locale}.json`);
3190
+ writeFileSync4(filePath, JSON.stringify(strings, null, 2) + "\n", "utf-8");
3191
+ p11.log.success(`Wrote ${chalk11.cyan(filePath)}`);
3192
+ }
3193
+ }
3194
+
3195
+ // src/commands/create-project.ts
3196
+ import * as p12 from "@clack/prompts";
3197
+ import chalk12 from "chalk";
3198
+ import { config as loadEnv6 } from "dotenv";
3199
+ loadEnv6();
3200
+ async function createProject(options) {
3201
+ const authData = readAuthData();
3202
+ if (!authData) {
3203
+ p12.log.error(
3204
+ "Not logged in. Run `npx @vocoder/cli init` to authenticate first."
3205
+ );
3206
+ return 1;
3207
+ }
3208
+ const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
3209
+ const api = new VocoderAPI({ apiKey: "", apiUrl });
3210
+ let repoCanonical;
3211
+ let appDir = options.appDir ?? ".";
3212
+ if (options.repo) {
3213
+ repoCanonical = options.repo;
3214
+ } else {
3215
+ const identity = resolveGitRepositoryIdentity();
3216
+ if (identity) {
3217
+ repoCanonical = identity.repoCanonical;
3218
+ if (!options.appDir && identity.repoAppDir) {
3219
+ appDir = identity.repoAppDir;
3220
+ }
3221
+ } else {
3222
+ p12.log.warn(
3223
+ "Could not detect a git remote. The project will be created without repo binding \u2014 sync-on-push will not function until a repository is connected via the Vocoder dashboard."
3224
+ );
3225
+ }
3226
+ }
3227
+ const targetLocales = options.targetLocales ? options.targetLocales.split(",").map((l) => l.trim()).filter(Boolean) : [];
3228
+ const targetBranches = options.targetBranches ? options.targetBranches.split(",").map((b) => b.trim()).filter(Boolean) : ["main"];
3229
+ const spinner7 = p12.spinner();
3230
+ spinner7.start(`Creating project "${options.name}"\u2026`);
3231
+ try {
3232
+ const result = await api.createProject(authData.token, {
3233
+ organizationId: options.workspace,
3234
+ name: options.name,
3235
+ sourceLocale: options.sourceLocale,
3236
+ targetLocales,
3237
+ targetBranches,
3238
+ appDirs: [appDir],
3239
+ ...repoCanonical ? { repoCanonical } : {}
3240
+ });
3241
+ spinner7.stop(`Created project ${chalk12.bold(result.projectName)}`);
3242
+ const lines = [
3243
+ `Project ID: ${result.projectId}`,
3244
+ `Source locale: ${chalk12.cyan(result.sourceLocale)}`,
3245
+ `Target locales: ${result.targetLocales.length > 0 ? result.targetLocales.map((l) => chalk12.cyan(l)).join(", ") : chalk12.dim("(none)")}`,
3246
+ `Branches: ${result.targetBranches.map((b) => chalk12.cyan(b)).join(", ")}`,
3247
+ ...repoCanonical ? [`Repository: ${chalk12.cyan(repoCanonical)}${appDir !== "." ? ` (${appDir})` : ""}`] : [],
3248
+ "",
3249
+ `Add this to your .env file:`,
3250
+ ` ${chalk12.bold("VOCODER_API_KEY")}=${chalk12.cyan(result.apiKey)}`
3251
+ ];
3252
+ p12.note(lines.join("\n"), "Project created");
3253
+ if (!result.repositoryBound && repoCanonical) {
3254
+ p12.log.warn(
3255
+ `Repository "${repoCanonical}" was not automatically connected. Ensure your GitHub App installation covers this repository.`
3256
+ );
3257
+ }
3258
+ return 0;
3259
+ } catch (error) {
3260
+ spinner7.stop("Failed to create project.");
3261
+ if (error instanceof VocoderAPIError && error.limitError) {
3262
+ const { limitError } = error;
3263
+ p12.log.error(limitError.message);
3264
+ for (const line of getLimitErrorGuidance(limitError)) {
3265
+ p12.log.info(line);
3266
+ }
3267
+ return 1;
3268
+ }
3269
+ p12.log.error(
3270
+ error instanceof Error ? error.message : "Unknown error."
3271
+ );
3272
+ return 1;
3273
+ }
3274
+ }
3275
+
3276
+ // src/commands/whoami.ts
3277
+ import * as p13 from "@clack/prompts";
3278
+ import chalk13 from "chalk";
3692
3279
  async function whoami(options = {}) {
3693
3280
  const stored = readAuthData();
3694
3281
  if (!stored) {
3695
- p9.log.info("Not logged in. Run `vocoder init` to authenticate.");
3282
+ p13.log.info("Not logged in. Run `vocoder init` to authenticate.");
3696
3283
  return 1;
3697
3284
  }
3698
3285
  const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
3699
3286
  const api = new VocoderAPI({ apiUrl, apiKey: "" });
3700
3287
  try {
3701
3288
  const info = await api.getCliUserInfo(stored.token);
3702
- p9.log.info(`Logged in as ${chalk9.bold(info.email)}`);
3289
+ p13.log.info(`Logged in as ${chalk13.bold(info.email)}`);
3703
3290
  if (info.name) {
3704
- p9.log.info(`Name: ${info.name}`);
3291
+ p13.log.info(`Name: ${info.name}`);
3705
3292
  }
3706
- p9.log.info(`API: ${apiUrl}`);
3293
+ p13.log.info(`API: ${apiUrl}`);
3707
3294
  return 0;
3708
3295
  } catch {
3709
- p9.log.error(
3296
+ p13.log.error(
3710
3297
  "Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate."
3711
3298
  );
3712
3299
  return 1;
@@ -3738,5 +3325,38 @@ program.command("sync").description("Extract strings and sync translations").opt
3738
3325
  });
3739
3326
  program.command("logout").description("Log out and remove stored credentials").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(logout, options));
3740
3327
  program.command("whoami").description("Show the currently authenticated user").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(whoami, options));
3328
+ var localesCmd = program.command("locales").description("Manage project target locales").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listProjectLocales, options));
3329
+ localesCmd.command("add <codes...>").description("Add one or more target locales by BCP 47 code (e.g. fr de pt-BR)").option("--api-url <url>", "Override Vocoder API URL").action(
3330
+ (codes, options) => runCommand((opts) => addLocales(codes, opts), options)
3331
+ );
3332
+ localesCmd.command("remove <codes...>").description("Remove one or more target locales by BCP 47 code").option("--api-url <url>", "Override Vocoder API URL").action(
3333
+ (codes, options) => runCommand((opts) => removeLocales(codes, opts), options)
3334
+ );
3335
+ localesCmd.command("supported").description("List all locales supported by Vocoder").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listSupportedLocales, options));
3336
+ program.command("project").description("Show current project configuration").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(projectConfig, options));
3337
+ program.command("translations").description("Download the current translation snapshot").option("--branch <branch>", "Git branch (auto-detected if omitted)").option("--locale <locale>", "Fetch a specific locale only").option("--output <dir>", "Write locale JSON files to this directory").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(getTranslations, options));
3338
+ program.command("create-project").description("Create a new Vocoder project (requires prior `vocoder init`)").requiredOption("--name <name>", "Project display name").requiredOption("--source-locale <code>", "Source language BCP 47 code (e.g. en)").requiredOption("--workspace <org-id>", "Workspace organization ID").option(
3339
+ "--target-locales <codes>",
3340
+ "Comma-separated target locale codes (e.g. fr,de,pt-BR)"
3341
+ ).option(
3342
+ "--target-branches <branches>",
3343
+ "Comma-separated branch names to sync (default: main)"
3344
+ ).option(
3345
+ "--repo <canonical>",
3346
+ "Git repo canonical (e.g. github:owner/repo). Auto-detected from git remote if omitted."
3347
+ ).option(
3348
+ "--app-dir <path>",
3349
+ "App directory within the repo for monorepos (default: .)"
3350
+ ).option("--api-url <url>", "Override Vocoder API URL").action((options) => {
3351
+ const translated = {
3352
+ ...options,
3353
+ // Commander camelCases dashed options
3354
+ sourceLocale: options.sourceLocale,
3355
+ targetLocales: options.targetLocales,
3356
+ targetBranches: options.targetBranches,
3357
+ workspace: options.workspace
3358
+ };
3359
+ return runCommand(createProject, translated);
3360
+ });
3741
3361
  program.parse(process.argv);
3742
3362
  //# sourceMappingURL=bin.mjs.map