@vocoder/cli 0.9.0 → 0.11.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,756 +2,36 @@
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-IZN5HVYD.mjs";
12
+ loadVocoderConfig,
13
+ readAuthData,
14
+ writeAuthData
15
+ } from "./chunk-XF3KGGYQ.mjs";
11
16
 
12
17
  // src/bin.ts
13
18
  import { Command } from "commander";
14
19
 
15
20
  // 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
21
  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
- }));
172
- }
173
- async submitTranslation(branch, entries, targetLocales, options, repoIdentity) {
174
- const stringEntries = this.normalizeStringEntries(entries);
175
- const strings = stringEntries.map((entry) => entry.text);
176
- const crypto = await import("crypto");
177
- const sortedStrings = [...strings].sort();
178
- const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
179
- return this.request(
180
- "/api/cli/sync",
181
- {
182
- method: "POST",
183
- headers: {
184
- "Content-Type": "application/json"
185
- },
186
- body: JSON.stringify({
187
- branch,
188
- stringEntries,
189
- targetLocales,
190
- ...options?.force ? {} : { stringsHash },
191
- ...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
192
- ...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
193
- ...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
194
- ...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
195
- ...repoIdentity?.repoAppDir !== void 0 ? { repoAppDir: repoIdentity.repoAppDir } : {},
196
- ...repoIdentity?.commitSha ? { commitSha: repoIdentity.commitSha } : {}
197
- })
198
- },
199
- "Translation submission failed"
200
- );
201
- }
202
- /**
203
- * Check translation status
204
- */
205
- async getTranslationStatus(batchId) {
206
- return this.request(
207
- `/api/cli/sync/status/${batchId}`,
208
- {},
209
- "Failed to check translation status"
210
- );
211
- }
212
- async getTranslationSnapshot(params) {
213
- const search = new URLSearchParams();
214
- search.set("branch", params.branch);
215
- for (const locale of params.targetLocales) {
216
- search.append("targetLocale", locale);
217
- }
218
- return this.request(
219
- `/api/cli/sync/snapshot?${search.toString()}`,
220
- {},
221
- "Failed to fetch translation snapshot"
222
- );
223
- }
224
- /**
225
- * Wait for translation to complete with polling
226
- */
227
- async waitForCompletion(batchId, timeout = 6e4, onProgress) {
228
- const startTime = Date.now();
229
- const pollInterval = 1e3;
230
- while (Date.now() - startTime < timeout) {
231
- const status = await this.getTranslationStatus(batchId);
232
- if (onProgress) {
233
- onProgress(status.progress);
234
- }
235
- if (status.status === "COMPLETED") {
236
- if (!status.translations) {
237
- throw new Error("Translation completed but no translations returned");
238
- }
239
- return {
240
- translations: status.translations,
241
- localeMetadata: status.localeMetadata
242
- };
243
- }
244
- if (status.status === "FAILED") {
245
- throw new Error(
246
- `Translation failed: ${status.errorMessage || "Unknown error"}`
247
- );
248
- }
249
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
250
- }
251
- throw new Error(`Translation timeout after ${timeout}ms`);
252
- }
253
- async startInitSession(input) {
254
- const response = await fetch(`${this.apiUrl}/api/cli/init/start`, {
255
- method: "POST",
256
- headers: {
257
- "Content-Type": "application/json"
258
- },
259
- body: JSON.stringify(input)
260
- });
261
- const payload = await readPayload(response);
262
- if (!response.ok) {
263
- throw new VocoderAPIError({
264
- message: extractErrorMessage(
265
- payload,
266
- `Failed to start init session (${response.status})`
267
- ),
268
- status: response.status,
269
- payload
270
- });
271
- }
272
- return payload;
273
- }
274
- async getInitSessionStatus(params) {
275
- const response = await fetch(
276
- `${this.apiUrl}/api/cli/init/status/${params.sessionId}`,
277
- {
278
- headers: {
279
- Authorization: `Bearer ${params.pollToken}`
280
- }
281
- }
282
- );
283
- const payload = await readPayload(response);
284
- if (!response.ok) {
285
- throw new VocoderAPIError({
286
- message: extractErrorMessage(
287
- payload,
288
- `Failed to get init status (${response.status})`
289
- ),
290
- status: response.status,
291
- payload
292
- });
293
- }
294
- return payload;
295
- }
296
- // ── CLI Auth endpoints (no project API key needed) ──────────────────────────
297
- /**
298
- * Start a CLI auth session. Returns `{ sessionId, verificationUrl, expiresAt }`.
299
- * `sessionId` is the raw poll token — keep it secret, used for polling.
300
- */
301
- async startCliAuthSession(callbackPort, repoCanonical) {
302
- const response = await fetch(`${this.apiUrl}/api/cli/auth/start`, {
303
- method: "POST",
304
- headers: { "Content-Type": "application/json" },
305
- body: JSON.stringify({
306
- ...callbackPort != null ? { callbackPort } : {},
307
- ...repoCanonical ? { repoCanonical } : {}
308
- })
309
- });
310
- const payload = await readPayload(response);
311
- if (!response.ok) {
312
- throw new VocoderAPIError({
313
- message: extractErrorMessage(
314
- payload,
315
- `Failed to start auth session (${response.status})`
316
- ),
317
- status: response.status,
318
- payload
319
- });
320
- }
321
- return payload;
322
- }
323
- /**
324
- * Poll for CLI auth session completion.
325
- * Returns `{ token }` on success, throws on failure/expiry.
326
- * The server returns HTTP 202 while still pending.
327
- */
328
- async pollCliAuthSession(pollToken) {
329
- const response = await fetch(
330
- `${this.apiUrl}/api/cli/auth/session?session=${encodeURIComponent(pollToken)}`
331
- );
332
- const payload = await readPayload(response);
333
- if (response.status === 202) {
334
- return { status: "pending" };
335
- }
336
- if (response.status === 410) {
337
- return {
338
- status: "failed",
339
- reason: extractErrorMessage(payload, "Auth session expired or failed")
340
- };
341
- }
342
- if (!response.ok) {
343
- return {
344
- status: "failed",
345
- reason: extractErrorMessage(
346
- payload,
347
- `Auth session error (${response.status})`
348
- )
349
- };
350
- }
351
- const result = payload;
352
- if (!result.token) {
353
- return { status: "failed", reason: "No token in response" };
354
- }
355
- return {
356
- status: "complete",
357
- token: result.token,
358
- ...result.organizationId ? { organizationId: result.organizationId } : {}
359
- };
360
- }
361
- /**
362
- * Validate a CLI user token and return the authenticated user's info.
363
- * Used by the CLI to verify stored credentials on startup.
364
- */
365
- async getCliUserInfo(userToken) {
366
- const response = await fetch(`${this.apiUrl}/api/cli/auth/me`, {
367
- headers: { Authorization: `Bearer ${userToken}` }
368
- });
369
- const payload = await readPayload(response);
370
- if (!response.ok) {
371
- throw new VocoderAPIError({
372
- message: extractErrorMessage(
373
- payload,
374
- `Token validation failed (${response.status})`
375
- ),
376
- status: response.status,
377
- payload
378
- });
379
- }
380
- return payload;
381
- }
382
- /**
383
- * Revoke the given CLI user token server-side.
384
- */
385
- async revokeCliToken(userToken) {
386
- const response = await fetch(`${this.apiUrl}/api/cli/auth/token`, {
387
- method: "DELETE",
388
- headers: { Authorization: `Bearer ${userToken}` }
389
- });
390
- if (!response.ok) {
391
- const payload = await readPayload(response);
392
- throw new VocoderAPIError({
393
- message: extractErrorMessage(
394
- payload,
395
- `Token revocation failed (${response.status})`
396
- ),
397
- status: response.status,
398
- payload
399
- });
400
- }
401
- }
402
- // ── Workspaces ────────────────────────────────────────────────────────────────
403
- async listWorkspaces(userToken, params) {
404
- const url = new URL(`${this.apiUrl}/api/cli/workspaces`);
405
- if (params?.repo) url.searchParams.set("repo", params.repo);
406
- const response = await fetch(url.toString(), {
407
- headers: { Authorization: `Bearer ${userToken}` }
408
- });
409
- const payload = await readPayload(response);
410
- if (!response.ok) {
411
- throw new VocoderAPIError({
412
- message: extractErrorMessage(
413
- payload,
414
- `Failed to list workspaces (${response.status})`
415
- ),
416
- status: response.status,
417
- payload
418
- });
419
- }
420
- return payload;
421
- }
422
- async listProjects(userToken, organizationId) {
423
- const url = new URL(`${this.apiUrl}/api/cli/projects`);
424
- url.searchParams.set("organizationId", organizationId);
425
- const response = await fetch(url.toString(), {
426
- headers: { Authorization: `Bearer ${userToken}` }
427
- });
428
- const payload = await readPayload(response);
429
- if (!response.ok) {
430
- throw new VocoderAPIError({
431
- message: extractErrorMessage(
432
- payload,
433
- `Failed to list projects (${response.status})`
434
- ),
435
- status: response.status,
436
- payload
437
- });
438
- }
439
- const result = payload;
440
- return result.projects;
441
- }
442
- async regenerateProjectApiKey(userToken, projectId) {
443
- const response = await fetch(
444
- `${this.apiUrl}/api/cli/project/regenerate-key`,
445
- {
446
- method: "POST",
447
- headers: {
448
- "Content-Type": "application/json",
449
- Authorization: `Bearer ${userToken}`
450
- },
451
- body: JSON.stringify({ projectId })
452
- }
453
- );
454
- const payload = await readPayload(response);
455
- if (!response.ok) {
456
- throw new VocoderAPIError({
457
- message: extractErrorMessage(
458
- payload,
459
- `Failed to regenerate API key (${response.status})`
460
- ),
461
- status: response.status,
462
- payload
463
- });
464
- }
465
- return payload;
466
- }
467
- // ── CLI GitHub endpoints ──────────────────────────────────────────────────────
468
- async startCliGitHubInstall(userToken, params) {
469
- const response = await fetch(
470
- `${this.apiUrl}/api/cli/github/install/start`,
471
- {
472
- method: "POST",
473
- headers: {
474
- Authorization: `Bearer ${userToken}`,
475
- "Content-Type": "application/json"
476
- },
477
- body: JSON.stringify(params)
478
- }
479
- );
480
- const payload = await readPayload(response);
481
- if (!response.ok) {
482
- throw new VocoderAPIError({
483
- message: extractErrorMessage(
484
- payload,
485
- `Failed to start GitHub install (${response.status})`
486
- ),
487
- status: response.status,
488
- payload
489
- });
490
- }
491
- return payload;
492
- }
493
- /**
494
- * Start the "link existing installation" discovery flow.
495
- * Unlike startCliGitHubOAuth, this requires no bearer token — the Vocoder
496
- * account is created from the OAuth code in the callback.
497
- */
498
- async startCliGitHubLinkSession(sessionId, callbackPort) {
499
- const response = await fetch(
500
- `${this.apiUrl}/api/cli/github/oauth/link-start`,
501
- {
502
- method: "POST",
503
- headers: { "Content-Type": "application/json" },
504
- body: JSON.stringify({
505
- sessionId,
506
- ...callbackPort != null ? { callbackPort } : {}
507
- })
508
- }
509
- );
510
- const payload = await readPayload(response);
511
- if (!response.ok) {
512
- throw new VocoderAPIError({
513
- message: extractErrorMessage(
514
- payload,
515
- `Failed to start GitHub link session (${response.status})`
516
- ),
517
- status: response.status,
518
- payload
519
- });
520
- }
521
- return payload;
522
- }
523
- async startCliGitHubOAuth(userToken, params) {
524
- const response = await fetch(`${this.apiUrl}/api/cli/github/oauth/start`, {
525
- method: "POST",
526
- headers: {
527
- Authorization: `Bearer ${userToken}`,
528
- "Content-Type": "application/json"
529
- },
530
- body: JSON.stringify(params)
531
- });
532
- const payload = await readPayload(response);
533
- if (!response.ok) {
534
- throw new VocoderAPIError({
535
- message: extractErrorMessage(
536
- payload,
537
- `Failed to start GitHub OAuth (${response.status})`
538
- ),
539
- status: response.status,
540
- payload
541
- });
542
- }
543
- return payload;
544
- }
545
- async getCliGitHubDiscovery(userToken) {
546
- const response = await fetch(`${this.apiUrl}/api/cli/github/discovery`, {
547
- headers: { Authorization: `Bearer ${userToken}` }
548
- });
549
- const payload = await readPayload(response);
550
- if (!response.ok) {
551
- throw new VocoderAPIError({
552
- message: extractErrorMessage(
553
- payload,
554
- `Failed to fetch GitHub discovery (${response.status})`
555
- ),
556
- status: response.status,
557
- payload
558
- });
559
- }
560
- return payload;
561
- }
562
- async claimCliGitHubInstallation(userToken, params) {
563
- const response = await fetch(`${this.apiUrl}/api/cli/github/claim`, {
564
- method: "POST",
565
- headers: {
566
- Authorization: `Bearer ${userToken}`,
567
- "Content-Type": "application/json"
568
- },
569
- body: JSON.stringify(params)
570
- });
571
- const payload = await readPayload(response);
572
- if (!response.ok) {
573
- throw new VocoderAPIError({
574
- message: extractErrorMessage(
575
- payload,
576
- `Failed to claim GitHub installation (${response.status})`
577
- ),
578
- status: response.status,
579
- payload
580
- });
581
- }
582
- return payload;
583
- }
584
- // ── Locales ───────────────────────────────────────────────────────────────────
585
- async listLocales(userToken) {
586
- const response = await fetch(`${this.apiUrl}/api/cli/locales`, {
587
- headers: { Authorization: `Bearer ${userToken}` }
588
- });
589
- const payload = await readPayload(response);
590
- if (!response.ok) {
591
- throw new VocoderAPIError({
592
- message: extractErrorMessage(
593
- payload,
594
- `Failed to list locales (${response.status})`
595
- ),
596
- status: response.status,
597
- payload
598
- });
599
- }
600
- const result = payload;
601
- return result;
602
- }
603
- async listCompatibleLocales(userToken, sourceLocale) {
604
- const url = `${this.apiUrl}/api/cli/locales/compatible?source=${encodeURIComponent(sourceLocale)}`;
605
- const response = await fetch(url, {
606
- headers: { Authorization: `Bearer ${userToken}` }
607
- });
608
- const payload = await readPayload(response);
609
- if (!response.ok) {
610
- throw new VocoderAPIError({
611
- message: extractErrorMessage(
612
- payload,
613
- `Failed to list compatible locales (${response.status})`
614
- ),
615
- status: response.status,
616
- payload
617
- });
618
- }
619
- const result = payload;
620
- return result.locales;
621
- }
622
- // ── Project creation ──────────────────────────────────────────────────────────
623
- async createProject(userToken, params) {
624
- const response = await fetch(`${this.apiUrl}/api/cli/projects`, {
625
- method: "POST",
626
- headers: {
627
- "Content-Type": "application/json",
628
- Authorization: `Bearer ${userToken}`
629
- },
630
- body: JSON.stringify(params)
631
- });
632
- const payload = await readPayload(response);
633
- if (!response.ok) {
634
- throw new VocoderAPIError({
635
- message: extractErrorMessage(
636
- payload,
637
- `Failed to create project (${response.status})`
638
- ),
639
- status: response.status,
640
- payload
641
- });
642
- }
643
- return payload;
644
- }
645
- // ── Project lookup ────────────────────────────────────────────────────────────
646
- /**
647
- * Look up all project apps for a given repo. Returns info about exact matches,
648
- * existing apps in other scopes, and whether a whole-repo app exists.
649
- * No auth required.
650
- */
651
- async lookupProjectByRepo(params) {
652
- try {
653
- const response = await fetch(`${this.apiUrl}/api/cli/init/lookup`, {
654
- method: "POST",
655
- headers: { "Content-Type": "application/json" },
656
- body: JSON.stringify({
657
- repo: params.repoCanonical,
658
- appDir: params.appDir
659
- })
660
- });
661
- if (!response.ok) {
662
- return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
663
- }
664
- const data = await response.json();
665
- return {
666
- exactMatch: data.exactMatch ?? null,
667
- existingApps: data.existingApps ?? [],
668
- hasWholeRepoApp: data.hasWholeRepoApp ?? false
669
- };
670
- } catch {
671
- return { exactMatch: null, existingApps: [], hasWholeRepoApp: false };
672
- }
673
- }
674
- /**
675
- * Add a new ProjectApp to an existing project (monorepo: new app directory).
676
- * Does not check plan limits — no new project is created.
677
- */
678
- async createProjectApp(userToken, params) {
679
- const response = await fetch(`${this.apiUrl}/api/cli/project/apps`, {
680
- method: "POST",
681
- headers: {
682
- "Content-Type": "application/json",
683
- Authorization: `Bearer ${userToken}`
684
- },
685
- body: JSON.stringify(params)
686
- });
687
- const payload = await readPayload(response);
688
- if (!response.ok) {
689
- throw new VocoderAPIError({
690
- message: extractErrorMessage(
691
- payload,
692
- `Failed to create project app (${response.status})`
693
- ),
694
- status: response.status,
695
- payload
696
- });
697
- }
698
- return payload;
699
- }
700
- };
701
-
702
- // src/utils/auth-store.ts
703
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
704
- import { homedir } from "os";
705
- import { dirname, join } from "path";
706
- function getAuthFilePath() {
707
- return join(homedir(), ".config", "vocoder", "auth.json");
708
- }
709
- function readAuthData() {
710
- const filePath = getAuthFilePath();
711
- try {
712
- const raw = readFileSync(filePath, "utf8");
713
- const parsed = JSON.parse(raw);
714
- if (!parsed || typeof parsed !== "object") return null;
715
- const data = parsed;
716
- if (typeof data.token !== "string" || typeof data.apiUrl !== "string" || typeof data.userId !== "string" || typeof data.email !== "string" || typeof data.createdAt !== "string") {
717
- return null;
718
- }
719
- return {
720
- token: data.token,
721
- apiUrl: data.apiUrl,
722
- userId: data.userId,
723
- email: data.email,
724
- name: typeof data.name === "string" ? data.name : null,
725
- createdAt: data.createdAt
726
- };
727
- } catch {
728
- return null;
729
- }
730
- }
731
- function writeAuthData(data) {
732
- const filePath = getAuthFilePath();
733
- const dir = dirname(filePath);
734
- mkdirSync(dir, { recursive: true, mode: 448 });
735
- writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 384 });
736
- }
737
- function clearAuthData() {
738
- const filePath = getAuthFilePath();
739
- try {
740
- unlinkSync(filePath);
741
- } catch {
742
- }
743
- }
22
+ import { execSync as execSync3, spawn as spawn2 } from "child_process";
23
+ import { existsSync as existsSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
744
24
 
745
25
  // src/utils/write-config.ts
746
- import { existsSync, writeFileSync as writeFileSync2 } from "fs";
747
- import { join as join2 } from "path";
26
+ import { existsSync, writeFileSync } from "fs";
27
+ import { join } from "path";
748
28
  function findExistingConfig(cwd = process.cwd()) {
749
29
  for (const name of [
750
30
  "vocoder.config.ts",
751
31
  "vocoder.config.js",
752
32
  "vocoder.config.json"
753
33
  ]) {
754
- const candidate = join2(cwd, name);
34
+ const candidate = join(cwd, name);
755
35
  if (existsSync(candidate)) return candidate;
756
36
  }
757
37
  return null;
@@ -764,7 +44,7 @@ function writeVocoderConfig(options) {
764
44
  } = options;
765
45
  if (findExistingConfig(cwd)) return null;
766
46
  const ext = useTypeScript ? "ts" : "js";
767
- const configPath = join2(cwd, `vocoder.config.${ext}`);
47
+ const configPath = join(cwd, `vocoder.config.${ext}`);
768
48
  const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
769
49
  const content = `import { defineConfig } from '@vocoder/config'
770
50
 
@@ -782,121 +62,13 @@ export default defineConfig({
782
62
  })
783
63
  `;
784
64
  try {
785
- writeFileSync2(configPath, content, "utf-8");
65
+ writeFileSync(configPath, content, "utf-8");
786
66
  return `vocoder.config.${ext}`;
787
67
  } catch {
788
68
  return null;
789
69
  }
790
70
  }
791
71
 
792
- // src/utils/git-identity.ts
793
- import { execSync } from "child_process";
794
- import { relative, resolve } from "path";
795
- var SHA_REGEX = /^[0-9a-f]{40}$/i;
796
- function detectCommitSha() {
797
- if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
798
- return process.env.VOCODER_COMMIT_SHA;
799
- }
800
- 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;
801
- if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
802
- return safeExec("git rev-parse HEAD");
803
- }
804
- function safeExec(command) {
805
- try {
806
- const output = execSync(command, {
807
- encoding: "utf-8",
808
- stdio: ["pipe", "pipe", "ignore"]
809
- }).trim();
810
- return output.length > 0 ? output : null;
811
- } catch {
812
- return null;
813
- }
814
- }
815
- function normalizePath(pathname) {
816
- const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
817
- if (!cleaned || !cleaned.includes("/")) {
818
- return null;
819
- }
820
- return cleaned;
821
- }
822
- function parseRemoteUrl(remoteUrl) {
823
- const trimmed = remoteUrl.trim();
824
- if (!trimmed) {
825
- return null;
826
- }
827
- if (!trimmed.includes("://")) {
828
- const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
829
- if (scpMatch) {
830
- const host = (scpMatch[1] || "").toLowerCase();
831
- const ownerRepoPath = normalizePath(scpMatch[2] || "");
832
- if (!host || !ownerRepoPath) {
833
- return null;
834
- }
835
- return { host, ownerRepoPath };
836
- }
837
- return null;
838
- }
839
- try {
840
- const parsed = new URL(trimmed);
841
- const host = parsed.hostname.toLowerCase();
842
- const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
843
- if (!host || !ownerRepoPath) {
844
- return null;
845
- }
846
- return { host, ownerRepoPath };
847
- } catch {
848
- return null;
849
- }
850
- }
851
- function toCanonical(host, ownerRepoPath) {
852
- if (host.includes("github.com")) {
853
- return `github:${ownerRepoPath.toLowerCase()}`;
854
- }
855
- if (host.includes("gitlab.com")) {
856
- return `gitlab:${ownerRepoPath.toLowerCase()}`;
857
- }
858
- if (host.includes("bitbucket.org")) {
859
- return `bitbucket:${ownerRepoPath.toLowerCase()}`;
860
- }
861
- return `git:${host}/${ownerRepoPath.toLowerCase()}`;
862
- }
863
- function resolveGitRepositoryIdentity() {
864
- const remoteUrl = safeExec("git config --get remote.origin.url");
865
- if (!remoteUrl) {
866
- return null;
867
- }
868
- const parsed = parseRemoteUrl(remoteUrl);
869
- if (!parsed) {
870
- return null;
871
- }
872
- const repositoryRoot = safeExec("git rev-parse --show-toplevel");
873
- const currentDirectory = process.cwd();
874
- let repoAppDir = "";
875
- if (repositoryRoot) {
876
- const relativePath = relative(
877
- resolve(repositoryRoot),
878
- resolve(currentDirectory)
879
- ).replace(/\\/g, "/").trim();
880
- if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
881
- repoAppDir = relativePath;
882
- }
883
- }
884
- return {
885
- repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
886
- repoAppDir
887
- };
888
- }
889
- function resolveGitContext() {
890
- const warnings = [];
891
- const identity = resolveGitRepositoryIdentity();
892
- if (!identity) {
893
- warnings.push(
894
- "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
895
- );
896
- }
897
- return { identity, warnings };
898
- }
899
-
900
72
  // src/utils/github-connect.ts
901
73
  import { spawn } from "child_process";
902
74
  import * as p from "@clack/prompts";
@@ -1182,7 +354,7 @@ import * as p3 from "@clack/prompts";
1182
354
  import chalk4 from "chalk";
1183
355
 
1184
356
  // src/utils/branch-select.ts
1185
- import { execSync as execSync2 } from "child_process";
357
+ import { execSync } from "child_process";
1186
358
  import { isCancel as isCancel2, Prompt } from "@clack/core";
1187
359
  import chalk2 from "chalk";
1188
360
  var S_BAR = "\u2502";
@@ -1213,14 +385,14 @@ function symbol(state) {
1213
385
  function detectGitBranches(cwd) {
1214
386
  const workDir = cwd ?? process.cwd();
1215
387
  try {
1216
- const localOut = execSync2("git branch", {
388
+ const localOut = execSync("git branch", {
1217
389
  cwd: workDir,
1218
390
  stdio: "pipe"
1219
391
  }).toString();
1220
392
  const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1221
393
  let remoteBranches = [];
1222
394
  try {
1223
- const remoteOut = execSync2("git branch -r", {
395
+ const remoteOut = execSync("git branch -r", {
1224
396
  cwd: workDir,
1225
397
  stdio: "pipe"
1226
398
  }).toString();
@@ -1230,7 +402,7 @@ function detectGitBranches(cwd) {
1230
402
  const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1231
403
  let defaultBranch = "main";
1232
404
  try {
1233
- const ref = execSync2("git symbolic-ref refs/remotes/origin/HEAD", {
405
+ const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1234
406
  cwd: workDir,
1235
407
  stdio: "pipe"
1236
408
  }).toString().trim();
@@ -1916,6 +1088,119 @@ async function runProjectAppCreate(params) {
1916
1088
  }
1917
1089
  }
1918
1090
 
1091
+ // src/commands/init.ts
1092
+ import chalk6 from "chalk";
1093
+ import { join as join2 } from "path";
1094
+ import { config as loadEnv } from "dotenv";
1095
+
1096
+ // src/utils/git-identity.ts
1097
+ import { execSync as execSync2 } from "child_process";
1098
+ import { relative, resolve } from "path";
1099
+ var SHA_REGEX = /^[0-9a-f]{40}$/i;
1100
+ function detectCommitSha() {
1101
+ if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1102
+ return process.env.VOCODER_COMMIT_SHA;
1103
+ }
1104
+ 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;
1105
+ if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1106
+ return safeExec("git rev-parse HEAD");
1107
+ }
1108
+ function safeExec(command) {
1109
+ try {
1110
+ const output = execSync2(command, {
1111
+ encoding: "utf-8",
1112
+ stdio: ["pipe", "pipe", "ignore"]
1113
+ }).trim();
1114
+ return output.length > 0 ? output : null;
1115
+ } catch {
1116
+ return null;
1117
+ }
1118
+ }
1119
+ function normalizePath(pathname) {
1120
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1121
+ if (!cleaned || !cleaned.includes("/")) {
1122
+ return null;
1123
+ }
1124
+ return cleaned;
1125
+ }
1126
+ function parseRemoteUrl(remoteUrl) {
1127
+ const trimmed = remoteUrl.trim();
1128
+ if (!trimmed) {
1129
+ return null;
1130
+ }
1131
+ if (!trimmed.includes("://")) {
1132
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1133
+ if (scpMatch) {
1134
+ const host = (scpMatch[1] || "").toLowerCase();
1135
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
1136
+ if (!host || !ownerRepoPath) {
1137
+ return null;
1138
+ }
1139
+ return { host, ownerRepoPath };
1140
+ }
1141
+ return null;
1142
+ }
1143
+ try {
1144
+ const parsed = new URL(trimmed);
1145
+ const host = parsed.hostname.toLowerCase();
1146
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
1147
+ if (!host || !ownerRepoPath) {
1148
+ return null;
1149
+ }
1150
+ return { host, ownerRepoPath };
1151
+ } catch {
1152
+ return null;
1153
+ }
1154
+ }
1155
+ function toCanonical(host, ownerRepoPath) {
1156
+ if (host.includes("github.com")) {
1157
+ return `github:${ownerRepoPath.toLowerCase()}`;
1158
+ }
1159
+ if (host.includes("gitlab.com")) {
1160
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
1161
+ }
1162
+ if (host.includes("bitbucket.org")) {
1163
+ return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1164
+ }
1165
+ return `git:${host}/${ownerRepoPath.toLowerCase()}`;
1166
+ }
1167
+ function resolveGitRepositoryIdentity() {
1168
+ const remoteUrl = safeExec("git config --get remote.origin.url");
1169
+ if (!remoteUrl) {
1170
+ return null;
1171
+ }
1172
+ const parsed = parseRemoteUrl(remoteUrl);
1173
+ if (!parsed) {
1174
+ return null;
1175
+ }
1176
+ const repositoryRoot = safeExec("git rev-parse --show-toplevel");
1177
+ const currentDirectory = process.cwd();
1178
+ let repoAppDir = "";
1179
+ if (repositoryRoot) {
1180
+ const relativePath = relative(
1181
+ resolve(repositoryRoot),
1182
+ resolve(currentDirectory)
1183
+ ).replace(/\\/g, "/").trim();
1184
+ if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
1185
+ repoAppDir = relativePath;
1186
+ }
1187
+ }
1188
+ return {
1189
+ repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
1190
+ repoAppDir
1191
+ };
1192
+ }
1193
+ function resolveGitContext() {
1194
+ const warnings = [];
1195
+ const identity = resolveGitRepositoryIdentity();
1196
+ if (!identity) {
1197
+ warnings.push(
1198
+ "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
1199
+ );
1200
+ }
1201
+ return { identity, warnings };
1202
+ }
1203
+
1919
1204
  // src/utils/workspace.ts
1920
1205
  import * as p4 from "@clack/prompts";
1921
1206
  import chalk5 from "chalk";
@@ -2109,10 +1394,10 @@ function runScaffold(params) {
2109
1394
  p5.log.message(chalk6.gray(" Docs: https://vocoder.app/docs/getting-started"));
2110
1395
  }
2111
1396
  function writeApiKeyToEnv(apiKey) {
2112
- const envPath = join3(process.cwd(), ".env");
1397
+ const envPath = join2(process.cwd(), ".env");
2113
1398
  if (!existsSync2(envPath)) return false;
2114
1399
  try {
2115
- const content = readFileSync2(envPath, "utf-8");
1400
+ const content = readFileSync(envPath, "utf-8");
2116
1401
  const keyLine = `VOCODER_API_KEY=${apiKey}`;
2117
1402
  let updated;
2118
1403
  if (/^VOCODER_API_KEY=/m.test(content)) {
@@ -2122,7 +1407,7 @@ function writeApiKeyToEnv(apiKey) {
2122
1407
  updated = `${content}${sep}${keyLine}
2123
1408
  `;
2124
1409
  }
2125
- writeFileSync3(envPath, updated);
1410
+ writeFileSync2(envPath, updated);
2126
1411
  return true;
2127
1412
  } catch {
2128
1413
  return false;
@@ -2399,7 +1684,7 @@ async function init(options = {}) {
2399
1684
  let userName;
2400
1685
  let authOrganizationId;
2401
1686
  const stored = readAuthData();
2402
- if (stored && stored.apiUrl === apiUrl) {
1687
+ if (stored) {
2403
1688
  const verified = await verifyStoredToken(api, stored.token);
2404
1689
  if (verified && !("userGone" in verified)) {
2405
1690
  p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
@@ -2427,7 +1712,6 @@ async function init(options = {}) {
2427
1712
  authOrganizationId = authResult.organizationId;
2428
1713
  writeAuthData({
2429
1714
  token: userToken,
2430
- apiUrl,
2431
1715
  userId: authResult.userId,
2432
1716
  email: userEmail,
2433
1717
  name: userName,
@@ -2448,7 +1732,6 @@ async function init(options = {}) {
2448
1732
  authOrganizationId = authResult.organizationId;
2449
1733
  writeAuthData({
2450
1734
  token: userToken,
2451
- apiUrl,
2452
1735
  userId: authResult.userId,
2453
1736
  email: userEmail,
2454
1737
  name: userName,
@@ -2880,8 +2163,8 @@ async function logout(options = {}) {
2880
2163
 
2881
2164
  // src/commands/sync.ts
2882
2165
  import { createHash, randomUUID } from "crypto";
2883
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
2884
- import { join as join4 } from "path";
2166
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2167
+ import { join as join3 } from "path";
2885
2168
  import * as p8 from "@clack/prompts";
2886
2169
  import chalk8 from "chalk";
2887
2170
 
@@ -3162,7 +2445,7 @@ function parseTranslations(value) {
3162
2445
  return Object.keys(translations).length > 0 ? translations : null;
3163
2446
  }
3164
2447
  function getCacheFilePath(projectRoot, fingerprint) {
3165
- return join4(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
2448
+ return join3(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
3166
2449
  }
3167
2450
  function buildTranslationData(params) {
3168
2451
  const textToHash = new Map(params.stringEntries.map((e) => [e.text, e.key]));
@@ -3189,7 +2472,7 @@ function readLocalCache(params) {
3189
2472
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
3190
2473
  if (!existsSync3(cacheFilePath)) return null;
3191
2474
  try {
3192
- const raw = readFileSync3(cacheFilePath, "utf-8");
2475
+ const raw = readFileSync2(cacheFilePath, "utf-8");
3193
2476
  const parsed = JSON.parse(raw);
3194
2477
  if (!isRecord(parsed)) return null;
3195
2478
  const inner = isRecord(parsed.config) ? parsed : null;
@@ -3203,10 +2486,10 @@ function readLocalCache(params) {
3203
2486
  }
3204
2487
  }
3205
2488
  function writeCache(params) {
3206
- const cacheDir = join4(params.projectRoot, "node_modules", ".vocoder", "cache");
3207
- mkdirSync2(cacheDir, { recursive: true });
2489
+ const cacheDir = join3(params.projectRoot, "node_modules", ".vocoder", "cache");
2490
+ mkdirSync(cacheDir, { recursive: true });
3208
2491
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
3209
- writeFileSync4(cacheFilePath, JSON.stringify(params.data), "utf-8");
2492
+ writeFileSync3(cacheFilePath, JSON.stringify(params.data), "utf-8");
3210
2493
  return cacheFilePath;
3211
2494
  }
3212
2495
  function resolveEffectiveModeFromPolicy(params) {
@@ -3325,7 +2608,8 @@ function buildStringEntries(extractedStrings) {
3325
2608
  key: str.key,
3326
2609
  text: str.text,
3327
2610
  ...str.context ? { context: str.context } : {},
3328
- ...str.formality ? { formality: str.formality } : {}
2611
+ ...str.formality ? { formality: str.formality } : {},
2612
+ ...str.uiRole ? { uiRole: str.uiRole } : {}
3329
2613
  });
3330
2614
  continue;
3331
2615
  }
@@ -3374,9 +2658,7 @@ async function sync(options = {}) {
3374
2658
  }
3375
2659
  const spinner4 = p8.spinner();
3376
2660
  try {
3377
- spinner4.start("Detecting branch");
3378
2661
  const branch = detectBranch(options.branch);
3379
- spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
3380
2662
  spinner4.start("Loading project configuration");
3381
2663
  const localConfig = {
3382
2664
  apiKey: mergedConfig.apiKey,
@@ -3391,14 +2673,17 @@ async function sync(options = {}) {
3391
2673
  policyDefaultMaxWaitMs: apiConfig.syncPolicy.defaultMaxWaitMs,
3392
2674
  fallbackTimeoutMs: 6e4
3393
2675
  });
2676
+ const fileConfig = loadVocoderConfig(process.cwd());
3394
2677
  const config = {
3395
2678
  ...localConfig,
3396
2679
  ...apiConfig,
3397
2680
  includePattern: mergedConfig.includePattern,
3398
2681
  excludePattern: mergedConfig.excludePattern,
3399
- timeout: waitTimeoutMs
2682
+ timeout: waitTimeoutMs,
2683
+ ...fileConfig?.appIndustry ? { appIndustry: fileConfig.appIndustry } : {},
2684
+ ...fileConfig?.formality ? { formality: fileConfig.formality } : {}
3400
2685
  };
3401
- spinner4.stop("Project configuration loaded");
2686
+ spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
3402
2687
  if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
3403
2688
  p8.log.warn(
3404
2689
  `Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
@@ -3487,19 +2772,20 @@ async function sync(options = {}) {
3487
2772
  requestedMode,
3488
2773
  requestedMaxWaitMs: waitTimeoutMs,
3489
2774
  clientRunId: randomUUID(),
3490
- force: options.force
2775
+ force: options.force,
2776
+ // Sync appIndustry from vocoder.config.ts to ProjectApp on every push
2777
+ ...config.appIndustry ? { appIndustry: config.appIndustry } : {}
3491
2778
  },
3492
2779
  repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
3493
2780
  );
3494
- spinner4.stop(
3495
- `Submitted to API - Batch ${chalk8.cyan(batchResponse.batchId)}`
3496
- );
2781
+ spinner4.stop("Strings submitted");
3497
2782
  const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
3498
2783
  branch,
3499
2784
  requestedMode,
3500
2785
  policy: config.syncPolicy
3501
2786
  });
3502
2787
  if (options.verbose) {
2788
+ p8.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
3503
2789
  p8.log.info(`Requested mode: ${requestedMode}`);
3504
2790
  p8.log.info(`Effective mode: ${effectiveMode}`);
3505
2791
  p8.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
@@ -3508,24 +2794,17 @@ async function sync(options = {}) {
3508
2794
  }
3509
2795
  }
3510
2796
  if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
3511
- p8.log.success("No changes detected - strings are up to date");
3512
- }
3513
- p8.log.info(`New strings: ${chalk8.cyan(batchResponse.newStrings)}`);
3514
- if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
3515
- p8.log.info(
3516
- `Deleted strings: ${chalk8.yellow(batchResponse.deletedStrings)} (archived)`
3517
- );
3518
- }
3519
- p8.log.info(`Total strings: ${chalk8.cyan(batchResponse.totalStrings)}`);
3520
- if (batchResponse.newStrings === 0) {
3521
- p8.log.success("No new strings - using existing translations");
2797
+ p8.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
2798
+ } else if (batchResponse.newStrings === 0) {
2799
+ const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk8.yellow(batchResponse.deletedStrings)} archived` : "";
2800
+ p8.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
3522
2801
  } else {
3523
- p8.log.info(
3524
- `Syncing to ${config.targetLocales.length} locales (${config.targetLocales.join(", ")})`
3525
- );
3526
- if (batchResponse.estimatedTime) {
3527
- p8.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
2802
+ const statParts = [`${chalk8.cyan(batchResponse.newStrings)} new, ${chalk8.cyan(batchResponse.totalStrings)} total`];
2803
+ if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
2804
+ statParts.push(`${chalk8.yellow(batchResponse.deletedStrings)} archived`);
3528
2805
  }
2806
+ const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
2807
+ p8.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
3529
2808
  }
3530
2809
  let artifacts = null;
3531
2810
  if (batchResponse.translations) {
@@ -3536,7 +2815,8 @@ async function sync(options = {}) {
3536
2815
  }
3537
2816
  let waitError = null;
3538
2817
  if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
3539
- spinner4.start(`Waiting for translations (max ${waitTimeoutMs}ms)`);
2818
+ const waitTimeoutSecs = Math.round(waitTimeoutMs / 1e3);
2819
+ spinner4.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
3540
2820
  let lastProgress = 0;
3541
2821
  try {
3542
2822
  const completion = await api.waitForCompletion(