@vocoder/cli 0.16.0 → 0.16.1

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
@@ -13,1042 +13,637 @@ import {
13
13
  readAuthData,
14
14
  verifyStoredAuth,
15
15
  writeAuthData
16
- } from "./chunk-IK4ZCESQ.mjs";
16
+ } from "./chunk-2JERZ6DL.mjs";
17
17
 
18
18
  // src/bin.ts
19
19
  import { Command } from "commander";
20
20
 
21
21
  // src/commands/init.ts
22
- import * as p6 from "@clack/prompts";
23
- import { execSync as execSync3, spawn as spawn2 } from "child_process";
24
- import { existsSync as existsSync3, readFileSync, writeFileSync as writeFileSync2 } from "fs";
25
-
26
- // src/utils/write-config.ts
27
- import { existsSync, writeFileSync } from "fs";
28
- import { join } from "path";
29
- function findExistingConfig(cwd = process.cwd()) {
30
- for (const name of [
31
- "vocoder.config.ts",
32
- "vocoder.config.js",
33
- "vocoder.config.json"
34
- ]) {
35
- const candidate = join(cwd, name);
36
- if (existsSync(candidate)) return candidate;
37
- }
38
- return null;
39
- }
40
- function writeVocoderConfig(options) {
41
- const {
42
- targetBranches = ["main"],
43
- useTypeScript = true,
44
- cwd = process.cwd(),
45
- appId
46
- } = options;
47
- if (findExistingConfig(cwd)) return null;
48
- const ext = useTypeScript ? "ts" : "js";
49
- const configPath = join(cwd, `vocoder.config.${ext}`);
50
- const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
51
- const includes = ["**/*.{tsx,jsx,ts,js}"];
52
- const includesStr = includes.map((p15) => `'${p15}'`).join(", ");
53
- const appIdLine = appId ? ` appId: '${appId}',
54
- ` : "";
55
- const content = `import { defineConfig } from '@vocoder/config'
56
-
57
- export default defineConfig({
58
- ${appIdLine} targetBranches: [${branchesStr}],
59
- include: [${includesStr}],
60
- })
61
- `;
62
- try {
63
- writeFileSync(configPath, content, "utf-8");
64
- return `vocoder.config.${ext}`;
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- // src/utils/theme.ts
71
- import chalk from "chalk";
72
- var ORANGE = "#FC5206";
73
- var PINK = "#D51977";
74
- var BLUE = "#2450A9";
75
- var noColor = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
76
- var hex = (color) => (s) => noColor ? s : chalk.hex(color)(s);
77
- var dim = (s) => noColor ? s : chalk.dim(s);
78
- var bld = (s) => noColor ? s : chalk.bold(s);
79
- var grn = (s) => noColor ? s : chalk.green(s);
80
- var ylw = (s) => noColor ? s : chalk.yellow(s);
81
- var red = (s) => noColor ? s : chalk.red(s);
82
- var highlight = hex(PINK);
83
- var info = hex(BLUE);
84
- var active = hex(ORANGE);
85
-
86
- // src/commands/init.ts
87
- import { join as join2, resolve as resolve2 } from "path";
88
-
89
- // src/utils/project-create.ts
90
- import * as p3 from "@clack/prompts";
91
- import chalk2 from "chalk";
92
-
93
- // src/utils/app-dir-select.ts
94
- import { existsSync as existsSync2, statSync } from "fs";
95
- import { resolve } from "path";
96
- import { isCancel, Prompt } from "@clack/core";
97
- import * as p from "@clack/prompts";
98
- var S_BAR = "\u2502";
99
- var S_BAR_END = "\u2514";
100
- var S_ACTIVE = "\u25C6";
101
- var S_SUBMIT = "\u25C6";
102
- var S_CANCEL = "\u25A0";
103
- var S_ERROR = "\u25B2";
104
- function symbol(state) {
105
- switch (state) {
106
- case "submit":
107
- return grn(S_SUBMIT);
108
- case "cancel":
109
- return red(S_CANCEL);
110
- case "error":
111
- return ylw(S_ERROR);
112
- default:
113
- return active(S_ACTIVE);
114
- }
115
- }
116
- function validateAppDirPath(val, existing, opts = {}) {
117
- if (val.startsWith("/")) return "Must be a relative path (e.g. apps/web)";
118
- if (val.includes("..")) return "Path traversal not allowed";
119
- const hasWholeRepo = existing.includes("");
120
- const hasScoped = existing.some((d) => d !== "");
121
- if (val === "" && hasScoped) return "Cannot add whole-repo scope to a monorepo project";
122
- if (val !== "" && hasWholeRepo) return "Cannot add a scoped directory to a whole-repo project";
123
- if (existing.includes(val)) return `Already added: ${val}`;
124
- const nested = existing.find(
125
- (d) => d !== "" && (val.startsWith(d + "/") || d.startsWith(val + "/"))
126
- );
127
- if (nested) return `"${val}" overlaps with already-added "${nested}"`;
128
- if (val !== "") {
129
- const abs = resolve(opts.cwd ?? process.cwd(), val);
130
- if (!existsSync2(abs)) return `Directory not found: ${val}`;
131
- if (!statSync(abs).isDirectory()) return `Not a directory: ${val}`;
132
- }
133
- return null;
134
- }
135
- async function collectAppDirs(opts = {}) {
136
- const added = [];
137
- let filter = "";
138
- let cursor = 0;
139
- let addCursor = false;
140
- const isNewDir = () => {
141
- const t = filter.trim();
142
- return t.length > 0 && !added.includes(t);
143
- };
144
- const clampCursor = () => {
145
- const max = added.length - 1;
146
- if (cursor > max) cursor = Math.max(0, max);
147
- };
148
- const prompt = new Prompt(
149
- {
150
- validate() {
151
- return void 0;
152
- },
153
- render() {
154
- const trimmed = filter.trim();
155
- const hdr = `${dim(S_BAR)}
156
- ${symbol(this.state)} App directories
157
- `;
158
- switch (this.state) {
159
- case "submit": {
160
- const summary = added.length > 0 ? bld(added.join(", ")) : dim("none (single-app project)");
161
- return `${hdr}${dim(S_BAR)} ${summary}`;
162
- }
163
- case "cancel":
164
- return `${hdr}${dim(S_BAR)}`;
165
- default: {
166
- const inputHint = filter.length > 0 ? filter : added.length === 0 ? dim("e.g. apps/web") : dim("e.g. apps/api");
167
- const lines = [
168
- hdr.trimEnd(),
169
- `${info(S_BAR)} ${dim("/")} ${inputHint}`,
170
- info(S_BAR)
171
- ];
172
- for (let i = 0; i < added.length; i++) {
173
- const isCursor = i === cursor && !addCursor;
174
- const icon = active("\u25FC");
175
- const label = isCursor ? bld(added[i]) : added[i];
176
- lines.push(`${info(S_BAR)} ${icon} ${label}`);
177
- }
178
- const atLimit = opts.maxDirs !== void 0 && added.length >= opts.maxDirs;
179
- if (atLimit) {
180
- lines.push(`${info(S_BAR)} ${dim(`App limit reached (${added.length}/${opts.maxDirs} on your plan)`)}`);
181
- } else if (isNewDir()) {
182
- const err = validateAppDirPath(trimmed, added, opts);
183
- const icon = addCursor ? active("\u25FB") : dim("\u25FB");
184
- const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}"`;
185
- lines.push(`${info(S_BAR)} ${icon} ${label}`);
186
- }
187
- lines.push(info(S_BAR));
188
- if (atLimit) {
189
- lines.push(dim(`${S_BAR} \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
190
- } else if (added.length === 0 && !isNewDir()) {
191
- lines.push(dim(`${S_BAR} Monorepo? Type each app's subdirectory path and press Space.`));
192
- lines.push(dim(`${S_BAR} Single app? Press Enter to skip this step.`));
193
- } else if (added.length > 0) {
194
- lines.push(dim(`${S_BAR} ${added.length} added \xB7 \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
195
- }
196
- const barEnd = this.state === "error" ? ylw(S_BAR_END) : info(S_BAR_END);
197
- if (this.state === "error") {
198
- lines.push(`${ylw(S_BAR_END)} ${ylw(this.error)}`);
199
- } else {
200
- lines.push(barEnd);
201
- }
202
- lines.push("");
203
- return lines.join("\n");
204
- }
205
- }
206
- }
207
- },
208
- false
209
- );
210
- prompt.on("key", (key) => {
211
- if (!key || key === " ") return;
212
- const cp = key.codePointAt(0) ?? 0;
213
- if (cp === 127 || cp === 8) {
214
- filter = filter.slice(0, -1);
215
- addCursor = false;
216
- } else if (cp >= 32 && cp !== 127) {
217
- filter += key;
218
- cursor = 0;
219
- addCursor = false;
220
- }
221
- });
222
- prompt.on("cursor", (action) => {
223
- switch (action) {
224
- case "up":
225
- if (addCursor) {
226
- addCursor = false;
227
- cursor = Math.max(0, added.length - 1);
228
- } else {
229
- cursor = Math.max(0, cursor - 1);
230
- }
231
- break;
232
- case "down":
233
- if (!addCursor && cursor >= added.length - 1 && isNewDir()) {
234
- addCursor = true;
235
- } else if (!addCursor) {
236
- cursor = Math.min(added.length - 1, cursor + 1);
237
- }
238
- break;
239
- case "space": {
240
- if (addCursor || filter.trim().length > 0 && isNewDir()) {
241
- if (opts.maxDirs !== void 0 && added.length >= opts.maxDirs) break;
242
- const trimmed = filter.trim();
243
- const err = validateAppDirPath(trimmed, added, opts);
244
- if (!err) {
245
- added.push(trimmed);
246
- filter = "";
247
- addCursor = false;
248
- cursor = 0;
249
- }
250
- } else if (added.length > 0 && !isNewDir()) {
251
- clampCursor();
252
- added.splice(cursor, 1);
253
- if (cursor >= added.length) cursor = Math.max(0, added.length - 1);
254
- }
255
- break;
256
- }
257
- }
258
- });
259
- prompt.on("finalize", () => {
260
- if (prompt.state === "submit") {
261
- prompt.value = [...added];
262
- }
263
- });
264
- const result = await prompt.prompt();
265
- if (isCancel(result)) return null;
266
- return result;
267
- }
268
- async function promptSingleAppDir(params) {
269
- const { existingDirs, cwd } = params;
270
- const input = await p.text({
271
- message: "App directory to add",
272
- placeholder: "apps/web",
273
- validate(val) {
274
- const err = validateAppDirPath(val ?? "", existingDirs, { cwd });
275
- if (err) return err;
276
- if (!val) return "Directory is required";
277
- return void 0;
278
- }
279
- });
280
- if (p.isCancel(input)) return null;
281
- return input;
282
- }
283
-
284
- // src/utils/branch-select.ts
285
- import { execSync } from "child_process";
286
- import { isCancel as isCancel3, Prompt as Prompt2 } from "@clack/core";
287
- var S_BAR2 = "\u2502";
288
- var S_BAR_END2 = "\u2514";
289
- var S_ACTIVE2 = "\u25C6";
290
- var S_SUBMIT2 = "\u25C6";
291
- var S_CANCEL2 = "\u25A0";
292
- var S_ERROR2 = "\u25B2";
293
- function symbol2(state) {
294
- switch (state) {
295
- case "submit":
296
- return grn(S_SUBMIT2);
297
- case "cancel":
298
- return red(S_CANCEL2);
299
- case "error":
300
- return ylw(S_ERROR2);
301
- default:
302
- return active(S_ACTIVE2);
303
- }
304
- }
305
- function detectGitBranches(cwd) {
306
- const workDir = cwd ?? process.cwd();
307
- try {
308
- const localOut = execSync("git branch", {
309
- cwd: workDir,
310
- stdio: "pipe"
311
- }).toString();
312
- const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
313
- let remoteBranches = [];
314
- try {
315
- const remoteOut = execSync("git branch -r", {
316
- cwd: workDir,
317
- stdio: "pipe"
318
- }).toString();
319
- remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
320
- } catch {
321
- }
322
- const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
323
- let defaultBranch = "main";
324
- try {
325
- const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
326
- cwd: workDir,
327
- stdio: "pipe"
328
- }).toString().trim();
329
- defaultBranch = ref.split("/").pop() ?? "main";
330
- } catch {
331
- }
332
- return {
333
- branches: branches.length > 0 ? branches : [defaultBranch],
334
- defaultBranch
335
- };
336
- } catch {
337
- return { branches: ["main"], defaultBranch: "main" };
338
- }
339
- }
340
- var INVALID_CHARS = /[\s?^~:[\]\\]/;
341
- function validateBranchPattern(pattern) {
342
- const t = pattern.trim();
343
- if (!t) return "Pattern cannot be empty";
344
- if (INVALID_CHARS.test(t))
345
- return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
346
- if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
347
- if (t.includes("//")) return "Cannot contain //";
348
- return null;
349
- }
350
- var MAX_VISIBLE = 10;
351
- function buildItems(branches, defaultBranch, customPatterns) {
352
- const items = branches.map((b) => ({
353
- value: b,
354
- label: b === defaultBranch ? `${b} (default branch)` : b
355
- }));
356
- for (const pt of customPatterns) {
357
- if (!branches.includes(pt)) {
358
- items.push({ value: pt, label: pt, isCustom: true });
359
- }
360
- }
361
- return items;
362
- }
363
- function filterItems(items, query) {
364
- if (!query.trim()) return items;
365
- const lower = query.toLowerCase();
366
- return items.filter((i) => i.value.toLowerCase().includes(lower));
367
- }
368
- function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedPatterns = /* @__PURE__ */ new Set()) {
369
- const lines = [info(S_BAR2)];
370
- const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
371
- for (let i = scrollOffset; i < end; i++) {
372
- const item = filtered[i];
373
- const isCursor = i === cursor && !addCursor;
374
- const isChecked = selected.has(item.value);
375
- const icon = isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB");
376
- let label = item.isCustom ? `${item.label} ${dim("(custom)")}` : item.label;
377
- if (isCursor) label = bld(label);
378
- lines.push(`${info(S_BAR2)} ${icon} ${label}`);
379
- }
380
- const trimmed = filter.trim();
381
- const isNewPattern = trimmed.length > 0 && !filtered.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
382
- if (isNewPattern) {
383
- const err = validateBranchPattern(trimmed) ?? (excludedPatterns.has(trimmed) ? "Already used for automatic translation" : null);
384
- const icon = addCursor ? active("\u25FB") : dim("\u25FB");
385
- const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}" as branch pattern`;
386
- lines.push(`${info(S_BAR2)} ${icon} ${label}`);
387
- } else if (filtered.length === 0 && trimmed.length === 0) {
388
- lines.push(dim(`${S_BAR2} No branches detected`));
389
- }
390
- const hidden = filtered.length - (end - scrollOffset);
391
- if (hidden > 0) lines.push(dim(`${S_BAR2} ${hidden} more \u2014 keep typing to narrow`));
392
- return lines.join("\n");
393
- }
394
- async function filterableBranchSelect(params) {
395
- const { message, branches, defaultBranch } = params;
396
- const optional = params.optional ?? false;
397
- const excludedSet = new Set(params.excludedPatterns ?? []);
398
- let filter = "";
399
- let cursor = 0;
400
- let scrollOffset = 0;
401
- let addCursor = false;
402
- const customPatterns = [];
403
- const selected = new Set(params.initialValues ?? [defaultBranch]);
404
- const getItems = () => buildItems(branches, defaultBranch, customPatterns);
405
- const getFiltered = () => filterItems(getItems(), filter);
406
- const isNewPattern = () => {
407
- const t = filter.trim();
408
- if (!t) return false;
409
- return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
410
- };
411
- const clampCursor = (filtered) => {
412
- const hasAdd = isNewPattern();
413
- const max = filtered.length - 1 + (hasAdd ? 1 : 0);
414
- if (cursor > max && !addCursor) cursor = Math.max(0, max);
415
- if (!addCursor) {
416
- if (cursor < scrollOffset) scrollOffset = cursor;
417
- if (cursor >= scrollOffset + MAX_VISIBLE)
418
- scrollOffset = cursor - MAX_VISIBLE + 1;
419
- if (scrollOffset < 0) scrollOffset = 0;
420
- }
421
- };
422
- const prompt = new Prompt2(
423
- {
424
- validate() {
425
- if (!optional && selected.size === 0)
426
- return "At least one branch is required.";
427
- return void 0;
428
- },
429
- render() {
430
- const filtered = getFiltered();
431
- clampCursor(filtered);
432
- const hdr = `${dim(S_BAR2)}
433
- ${symbol2(this.state)} ${message}
434
- `;
435
- const inputHint = filter.length > 0 ? filter : dim("type to filter \xB7 type a custom pattern to add it");
436
- const trimmedFilter = filter.trim();
437
- const footer = (() => {
438
- if (trimmedFilter.length > 0 && isNewPattern()) {
439
- return dim(`${S_BAR2} Space to add "${trimmedFilter}" \xB7 \u2191\u2193 navigate \xB7 Enter to confirm`);
440
- }
441
- if (selected.size > 0) {
442
- return dim(`${S_BAR2} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
443
- }
444
- return optional ? dim(`${S_BAR2} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to skip`) : dim(`${S_BAR2} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
445
- })();
446
- switch (this.state) {
447
- case "submit": {
448
- const summary = selected.size > 0 ? bld(Array.from(selected).join(", ")) : dim("none");
449
- return `${hdr}${dim(S_BAR2)} ${summary}`;
450
- }
451
- case "cancel":
452
- return `${hdr}${dim(S_BAR2)}`;
453
- case "error":
454
- return [
455
- hdr.trimEnd(),
456
- `${ylw(S_BAR2)} ${dim("/")} ${inputHint}`,
457
- buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
458
- footer,
459
- `${ylw(S_BAR_END2)} ${ylw(this.error)}`,
460
- ""
461
- ].join("\n");
462
- default:
463
- return [
464
- hdr.trimEnd(),
465
- `${info(S_BAR2)} ${dim("/")} ${inputHint}`,
466
- buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
467
- footer,
468
- `${info(S_BAR_END2)}`,
469
- ""
470
- ].join("\n");
471
- }
472
- }
473
- },
474
- false
475
- );
476
- prompt.on("key", (key) => {
477
- if (!key || key === " ") return;
478
- const cp = key.codePointAt(0) ?? 0;
479
- if (cp === 127 || cp === 8) {
480
- filter = filter.slice(0, -1);
481
- cursor = 0;
482
- scrollOffset = 0;
483
- addCursor = false;
484
- } else if (cp >= 32 && cp !== 127) {
485
- filter += key;
486
- cursor = 0;
487
- scrollOffset = 0;
488
- addCursor = false;
489
- }
490
- if (isNewPattern()) {
491
- addCursor = true;
492
- }
493
- });
494
- prompt.on("cursor", (action) => {
495
- const filtered = getFiltered();
496
- const hasAdd = isNewPattern();
497
- switch (action) {
498
- case "up":
499
- if (addCursor) {
500
- addCursor = false;
501
- cursor = Math.max(0, filtered.length - 1);
502
- } else cursor = Math.max(0, cursor - 1);
503
- break;
504
- case "down":
505
- if (!addCursor && cursor >= filtered.length - 1 && hasAdd)
506
- addCursor = true;
507
- else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
508
- break;
509
- case "space": {
510
- const t = filter.trim();
511
- if (addCursor || t.length > 0 && isNewPattern()) {
512
- const err = validateBranchPattern(t) ?? (excludedSet.has(t) ? "Already used for automatic translation" : null);
513
- if (!err) {
514
- customPatterns.push(t);
515
- selected.add(t);
516
- filter = "";
517
- cursor = 0;
518
- scrollOffset = 0;
519
- addCursor = false;
520
- }
521
- } else {
522
- const item = filtered[cursor];
523
- if (item) {
524
- if (selected.has(item.value)) selected.delete(item.value);
525
- else selected.add(item.value);
526
- }
527
- }
528
- break;
529
- }
530
- }
531
- });
532
- prompt.on("finalize", () => {
533
- if (prompt.state === "submit") {
534
- prompt.value = Array.from(selected);
535
- }
536
- });
537
- const result = await prompt.prompt();
538
- if (isCancel3(result)) return null;
539
- return result;
540
- }
541
-
542
- // src/utils/locale-search.ts
543
- import { isCancel as isCancel4, Prompt as Prompt3 } from "@clack/core";
544
- import * as p2 from "@clack/prompts";
545
- var S_BAR3 = "\u2502";
546
- var S_BAR_END3 = "\u2514";
547
- var S_ACTIVE3 = "\u25C6";
548
- var S_SUBMIT3 = "\u25C6";
549
- var S_CANCEL3 = "\u25A0";
550
- var S_ERROR3 = "\u25B2";
551
- function symbol3(state) {
552
- switch (state) {
553
- case "submit":
554
- return grn(S_SUBMIT3);
555
- case "cancel":
556
- return red(S_CANCEL3);
557
- case "error":
558
- return ylw(S_ERROR3);
559
- default:
560
- return active(S_ACTIVE3);
561
- }
562
- }
563
- var MAX_VISIBLE2 = 12;
564
- function filterLocales(options, query) {
565
- if (!query.trim()) return options;
566
- const lower = query.toLowerCase();
567
- return options.filter(
568
- (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
569
- );
570
- }
571
- function buildList2(filtered, cursor, scrollOffset, selected) {
572
- const isMulti = selected !== null;
573
- const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
574
- const visibleLines = [info(S_BAR3)];
575
- for (let i = scrollOffset; i < end; i++) {
576
- const opt = filtered[i];
577
- const isCursor = i === cursor;
578
- const isChecked = isMulti && selected.has(opt.bcp47);
579
- const icon = isMulti ? isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB") : isCursor ? active("\u25CF") : dim("\u25CB");
580
- visibleLines.push(
581
- `${info(S_BAR3)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`
582
- );
583
- }
584
- const hidden = filtered.length - (end - scrollOffset);
585
- if (hidden > 0) visibleLines.push(dim(`${S_BAR3} ${hidden} more \u2014 keep typing to narrow`));
586
- if (filtered.length === 0) visibleLines.push(dim(`${S_BAR3} No matches`));
587
- return visibleLines.join("\n");
588
- }
589
- async function runFilterablePrompt(opts) {
590
- const { message, options, multi } = opts;
591
- let filter = "";
592
- let cursor = 0;
593
- let scrollOffset = 0;
594
- const selected = new Set(multi ? opts.initialValues ?? [] : []);
595
- if (!multi && opts.initialValue) {
596
- const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
597
- if (idx >= 0) cursor = idx;
598
- }
599
- const getFiltered = () => filterLocales(options, filter);
600
- const clampCursor = (filtered) => {
601
- if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
602
- if (cursor < scrollOffset) scrollOffset = cursor;
603
- if (cursor >= scrollOffset + MAX_VISIBLE2)
604
- scrollOffset = cursor - MAX_VISIBLE2 + 1;
605
- if (scrollOffset < 0) scrollOffset = 0;
606
- };
607
- const prompt = new Prompt3(
608
- {
609
- initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
610
- validate() {
611
- const f = getFiltered();
612
- if (multi && selected.size === 0)
613
- return "At least one target language is required.";
614
- if (!multi && !f[cursor]) return "Please select a language.";
615
- return void 0;
616
- },
617
- render() {
618
- const filtered = getFiltered();
619
- clampCursor(filtered);
620
- const hdr = `${dim(S_BAR3)}
621
- ${symbol3(this.state)} ${message}
622
- `;
623
- const inputHint = filter.length > 0 ? filter : dim("type to filter");
624
- const footer = multi ? selected.size > 0 ? dim(`${S_BAR3} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : dim(`${S_BAR3} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : dim(`${S_BAR3} \u2191\u2193 navigate \xB7 Enter to confirm`);
625
- switch (this.state) {
626
- case "submit": {
627
- const val = multi ? Array.from(selected).map((id) => options.find((o) => o.bcp47 === id)?.label ?? id).join(", ") : options.find((o) => o.bcp47 === this.value)?.label ?? "";
628
- return `${hdr}${dim(S_BAR3)} ${bld(val || dim("none"))}`;
629
- }
630
- case "cancel":
631
- return `${hdr}${dim(S_BAR3)}`;
632
- case "error":
633
- return [
634
- hdr.trimEnd(),
635
- `${ylw(S_BAR3)} ${dim("/")} ${inputHint}`,
636
- buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
637
- footer,
638
- `${ylw(S_BAR_END3)} ${ylw(this.error)}`,
639
- ""
640
- ].join("\n");
641
- default:
642
- return [
643
- hdr.trimEnd(),
644
- `${info(S_BAR3)} ${dim("/")} ${inputHint}`,
645
- buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
646
- footer,
647
- `${info(S_BAR_END3)}`,
648
- ""
649
- ].join("\n");
650
- }
651
- }
652
- },
653
- false
654
- // trackValue=false — we manage value manually
655
- );
656
- prompt.on("key", (key) => {
657
- if (!key || key === " ") return;
658
- const cp = key.codePointAt(0) ?? 0;
659
- if (cp === 127 || cp === 8) {
660
- filter = filter.slice(0, -1);
661
- cursor = 0;
662
- scrollOffset = 0;
663
- } else if (cp >= 32 && cp !== 127) {
664
- filter += key;
665
- cursor = 0;
666
- scrollOffset = 0;
667
- }
668
- });
669
- prompt.on("cursor", (action) => {
670
- const filtered = getFiltered();
671
- switch (action) {
672
- case "up":
673
- cursor = Math.max(0, cursor - 1);
674
- break;
675
- case "down":
676
- cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
677
- break;
678
- case "space":
679
- if (multi) {
680
- const opt = filtered[cursor];
681
- if (opt) {
682
- if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
683
- else selected.add(opt.bcp47);
684
- }
685
- }
686
- break;
687
- }
688
- if (!multi) {
689
- const opt = getFiltered()[cursor];
690
- prompt.value = opt?.bcp47 ?? null;
691
- }
692
- });
693
- prompt.on("finalize", () => {
694
- if (prompt.state === "submit") {
695
- if (multi) {
696
- prompt.value = Array.from(selected);
697
- } else {
698
- const f = getFiltered();
699
- prompt.value = f[cursor]?.bcp47 ?? null;
700
- }
22
+ import * as p12 from "@clack/prompts";
23
+
24
+ // src/utils/auth-flow.ts
25
+ import * as p from "@clack/prompts";
26
+ import chalk from "chalk";
27
+
28
+ // src/utils/browser.ts
29
+ import { spawn } from "child_process";
30
+ async function tryOpenBrowser(url) {
31
+ if (!process.stdout.isTTY || process.env.CI === "true") {
32
+ return false;
33
+ }
34
+ let command;
35
+ let args;
36
+ if (process.platform === "darwin") {
37
+ command = "open";
38
+ args = [url];
39
+ } else if (process.platform === "win32") {
40
+ command = "rundll32";
41
+ args = ["url.dll,FileProtocolHandler", url];
42
+ } else {
43
+ command = "xdg-open";
44
+ args = [url];
45
+ }
46
+ return new Promise((resolve3) => {
47
+ try {
48
+ const child = spawn(command, args, {
49
+ detached: true,
50
+ stdio: "ignore",
51
+ windowsHide: true
52
+ });
53
+ let settled = false;
54
+ child.once("spawn", () => {
55
+ if (settled) return;
56
+ settled = true;
57
+ child.unref();
58
+ resolve3(true);
59
+ });
60
+ child.once("error", () => {
61
+ if (settled) return;
62
+ settled = true;
63
+ resolve3(false);
64
+ });
65
+ setTimeout(() => {
66
+ if (settled) return;
67
+ settled = true;
68
+ resolve3(false);
69
+ }, 300);
70
+ } catch {
71
+ resolve3(false);
701
72
  }
702
73
  });
703
- const result = await prompt.prompt();
704
- if (isCancel4(result)) return null;
705
- return result;
706
- }
707
- async function searchSelectLocale(options, message, initialValue) {
708
- const result = await runFilterablePrompt({
709
- message,
710
- options,
711
- multi: false,
712
- initialValue
713
- });
714
- return typeof result === "string" ? result : null;
715
74
  }
716
- async function searchMultiSelectLocales(options, message, initialValues) {
717
- const result = await runFilterablePrompt({
718
- message,
719
- options,
720
- multi: true,
721
- initialValues
75
+
76
+ // src/utils/local-server.ts
77
+ import { createServer } from "http";
78
+ import { URL as URL2 } from "url";
79
+ function startCallbackServer() {
80
+ return new Promise((resolve3, reject) => {
81
+ let settled = false;
82
+ let callbackResolve = null;
83
+ let callbackReject = null;
84
+ const callbackPromise = new Promise((res, rej) => {
85
+ callbackResolve = res;
86
+ callbackReject = rej;
87
+ });
88
+ const server = createServer((req, res) => {
89
+ if (!req.url) {
90
+ res.writeHead(400);
91
+ res.end();
92
+ return;
93
+ }
94
+ let pathname;
95
+ let params;
96
+ try {
97
+ const parsed = new URL2(req.url, "http://localhost");
98
+ pathname = parsed.pathname;
99
+ params = Object.fromEntries(parsed.searchParams.entries());
100
+ } catch {
101
+ res.writeHead(400);
102
+ res.end("Bad request");
103
+ return;
104
+ }
105
+ if (pathname !== "/callback") {
106
+ res.writeHead(404);
107
+ res.end("Not found");
108
+ return;
109
+ }
110
+ res.writeHead(200, { "Content-Type": "text/html" });
111
+ res.end(
112
+ '<!DOCTYPE html><html><head><title>Authenticated</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;"><h2>Authenticated</h2><p>Return to your terminal to continue. You can close this tab.</p></body></html>'
113
+ );
114
+ if (callbackResolve) {
115
+ callbackResolve(params);
116
+ callbackResolve = null;
117
+ }
118
+ setImmediate(() => server.close());
119
+ });
120
+ server.on("error", (err) => {
121
+ if (!settled) {
122
+ settled = true;
123
+ if (callbackReject) callbackReject(err);
124
+ reject(err);
125
+ }
126
+ });
127
+ server.listen(0, "127.0.0.1", () => {
128
+ if (settled) return;
129
+ settled = true;
130
+ const port = server.address().port;
131
+ resolve3({
132
+ port,
133
+ waitForCallback: () => callbackPromise,
134
+ close: () => server.close()
135
+ });
136
+ });
722
137
  });
723
- if (result === null) return null;
724
- const picks = result;
725
- if (picks.length === 0) {
726
- p2.log.warn(
727
- "At least one target language is required. Please select at least one."
728
- );
729
- return searchMultiSelectLocales(options, message, initialValues);
730
- }
731
- return picks;
732
138
  }
733
139
 
734
- // src/utils/project-create.ts
735
- function buildLocaleOptions(locales) {
736
- return locales.map((l) => ({
737
- bcp47: l.code,
738
- label: `${l.name} \u2014 ${l.code}`
739
- }));
140
+ // src/utils/auth-flow.ts
141
+ async function sleep(ms) {
142
+ await new Promise((resolve3) => setTimeout(resolve3, ms));
740
143
  }
741
- function buildLanguageOptions(locales) {
742
- const byFamily = /* @__PURE__ */ new Map();
743
- for (const l of locales) {
744
- const family = l.code.split("-")[0].toLowerCase();
745
- const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
746
- const existing = byFamily.get(family);
747
- if (!existing || l.code.length < existing.bcp47.length) {
748
- byFamily.set(family, opt);
144
+ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
145
+ let server = null;
146
+ if (!options.ci) {
147
+ try {
148
+ server = await startCallbackServer();
149
+ } catch {
749
150
  }
750
151
  }
751
- return Array.from(byFamily.values());
752
- }
753
- async function runProjectCreate(params) {
754
- const { api, userToken, organizationId, repoCanonical, repoRoot } = params;
755
- const projectName = (params.defaultName ?? "my-project").trim();
756
- p3.log.success(`Project: ${chalk2.bold(projectName)}`);
757
- let sourceLocales;
758
- try {
759
- ({ sourceLocales } = await api.listLocales(userToken));
760
- } catch {
761
- p3.log.error(
762
- "Failed to fetch supported locales. Check your connection and try again."
763
- );
764
- return null;
765
- }
766
- const languageOptions = buildLanguageOptions(sourceLocales);
767
- const appDirs = await collectAppDirs({ cwd: repoRoot, maxDirs: params.maxAppDirs });
768
- if (appDirs === null) return null;
769
- if (appDirs.length > 0) {
770
- p3.log.success(`App directories: ${appDirs.map((d) => chalk2.bold(d)).join(", ")}`);
152
+ const session = await api.startCliAuthSession(server?.port, repoCanonical);
153
+ const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
154
+ const expiresAt = new Date(session.expiresAt).getTime();
155
+ p.log.info(browserUrl);
156
+ if (options.ci) {
157
+ process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
158
+ `);
159
+ process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
160
+ `);
161
+ } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
162
+ if (reauth) {
163
+ if (!options.yes) {
164
+ const shouldOpen = await p.confirm({
165
+ message: "Open your browser to sign in again?"
166
+ });
167
+ if (p.isCancel(shouldOpen)) {
168
+ server?.close();
169
+ p.cancel("Setup cancelled.");
170
+ return null;
171
+ }
172
+ if (!shouldOpen) {
173
+ server?.close();
174
+ p.cancel("Setup cancelled.");
175
+ return null;
176
+ }
177
+ const opened = await tryOpenBrowser(browserUrl);
178
+ if (!opened) {
179
+ p.note(browserUrl, "Sign In");
180
+ p.log.info("Open the URL above manually to continue.");
181
+ }
182
+ } else {
183
+ await tryOpenBrowser(browserUrl);
184
+ }
185
+ } else {
186
+ let isLinkFlow = false;
187
+ if (!options.yes) {
188
+ const connectChoice = await p.select({
189
+ message: "Vocoder needs to be installed on your GitHub account to get started",
190
+ options: [
191
+ {
192
+ value: "install",
193
+ label: "Install GitHub App",
194
+ hint: "new user"
195
+ },
196
+ {
197
+ value: "link",
198
+ label: "Already installed? Link your account",
199
+ hint: "returning user"
200
+ }
201
+ ]
202
+ });
203
+ if (p.isCancel(connectChoice)) {
204
+ server?.close();
205
+ p.cancel("Setup cancelled.");
206
+ return null;
207
+ }
208
+ isLinkFlow = connectChoice === "link";
209
+ }
210
+ let urlToOpen = browserUrl;
211
+ if (isLinkFlow) {
212
+ try {
213
+ const linkSession = await api.startCliGitHubLinkSession(
214
+ session.sessionId,
215
+ server?.port
216
+ );
217
+ urlToOpen = linkSession.oauthUrl;
218
+ } catch {
219
+ urlToOpen = browserUrl;
220
+ }
221
+ }
222
+ const opened = await tryOpenBrowser(urlToOpen);
223
+ if (!opened) {
224
+ p.log.warn("Could not open your browser automatically.");
225
+ p.note(urlToOpen, "GitHub");
226
+ p.log.info("Open the URL above to continue.");
227
+ }
228
+ }
771
229
  }
772
- const sourceLocale = await searchSelectLocale(
773
- languageOptions,
774
- "Source language (the language your code is written in)",
775
- params.defaultSourceLocale ?? "en"
776
- );
777
- if (sourceLocale === null) return null;
778
- let compatibleTargets;
779
- try {
780
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
781
- } catch {
782
- p3.log.error(
783
- "Failed to fetch compatible target locales. Check your connection and try again."
784
- );
230
+ const authSpinner = p.spinner();
231
+ authSpinner.start("Waiting for GitHub authorization...");
232
+ let rawToken = null;
233
+ let callbackOrganizationId;
234
+ let callbackDiscoveryReady = false;
235
+ const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
236
+ let stopPolling = false;
237
+ const serverCallback = server ? server.waitForCallback().catch(() => null) : Promise.resolve(null);
238
+ const sessionPoll = (async () => {
239
+ while (!stopPolling && Date.now() < expiresAt) {
240
+ try {
241
+ const result = await api.pollCliAuthSession(session.sessionId);
242
+ if (result.status === "complete" || result.status === "failed") {
243
+ return result;
244
+ }
245
+ } catch {
246
+ }
247
+ if (!stopPolling) await sleep(2e3);
248
+ }
785
249
  return null;
786
- }
787
- const localeOptions = buildLocaleOptions(compatibleTargets);
788
- const targetOptions = localeOptions.filter(
789
- (opt) => opt.bcp47 !== sourceLocale
790
- );
791
- const targetLocales = await searchMultiSelectLocales(
792
- targetOptions,
793
- "Target languages (languages to translate into)"
794
- );
795
- if (targetLocales === null) return null;
796
- if (targetLocales.length === 0) {
797
- p3.log.warn(
798
- "No target languages selected \u2014 you can add them later from the dashboard."
250
+ })();
251
+ const winner = await new Promise((resolve3) => {
252
+ let done = false;
253
+ serverCallback.then((params) => {
254
+ if (done || params === null || typeof params.token !== "string") return;
255
+ done = true;
256
+ resolve3({ kind: "server", params });
257
+ }).catch(() => {
258
+ });
259
+ sessionPoll.then((result) => {
260
+ if (done || result === null) return;
261
+ if (result.status === "complete" || result.status === "failed") {
262
+ done = true;
263
+ resolve3({
264
+ kind: "poll",
265
+ result
266
+ });
267
+ }
268
+ }).catch(() => {
269
+ });
270
+ setTimeout(
271
+ () => {
272
+ if (!done) {
273
+ done = true;
274
+ resolve3(null);
275
+ }
276
+ },
277
+ Math.max(0, deadline - Date.now())
799
278
  );
800
- }
801
- const detected = detectGitBranches();
802
- const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
803
- let pushBranches = [];
804
- {
805
- let initial = initialBranches;
806
- while (pushBranches.length === 0) {
807
- const result2 = await filterableBranchSelect({
808
- message: "Which branches should trigger translations?",
809
- branches: detected.branches,
810
- defaultBranch: detected.defaultBranch,
811
- initialValues: initial
812
- });
813
- if (result2 === null) return null;
814
- if (result2.length === 0) {
815
- p3.log.warn(
816
- "At least one branch is required. Please select at least one."
817
- );
818
- initial = [detected.defaultBranch];
819
- } else {
820
- pushBranches = result2;
279
+ });
280
+ stopPolling = true;
281
+ server?.close();
282
+ if (winner !== null) {
283
+ if (winner.kind === "server") {
284
+ rawToken = winner.params.token;
285
+ if (typeof winner.params.organizationId === "string" && winner.params.organizationId) {
286
+ callbackOrganizationId = winner.params.organizationId;
287
+ }
288
+ if (winner.params.discovery_ready === "1") {
289
+ callbackDiscoveryReady = true;
290
+ }
291
+ } else if (winner.result.status === "complete") {
292
+ rawToken = winner.result.token;
293
+ if (winner.result.organizationId) {
294
+ callbackOrganizationId = winner.result.organizationId;
821
295
  }
296
+ } else {
297
+ authSpinner.stop();
298
+ p.log.error(winner.result.reason);
299
+ return null;
822
300
  }
823
301
  }
824
- const targetBranches = pushBranches;
825
- const result = await api.createProject(userToken, {
826
- organizationId,
827
- name: projectName,
828
- sourceLocale,
829
- targetLocales,
830
- targetBranches,
831
- appDirs,
832
- repoCanonical
833
- });
834
- p3.log.success(`Project ${chalk2.bold(result.projectName)} created!`);
302
+ if (!rawToken) {
303
+ authSpinner.stop();
304
+ p.log.error("The authentication link expired. Run `vocoder init` again.");
305
+ return null;
306
+ }
307
+ const userInfo = await api.getCliUserInfo(rawToken);
308
+ authSpinner.stop(`Authenticated as ${chalk.bold(userInfo.email)}`);
835
309
  return {
836
- projectId: result.projectId,
837
- projectName: result.projectName,
838
- apiKey: result.apiKey,
839
- sourceLocale,
840
- targetLocales,
841
- targetBranches,
842
- repositoryBound: result.repositoryBound,
843
- configureUrl: result.configureUrl,
844
- apps: result.apps
310
+ token: rawToken,
311
+ ...userInfo,
312
+ organizationId: callbackOrganizationId,
313
+ discoveryReady: callbackDiscoveryReady
845
314
  };
846
315
  }
847
- async function runAppCreate(params) {
848
- const { api, userToken, projectId, projectName, repoCanonical } = params;
849
- const existingDirs = params.existingApps.map((a) => a.appDir);
850
- const appDir = await promptSingleAppDir({ existingDirs });
851
- if (appDir === null) return null;
852
- if (appDir) {
853
- p3.log.success(`App directory: ${chalk2.bold(appDir)}`);
316
+
317
+ // src/utils/output.ts
318
+ import * as p2 from "@clack/prompts";
319
+ import chalk2 from "chalk";
320
+ import { execSync } from "child_process";
321
+ import { existsSync, readFileSync, writeFileSync } from "fs";
322
+ import { join } from "path";
323
+ function tryClipboard(text2) {
324
+ const tools = [
325
+ { cmd: "pbcopy" },
326
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
327
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
328
+ { cmd: "wl-copy" },
329
+ { cmd: "clip" }
330
+ ];
331
+ for (const { cmd, args = [] } of tools) {
332
+ try {
333
+ execSync([cmd, ...args].join(" "), {
334
+ input: text2,
335
+ stdio: ["pipe", "ignore", "ignore"]
336
+ });
337
+ return true;
338
+ } catch {
339
+ continue;
340
+ }
854
341
  }
855
- let sourceLocales;
856
- try {
857
- ({ sourceLocales } = await api.listLocales(userToken));
858
- } catch {
859
- p3.log.error(
860
- "Failed to fetch supported locales. Check your connection and try again."
861
- );
862
- return null;
342
+ return false;
343
+ }
344
+ function printCommand(cmd) {
345
+ const copied = tryClipboard(cmd);
346
+ process.stdout.write("\n");
347
+ process.stdout.write(` ${chalk2.dim("$")} ${chalk2.cyan(cmd)}
348
+ `);
349
+ if (copied) process.stdout.write(` ${chalk2.dim("\u2191 copied to clipboard")}
350
+ `);
351
+ process.stdout.write("\n");
352
+ }
353
+ function printCodeBlock(code) {
354
+ process.stdout.write("\n");
355
+ for (const line of code.split("\n")) {
356
+ process.stdout.write(` ${line}
357
+ `);
863
358
  }
864
- const languageOptions = buildLanguageOptions(sourceLocales);
865
- const sourceLocale = await searchSelectLocale(
866
- languageOptions,
867
- "Source language",
868
- "en"
869
- );
870
- if (sourceLocale === null) return null;
871
- let compatibleTargets;
359
+ process.stdout.write("\n");
360
+ }
361
+ function writeApiKeyToEnv(apiKey, repoRoot) {
362
+ const envPath = join(repoRoot ?? process.cwd(), ".env");
363
+ if (!existsSync(envPath)) return false;
872
364
  try {
873
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
365
+ const content = readFileSync(envPath, "utf-8");
366
+ const keyLine = `VOCODER_API_KEY=${apiKey}`;
367
+ let updated;
368
+ if (/^VOCODER_API_KEY=/m.test(content)) {
369
+ updated = content.replace(/^VOCODER_API_KEY=.*/m, keyLine);
370
+ } else {
371
+ const sep = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
372
+ updated = `${content}${sep}${keyLine}
373
+ `;
374
+ }
375
+ writeFileSync(envPath, updated);
376
+ return true;
874
377
  } catch {
875
- p3.log.error(
876
- "Failed to fetch compatible target locales. Check your connection and try again."
877
- );
878
- return null;
378
+ return false;
879
379
  }
880
- const targetOptions = buildLocaleOptions(compatibleTargets).filter(
881
- (opt) => opt.bcp47 !== sourceLocale
882
- );
883
- const targetLocales = await searchMultiSelectLocales(
884
- targetOptions,
885
- "Target languages"
886
- );
887
- if (targetLocales === null) return null;
888
- if (targetLocales.length === 0) {
889
- p3.log.warn(
890
- "No target languages selected \u2014 you can add them later from the dashboard."
891
- );
380
+ }
381
+ function printApiKey(apiKey, repoRoot) {
382
+ const saved = writeApiKeyToEnv(apiKey, repoRoot);
383
+ p2.log.message("");
384
+ p2.log.message(chalk2.bold("Your API Key"));
385
+ printCodeBlock(`VOCODER_API_KEY=${apiKey}`);
386
+ if (saved) {
387
+ p2.log.success(chalk2.dim("Saved to .env"));
388
+ } else {
389
+ p2.log.message(chalk2.dim(" Add the above to your .env file"));
892
390
  }
893
- const detectedApp = detectGitBranches();
894
- let appPushBranches = [];
895
- {
896
- let initial = [detectedApp.defaultBranch];
897
- while (appPushBranches.length === 0) {
898
- const result2 = await filterableBranchSelect({
899
- message: "Which branches should trigger translations?",
900
- branches: detectedApp.branches,
901
- defaultBranch: detectedApp.defaultBranch,
902
- initialValues: initial
903
- });
904
- if (result2 === null) return null;
905
- if (result2.length === 0) {
906
- p3.log.warn("At least one branch is required.");
907
- initial = [detectedApp.defaultBranch];
908
- } else {
909
- appPushBranches = result2;
910
- }
911
- }
391
+ }
392
+
393
+ // src/utils/scaffold.ts
394
+ import * as p3 from "@clack/prompts";
395
+ import chalk4 from "chalk";
396
+ import { execSync as execSync2 } from "child_process";
397
+ import { resolve } from "path";
398
+
399
+ // src/utils/theme.ts
400
+ import chalk3 from "chalk";
401
+ var ORANGE = "#FC5206";
402
+ var PINK = "#D51977";
403
+ var BLUE = "#2450A9";
404
+ var noColor = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
405
+ var hex = (color) => (s) => noColor ? s : chalk3.hex(color)(s);
406
+ var dim = (s) => noColor ? s : chalk3.dim(s);
407
+ var bld = (s) => noColor ? s : chalk3.bold(s);
408
+ var grn = (s) => noColor ? s : chalk3.green(s);
409
+ var ylw = (s) => noColor ? s : chalk3.yellow(s);
410
+ var red = (s) => noColor ? s : chalk3.red(s);
411
+ var highlight = hex(PINK);
412
+ var info = hex(BLUE);
413
+ var active = hex(ORANGE);
414
+
415
+ // src/utils/write-config.ts
416
+ import { existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
417
+ import { join as join2 } from "path";
418
+ function findExistingConfig(cwd = process.cwd()) {
419
+ for (const name of [
420
+ "vocoder.config.ts",
421
+ "vocoder.config.js",
422
+ "vocoder.config.json"
423
+ ]) {
424
+ const candidate = join2(cwd, name);
425
+ if (existsSync2(candidate)) return candidate;
912
426
  }
913
- const targetBranches = appPushBranches;
914
- const result = await api.createApp(userToken, {
915
- projectId,
916
- appDir,
917
- sourceLocale,
918
- targetLocales,
919
- targetBranches,
920
- repoCanonical: repoCanonical ?? ""
921
- });
922
- p3.log.success(
923
- `App ${chalk2.bold(appDir || "(root)")} added to ${chalk2.bold(projectName)}!`
924
- );
925
- return {
926
- projectId: result.projectId,
927
- projectName: result.projectName,
928
- appDir: result.appDir,
929
- appId: result.appId,
930
- sourceLocale,
931
- targetLocales,
932
- targetBranches
933
- };
427
+ return null;
934
428
  }
429
+ function writeVocoderConfig(options) {
430
+ const {
431
+ targetBranches = ["main"],
432
+ useTypeScript = true,
433
+ cwd = process.cwd(),
434
+ appId
435
+ } = options;
436
+ if (findExistingConfig(cwd)) return null;
437
+ const ext = useTypeScript ? "ts" : "js";
438
+ const configPath = join2(cwd, `vocoder.config.${ext}`);
439
+ const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
440
+ const includes = ["**/*.{tsx,jsx,ts,js}"];
441
+ const includesStr = includes.map((p21) => `'${p21}'`).join(", ");
442
+ const appIdLine = appId ? ` appId: '${appId}',
443
+ ` : "";
444
+ const content = `import { defineConfig } from '@vocoder/config'
935
445
 
936
- // src/utils/github-connect.ts
937
- import * as p4 from "@clack/prompts";
938
- import chalk3 from "chalk";
939
- import { spawn } from "child_process";
446
+ export default defineConfig({
447
+ ${appIdLine} targetBranches: [${branchesStr}],
448
+ include: [${includesStr}],
449
+ })
450
+ `;
451
+ try {
452
+ writeFileSync2(configPath, content, "utf-8");
453
+ return `vocoder.config.${ext}`;
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
940
458
 
941
- // src/utils/local-server.ts
942
- import { createServer } from "http";
943
- import { URL as URL2 } from "url";
944
- function startCallbackServer() {
945
- return new Promise((resolve3, reject) => {
946
- let settled = false;
947
- let callbackResolve = null;
948
- let callbackReject = null;
949
- const callbackPromise = new Promise((res, rej) => {
950
- callbackResolve = res;
951
- callbackReject = rej;
952
- });
953
- const server = createServer((req, res) => {
954
- if (!req.url) {
955
- res.writeHead(400);
956
- res.end();
957
- return;
958
- }
959
- let pathname;
960
- let params;
961
- try {
962
- const parsed = new URL2(req.url, "http://localhost");
963
- pathname = parsed.pathname;
964
- params = Object.fromEntries(parsed.searchParams.entries());
965
- } catch {
966
- res.writeHead(400);
967
- res.end("Bad request");
968
- return;
969
- }
970
- if (pathname !== "/callback") {
971
- res.writeHead(404);
972
- res.end("Not found");
973
- return;
974
- }
975
- res.writeHead(200, { "Content-Type": "text/html" });
976
- res.end(
977
- '<!DOCTYPE html><html><head><title>Authenticated</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;"><h2>Authenticated</h2><p>Return to your terminal to continue. You can close this tab.</p></body></html>'
978
- );
979
- if (callbackResolve) {
980
- callbackResolve(params);
981
- callbackResolve = null;
459
+ // src/utils/scaffold.ts
460
+ function runScaffold(params) {
461
+ const { targetBranches } = params;
462
+ const detection = detectLocalEcosystem();
463
+ if (detection.ecosystem) {
464
+ const frameworkLabel = detection.framework ?? detection.ecosystem;
465
+ const pmLabel = detection.packageManager;
466
+ p3.log.info(`Detected: ${chalk4.bold(frameworkLabel)} (${pmLabel})`);
467
+ }
468
+ const { devPackages, runtimePackages } = getPackagesToInstall(detection);
469
+ const allPackages = [...devPackages, ...runtimePackages];
470
+ if (allPackages.length > 0) {
471
+ p3.log.info("");
472
+ const installSpinner = p3.spinner();
473
+ installSpinner.start(`Installing ${allPackages.join(", ")}...`);
474
+ try {
475
+ if (devPackages.length > 0) {
476
+ execSync2(
477
+ buildInstallCommand(detection.packageManager, devPackages, true),
478
+ { stdio: "pipe", cwd: process.cwd() }
479
+ );
982
480
  }
983
- setImmediate(() => server.close());
984
- });
985
- server.on("error", (err) => {
986
- if (!settled) {
987
- settled = true;
988
- if (callbackReject) callbackReject(err);
989
- reject(err);
481
+ if (runtimePackages.length > 0) {
482
+ execSync2(
483
+ buildInstallCommand(detection.packageManager, runtimePackages, false),
484
+ { stdio: "pipe", cwd: process.cwd() }
485
+ );
990
486
  }
487
+ installSpinner.stop(`Installed ${allPackages.join(", ")}`);
488
+ } catch {
489
+ installSpinner.stop("Package installation failed");
490
+ const cmds = [
491
+ devPackages.length > 0 ? buildInstallCommand(detection.packageManager, devPackages, true) : null,
492
+ runtimePackages.length > 0 ? buildInstallCommand(detection.packageManager, runtimePackages, false) : null
493
+ ].filter(Boolean).join(" && ");
494
+ p3.log.warn(`Run manually: ${highlight(cmds)}`);
495
+ }
496
+ } else if (detection.ecosystem) {
497
+ p3.log.info(`Packages: ${chalk4.green("already installed")}`);
498
+ }
499
+ const branchList = targetBranches.length > 0 ? targetBranches.map((b) => highlight(b)).join(" or ") : highlight("your target branch");
500
+ p3.log.message("");
501
+ p3.log.success(`Push to ${branchList} to trigger your first translation run.`);
502
+ p3.log.message(info(" Docs: https://vocoder.app/docs/getting-started"));
503
+ }
504
+ function writeAppConfigs(apps, targetBranches, useTypeScript, repoRoot) {
505
+ const base = repoRoot ?? process.cwd();
506
+ for (const app of apps) {
507
+ const dir = app.appDir ? resolve(base, app.appDir) : base;
508
+ const written = writeVocoderConfig({
509
+ targetBranches,
510
+ appId: app.appId,
511
+ cwd: dir,
512
+ useTypeScript
991
513
  });
992
- server.listen(0, "127.0.0.1", () => {
993
- if (settled) return;
994
- settled = true;
995
- const port = server.address().port;
996
- resolve3({
997
- port,
998
- waitForCallback: () => callbackPromise,
999
- close: () => server.close()
1000
- });
1001
- });
1002
- });
514
+ if (written) {
515
+ const displayPath = app.appDir ? `${app.appDir}/${written}` : written;
516
+ p3.log.success(`Created ${highlight(displayPath)}`);
517
+ } else if (!findExistingConfig(dir)) {
518
+ const ext = useTypeScript ? "ts" : "js";
519
+ p3.log.warn(
520
+ `Could not write ${app.appDir ? `${app.appDir}/` : ""}vocoder.config.${ext} \u2014 create it manually.`
521
+ );
522
+ }
523
+ }
1003
524
  }
1004
525
 
1005
- // src/utils/github-connect.ts
1006
- async function tryOpenBrowser(url) {
1007
- if (!process.stdout.isTTY || process.env.CI === "true") {
1008
- return false;
1009
- }
1010
- const platform = process.platform;
1011
- let command;
1012
- let args;
1013
- if (platform === "darwin") {
1014
- command = "open";
1015
- args = [url];
1016
- } else if (platform === "win32") {
1017
- command = "rundll32";
1018
- args = ["url.dll,FileProtocolHandler", url];
1019
- } else {
1020
- command = "xdg-open";
1021
- args = [url];
1022
- }
1023
- return new Promise((resolve3) => {
526
+ // src/utils/mcp-setup.ts
527
+ import * as p4 from "@clack/prompts";
528
+ import chalk5 from "chalk";
529
+ import { execSync as execSync3 } from "child_process";
530
+ var MCP_DOCS_URL = "https://vocoder.app/docs/mcp";
531
+ function mcpServerJson(apiKey) {
532
+ return JSON.stringify(
533
+ {
534
+ mcpServers: {
535
+ vocoder: {
536
+ type: "stdio",
537
+ command: "npx",
538
+ args: ["-y", "@vocoder/mcp"],
539
+ env: { VOCODER_API_KEY: apiKey }
540
+ }
541
+ }
542
+ },
543
+ null,
544
+ 2
545
+ );
546
+ }
547
+ async function runMcpSetup(apiKey) {
548
+ const tool = await p4.select({
549
+ message: "Which AI editor?",
550
+ options: [
551
+ { value: "claude", label: "Claude Code" },
552
+ { value: "cursor", label: "Cursor" },
553
+ { value: "windsurf", label: "Windsurf" },
554
+ { value: "vscode", label: "VS Code (GitHub Copilot)" },
555
+ { value: "other", label: "Other \u2014 show the config JSON" }
556
+ ]
557
+ });
558
+ if (p4.isCancel(tool)) return;
559
+ if (tool === "claude") {
1024
560
  try {
1025
- const child = spawn(command, args, {
1026
- detached: true,
1027
- stdio: "ignore",
1028
- windowsHide: true
1029
- });
1030
- let settled = false;
1031
- child.once("spawn", () => {
1032
- if (settled) return;
1033
- settled = true;
1034
- child.unref();
1035
- resolve3(true);
1036
- });
1037
- child.once("error", () => {
1038
- if (settled) return;
1039
- settled = true;
1040
- resolve3(false);
1041
- });
1042
- setTimeout(() => {
1043
- if (settled) return;
1044
- settled = true;
1045
- resolve3(false);
1046
- }, 300);
561
+ execSync3(
562
+ `claude mcp add --scope user --transport stdio --env VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`,
563
+ { stdio: "pipe" }
564
+ );
565
+ p4.log.success("Vocoder MCP server registered in Claude Code.");
1047
566
  } catch {
1048
- resolve3(false);
567
+ p4.log.message("Run this to register the MCP server:");
568
+ printCommand(
569
+ `claude mcp add --scope user --transport stdio --env VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`
570
+ );
571
+ p4.log.message(info(` Docs: ${MCP_DOCS_URL}`));
572
+ }
573
+ return;
574
+ }
575
+ const configPaths = {
576
+ cursor: { path: "~/.cursor/mcp.json", merge: true },
577
+ windsurf: { path: "~/.codeium/windsurf/mcp_config.json", merge: true },
578
+ vscode: { path: ".vscode/mcp.json", merge: true },
579
+ other: { path: ".mcp.json", merge: false }
580
+ };
581
+ const { path: configPath, merge } = configPaths[tool];
582
+ const mergeNote = merge ? chalk5.dim(` Merge into ${configPath} (create if missing):`) : chalk5.dim(` Add to ${configPath}:`);
583
+ p4.log.message(mergeNote);
584
+ printCodeBlock(mcpServerJson(apiKey));
585
+ p4.log.message(info(` Docs: ${MCP_DOCS_URL}`));
586
+ }
587
+
588
+ // src/utils/plan-check.ts
589
+ import * as p5 from "@clack/prompts";
590
+ import chalk6 from "chalk";
591
+ var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
592
+ function getSubscriptionSettingsUrl(apiUrl) {
593
+ return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
594
+ }
595
+ function isPlanLimitFailure(message) {
596
+ if (!message) return false;
597
+ return /limit|upgrade/i.test(message);
598
+ }
599
+ function printPlanLimitMessage(apiUrl, message) {
600
+ p5.log.error(`You are over your plan limits.
601
+ ${message}`);
602
+ p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
603
+ }
604
+ async function checkPlanLimits(api, userToken, organizationId, apiUrl) {
605
+ try {
606
+ const { organizations } = await api.listOrganizations(userToken);
607
+ const organization = organizations.find((o) => o.id === organizationId);
608
+ if (!organization) {
609
+ return { atLimit: false };
610
+ }
611
+ if (organization.maxApps !== -1 && organization.appCount >= organization.maxApps) {
612
+ p5.log.warn(
613
+ `App limit reached \u2014 ${organization.appCount}/${organization.maxApps} on your ${chalk6.bold(organization.planId)} plan.`
614
+ );
615
+ const limitAction = await p5.select({
616
+ message: "What would you like to do?",
617
+ options: [
618
+ { value: "upgrade", label: "Upgrade plan" },
619
+ { value: "cancel", label: "Cancel" }
620
+ ]
621
+ });
622
+ if (p5.isCancel(limitAction) || limitAction === "cancel") {
623
+ p5.cancel("Setup cancelled.");
624
+ return { atLimit: true };
625
+ }
626
+ await tryOpenBrowser(getSubscriptionSettingsUrl(apiUrl));
627
+ p5.cancel("Upgrade your plan in the browser, then re-run `vocoder init`.");
628
+ return { atLimit: true };
1049
629
  }
1050
- });
630
+ const remaining = organization.maxApps === -1 ? void 0 : Math.max(0, organization.maxApps - organization.appCount);
631
+ return { atLimit: false, remaining };
632
+ } catch {
633
+ p5.log.warn(
634
+ "Could not verify plan limits \u2014 proceeding, the server will enforce them."
635
+ );
636
+ return { atLimit: false };
637
+ }
1051
638
  }
639
+
640
+ // src/utils/organization-select.ts
641
+ import * as p8 from "@clack/prompts";
642
+ import chalk9 from "chalk";
643
+
644
+ // src/utils/github-connect.ts
645
+ import * as p6 from "@clack/prompts";
646
+ import chalk7 from "chalk";
1052
647
  async function runGitHubInstallFlow(params) {
1053
648
  let server = null;
1054
649
  try {
@@ -1062,23 +657,23 @@ async function runGitHubInstallFlow(params) {
1062
657
  callbackPort: server?.port
1063
658
  }
1064
659
  );
1065
- p4.log.info("Opening GitHub to install the Vocoder App...");
660
+ p6.log.info("Opening GitHub to install the Vocoder App...");
1066
661
  if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1067
- const shouldOpen = params.yes ? true : await p4.confirm({ message: "Open in your browser?" });
1068
- if (p4.isCancel(shouldOpen)) {
662
+ const shouldOpen = params.yes ? true : await p6.confirm({ message: "Open in your browser?" });
663
+ if (p6.isCancel(shouldOpen)) {
1069
664
  server?.close();
1070
665
  return null;
1071
666
  }
1072
667
  if (shouldOpen) {
1073
668
  const opened = await tryOpenBrowser(installUrl);
1074
669
  if (!opened) {
1075
- p4.log.info(
670
+ p6.log.info(
1076
671
  "Could not open a browser automatically. Use the URL above."
1077
672
  );
1078
673
  }
1079
674
  }
1080
675
  }
1081
- const connectSpinner = p4.spinner();
676
+ const connectSpinner = p6.spinner();
1082
677
  connectSpinner.start("Waiting for GitHub App installation...");
1083
678
  if (server) {
1084
679
  try {
@@ -1092,24 +687,24 @@ async function runGitHubInstallFlow(params) {
1092
687
  server.close();
1093
688
  if (!callbackParams) {
1094
689
  connectSpinner.stop("GitHub App installation timed out");
1095
- p4.log.error(
690
+ p6.log.error(
1096
691
  "The installation flow timed out. Run `vocoder init` again."
1097
692
  );
1098
693
  return null;
1099
694
  }
1100
695
  if (callbackParams.error) {
1101
696
  connectSpinner.stop("GitHub App installation failed");
1102
- p4.log.error(callbackParams.error);
697
+ p6.log.error(callbackParams.error);
1103
698
  return null;
1104
699
  }
1105
700
  const { organizationId, connectionLabel, workspace_created } = callbackParams;
1106
701
  if (!organizationId || !connectionLabel) {
1107
702
  connectSpinner.stop("GitHub App installation incomplete");
1108
- p4.log.error("Missing organization or connection data from callback.");
703
+ p6.log.error("Missing organization or connection data from callback.");
1109
704
  return null;
1110
705
  }
1111
706
  connectSpinner.stop(
1112
- `Connected to GitHub as ${chalk3.bold(connectionLabel)}`
707
+ `Connected to GitHub as ${chalk7.bold(connectionLabel)}`
1113
708
  );
1114
709
  const orgName = workspace_created ? connectionLabel : organizationId;
1115
710
  return {
@@ -1124,7 +719,7 @@ async function runGitHubInstallFlow(params) {
1124
719
  }
1125
720
  }
1126
721
  connectSpinner.stop("Could not detect GitHub App installation automatically");
1127
- p4.log.warn(
722
+ p6.log.warn(
1128
723
  "Complete the installation in your browser, then run `vocoder init` again."
1129
724
  );
1130
725
  return null;
@@ -1139,21 +734,21 @@ async function runGitHubDiscoveryFlow(params) {
1139
734
  organizationId: params.organizationId,
1140
735
  callbackPort: server?.port
1141
736
  });
1142
- p4.log.info("Opening GitHub to authorize your account...");
737
+ p6.log.info("Opening GitHub to authorize your account...");
1143
738
  if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1144
- const shouldOpen = params.yes ? true : await p4.confirm({ message: "Open in your browser?" });
1145
- if (p4.isCancel(shouldOpen)) {
739
+ const shouldOpen = params.yes ? true : await p6.confirm({ message: "Open in your browser?" });
740
+ if (p6.isCancel(shouldOpen)) {
1146
741
  server?.close();
1147
742
  return null;
1148
743
  }
1149
744
  if (shouldOpen) {
1150
745
  const opened = await tryOpenBrowser(oauthUrl);
1151
746
  if (!opened) {
1152
- p4.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
747
+ p6.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
1153
748
  }
1154
749
  }
1155
750
  }
1156
- const oauthSpinner = p4.spinner();
751
+ const oauthSpinner = p6.spinner();
1157
752
  oauthSpinner.start("Waiting for GitHub authorization...");
1158
753
  if (server) {
1159
754
  try {
@@ -1171,7 +766,7 @@ async function runGitHubDiscoveryFlow(params) {
1171
766
  }
1172
767
  if (callbackParams.error) {
1173
768
  oauthSpinner.stop("GitHub authorization failed");
1174
- p4.log.error(callbackParams.error);
769
+ p6.log.error(callbackParams.error);
1175
770
  return null;
1176
771
  }
1177
772
  } catch {
@@ -1199,599 +794,1272 @@ async function selectGitHubInstallation(installations, canInstallNew) {
1199
794
  if (canInstallNew) {
1200
795
  options.push({
1201
796
  value: "install_new",
1202
- label: `Install on a new account ${chalk3.dim("(creates a new workspace)")}`
797
+ label: `Install on a new account ${chalk7.dim("(creates a new workspace)")}`
1203
798
  });
1204
799
  }
1205
- const selected = await p4.select({
800
+ const selected = await p6.select({
1206
801
  message: "Which GitHub account should this workspace connect to?",
1207
802
  options
1208
803
  });
1209
- if (p4.isCancel(selected)) return null;
1210
- if (selected === "install_new") return "install_new";
1211
- return Number(selected);
1212
- }
1213
-
1214
- // src/commands/init.ts
1215
- import chalk5 from "chalk";
1216
- import { config as loadEnv } from "dotenv";
1217
-
1218
- // src/utils/git-identity.ts
1219
- import { execSync as execSync2 } from "child_process";
1220
- var SHA_REGEX = /^[0-9a-f]{40}$/i;
1221
- function detectCommitSha() {
1222
- if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1223
- return process.env.VOCODER_COMMIT_SHA;
1224
- }
1225
- 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;
1226
- if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1227
- return safeExec("git rev-parse HEAD");
1228
- }
1229
- function safeExec(command) {
1230
- try {
1231
- const output = execSync2(command, {
1232
- encoding: "utf-8",
1233
- stdio: ["pipe", "pipe", "ignore"]
1234
- }).trim();
1235
- return output.length > 0 ? output : null;
1236
- } catch {
1237
- return null;
1238
- }
1239
- }
1240
- function normalizePath(pathname) {
1241
- const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1242
- if (!cleaned || !cleaned.includes("/")) {
1243
- return null;
1244
- }
1245
- return cleaned;
1246
- }
1247
- function parseRemoteUrl(remoteUrl) {
1248
- const trimmed = remoteUrl.trim();
1249
- if (!trimmed) {
1250
- return null;
1251
- }
1252
- if (!trimmed.includes("://")) {
1253
- const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1254
- if (scpMatch) {
1255
- const host = (scpMatch[1] || "").toLowerCase();
1256
- const ownerRepoPath = normalizePath(scpMatch[2] || "");
1257
- if (!host || !ownerRepoPath) {
1258
- return null;
804
+ if (p6.isCancel(selected)) return null;
805
+ if (selected === "install_new") return "install_new";
806
+ return Number(selected);
807
+ }
808
+
809
+ // src/utils/organization.ts
810
+ import * as p7 from "@clack/prompts";
811
+ import chalk8 from "chalk";
812
+ async function selectOrganization(result) {
813
+ const { organizations, canCreateOrganization } = result;
814
+ if (organizations.length === 0) {
815
+ return { action: "create" };
816
+ }
817
+ const options = organizations.map((org) => {
818
+ const atLimit = org.maxApps !== -1 && org.appCount >= org.maxApps;
819
+ const hint = [
820
+ org.projectCount > 0 ? `${org.projectCount} project${org.projectCount !== 1 ? "s" : ""}` : "",
821
+ org.connectionLabel ? `GitHub: ${org.connectionLabel}` : "",
822
+ atLimit ? chalk8.yellow(`${org.appCount}/${org.maxApps} apps \u2014 upgrade for more`) : ""
823
+ ].filter(Boolean).join(" \xB7 ") || void 0;
824
+ return { value: org.id, label: org.name, hint };
825
+ });
826
+ if (canCreateOrganization) {
827
+ options.push({ value: "create", label: "Create new workspace" });
828
+ }
829
+ const selected = await p7.select({
830
+ message: "Select workspace",
831
+ options
832
+ });
833
+ if (p7.isCancel(selected)) {
834
+ return { action: "cancelled" };
835
+ }
836
+ if (selected === "create") {
837
+ return { action: "create" };
838
+ }
839
+ const organization = organizations.find((org) => org.id === selected);
840
+ if (!organization) {
841
+ return { action: "cancelled" };
842
+ }
843
+ return { action: "use", organization };
844
+ }
845
+
846
+ // src/utils/organization-select.ts
847
+ async function selectOrganizationForInit(params) {
848
+ const { api, userToken, userEmail, identity, lookup, repoProjectId, options } = params;
849
+ if (params.authOrganizationId) {
850
+ const organizationData2 = await api.listOrganizations(userToken);
851
+ const organization = organizationData2.organizations.find(
852
+ (o) => o.id === params.authOrganizationId
853
+ );
854
+ const organizationName = organization?.name ?? userEmail;
855
+ p8.log.success(
856
+ `Connected as ${chalk9.bold(userEmail)} \u2014 workspace: ${chalk9.bold(organizationName)}`
857
+ );
858
+ return { organizationId: params.authOrganizationId, organizationName };
859
+ }
860
+ const repoOrgContext = identity ? lookup?.organizationContext ?? null : null;
861
+ if (repoOrgContext && !repoProjectId) {
862
+ p8.log.success(`Workspace: ${chalk9.bold(repoOrgContext.organizationName)}`);
863
+ return {
864
+ organizationId: repoOrgContext.organizationId,
865
+ organizationName: repoOrgContext.organizationName
866
+ };
867
+ }
868
+ const organizationData = await api.listOrganizations(userToken, {
869
+ repo: identity?.repoCanonical
870
+ });
871
+ const repoCanonical = identity?.repoCanonical ?? null;
872
+ const covering = repoCanonical ? organizationData.organizations.filter((o) => o.coversRepo === true) : [];
873
+ const connected = organizationData.organizations.filter(
874
+ (o) => o.hasGitHubConnection
875
+ );
876
+ if (repoCanonical && covering.length === 1) {
877
+ const organization = covering[0];
878
+ p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
879
+ return { organizationId: organization.id, organizationName: organization.name };
880
+ }
881
+ if (repoCanonical && covering.length > 1) {
882
+ const choice = await p8.select({
883
+ message: "Select workspace for this repo",
884
+ options: covering.map((o) => ({
885
+ value: o.id,
886
+ label: `${o.name} ${chalk9.dim(`(${o.appCount} app${o.appCount !== 1 ? "s" : ""})`)}`
887
+ }))
888
+ });
889
+ if (p8.isCancel(choice)) {
890
+ p8.cancel("Setup cancelled.");
891
+ return null;
892
+ }
893
+ const organization = covering.find((o) => o.id === choice);
894
+ p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
895
+ return { organizationId: organization.id, organizationName: organization.name };
896
+ }
897
+ if (repoCanonical && covering.length === 0 && connected.length > 0) {
898
+ const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
899
+ p8.log.warn(
900
+ `${chalk9.bold(shortRepo)} isn't accessible from your Vocoder installation.
901
+ Grant access to this repository or install on the account that owns it.`
902
+ );
903
+ const fixOptions = [];
904
+ for (const organization of connected) {
905
+ if (organization.installationConfigureUrl) {
906
+ fixOptions.push({
907
+ value: `grant:${organization.id}`,
908
+ label: `Configure ${chalk9.bold(organization.connectionLabel ?? organization.name)}'s GitHub App installation`
909
+ });
910
+ }
911
+ }
912
+ fixOptions.push({
913
+ value: "install_new",
914
+ label: `Install on a different GitHub account ${chalk9.dim("(creates a new personal workspace)")}`
915
+ });
916
+ fixOptions.push({ value: "cancel", label: "Cancel" });
917
+ const fix = await p8.select({
918
+ message: "How would you like to fix this?",
919
+ options: fixOptions
920
+ });
921
+ if (p8.isCancel(fix) || fix === "cancel") {
922
+ p8.cancel("Setup cancelled.");
923
+ return null;
924
+ }
925
+ if (fix.startsWith("grant:")) {
926
+ const organization = connected.find((o) => `grant:${o.id}` === fix);
927
+ await tryOpenBrowser(organization.installationConfigureUrl);
928
+ p8.cancel(
929
+ `Grant access to ${chalk9.bold(shortRepo)} in your browser,
930
+ then re-run ${chalk9.bold("vocoder init")}.`
931
+ );
932
+ return null;
933
+ }
934
+ const connectResult = await runGitHubInstallFlow({
935
+ api,
936
+ userToken,
937
+ yes: options.yes
938
+ });
939
+ if (!connectResult) {
940
+ p8.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
941
+ return null;
942
+ }
943
+ p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
944
+ return {
945
+ organizationId: connectResult.organizationId,
946
+ organizationName: connectResult.organizationName
947
+ };
948
+ }
949
+ const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
950
+ const cachedInstallations = discoveryResult?.installations ?? [];
951
+ if (cachedInstallations.length > 0) {
952
+ if (repoCanonical) {
953
+ const repoOwner = repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
954
+ if (repoOwner) {
955
+ const hasMatchingAccount = cachedInstallations.some(
956
+ (i) => i.accountLogin.toLowerCase() === repoOwner
957
+ );
958
+ if (!hasMatchingAccount) {
959
+ p8.log.warn(
960
+ `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
961
+ The project will be created but translations won't trigger automatically.
962
+ To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
963
+ );
964
+ }
965
+ }
966
+ }
967
+ const validInstallations = cachedInstallations.filter(
968
+ (i) => !i.isSuspended && !i.conflictLabel
969
+ );
970
+ let selectedInstallationId2 = null;
971
+ if (validInstallations.length === 1 && cachedInstallations.length === 1) {
972
+ selectedInstallationId2 = validInstallations[0].installationId;
973
+ } else {
974
+ selectedInstallationId2 = await selectGitHubInstallation(
975
+ cachedInstallations.map((inst) => ({
976
+ installationId: inst.installationId,
977
+ accountLogin: inst.accountLogin,
978
+ accountType: inst.accountType,
979
+ isSuspended: inst.isSuspended,
980
+ conflictLabel: inst.conflictLabel
981
+ })),
982
+ false
983
+ );
984
+ }
985
+ if (selectedInstallationId2 === null || selectedInstallationId2 === "install_new") {
986
+ p8.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
987
+ return null;
988
+ }
989
+ const claimResult2 = await api.claimCliGitHubInstallation(userToken, {
990
+ installationId: String(selectedInstallationId2),
991
+ organizationId: null
992
+ });
993
+ p8.log.success(`Workspace: ${chalk9.bold(claimResult2.organizationName)}`);
994
+ return {
995
+ organizationId: claimResult2.organizationId,
996
+ organizationName: claimResult2.organizationName
997
+ };
998
+ }
999
+ if (organizationData.organizations.length === 1 && !organizationData.canCreateOrganization) {
1000
+ const organization = organizationData.organizations[0];
1001
+ p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
1002
+ return { organizationId: organization.id, organizationName: organization.name };
1003
+ }
1004
+ const organizationResult = await selectOrganization(organizationData);
1005
+ if (organizationResult.action === "cancelled") {
1006
+ p8.cancel("Setup cancelled.");
1007
+ return null;
1008
+ }
1009
+ if (organizationResult.action === "use") {
1010
+ const { organization } = organizationResult;
1011
+ p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
1012
+ return { organizationId: organization.id, organizationName: organization.name };
1013
+ }
1014
+ const connectChoice = await p8.select({
1015
+ message: "Connect your new workspace to GitHub",
1016
+ options: [
1017
+ { value: "install", label: "Install the Vocoder GitHub App" },
1018
+ { value: "link", label: "Link an existing installation" }
1019
+ ]
1020
+ });
1021
+ if (p8.isCancel(connectChoice)) {
1022
+ p8.cancel("Setup cancelled.");
1023
+ return null;
1024
+ }
1025
+ if (connectChoice === "install") {
1026
+ const connectResult = await runGitHubInstallFlow({
1027
+ api,
1028
+ userToken,
1029
+ yes: options.yes
1030
+ });
1031
+ if (!connectResult) {
1032
+ p8.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1033
+ return null;
1034
+ }
1035
+ p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
1036
+ return {
1037
+ organizationId: connectResult.organizationId,
1038
+ organizationName: connectResult.organizationName
1039
+ };
1040
+ }
1041
+ const installations = await runGitHubDiscoveryFlow({
1042
+ api,
1043
+ userToken,
1044
+ yes: options.yes
1045
+ });
1046
+ if (!installations) return null;
1047
+ if (installations.length === 0) {
1048
+ p8.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
1049
+ const installNow = await p8.confirm({
1050
+ message: "Open GitHub to install the App?"
1051
+ });
1052
+ if (p8.isCancel(installNow) || !installNow) return null;
1053
+ const connectResult = await runGitHubInstallFlow({
1054
+ api,
1055
+ userToken,
1056
+ yes: options.yes
1057
+ });
1058
+ if (!connectResult) return null;
1059
+ p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
1060
+ return {
1061
+ organizationId: connectResult.organizationId,
1062
+ organizationName: connectResult.organizationName
1063
+ };
1064
+ }
1065
+ const selectedInstallationId = await selectGitHubInstallation(
1066
+ installations.map((inst) => ({
1067
+ installationId: inst.installationId,
1068
+ accountLogin: inst.accountLogin,
1069
+ accountType: inst.accountType,
1070
+ isSuspended: inst.isSuspended,
1071
+ conflictLabel: inst.conflictLabel
1072
+ })),
1073
+ true
1074
+ );
1075
+ if (selectedInstallationId === null) {
1076
+ p8.cancel("Setup cancelled.");
1077
+ return null;
1078
+ }
1079
+ if (selectedInstallationId === "install_new") {
1080
+ const connectResult = await runGitHubInstallFlow({
1081
+ api,
1082
+ userToken,
1083
+ yes: options.yes
1084
+ });
1085
+ if (!connectResult) return null;
1086
+ p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
1087
+ return {
1088
+ organizationId: connectResult.organizationId,
1089
+ organizationName: connectResult.organizationName
1090
+ };
1091
+ }
1092
+ const claimResult = await api.claimCliGitHubInstallation(userToken, {
1093
+ installationId: String(selectedInstallationId),
1094
+ organizationId: null
1095
+ });
1096
+ p8.log.success(`Workspace: ${chalk9.bold(claimResult.organizationName)}`);
1097
+ return {
1098
+ organizationId: claimResult.organizationId,
1099
+ organizationName: claimResult.organizationName
1100
+ };
1101
+ }
1102
+
1103
+ // src/utils/project-create.ts
1104
+ import * as p11 from "@clack/prompts";
1105
+ import chalk10 from "chalk";
1106
+
1107
+ // src/utils/app-dir-select.ts
1108
+ import { existsSync as existsSync3, statSync } from "fs";
1109
+ import { resolve as resolve2 } from "path";
1110
+ import { isCancel as isCancel7, Prompt } from "@clack/core";
1111
+ import * as p9 from "@clack/prompts";
1112
+ var S_BAR = "\u2502";
1113
+ var S_BAR_END = "\u2514";
1114
+ var S_ACTIVE = "\u25C6";
1115
+ var S_SUBMIT = "\u25C6";
1116
+ var S_CANCEL = "\u25A0";
1117
+ var S_ERROR = "\u25B2";
1118
+ function symbol(state) {
1119
+ switch (state) {
1120
+ case "submit":
1121
+ return grn(S_SUBMIT);
1122
+ case "cancel":
1123
+ return red(S_CANCEL);
1124
+ case "error":
1125
+ return ylw(S_ERROR);
1126
+ default:
1127
+ return active(S_ACTIVE);
1128
+ }
1129
+ }
1130
+ function validateAppDirPath(val, existing, opts = {}) {
1131
+ if (val.startsWith("/")) return "Must be a relative path (e.g. apps/web)";
1132
+ if (val.includes("..")) return "Path traversal not allowed";
1133
+ const hasWholeRepo = existing.includes("");
1134
+ const hasScoped = existing.some((d) => d !== "");
1135
+ if (val === "" && hasScoped) return "Cannot add whole-repo scope to a monorepo project";
1136
+ if (val !== "" && hasWholeRepo) return "Cannot add a scoped directory to a whole-repo project";
1137
+ if (existing.includes(val)) return `Already added: ${val}`;
1138
+ const nested = existing.find(
1139
+ (d) => d !== "" && (val.startsWith(d + "/") || d.startsWith(val + "/"))
1140
+ );
1141
+ if (nested) return `"${val}" overlaps with already-added "${nested}"`;
1142
+ if (val !== "") {
1143
+ const abs = resolve2(opts.cwd ?? process.cwd(), val);
1144
+ if (!existsSync3(abs)) return `Directory not found: ${val}`;
1145
+ if (!statSync(abs).isDirectory()) return `Not a directory: ${val}`;
1146
+ }
1147
+ return null;
1148
+ }
1149
+ async function collectAppDirs(opts = {}) {
1150
+ const added = [];
1151
+ let filter = "";
1152
+ let cursor = 0;
1153
+ let addCursor = false;
1154
+ const isNewDir = () => {
1155
+ const t = filter.trim();
1156
+ return t.length > 0 && !added.includes(t);
1157
+ };
1158
+ const clampCursor = () => {
1159
+ const max = added.length - 1;
1160
+ if (cursor > max) cursor = Math.max(0, max);
1161
+ };
1162
+ const prompt = new Prompt(
1163
+ {
1164
+ validate() {
1165
+ return void 0;
1166
+ },
1167
+ render() {
1168
+ const trimmed = filter.trim();
1169
+ const hdr = `${dim(S_BAR)}
1170
+ ${symbol(this.state)} App directories
1171
+ `;
1172
+ switch (this.state) {
1173
+ case "submit": {
1174
+ const summary = added.length > 0 ? bld(added.join(", ")) : dim("none (single-app project)");
1175
+ return `${hdr}${dim(S_BAR)} ${summary}`;
1176
+ }
1177
+ case "cancel":
1178
+ return `${hdr}${dim(S_BAR)}`;
1179
+ default: {
1180
+ const inputHint = filter.length > 0 ? filter : added.length === 0 ? dim("e.g. apps/web") : dim("e.g. apps/api");
1181
+ const lines = [
1182
+ hdr.trimEnd(),
1183
+ `${info(S_BAR)} ${dim("/")} ${inputHint}`,
1184
+ info(S_BAR)
1185
+ ];
1186
+ for (let i = 0; i < added.length; i++) {
1187
+ const isCursor = i === cursor && !addCursor;
1188
+ const icon = active("\u25FC");
1189
+ const label = isCursor ? bld(added[i]) : added[i];
1190
+ lines.push(`${info(S_BAR)} ${icon} ${label}`);
1191
+ }
1192
+ const atLimit = opts.maxDirs !== void 0 && added.length >= opts.maxDirs;
1193
+ if (atLimit) {
1194
+ lines.push(`${info(S_BAR)} ${dim(`App limit reached (${added.length}/${opts.maxDirs} on your plan)`)}`);
1195
+ } else if (isNewDir()) {
1196
+ const err = validateAppDirPath(trimmed, added, opts);
1197
+ const icon = addCursor ? active("\u25FB") : dim("\u25FB");
1198
+ const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}"`;
1199
+ lines.push(`${info(S_BAR)} ${icon} ${label}`);
1200
+ }
1201
+ lines.push(info(S_BAR));
1202
+ if (atLimit) {
1203
+ lines.push(dim(`${S_BAR} \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
1204
+ } else if (added.length === 0 && !isNewDir()) {
1205
+ lines.push(dim(`${S_BAR} Monorepo? Type each app's subdirectory path and press Space.`));
1206
+ lines.push(dim(`${S_BAR} Single app? Press Enter to skip this step.`));
1207
+ } else if (added.length > 0) {
1208
+ lines.push(dim(`${S_BAR} ${added.length} added \xB7 \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
1209
+ }
1210
+ const barEnd = this.state === "error" ? ylw(S_BAR_END) : info(S_BAR_END);
1211
+ if (this.state === "error") {
1212
+ lines.push(`${ylw(S_BAR_END)} ${ylw(this.error)}`);
1213
+ } else {
1214
+ lines.push(barEnd);
1215
+ }
1216
+ lines.push("");
1217
+ return lines.join("\n");
1218
+ }
1219
+ }
1220
+ }
1221
+ },
1222
+ false
1223
+ );
1224
+ prompt.on("key", (key) => {
1225
+ if (!key || key === " ") return;
1226
+ const cp = key.codePointAt(0) ?? 0;
1227
+ if (cp === 127 || cp === 8) {
1228
+ filter = filter.slice(0, -1);
1229
+ addCursor = false;
1230
+ } else if (cp >= 32 && cp !== 127) {
1231
+ filter += key;
1232
+ cursor = 0;
1233
+ addCursor = false;
1234
+ }
1235
+ });
1236
+ prompt.on("cursor", (action) => {
1237
+ switch (action) {
1238
+ case "up":
1239
+ if (addCursor) {
1240
+ addCursor = false;
1241
+ cursor = Math.max(0, added.length - 1);
1242
+ } else {
1243
+ cursor = Math.max(0, cursor - 1);
1244
+ }
1245
+ break;
1246
+ case "down":
1247
+ if (!addCursor && cursor >= added.length - 1 && isNewDir()) {
1248
+ addCursor = true;
1249
+ } else if (!addCursor) {
1250
+ cursor = Math.min(added.length - 1, cursor + 1);
1251
+ }
1252
+ break;
1253
+ case "space": {
1254
+ if (addCursor || filter.trim().length > 0 && isNewDir()) {
1255
+ if (opts.maxDirs !== void 0 && added.length >= opts.maxDirs) break;
1256
+ const trimmed = filter.trim();
1257
+ const err = validateAppDirPath(trimmed, added, opts);
1258
+ if (!err) {
1259
+ added.push(trimmed);
1260
+ filter = "";
1261
+ addCursor = false;
1262
+ cursor = 0;
1263
+ }
1264
+ } else if (added.length > 0 && !isNewDir()) {
1265
+ clampCursor();
1266
+ added.splice(cursor, 1);
1267
+ if (cursor >= added.length) cursor = Math.max(0, added.length - 1);
1268
+ }
1269
+ break;
1259
1270
  }
1260
- return { host, ownerRepoPath };
1261
1271
  }
1262
- return null;
1263
- }
1264
- try {
1265
- const parsed = new URL(trimmed);
1266
- const host = parsed.hostname.toLowerCase();
1267
- const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
1268
- if (!host || !ownerRepoPath) {
1269
- return null;
1272
+ });
1273
+ prompt.on("finalize", () => {
1274
+ if (prompt.state === "submit") {
1275
+ prompt.value = [...added];
1270
1276
  }
1271
- return { host, ownerRepoPath };
1272
- } catch {
1273
- return null;
1274
- }
1275
- }
1276
- function toCanonical(host, ownerRepoPath) {
1277
- if (host.includes("github.com")) {
1278
- return `github:${ownerRepoPath.toLowerCase()}`;
1279
- }
1280
- if (host.includes("gitlab.com")) {
1281
- return `gitlab:${ownerRepoPath.toLowerCase()}`;
1282
- }
1283
- if (host.includes("bitbucket.org")) {
1284
- return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1285
- }
1286
- return `git:${host}/${ownerRepoPath.toLowerCase()}`;
1287
- }
1288
- function resolveGitRepositoryIdentity() {
1289
- const remoteUrl = safeExec("git config --get remote.origin.url");
1290
- if (!remoteUrl) {
1291
- return null;
1292
- }
1293
- const parsed = parseRemoteUrl(remoteUrl);
1294
- if (!parsed) {
1295
- return null;
1296
- }
1297
- const repoRoot = safeExec("git rev-parse --show-toplevel");
1298
- if (!repoRoot) {
1299
- return null;
1300
- }
1301
- return {
1302
- repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
1303
- repoRoot
1304
- };
1305
- }
1306
- function resolveGitContext() {
1307
- const warnings = [];
1308
- const identity = resolveGitRepositoryIdentity();
1309
- if (!identity) {
1310
- warnings.push(
1311
- "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
1312
- );
1313
- }
1314
- return { identity, warnings };
1277
+ });
1278
+ const result = await prompt.prompt();
1279
+ if (isCancel7(result)) return null;
1280
+ return result;
1315
1281
  }
1316
-
1317
- // src/utils/organization.ts
1318
- import * as p5 from "@clack/prompts";
1319
- import chalk4 from "chalk";
1320
- async function selectOrganization(result) {
1321
- const { organizations, canCreateOrganization } = result;
1322
- if (organizations.length === 0) {
1323
- return { action: "create" };
1324
- }
1325
- const options = organizations.map((org) => ({
1326
- value: org.id,
1327
- label: org.name,
1328
- hint: [
1329
- org.projectCount > 0 ? `${org.projectCount} project${org.projectCount !== 1 ? "s" : ""}` : "",
1330
- org.connectionLabel ? `GitHub: ${org.connectionLabel}` : ""
1331
- ].filter(Boolean).join(" \xB7 ") || void 0
1332
- }));
1333
- if (canCreateOrganization) {
1334
- options.push({ value: "create", label: "Create new workspace" });
1335
- }
1336
- const selected = await p5.select({
1337
- message: "Select workspace",
1338
- options
1282
+ async function promptSingleAppDir(params) {
1283
+ const { existingDirs, cwd } = params;
1284
+ const input = await p9.text({
1285
+ message: "App directory to add",
1286
+ placeholder: "apps/web",
1287
+ validate(val) {
1288
+ const err = validateAppDirPath(val ?? "", existingDirs, { cwd });
1289
+ if (err) return err;
1290
+ if (!val) return "Directory is required";
1291
+ return void 0;
1292
+ }
1339
1293
  });
1340
- if (p5.isCancel(selected)) {
1341
- return { action: "cancelled" };
1342
- }
1343
- if (selected === "create") {
1344
- return { action: "create" };
1345
- }
1346
- const organization = organizations.find((org) => org.id === selected);
1347
- if (!organization) {
1348
- return { action: "cancelled" };
1349
- }
1350
- return { action: "use", organization };
1294
+ if (p9.isCancel(input)) return null;
1295
+ return input;
1351
1296
  }
1352
1297
 
1353
- // src/commands/init.ts
1354
- loadEnv();
1355
- var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
1356
- async function sleep(ms) {
1357
- await new Promise((resolve3) => setTimeout(resolve3, ms));
1358
- }
1359
- async function tryOpenBrowser2(url) {
1360
- if (!process.stdout.isTTY || process.env.CI === "true") {
1361
- return false;
1362
- }
1363
- let command;
1364
- let args;
1365
- if (process.platform === "darwin") {
1366
- command = "open";
1367
- args = [url];
1368
- } else if (process.platform === "win32") {
1369
- command = "rundll32";
1370
- args = ["url.dll,FileProtocolHandler", url];
1371
- } else {
1372
- command = "xdg-open";
1373
- args = [url];
1298
+ // src/utils/branch-select.ts
1299
+ import { execSync as execSync4 } from "child_process";
1300
+ import { isCancel as isCancel9, Prompt as Prompt2 } from "@clack/core";
1301
+ var S_BAR2 = "\u2502";
1302
+ var S_BAR_END2 = "\u2514";
1303
+ var S_ACTIVE2 = "\u25C6";
1304
+ var S_SUBMIT2 = "\u25C6";
1305
+ var S_CANCEL2 = "\u25A0";
1306
+ var S_ERROR2 = "\u25B2";
1307
+ function symbol2(state) {
1308
+ switch (state) {
1309
+ case "submit":
1310
+ return grn(S_SUBMIT2);
1311
+ case "cancel":
1312
+ return red(S_CANCEL2);
1313
+ case "error":
1314
+ return ylw(S_ERROR2);
1315
+ default:
1316
+ return active(S_ACTIVE2);
1374
1317
  }
1375
- return await new Promise((resolve3) => {
1376
- try {
1377
- const child = spawn2(command, args, {
1378
- detached: true,
1379
- stdio: "ignore",
1380
- windowsHide: true
1381
- });
1382
- let settled = false;
1383
- child.once("spawn", () => {
1384
- if (settled) return;
1385
- settled = true;
1386
- child.unref();
1387
- resolve3(true);
1388
- });
1389
- child.once("error", () => {
1390
- if (settled) return;
1391
- settled = true;
1392
- resolve3(false);
1393
- });
1394
- setTimeout(() => {
1395
- if (settled) return;
1396
- settled = true;
1397
- resolve3(false);
1398
- }, 300);
1318
+ }
1319
+ function detectGitBranches(cwd) {
1320
+ const workDir = cwd ?? process.cwd();
1321
+ try {
1322
+ const localOut = execSync4("git branch", {
1323
+ cwd: workDir,
1324
+ stdio: "pipe"
1325
+ }).toString();
1326
+ const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1327
+ let remoteBranches = [];
1328
+ try {
1329
+ const remoteOut = execSync4("git branch -r", {
1330
+ cwd: workDir,
1331
+ stdio: "pipe"
1332
+ }).toString();
1333
+ remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
1399
1334
  } catch {
1400
- resolve3(false);
1401
1335
  }
1402
- });
1336
+ const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1337
+ let defaultBranch = "main";
1338
+ try {
1339
+ const ref = execSync4("git symbolic-ref refs/remotes/origin/HEAD", {
1340
+ cwd: workDir,
1341
+ stdio: "pipe"
1342
+ }).toString().trim();
1343
+ defaultBranch = ref.split("/").pop() ?? "main";
1344
+ } catch {
1345
+ }
1346
+ return {
1347
+ branches: branches.length > 0 ? branches : [defaultBranch],
1348
+ defaultBranch
1349
+ };
1350
+ } catch {
1351
+ return { branches: ["main"], defaultBranch: "main" };
1352
+ }
1403
1353
  }
1404
- function isPlanLimitFailure(message) {
1405
- if (!message) return false;
1406
- return /limit|upgrade/i.test(message);
1354
+ var INVALID_CHARS = /[\s?^~:[\]\\]/;
1355
+ function validateBranchPattern(pattern) {
1356
+ const t = pattern.trim();
1357
+ if (!t) return "Pattern cannot be empty";
1358
+ if (INVALID_CHARS.test(t))
1359
+ return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
1360
+ if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
1361
+ if (t.includes("//")) return "Cannot contain //";
1362
+ return null;
1407
1363
  }
1408
- function getSubscriptionSettingsUrl(apiUrl) {
1409
- return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
1364
+ var MAX_VISIBLE = 10;
1365
+ function buildItems(branches, defaultBranch, customPatterns) {
1366
+ const items = branches.map((b) => ({
1367
+ value: b,
1368
+ label: b === defaultBranch ? `${b} (default branch)` : b
1369
+ }));
1370
+ for (const pt of customPatterns) {
1371
+ if (!branches.includes(pt)) {
1372
+ items.push({ value: pt, label: pt, isCustom: true });
1373
+ }
1374
+ }
1375
+ return items;
1410
1376
  }
1411
- function printPlanLimitMessage(apiUrl, message) {
1412
- p6.log.error(`You are over your plan limits.
1413
- ${message}`);
1414
- p6.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
1377
+ function filterItems(items, query) {
1378
+ if (!query.trim()) return items;
1379
+ const lower = query.toLowerCase();
1380
+ return items.filter((i) => i.value.toLowerCase().includes(lower));
1415
1381
  }
1416
- function runScaffold(params) {
1417
- const { targetBranches } = params;
1418
- const detection = detectLocalEcosystem();
1419
- const useTypeScript = detection.isTypeScript;
1420
- if (detection.ecosystem) {
1421
- const frameworkLabel = detection.framework ?? detection.ecosystem;
1422
- const pmLabel = detection.packageManager;
1423
- p6.log.info(`Detected: ${chalk5.bold(frameworkLabel)} (${pmLabel})`);
1382
+ function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedPatterns = /* @__PURE__ */ new Set()) {
1383
+ const lines = [info(S_BAR2)];
1384
+ const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
1385
+ for (let i = scrollOffset; i < end; i++) {
1386
+ const item = filtered[i];
1387
+ const isCursor = i === cursor && !addCursor;
1388
+ const isChecked = selected.has(item.value);
1389
+ const icon = isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB");
1390
+ let label = item.isCustom ? `${item.label} ${dim("(custom)")}` : item.label;
1391
+ if (isCursor) label = bld(label);
1392
+ lines.push(`${info(S_BAR2)} ${icon} ${label}`);
1424
1393
  }
1425
- const { devPackages, runtimePackages } = getPackagesToInstall(detection);
1426
- const allPackages = [...devPackages, ...runtimePackages];
1427
- if (allPackages.length > 0) {
1428
- p6.log.info("");
1429
- const installSpinner = p6.spinner();
1430
- installSpinner.start(`Installing ${allPackages.join(", ")}...`);
1431
- try {
1432
- if (devPackages.length > 0) {
1433
- execSync3(
1434
- buildInstallCommand(detection.packageManager, devPackages, true),
1435
- { stdio: "pipe", cwd: process.cwd() }
1436
- );
1437
- }
1438
- if (runtimePackages.length > 0) {
1439
- execSync3(
1440
- buildInstallCommand(detection.packageManager, runtimePackages, false),
1441
- { stdio: "pipe", cwd: process.cwd() }
1442
- );
1443
- }
1444
- installSpinner.stop(`Installed ${allPackages.join(", ")}`);
1445
- } catch {
1446
- installSpinner.stop("Package installation failed");
1447
- const cmds = [
1448
- devPackages.length > 0 ? buildInstallCommand(detection.packageManager, devPackages, true) : null,
1449
- runtimePackages.length > 0 ? buildInstallCommand(
1450
- detection.packageManager,
1451
- runtimePackages,
1452
- false
1453
- ) : null
1454
- ].filter(Boolean).join(" && ");
1455
- p6.log.warn(`Run manually: ${highlight(cmds)}`);
1456
- }
1457
- } else if (detection.ecosystem) {
1458
- p6.log.info(`Packages: ${chalk5.green("already installed")}`);
1394
+ const trimmed = filter.trim();
1395
+ const isNewPattern = trimmed.length > 0 && !filtered.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
1396
+ if (isNewPattern) {
1397
+ const err = validateBranchPattern(trimmed) ?? (excludedPatterns.has(trimmed) ? "Already used for automatic translation" : null);
1398
+ const icon = addCursor ? active("\u25FB") : dim("\u25FB");
1399
+ const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}" as branch pattern`;
1400
+ lines.push(`${info(S_BAR2)} ${icon} ${label}`);
1401
+ } else if (filtered.length === 0 && trimmed.length === 0) {
1402
+ lines.push(dim(`${S_BAR2} No branches detected`));
1459
1403
  }
1460
- const branchList = targetBranches.length > 0 ? targetBranches.map((b) => highlight(b)).join(" or ") : highlight("your target branch");
1461
- p6.log.message("");
1462
- p6.log.success(`Push to ${branchList} to trigger your first translation run.`);
1463
- p6.log.message(info(" Docs: https://vocoder.app/docs/getting-started"));
1404
+ const hidden = filtered.length - (end - scrollOffset);
1405
+ if (hidden > 0) lines.push(dim(`${S_BAR2} ${hidden} more \u2014 keep typing to narrow`));
1406
+ return lines.join("\n");
1464
1407
  }
1465
- function writeApiKeyToEnv(apiKey, repoRoot) {
1466
- const envPath = join2(repoRoot ?? process.cwd(), ".env");
1467
- if (!existsSync3(envPath)) return false;
1468
- try {
1469
- const content = readFileSync(envPath, "utf-8");
1470
- const keyLine = `VOCODER_API_KEY=${apiKey}`;
1471
- let updated;
1472
- if (/^VOCODER_API_KEY=/m.test(content)) {
1473
- updated = content.replace(/^VOCODER_API_KEY=.*/m, keyLine);
1474
- } else {
1475
- const sep = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
1476
- updated = `${content}${sep}${keyLine}
1408
+ async function filterableBranchSelect(params) {
1409
+ const { message, branches, defaultBranch } = params;
1410
+ const optional = params.optional ?? false;
1411
+ const excludedSet = new Set(params.excludedPatterns ?? []);
1412
+ let filter = "";
1413
+ let cursor = 0;
1414
+ let scrollOffset = 0;
1415
+ let addCursor = false;
1416
+ const customPatterns = [];
1417
+ const selected = new Set(params.initialValues ?? [defaultBranch]);
1418
+ const getItems = () => buildItems(branches, defaultBranch, customPatterns);
1419
+ const getFiltered = () => filterItems(getItems(), filter);
1420
+ const isNewPattern = () => {
1421
+ const t = filter.trim();
1422
+ if (!t) return false;
1423
+ return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
1424
+ };
1425
+ const clampCursor = (filtered) => {
1426
+ const hasAdd = isNewPattern();
1427
+ const max = filtered.length - 1 + (hasAdd ? 1 : 0);
1428
+ if (cursor > max && !addCursor) cursor = Math.max(0, max);
1429
+ if (!addCursor) {
1430
+ if (cursor < scrollOffset) scrollOffset = cursor;
1431
+ if (cursor >= scrollOffset + MAX_VISIBLE)
1432
+ scrollOffset = cursor - MAX_VISIBLE + 1;
1433
+ if (scrollOffset < 0) scrollOffset = 0;
1434
+ }
1435
+ };
1436
+ const prompt = new Prompt2(
1437
+ {
1438
+ validate() {
1439
+ if (!optional && selected.size === 0)
1440
+ return "At least one branch is required.";
1441
+ return void 0;
1442
+ },
1443
+ render() {
1444
+ const filtered = getFiltered();
1445
+ clampCursor(filtered);
1446
+ const hdr = `${dim(S_BAR2)}
1447
+ ${symbol2(this.state)} ${message}
1477
1448
  `;
1449
+ const inputHint = filter.length > 0 ? filter : dim("type to filter \xB7 type a custom pattern to add it");
1450
+ const trimmedFilter = filter.trim();
1451
+ const footer = (() => {
1452
+ if (trimmedFilter.length > 0 && isNewPattern()) {
1453
+ return dim(`${S_BAR2} Space to add "${trimmedFilter}" \xB7 \u2191\u2193 navigate \xB7 Enter to confirm`);
1454
+ }
1455
+ if (selected.size > 0) {
1456
+ return dim(`${S_BAR2} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
1457
+ }
1458
+ return optional ? dim(`${S_BAR2} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to skip`) : dim(`${S_BAR2} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
1459
+ })();
1460
+ switch (this.state) {
1461
+ case "submit": {
1462
+ const summary = selected.size > 0 ? bld(Array.from(selected).join(", ")) : dim("none");
1463
+ return `${hdr}${dim(S_BAR2)} ${summary}`;
1464
+ }
1465
+ case "cancel":
1466
+ return `${hdr}${dim(S_BAR2)}`;
1467
+ case "error":
1468
+ return [
1469
+ hdr.trimEnd(),
1470
+ `${ylw(S_BAR2)} ${dim("/")} ${inputHint}`,
1471
+ buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
1472
+ footer,
1473
+ `${ylw(S_BAR_END2)} ${ylw(this.error)}`,
1474
+ ""
1475
+ ].join("\n");
1476
+ default:
1477
+ return [
1478
+ hdr.trimEnd(),
1479
+ `${info(S_BAR2)} ${dim("/")} ${inputHint}`,
1480
+ buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
1481
+ footer,
1482
+ `${info(S_BAR_END2)}`,
1483
+ ""
1484
+ ].join("\n");
1485
+ }
1486
+ }
1487
+ },
1488
+ false
1489
+ );
1490
+ prompt.on("key", (key) => {
1491
+ if (!key || key === " ") return;
1492
+ const cp = key.codePointAt(0) ?? 0;
1493
+ if (cp === 127 || cp === 8) {
1494
+ filter = filter.slice(0, -1);
1495
+ cursor = 0;
1496
+ scrollOffset = 0;
1497
+ addCursor = false;
1498
+ } else if (cp >= 32 && cp !== 127) {
1499
+ filter += key;
1500
+ cursor = 0;
1501
+ scrollOffset = 0;
1502
+ addCursor = false;
1503
+ }
1504
+ if (isNewPattern()) {
1505
+ addCursor = true;
1506
+ }
1507
+ });
1508
+ prompt.on("cursor", (action) => {
1509
+ const filtered = getFiltered();
1510
+ const hasAdd = isNewPattern();
1511
+ switch (action) {
1512
+ case "up":
1513
+ if (addCursor) {
1514
+ addCursor = false;
1515
+ cursor = Math.max(0, filtered.length - 1);
1516
+ } else cursor = Math.max(0, cursor - 1);
1517
+ break;
1518
+ case "down":
1519
+ if (!addCursor && cursor >= filtered.length - 1 && hasAdd)
1520
+ addCursor = true;
1521
+ else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
1522
+ break;
1523
+ case "space": {
1524
+ const t = filter.trim();
1525
+ if (addCursor || t.length > 0 && isNewPattern()) {
1526
+ const err = validateBranchPattern(t) ?? (excludedSet.has(t) ? "Already used for automatic translation" : null);
1527
+ if (!err) {
1528
+ customPatterns.push(t);
1529
+ selected.add(t);
1530
+ filter = "";
1531
+ cursor = 0;
1532
+ scrollOffset = 0;
1533
+ addCursor = false;
1534
+ }
1535
+ } else {
1536
+ const item = filtered[cursor];
1537
+ if (item) {
1538
+ if (selected.has(item.value)) selected.delete(item.value);
1539
+ else selected.add(item.value);
1540
+ }
1541
+ }
1542
+ break;
1543
+ }
1478
1544
  }
1479
- writeFileSync2(envPath, updated);
1480
- return true;
1481
- } catch {
1482
- return false;
1483
- }
1545
+ });
1546
+ prompt.on("finalize", () => {
1547
+ if (prompt.state === "submit") {
1548
+ prompt.value = Array.from(selected);
1549
+ }
1550
+ });
1551
+ const result = await prompt.prompt();
1552
+ if (isCancel9(result)) return null;
1553
+ return result;
1484
1554
  }
1485
- function printApiKey(apiKey, repoRoot) {
1486
- const saved = writeApiKeyToEnv(apiKey, repoRoot);
1487
- p6.log.message("");
1488
- p6.log.message(chalk5.bold("Your API Key"));
1489
- printCodeBlock(`VOCODER_API_KEY=${apiKey}`);
1490
- if (saved) {
1491
- p6.log.success(chalk5.dim("Saved to .env"));
1492
- } else {
1493
- p6.log.message(chalk5.dim(" Add the above to your .env file"));
1555
+
1556
+ // src/utils/locale-search.ts
1557
+ import { isCancel as isCancel10, Prompt as Prompt3 } from "@clack/core";
1558
+ import * as p10 from "@clack/prompts";
1559
+ var S_BAR3 = "\u2502";
1560
+ var S_BAR_END3 = "\u2514";
1561
+ var S_ACTIVE3 = "\u25C6";
1562
+ var S_SUBMIT3 = "\u25C6";
1563
+ var S_CANCEL3 = "\u25A0";
1564
+ var S_ERROR3 = "\u25B2";
1565
+ function symbol3(state) {
1566
+ switch (state) {
1567
+ case "submit":
1568
+ return grn(S_SUBMIT3);
1569
+ case "cancel":
1570
+ return red(S_CANCEL3);
1571
+ case "error":
1572
+ return ylw(S_ERROR3);
1573
+ default:
1574
+ return active(S_ACTIVE3);
1494
1575
  }
1495
1576
  }
1496
- function writeAppConfigs(apps, targetBranches, useTypeScript, repoRoot) {
1497
- const base = repoRoot ?? process.cwd();
1498
- for (const app of apps) {
1499
- const dir = app.appDir ? resolve2(base, app.appDir) : base;
1500
- const written = writeVocoderConfig({
1501
- targetBranches,
1502
- appId: app.appId,
1503
- cwd: dir,
1504
- useTypeScript
1505
- });
1506
- if (written) {
1507
- const displayPath = app.appDir ? `${app.appDir}/${written}` : written;
1508
- p6.log.success(`Created ${highlight(displayPath)}`);
1509
- } else if (!findExistingConfig(dir)) {
1510
- const ext = useTypeScript ? "ts" : "js";
1511
- p6.log.warn(
1512
- `Could not write ${app.appDir ? `${app.appDir}/` : ""}vocoder.config.${ext} \u2014 create it manually.`
1513
- );
1514
- }
1577
+ var MAX_VISIBLE2 = 12;
1578
+ function filterLocales(options, query) {
1579
+ if (!query.trim()) return options;
1580
+ const lower = query.toLowerCase();
1581
+ return options.filter(
1582
+ (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
1583
+ );
1584
+ }
1585
+ function buildList2(filtered, cursor, scrollOffset, selected) {
1586
+ const isMulti = selected !== null;
1587
+ const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
1588
+ const visibleLines = [info(S_BAR3)];
1589
+ for (let i = scrollOffset; i < end; i++) {
1590
+ const opt = filtered[i];
1591
+ const isCursor = i === cursor;
1592
+ const isChecked = isMulti && selected.has(opt.bcp47);
1593
+ const icon = isMulti ? isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB") : isCursor ? active("\u25CF") : dim("\u25CB");
1594
+ visibleLines.push(
1595
+ `${info(S_BAR3)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`
1596
+ );
1515
1597
  }
1598
+ const hidden = filtered.length - (end - scrollOffset);
1599
+ if (hidden > 0) visibleLines.push(dim(`${S_BAR3} ${hidden} more \u2014 keep typing to narrow`));
1600
+ if (filtered.length === 0) visibleLines.push(dim(`${S_BAR3} No matches`));
1601
+ return visibleLines.join("\n");
1516
1602
  }
1517
- var MCP_DOCS_URL = "https://vocoder.app/docs/mcp";
1518
- function mcpServerJson(apiKey) {
1519
- return JSON.stringify(
1603
+ async function runFilterablePrompt(opts) {
1604
+ const { message, options, multi } = opts;
1605
+ let filter = "";
1606
+ let cursor = 0;
1607
+ let scrollOffset = 0;
1608
+ const selected = new Set(multi ? opts.initialValues ?? [] : []);
1609
+ if (!multi && opts.initialValue) {
1610
+ const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
1611
+ if (idx >= 0) cursor = idx;
1612
+ }
1613
+ const getFiltered = () => filterLocales(options, filter);
1614
+ const clampCursor = (filtered) => {
1615
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
1616
+ if (cursor < scrollOffset) scrollOffset = cursor;
1617
+ if (cursor >= scrollOffset + MAX_VISIBLE2)
1618
+ scrollOffset = cursor - MAX_VISIBLE2 + 1;
1619
+ if (scrollOffset < 0) scrollOffset = 0;
1620
+ };
1621
+ const prompt = new Prompt3(
1520
1622
  {
1521
- mcpServers: {
1522
- vocoder: {
1523
- type: "stdio",
1524
- command: "npx",
1525
- args: ["-y", "@vocoder/mcp"],
1526
- env: { VOCODER_API_KEY: apiKey }
1623
+ initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
1624
+ validate() {
1625
+ const f = getFiltered();
1626
+ if (multi && selected.size === 0)
1627
+ return "At least one target language is required.";
1628
+ if (!multi && !f[cursor]) return "Please select a language.";
1629
+ return void 0;
1630
+ },
1631
+ render() {
1632
+ const filtered = getFiltered();
1633
+ clampCursor(filtered);
1634
+ const hdr = `${dim(S_BAR3)}
1635
+ ${symbol3(this.state)} ${message}
1636
+ `;
1637
+ const inputHint = filter.length > 0 ? filter : dim("type to filter");
1638
+ const footer = multi ? selected.size > 0 ? dim(`${S_BAR3} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : dim(`${S_BAR3} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : dim(`${S_BAR3} \u2191\u2193 navigate \xB7 Enter to confirm`);
1639
+ switch (this.state) {
1640
+ case "submit": {
1641
+ const val = multi ? Array.from(selected).map((id) => options.find((o) => o.bcp47 === id)?.label ?? id).join(", ") : options.find((o) => o.bcp47 === this.value)?.label ?? "";
1642
+ return `${hdr}${dim(S_BAR3)} ${bld(val || dim("none"))}`;
1643
+ }
1644
+ case "cancel":
1645
+ return `${hdr}${dim(S_BAR3)}`;
1646
+ case "error":
1647
+ return [
1648
+ hdr.trimEnd(),
1649
+ `${ylw(S_BAR3)} ${dim("/")} ${inputHint}`,
1650
+ buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
1651
+ footer,
1652
+ `${ylw(S_BAR_END3)} ${ylw(this.error)}`,
1653
+ ""
1654
+ ].join("\n");
1655
+ default:
1656
+ return [
1657
+ hdr.trimEnd(),
1658
+ `${info(S_BAR3)} ${dim("/")} ${inputHint}`,
1659
+ buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
1660
+ footer,
1661
+ `${info(S_BAR_END3)}`,
1662
+ ""
1663
+ ].join("\n");
1527
1664
  }
1528
1665
  }
1529
1666
  },
1530
- null,
1531
- 2
1667
+ false
1668
+ // trackValue=false — we manage value manually
1532
1669
  );
1533
- }
1534
- async function runMcpSetup(apiKey) {
1535
- const tool = await p6.select({
1536
- message: "Which AI editor?",
1537
- options: [
1538
- { value: "claude", label: "Claude Code" },
1539
- { value: "cursor", label: "Cursor" },
1540
- { value: "windsurf", label: "Windsurf" },
1541
- { value: "vscode", label: "VS Code (GitHub Copilot)" },
1542
- { value: "other", label: "Other \u2014 show the config JSON" }
1543
- ]
1670
+ prompt.on("key", (key) => {
1671
+ if (!key || key === " ") return;
1672
+ const cp = key.codePointAt(0) ?? 0;
1673
+ if (cp === 127 || cp === 8) {
1674
+ filter = filter.slice(0, -1);
1675
+ cursor = 0;
1676
+ scrollOffset = 0;
1677
+ } else if (cp >= 32 && cp !== 127) {
1678
+ filter += key;
1679
+ cursor = 0;
1680
+ scrollOffset = 0;
1681
+ }
1544
1682
  });
1545
- if (p6.isCancel(tool)) return;
1546
- if (tool === "claude") {
1547
- try {
1548
- execSync3(
1549
- `claude mcp add --scope user --transport stdio --env VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`,
1550
- { stdio: "pipe" }
1551
- );
1552
- p6.log.success("Vocoder MCP server registered in Claude Code.");
1553
- } catch {
1554
- p6.log.message("Run this to register the MCP server:");
1555
- printCommand(
1556
- `claude mcp add --scope user --transport stdio --env VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`
1557
- );
1558
- p6.log.message(info(` Docs: ${MCP_DOCS_URL}`));
1683
+ prompt.on("cursor", (action) => {
1684
+ const filtered = getFiltered();
1685
+ switch (action) {
1686
+ case "up":
1687
+ cursor = Math.max(0, cursor - 1);
1688
+ break;
1689
+ case "down":
1690
+ cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
1691
+ break;
1692
+ case "space":
1693
+ if (multi) {
1694
+ const opt = filtered[cursor];
1695
+ if (opt) {
1696
+ if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
1697
+ else selected.add(opt.bcp47);
1698
+ }
1699
+ }
1700
+ break;
1559
1701
  }
1560
- return;
1561
- }
1562
- const configPaths = {
1563
- cursor: { path: "~/.cursor/mcp.json", merge: true },
1564
- windsurf: { path: "~/.codeium/windsurf/mcp_config.json", merge: true },
1565
- vscode: { path: ".vscode/mcp.json", merge: true },
1566
- other: { path: ".mcp.json", merge: false }
1567
- };
1568
- const { path: configPath, merge } = configPaths[tool];
1569
- const mergeNote = merge ? chalk5.dim(` Merge into ${highlight(configPath)} (create if missing):`) : chalk5.dim(` Add to ${highlight(configPath)}:`);
1570
- p6.log.message(mergeNote);
1571
- printCodeBlock(mcpServerJson(apiKey));
1572
- p6.log.message(info(` Docs: ${MCP_DOCS_URL}`));
1573
- }
1574
- function tryClipboard(text2) {
1575
- const tools = [
1576
- { cmd: "pbcopy" },
1577
- { cmd: "xclip", args: ["-selection", "clipboard"] },
1578
- { cmd: "xsel", args: ["--clipboard", "--input"] },
1579
- { cmd: "wl-copy" },
1580
- { cmd: "clip" }
1581
- ];
1582
- for (const { cmd, args = [] } of tools) {
1583
- try {
1584
- execSync3([cmd, ...args].join(" "), {
1585
- input: text2,
1586
- stdio: ["pipe", "ignore", "ignore"]
1587
- });
1588
- return true;
1589
- } catch {
1590
- continue;
1702
+ if (!multi) {
1703
+ const opt = getFiltered()[cursor];
1704
+ prompt.value = opt?.bcp47 ?? null;
1705
+ }
1706
+ });
1707
+ prompt.on("finalize", () => {
1708
+ if (prompt.state === "submit") {
1709
+ if (multi) {
1710
+ prompt.value = Array.from(selected);
1711
+ } else {
1712
+ const f = getFiltered();
1713
+ prompt.value = f[cursor]?.bcp47 ?? null;
1714
+ }
1591
1715
  }
1716
+ });
1717
+ const result = await prompt.prompt();
1718
+ if (isCancel10(result)) return null;
1719
+ return result;
1720
+ }
1721
+ async function searchSelectLocale(options, message, initialValue) {
1722
+ const result = await runFilterablePrompt({
1723
+ message,
1724
+ options,
1725
+ multi: false,
1726
+ initialValue
1727
+ });
1728
+ return typeof result === "string" ? result : null;
1729
+ }
1730
+ async function searchMultiSelectLocales(options, message, initialValues) {
1731
+ const result = await runFilterablePrompt({
1732
+ message,
1733
+ options,
1734
+ multi: true,
1735
+ initialValues
1736
+ });
1737
+ if (result === null) return null;
1738
+ const picks = result;
1739
+ if (picks.length === 0) {
1740
+ p10.log.warn(
1741
+ "At least one target language is required. Please select at least one."
1742
+ );
1743
+ return searchMultiSelectLocales(options, message, initialValues);
1592
1744
  }
1593
- return false;
1745
+ return picks;
1594
1746
  }
1595
- function printCommand(cmd) {
1596
- const copied = tryClipboard(cmd);
1597
- process.stdout.write("\n");
1598
- process.stdout.write(` ${chalk5.dim("$")} ${chalk5.cyan(cmd)}
1599
- `);
1600
- if (copied) process.stdout.write(` ${chalk5.dim("\u2191 copied to clipboard")}
1601
- `);
1602
- process.stdout.write("\n");
1747
+
1748
+ // src/utils/project-create.ts
1749
+ function buildLocaleOptions(locales) {
1750
+ return locales.map((l) => ({
1751
+ bcp47: l.code,
1752
+ label: `${l.name} \u2014 ${l.code}`
1753
+ }));
1603
1754
  }
1604
- function printCodeBlock(code) {
1605
- process.stdout.write("\n");
1606
- for (const line of code.split("\n")) {
1607
- process.stdout.write(` ${line}
1608
- `);
1755
+ function buildLanguageOptions(locales) {
1756
+ const byFamily = /* @__PURE__ */ new Map();
1757
+ for (const l of locales) {
1758
+ const family = l.code.split("-")[0].toLowerCase();
1759
+ const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
1760
+ const existing = byFamily.get(family);
1761
+ if (!existing || l.code.length < existing.bcp47.length) {
1762
+ byFamily.set(family, opt);
1763
+ }
1609
1764
  }
1610
- process.stdout.write("\n");
1765
+ return Array.from(byFamily.values());
1611
1766
  }
1612
- async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1613
- let server = null;
1614
- if (!options.ci) {
1615
- try {
1616
- server = await startCallbackServer();
1617
- } catch {
1618
- }
1767
+ async function runProjectCreate(params) {
1768
+ const { api, userToken, organizationId, repoCanonical, repoRoot } = params;
1769
+ const projectName = (params.defaultName ?? "my-project").trim();
1770
+ p11.log.success(`Project: ${chalk10.bold(projectName)}`);
1771
+ let sourceLocales;
1772
+ try {
1773
+ ({ sourceLocales } = await api.listLocales(userToken));
1774
+ } catch {
1775
+ p11.log.error(
1776
+ "Failed to fetch supported locales. Check your connection and try again."
1777
+ );
1778
+ return null;
1619
1779
  }
1620
- const session = await api.startCliAuthSession(server?.port, repoCanonical);
1621
- const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
1622
- const expiresAt = new Date(session.expiresAt).getTime();
1623
- p6.log.info(browserUrl);
1624
- if (options.ci) {
1625
- process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
1626
- `);
1627
- process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
1628
- `);
1629
- } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1630
- if (reauth) {
1631
- if (!options.yes) {
1632
- const shouldOpen = await p6.confirm({
1633
- message: "Open your browser to sign in again?"
1634
- });
1635
- if (p6.isCancel(shouldOpen)) {
1636
- server?.close();
1637
- p6.cancel("Setup cancelled.");
1638
- return null;
1639
- }
1640
- if (!shouldOpen) {
1641
- server?.close();
1642
- p6.cancel("Setup cancelled.");
1643
- return null;
1644
- } else {
1645
- const opened = await tryOpenBrowser2(browserUrl);
1646
- if (!opened) {
1647
- p6.note(browserUrl, "Sign In");
1648
- p6.log.info("Open the URL above manually to continue.");
1649
- }
1650
- }
1780
+ const languageOptions = buildLanguageOptions(sourceLocales);
1781
+ const appDirs = await collectAppDirs({ cwd: repoRoot, maxDirs: params.maxAppDirs });
1782
+ if (appDirs === null) return null;
1783
+ if (appDirs.length > 0) {
1784
+ p11.log.success(`App directories: ${appDirs.map((d) => chalk10.bold(d)).join(", ")}`);
1785
+ }
1786
+ const sourceLocale = await searchSelectLocale(
1787
+ languageOptions,
1788
+ "Source language (the language your code is written in)",
1789
+ params.defaultSourceLocale ?? "en"
1790
+ );
1791
+ if (sourceLocale === null) return null;
1792
+ let compatibleTargets;
1793
+ try {
1794
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
1795
+ } catch {
1796
+ p11.log.error(
1797
+ "Failed to fetch compatible target locales. Check your connection and try again."
1798
+ );
1799
+ return null;
1800
+ }
1801
+ const localeOptions = buildLocaleOptions(compatibleTargets);
1802
+ const targetOptions = localeOptions.filter(
1803
+ (opt) => opt.bcp47 !== sourceLocale
1804
+ );
1805
+ const targetLocales = await searchMultiSelectLocales(
1806
+ targetOptions,
1807
+ "Target languages (languages to translate into)"
1808
+ );
1809
+ if (targetLocales === null) return null;
1810
+ if (targetLocales.length === 0) {
1811
+ p11.log.warn(
1812
+ "No target languages selected \u2014 you can add them later from the dashboard."
1813
+ );
1814
+ }
1815
+ const detected = detectGitBranches();
1816
+ const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
1817
+ let pushBranches = [];
1818
+ {
1819
+ let initial = initialBranches;
1820
+ while (pushBranches.length === 0) {
1821
+ const result2 = await filterableBranchSelect({
1822
+ message: "Which branches should trigger translations?",
1823
+ branches: detected.branches,
1824
+ defaultBranch: detected.defaultBranch,
1825
+ initialValues: initial
1826
+ });
1827
+ if (result2 === null) return null;
1828
+ if (result2.length === 0) {
1829
+ p11.log.warn(
1830
+ "At least one branch is required. Please select at least one."
1831
+ );
1832
+ initial = [detected.defaultBranch];
1651
1833
  } else {
1652
- await tryOpenBrowser2(browserUrl);
1653
- }
1654
- } else {
1655
- let isLinkFlow = false;
1656
- if (!options.yes) {
1657
- const connectChoice = await p6.select({
1658
- message: "Vocoder needs to be installed on your GitHub account to get started",
1659
- options: [
1660
- {
1661
- value: "install",
1662
- label: "Install GitHub App",
1663
- hint: "new user"
1664
- },
1665
- {
1666
- value: "link",
1667
- label: "Already installed? Link your account",
1668
- hint: "returning user"
1669
- }
1670
- ]
1671
- });
1672
- if (p6.isCancel(connectChoice)) {
1673
- server?.close();
1674
- p6.cancel("Setup cancelled.");
1675
- return null;
1676
- }
1677
- isLinkFlow = connectChoice === "link";
1678
- }
1679
- let urlToOpen = browserUrl;
1680
- if (isLinkFlow) {
1681
- try {
1682
- const linkSession = await api.startCliGitHubLinkSession(
1683
- session.sessionId,
1684
- server?.port
1685
- );
1686
- urlToOpen = linkSession.oauthUrl;
1687
- } catch {
1688
- urlToOpen = browserUrl;
1689
- }
1690
- }
1691
- const opened = await tryOpenBrowser2(urlToOpen);
1692
- if (!opened) {
1693
- p6.log.warn("Could not open your browser automatically.");
1694
- p6.note(urlToOpen, "GitHub");
1695
- p6.log.info("Open the URL above to continue.");
1834
+ pushBranches = result2;
1696
1835
  }
1697
1836
  }
1698
1837
  }
1699
- const authSpinner = p6.spinner();
1700
- authSpinner.start("Waiting for GitHub authorization...");
1701
- let rawToken = null;
1702
- let callbackOrganizationId;
1703
- let callbackDiscoveryReady = false;
1704
- const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
1705
- let stopPolling = false;
1706
- const serverCallback = server ? server.waitForCallback().catch(() => null) : Promise.resolve(null);
1707
- const sessionPoll = (async () => {
1708
- while (!stopPolling && Date.now() < expiresAt) {
1709
- try {
1710
- const result = await api.pollCliAuthSession(session.sessionId);
1711
- if (result.status === "complete" || result.status === "failed") {
1712
- return result;
1713
- }
1714
- } catch {
1838
+ const targetBranches = pushBranches;
1839
+ const result = await api.createProject(userToken, {
1840
+ organizationId,
1841
+ name: projectName,
1842
+ sourceLocale,
1843
+ targetLocales,
1844
+ targetBranches,
1845
+ appDirs,
1846
+ repoCanonical
1847
+ });
1848
+ p11.log.success(`Project ${chalk10.bold(result.projectName)} created!`);
1849
+ return {
1850
+ projectId: result.projectId,
1851
+ projectName: result.projectName,
1852
+ apiKey: result.apiKey,
1853
+ sourceLocale,
1854
+ targetLocales,
1855
+ targetBranches,
1856
+ repositoryBound: result.repositoryBound,
1857
+ configureUrl: result.configureUrl,
1858
+ apps: result.apps
1859
+ };
1860
+ }
1861
+ async function runAppCreate(params) {
1862
+ const { api, userToken, projectId, projectName, repoCanonical } = params;
1863
+ const existingDirs = params.existingApps.map((a) => a.appDir);
1864
+ const appDir = await promptSingleAppDir({ existingDirs });
1865
+ if (appDir === null) return null;
1866
+ if (appDir) {
1867
+ p11.log.success(`App directory: ${chalk10.bold(appDir)}`);
1868
+ }
1869
+ let sourceLocales;
1870
+ try {
1871
+ ({ sourceLocales } = await api.listLocales(userToken));
1872
+ } catch {
1873
+ p11.log.error(
1874
+ "Failed to fetch supported locales. Check your connection and try again."
1875
+ );
1876
+ return null;
1877
+ }
1878
+ const languageOptions = buildLanguageOptions(sourceLocales);
1879
+ const sourceLocale = await searchSelectLocale(
1880
+ languageOptions,
1881
+ "Source language",
1882
+ "en"
1883
+ );
1884
+ if (sourceLocale === null) return null;
1885
+ let compatibleTargets;
1886
+ try {
1887
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
1888
+ } catch {
1889
+ p11.log.error(
1890
+ "Failed to fetch compatible target locales. Check your connection and try again."
1891
+ );
1892
+ return null;
1893
+ }
1894
+ const targetOptions = buildLocaleOptions(compatibleTargets).filter(
1895
+ (opt) => opt.bcp47 !== sourceLocale
1896
+ );
1897
+ const targetLocales = await searchMultiSelectLocales(
1898
+ targetOptions,
1899
+ "Target languages"
1900
+ );
1901
+ if (targetLocales === null) return null;
1902
+ if (targetLocales.length === 0) {
1903
+ p11.log.warn(
1904
+ "No target languages selected \u2014 you can add them later from the dashboard."
1905
+ );
1906
+ }
1907
+ const detectedApp = detectGitBranches();
1908
+ let appPushBranches = [];
1909
+ {
1910
+ let initial = [detectedApp.defaultBranch];
1911
+ while (appPushBranches.length === 0) {
1912
+ const result2 = await filterableBranchSelect({
1913
+ message: "Which branches should trigger translations?",
1914
+ branches: detectedApp.branches,
1915
+ defaultBranch: detectedApp.defaultBranch,
1916
+ initialValues: initial
1917
+ });
1918
+ if (result2 === null) return null;
1919
+ if (result2.length === 0) {
1920
+ p11.log.warn("At least one branch is required.");
1921
+ initial = [detectedApp.defaultBranch];
1922
+ } else {
1923
+ appPushBranches = result2;
1715
1924
  }
1716
- if (!stopPolling) await sleep(2e3);
1717
1925
  }
1718
- return null;
1719
- })();
1720
- const winner = await new Promise((resolve3) => {
1721
- let done = false;
1722
- serverCallback.then((params) => {
1723
- if (done || params === null || typeof params.token !== "string") return;
1724
- done = true;
1725
- resolve3({ kind: "server", params });
1726
- }).catch(() => {
1727
- });
1728
- sessionPoll.then((result) => {
1729
- if (done || result === null) return;
1730
- if (result.status === "complete" || result.status === "failed") {
1731
- done = true;
1732
- resolve3({
1733
- kind: "poll",
1734
- result
1735
- });
1736
- }
1737
- }).catch(() => {
1738
- });
1739
- setTimeout(
1740
- () => {
1741
- if (!done) {
1742
- done = true;
1743
- resolve3(null);
1744
- }
1745
- },
1746
- Math.max(0, deadline - Date.now())
1747
- );
1926
+ }
1927
+ const targetBranches = appPushBranches;
1928
+ const result = await api.createApp(userToken, {
1929
+ projectId,
1930
+ appDir,
1931
+ sourceLocale,
1932
+ targetLocales,
1933
+ targetBranches,
1934
+ repoCanonical: repoCanonical ?? ""
1748
1935
  });
1749
- stopPolling = true;
1750
- server?.close();
1751
- if (winner !== null) {
1752
- if (winner.kind === "server") {
1753
- rawToken = winner.params.token;
1754
- if (typeof winner.params.organizationId === "string" && winner.params.organizationId) {
1755
- callbackOrganizationId = winner.params.organizationId;
1756
- }
1757
- if (winner.params.discovery_ready === "1") {
1758
- callbackDiscoveryReady = true;
1759
- }
1760
- } else if (winner.result.status === "complete") {
1761
- rawToken = winner.result.token;
1762
- if (winner.result.organizationId) {
1763
- callbackOrganizationId = winner.result.organizationId;
1936
+ p11.log.success(
1937
+ `App ${chalk10.bold(appDir || "(root)")} added to ${chalk10.bold(projectName)}!`
1938
+ );
1939
+ return {
1940
+ projectId: result.projectId,
1941
+ projectName: result.projectName,
1942
+ appDir: result.appDir,
1943
+ appId: result.appId,
1944
+ sourceLocale,
1945
+ targetLocales,
1946
+ targetBranches
1947
+ };
1948
+ }
1949
+
1950
+ // src/commands/init.ts
1951
+ import chalk11 from "chalk";
1952
+ import { config as loadEnv } from "dotenv";
1953
+
1954
+ // src/utils/git-identity.ts
1955
+ import { execSync as execSync5 } from "child_process";
1956
+ var SHA_REGEX = /^[0-9a-f]{40}$/i;
1957
+ function detectCommitSha() {
1958
+ if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1959
+ return process.env.VOCODER_COMMIT_SHA;
1960
+ }
1961
+ 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;
1962
+ if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1963
+ return safeExec("git rev-parse HEAD");
1964
+ }
1965
+ function safeExec(command) {
1966
+ try {
1967
+ const output = execSync5(command, {
1968
+ encoding: "utf-8",
1969
+ stdio: ["pipe", "pipe", "ignore"]
1970
+ }).trim();
1971
+ return output.length > 0 ? output : null;
1972
+ } catch {
1973
+ return null;
1974
+ }
1975
+ }
1976
+ function normalizePath(pathname) {
1977
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1978
+ if (!cleaned || !cleaned.includes("/")) {
1979
+ return null;
1980
+ }
1981
+ return cleaned;
1982
+ }
1983
+ function parseRemoteUrl(remoteUrl) {
1984
+ const trimmed = remoteUrl.trim();
1985
+ if (!trimmed) {
1986
+ return null;
1987
+ }
1988
+ if (!trimmed.includes("://")) {
1989
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1990
+ if (scpMatch) {
1991
+ const host = (scpMatch[1] || "").toLowerCase();
1992
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
1993
+ if (!host || !ownerRepoPath) {
1994
+ return null;
1764
1995
  }
1765
- } else {
1766
- authSpinner.stop();
1767
- p6.log.error(winner.result.reason);
1996
+ return { host, ownerRepoPath };
1997
+ }
1998
+ return null;
1999
+ }
2000
+ try {
2001
+ const parsed = new URL(trimmed);
2002
+ const host = parsed.hostname.toLowerCase();
2003
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
2004
+ if (!host || !ownerRepoPath) {
1768
2005
  return null;
1769
2006
  }
2007
+ return { host, ownerRepoPath };
2008
+ } catch {
2009
+ return null;
1770
2010
  }
1771
- if (!rawToken) {
1772
- authSpinner.stop();
1773
- p6.log.error("The authentication link expired. Run `vocoder init` again.");
2011
+ }
2012
+ function toCanonical(host, ownerRepoPath) {
2013
+ if (host.includes("github.com")) {
2014
+ return `github:${ownerRepoPath.toLowerCase()}`;
2015
+ }
2016
+ if (host.includes("gitlab.com")) {
2017
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
2018
+ }
2019
+ if (host.includes("bitbucket.org")) {
2020
+ return `bitbucket:${ownerRepoPath.toLowerCase()}`;
2021
+ }
2022
+ return `git:${host}/${ownerRepoPath.toLowerCase()}`;
2023
+ }
2024
+ function resolveGitRepositoryIdentity() {
2025
+ const remoteUrl = safeExec("git config --get remote.origin.url");
2026
+ if (!remoteUrl) {
2027
+ return null;
2028
+ }
2029
+ const parsed = parseRemoteUrl(remoteUrl);
2030
+ if (!parsed) {
2031
+ return null;
2032
+ }
2033
+ const repoRoot = safeExec("git rev-parse --show-toplevel");
2034
+ if (!repoRoot) {
1774
2035
  return null;
1775
2036
  }
1776
- const userInfo = await api.getCliUserInfo(rawToken);
1777
- authSpinner.stop(`Authenticated as ${chalk5.bold(userInfo.email)}`);
1778
2037
  return {
1779
- token: rawToken,
1780
- ...userInfo,
1781
- organizationId: callbackOrganizationId,
1782
- discoveryReady: callbackDiscoveryReady
2038
+ repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
2039
+ repoRoot
1783
2040
  };
1784
2041
  }
2042
+ function resolveGitContext() {
2043
+ const warnings = [];
2044
+ const identity = resolveGitRepositoryIdentity();
2045
+ if (!identity) {
2046
+ warnings.push(
2047
+ "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
2048
+ );
2049
+ }
2050
+ return { identity, warnings };
2051
+ }
2052
+
2053
+ // src/commands/init.ts
2054
+ loadEnv();
1785
2055
  async function init(options = {}) {
1786
2056
  const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
1787
- p6.intro(chalk5.bold("Vocoder Setup"));
2057
+ p12.intro(chalk11.bold("Vocoder Setup"));
1788
2058
  try {
1789
2059
  const gitContext = resolveGitContext();
1790
2060
  const identity = gitContext.identity;
1791
- if (gitContext.warnings.length > 0) {
1792
- for (const warning of gitContext.warnings) {
1793
- p6.log.warn(warning);
1794
- }
2061
+ for (const warning of gitContext.warnings) {
2062
+ p12.log.warn(warning);
1795
2063
  }
1796
2064
  let existingAppsForRepo = [];
1797
2065
  let repoProjectId = null;
@@ -1806,19 +2074,19 @@ async function init(options = {}) {
1806
2074
  if (lookup.existingApps.length > 0) {
1807
2075
  const allApps = lookup.existingApps;
1808
2076
  const firstApp = allApps[0];
1809
- p6.log.success(`Project: ${chalk5.bold(firstApp.projectName)}`);
1810
- p6.log.info(
2077
+ p12.log.success(`Project: ${chalk11.bold(firstApp.projectName)}`);
2078
+ p12.log.info(
1811
2079
  `Configured apps: ${allApps.map((a) => highlight(a.appDir || "(entire repo)")).join(", ")}`
1812
2080
  );
1813
- const routeAction = await p6.select({
2081
+ const routeAction = await p12.select({
1814
2082
  message: "This repo is already set up. What would you like to do?",
1815
2083
  options: [
1816
2084
  { value: "key", label: "Get an API key for this project" },
1817
2085
  { value: "add", label: "Add a new app directory" }
1818
2086
  ]
1819
2087
  });
1820
- if (p6.isCancel(routeAction)) {
1821
- p6.cancel("Setup cancelled.");
2088
+ if (p12.isCancel(routeAction)) {
2089
+ p12.cancel("Setup cancelled.");
1822
2090
  return 1;
1823
2091
  }
1824
2092
  if (routeAction === "key") {
@@ -1830,20 +2098,20 @@ async function init(options = {}) {
1830
2098
  true
1831
2099
  );
1832
2100
  if (!authResult) return 1;
1833
- const spinner7 = p6.spinner();
1834
- spinner7.start("Generating API key...");
2101
+ const spinner9 = p12.spinner();
2102
+ spinner9.start("Generating API key...");
1835
2103
  let apiKey;
1836
2104
  try {
1837
2105
  ({ apiKey } = await anonApi2.regenerateProjectApiKey(
1838
2106
  authResult.token,
1839
2107
  firstApp.projectId
1840
2108
  ));
1841
- spinner7.stop("API key ready");
2109
+ spinner9.stop("API key ready");
1842
2110
  } catch (err) {
1843
- spinner7.stop("Failed to generate key");
2111
+ spinner9.stop("Failed to generate key");
1844
2112
  const msg = err instanceof Error ? err.message : String(err);
1845
- p6.log.error(`Could not generate API key: ${msg}`);
1846
- p6.log.info("Try again or generate one from the dashboard.");
2113
+ p12.log.error(`Could not generate API key: ${msg}`);
2114
+ p12.log.info("Try again or generate one from the dashboard.");
1847
2115
  return 1;
1848
2116
  }
1849
2117
  printApiKey(apiKey, identity.repoRoot);
@@ -1855,7 +2123,7 @@ async function init(options = {}) {
1855
2123
  detection2.isTypeScript,
1856
2124
  identity.repoRoot
1857
2125
  );
1858
- p6.outro("Vocoder is set up for this repository.");
2126
+ p12.outro("Vocoder is set up for this repository.");
1859
2127
  return 0;
1860
2128
  }
1861
2129
  existingAppsForRepo = allApps;
@@ -1870,16 +2138,16 @@ async function init(options = {}) {
1870
2138
  let authOrganizationId;
1871
2139
  const storedAuth = await verifyStoredAuth(api);
1872
2140
  if (storedAuth.status === "valid") {
1873
- p6.log.success(`Authenticated as ${chalk5.bold(storedAuth.email)}`);
2141
+ p12.log.success(`Authenticated as ${chalk11.bold(storedAuth.email)}`);
1874
2142
  userToken = storedAuth.token;
1875
2143
  userEmail = storedAuth.email;
1876
2144
  userName = storedAuth.name;
1877
2145
  } else {
1878
2146
  const reauth = storedAuth.status === "expired";
1879
2147
  if (reauth) {
1880
- p6.log.warn("Stored credentials expired \u2014 signing in again");
2148
+ p12.log.warn("Stored credentials expired \u2014 signing in again");
1881
2149
  } else if (storedAuth.status === "gone") {
1882
- p6.log.warn("Account not found \u2014 starting fresh setup");
2150
+ p12.log.warn("Account not found \u2014 starting fresh setup");
1883
2151
  }
1884
2152
  const authResult = await runAuthFlow(
1885
2153
  api,
@@ -1900,272 +2168,18 @@ async function init(options = {}) {
1900
2168
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1901
2169
  });
1902
2170
  }
1903
- let selectedOrganizationId;
1904
- let selectedOrganizationName;
1905
- const repoOrgContext = identity ? lookup?.organizationContext ?? null : null;
1906
- if (authOrganizationId) {
1907
- const organizationData = await api.listOrganizations(userToken);
1908
- const ws = organizationData.organizations.find(
1909
- (w) => w.id === authOrganizationId
1910
- );
1911
- selectedOrganizationId = authOrganizationId;
1912
- selectedOrganizationName = ws?.name ?? userEmail;
1913
- p6.log.success(
1914
- `Connected as ${chalk5.bold(userEmail)} \u2014 workspace: ${chalk5.bold(selectedOrganizationName)}`
1915
- );
1916
- } else if (repoOrgContext && !repoProjectId) {
1917
- selectedOrganizationId = repoOrgContext.organizationId;
1918
- selectedOrganizationName = repoOrgContext.organizationName;
1919
- p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1920
- } else {
1921
- const organizationData = await api.listOrganizations(userToken, {
1922
- repo: identity?.repoCanonical
1923
- });
1924
- {
1925
- const repoCanonical = identity?.repoCanonical ?? null;
1926
- const covering = repoCanonical ? organizationData.organizations.filter((w) => w.coversRepo === true) : [];
1927
- const connected = organizationData.organizations.filter(
1928
- (w) => w.hasGitHubConnection
1929
- );
1930
- if (repoCanonical && covering.length === 1) {
1931
- const ws = covering[0];
1932
- selectedOrganizationId = ws.id;
1933
- selectedOrganizationName = ws.name;
1934
- p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1935
- } else if (repoCanonical && covering.length > 1) {
1936
- const choice = await p6.select({
1937
- message: "Select workspace for this repo",
1938
- options: covering.map((w) => ({
1939
- value: w.id,
1940
- label: `${w.name} ${chalk5.dim(`(${w.appCount} app${w.appCount !== 1 ? "s" : ""})`)}`
1941
- }))
1942
- });
1943
- if (p6.isCancel(choice)) {
1944
- p6.cancel("Setup cancelled.");
1945
- return 1;
1946
- }
1947
- const ws = covering.find((w) => w.id === choice);
1948
- selectedOrganizationId = ws.id;
1949
- selectedOrganizationName = ws.name;
1950
- p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1951
- } else if (repoCanonical && covering.length === 0 && connected.length > 0) {
1952
- const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
1953
- p6.log.warn(
1954
- `${chalk5.bold(shortRepo)} isn't accessible from your Vocoder installation.
1955
- Grant access to this repository or install on the account that owns it.`
1956
- );
1957
- const fixOptions = [];
1958
- for (const ws of connected) {
1959
- if (ws.installationConfigureUrl) {
1960
- fixOptions.push({
1961
- value: `grant:${ws.id}`,
1962
- label: `Configure ${chalk5.bold(ws.connectionLabel ?? ws.name)}'s GitHub App installation`
1963
- });
1964
- }
1965
- }
1966
- fixOptions.push({
1967
- value: "install_new",
1968
- label: `Install on a different GitHub account ${chalk5.dim("(creates a new personal workspace)")}`
1969
- });
1970
- fixOptions.push({ value: "cancel", label: "Cancel" });
1971
- const fix = await p6.select({
1972
- message: "How would you like to fix this?",
1973
- options: fixOptions
1974
- });
1975
- if (p6.isCancel(fix) || fix === "cancel") {
1976
- p6.cancel("Setup cancelled.");
1977
- return 1;
1978
- }
1979
- if (fix.startsWith("grant:")) {
1980
- const ws = connected.find((w) => `grant:${w.id}` === fix);
1981
- await tryOpenBrowser2(ws.installationConfigureUrl);
1982
- p6.cancel(
1983
- `Grant access to ${chalk5.bold(shortRepo)} in your browser,
1984
- then re-run ${chalk5.bold("vocoder init")}.`
1985
- );
1986
- return 1;
1987
- }
1988
- const connectResult = await runGitHubInstallFlow({
1989
- api,
1990
- userToken,
1991
- yes: options.yes
1992
- });
1993
- if (!connectResult) {
1994
- p6.log.error(
1995
- "GitHub App installation did not complete. Run `vocoder init` again."
1996
- );
1997
- return 1;
1998
- }
1999
- selectedOrganizationId = connectResult.organizationId;
2000
- selectedOrganizationName = connectResult.organizationName;
2001
- p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
2002
- } else {
2003
- const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
2004
- const cachedInstallations = discoveryResult?.installations ?? [];
2005
- if (cachedInstallations.length > 0) {
2006
- if (identity?.repoCanonical) {
2007
- const repoOwner = identity.repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
2008
- if (repoOwner) {
2009
- const hasMatchingAccount = cachedInstallations.some(
2010
- (i) => i.accountLogin.toLowerCase() === repoOwner
2011
- );
2012
- if (!hasMatchingAccount) {
2013
- p6.log.warn(
2014
- `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
2015
- The project will be created but translations won't trigger automatically.
2016
- To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
2017
- );
2018
- }
2019
- }
2020
- }
2021
- const validInstallations = cachedInstallations.filter(
2022
- (i) => !i.isSuspended && !i.conflictLabel
2023
- );
2024
- let selectedInstallationId = null;
2025
- if (validInstallations.length === 1 && cachedInstallations.length === 1) {
2026
- selectedInstallationId = validInstallations[0].installationId;
2027
- } else {
2028
- selectedInstallationId = await selectGitHubInstallation(
2029
- cachedInstallations.map((inst) => ({
2030
- installationId: inst.installationId,
2031
- accountLogin: inst.accountLogin,
2032
- accountType: inst.accountType,
2033
- isSuspended: inst.isSuspended,
2034
- conflictLabel: inst.conflictLabel
2035
- })),
2036
- false
2037
- );
2038
- }
2039
- if (selectedInstallationId === null || selectedInstallationId === "install_new") {
2040
- p6.cancel(
2041
- "Setup cancelled. Re-run `vocoder init` and choose Install GitHub App."
2042
- );
2043
- return 1;
2044
- }
2045
- const claimResult = await api.claimCliGitHubInstallation(
2046
- userToken,
2047
- {
2048
- installationId: String(selectedInstallationId),
2049
- organizationId: null
2050
- }
2051
- );
2052
- selectedOrganizationId = claimResult.organizationId;
2053
- selectedOrganizationName = claimResult.organizationName;
2054
- p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
2055
- } else if (organizationData.organizations.length === 1 && !organizationData.canCreateOrganization) {
2056
- const ws = organizationData.organizations[0];
2057
- selectedOrganizationId = ws.id;
2058
- selectedOrganizationName = ws.name;
2059
- p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
2060
- } else {
2061
- const organizationResult = await selectOrganization(organizationData);
2062
- if (organizationResult.action === "cancelled") {
2063
- p6.cancel("Setup cancelled.");
2064
- return 1;
2065
- }
2066
- if (organizationResult.action === "use") {
2067
- selectedOrganizationId = organizationResult.organization.id;
2068
- selectedOrganizationName = organizationResult.organization.name;
2069
- p6.log.success(
2070
- `Workspace: ${chalk5.bold(selectedOrganizationName)}`
2071
- );
2072
- } else {
2073
- const connectChoice = await p6.select({
2074
- message: "Connect your new workspace to GitHub",
2075
- options: [
2076
- { value: "install", label: "Install the Vocoder GitHub App" },
2077
- { value: "link", label: "Link an existing installation" }
2078
- ]
2079
- });
2080
- if (p6.isCancel(connectChoice)) {
2081
- p6.cancel("Setup cancelled.");
2082
- return 1;
2083
- }
2084
- if (connectChoice === "install") {
2085
- const connectResult = await runGitHubInstallFlow({
2086
- api,
2087
- userToken,
2088
- yes: options.yes
2089
- });
2090
- if (!connectResult) {
2091
- p6.log.error(
2092
- "GitHub App installation did not complete. Run `vocoder init` again."
2093
- );
2094
- return 1;
2095
- }
2096
- selectedOrganizationId = connectResult.organizationId;
2097
- selectedOrganizationName = connectResult.organizationName;
2098
- p6.log.success(
2099
- `Workspace: ${chalk5.bold(selectedOrganizationName)}`
2100
- );
2101
- } else {
2102
- const installations = await runGitHubDiscoveryFlow({
2103
- api,
2104
- userToken,
2105
- yes: options.yes
2106
- });
2107
- if (!installations) return 1;
2108
- if (installations.length === 0) {
2109
- p6.log.warn(
2110
- "No GitHub installations found. Install the Vocoder GitHub App first."
2111
- );
2112
- const installNow = await p6.confirm({
2113
- message: "Open GitHub to install the App?"
2114
- });
2115
- if (p6.isCancel(installNow) || !installNow) return 1;
2116
- const connectResult = await runGitHubInstallFlow({
2117
- api,
2118
- userToken,
2119
- yes: options.yes
2120
- });
2121
- if (!connectResult) return 1;
2122
- selectedOrganizationId = connectResult.organizationId;
2123
- selectedOrganizationName = connectResult.organizationName;
2124
- } else {
2125
- const selectedInstallationId = await selectGitHubInstallation(
2126
- installations.map((inst) => ({
2127
- installationId: inst.installationId,
2128
- accountLogin: inst.accountLogin,
2129
- accountType: inst.accountType,
2130
- isSuspended: inst.isSuspended,
2131
- conflictLabel: inst.conflictLabel
2132
- })),
2133
- true
2134
- );
2135
- if (selectedInstallationId === null) {
2136
- p6.cancel("Setup cancelled.");
2137
- return 1;
2138
- }
2139
- if (selectedInstallationId === "install_new") {
2140
- const connectResult = await runGitHubInstallFlow({
2141
- api,
2142
- userToken,
2143
- yes: options.yes
2144
- });
2145
- if (!connectResult) return 1;
2146
- selectedOrganizationId = connectResult.organizationId;
2147
- selectedOrganizationName = connectResult.organizationName;
2148
- } else {
2149
- const claimResult = await api.claimCliGitHubInstallation(
2150
- userToken,
2151
- {
2152
- installationId: String(selectedInstallationId),
2153
- organizationId: null
2154
- }
2155
- );
2156
- selectedOrganizationId = claimResult.organizationId;
2157
- selectedOrganizationName = claimResult.organizationName;
2158
- }
2159
- }
2160
- p6.log.success(
2161
- `Workspace: ${chalk5.bold(selectedOrganizationName)}`
2162
- );
2163
- }
2164
- }
2165
- }
2166
- }
2167
- }
2168
- }
2171
+ const organizationResult = await selectOrganizationForInit({
2172
+ api,
2173
+ userToken,
2174
+ userEmail,
2175
+ identity: identity ?? null,
2176
+ lookup,
2177
+ repoProjectId,
2178
+ authOrganizationId,
2179
+ options
2180
+ });
2181
+ if (!organizationResult) return 1;
2182
+ const { organizationId: selectedOrganizationId, organizationName: selectedOrganizationName } = organizationResult;
2169
2183
  if (repoProjectId && repoProjectName && existingAppsForRepo.length > 0) {
2170
2184
  const appResult = await runAppCreate({
2171
2185
  api,
@@ -2177,7 +2191,7 @@ async function init(options = {}) {
2177
2191
  existingApps: existingAppsForRepo
2178
2192
  });
2179
2193
  if (!appResult) {
2180
- p6.log.error("App setup failed. Run `vocoder init` again.");
2194
+ p12.log.error("App setup failed. Run `vocoder init` again.");
2181
2195
  return 1;
2182
2196
  }
2183
2197
  const detection2 = detectLocalEcosystem();
@@ -2188,45 +2202,17 @@ async function init(options = {}) {
2188
2202
  detection2.isTypeScript,
2189
2203
  identity?.repoRoot
2190
2204
  );
2191
- p6.log.info(
2192
- chalk5.dim("Use the VOCODER_API_KEY already in your root .env")
2193
- );
2194
- p6.outro("You're all set.");
2205
+ p12.log.info(chalk11.dim("Use the VOCODER_API_KEY already in your root .env"));
2206
+ p12.outro("You're all set.");
2195
2207
  return 0;
2196
2208
  }
2197
- let remainingApps;
2198
- try {
2199
- const wsCheck = await api.listOrganizations(userToken);
2200
- const ws = wsCheck.organizations.find(
2201
- (w) => w.id === selectedOrganizationId
2202
- );
2203
- if (ws) {
2204
- if (ws.maxApps !== -1 && ws.appCount >= ws.maxApps) {
2205
- p6.log.warn(
2206
- `App limit reached \u2014 ${ws.appCount}/${ws.maxApps} on your ${chalk5.bold(ws.planId)} plan.`
2207
- );
2208
- const limitAction = await p6.select({
2209
- message: "What would you like to do?",
2210
- options: [
2211
- { value: "upgrade", label: "Upgrade plan" },
2212
- { value: "cancel", label: "Cancel" }
2213
- ]
2214
- });
2215
- if (p6.isCancel(limitAction) || limitAction === "cancel") {
2216
- p6.cancel("Setup cancelled.");
2217
- return 1;
2218
- }
2219
- await tryOpenBrowser2(`${apiUrl}${SUBSCRIPTION_SETTINGS_PATH}`);
2220
- p6.cancel(
2221
- "Upgrade your plan in the browser, then re-run `vocoder init`."
2222
- );
2223
- return 1;
2224
- }
2225
- remainingApps = ws.maxApps === -1 ? void 0 : Math.max(0, ws.maxApps - ws.appCount);
2226
- }
2227
- } catch {
2228
- p6.log.warn("Could not verify plan limits \u2014 proceeding, the server will enforce them.");
2229
- }
2209
+ const planCheck = await checkPlanLimits(
2210
+ api,
2211
+ userToken,
2212
+ selectedOrganizationId,
2213
+ apiUrl
2214
+ );
2215
+ if (planCheck.atLimit) return 1;
2230
2216
  const projectResult = await runProjectCreate({
2231
2217
  api,
2232
2218
  userToken,
@@ -2236,18 +2222,18 @@ async function init(options = {}) {
2236
2222
  repoCanonical: identity?.repoCanonical,
2237
2223
  repoRoot: identity?.repoRoot,
2238
2224
  defaultBranches: ["main"],
2239
- maxAppDirs: remainingApps
2225
+ maxAppDirs: planCheck.remaining
2240
2226
  });
2241
2227
  if (!projectResult) return 1;
2242
2228
  if (!projectResult.repositoryBound && identity?.repoCanonical) {
2243
- p6.log.warn(
2229
+ p12.log.warn(
2244
2230
  `This repository isn't accessible to your GitHub App installation.
2245
2231
  Translations won't run automatically until you grant access.
2246
2232
 
2247
2233
  To fix: go to your GitHub App installation settings and add this
2248
2234
  repository to the allowed list, or switch to "All repositories".
2249
2235
  ` + (projectResult.configureUrl ? `
2250
- ${chalk5.dim(projectResult.configureUrl)}
2236
+ ${chalk11.dim(projectResult.configureUrl)}
2251
2237
  ` : "")
2252
2238
  );
2253
2239
  }
@@ -2260,28 +2246,28 @@ Translations won't run automatically until you grant access.
2260
2246
  identity?.repoRoot
2261
2247
  );
2262
2248
  printApiKey(projectResult.apiKey, identity?.repoRoot);
2263
- const wantsMcp = await p6.confirm({
2249
+ const wantsMcp = await p12.confirm({
2264
2250
  message: "Set up the Vocoder MCP server for your AI editor?"
2265
2251
  });
2266
- if (!p6.isCancel(wantsMcp) && wantsMcp) {
2252
+ if (!p12.isCancel(wantsMcp) && wantsMcp) {
2267
2253
  await runMcpSetup(projectResult.apiKey);
2268
2254
  }
2269
- p6.outro("You're all set.");
2255
+ p12.outro("You're all set.");
2270
2256
  return 0;
2271
2257
  } catch (error) {
2272
2258
  const message = error instanceof Error ? error.message : "Unknown setup error";
2273
2259
  if (isPlanLimitFailure(message)) {
2274
2260
  printPlanLimitMessage(apiUrl, message);
2275
2261
  } else {
2276
- p6.log.error(message);
2262
+ p12.log.error(message);
2277
2263
  }
2278
2264
  return 1;
2279
2265
  }
2280
2266
  }
2281
2267
 
2282
2268
  // src/commands/locales.ts
2283
- import * as p9 from "@clack/prompts";
2284
- import chalk7 from "chalk";
2269
+ import * as p15 from "@clack/prompts";
2270
+ import chalk13 from "chalk";
2285
2271
  import { config as loadEnv3 } from "dotenv";
2286
2272
  import { readFileSync as readFileSync3 } from "fs";
2287
2273
 
@@ -2289,11 +2275,11 @@ import { readFileSync as readFileSync3 } from "fs";
2289
2275
  import { randomUUID } from "crypto";
2290
2276
  import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2291
2277
  import { join as join3 } from "path";
2292
- import * as p8 from "@clack/prompts";
2293
- import chalk6 from "chalk";
2278
+ import * as p14 from "@clack/prompts";
2279
+ import chalk12 from "chalk";
2294
2280
 
2295
2281
  // src/utils/branch.ts
2296
- import { execSync as execSync4 } from "child_process";
2282
+ import { execSync as execSync6 } from "child_process";
2297
2283
  var REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/g;
2298
2284
  function escapeRegexChar(value) {
2299
2285
  return value.replace(REGEX_SPECIAL_CHARS, "\\$&");
@@ -2315,7 +2301,7 @@ function detectBranch(override) {
2315
2301
  return envBranch;
2316
2302
  }
2317
2303
  try {
2318
- const branch = execSync4("git rev-parse --abbrev-ref HEAD", {
2304
+ const branch = execSync6("git rev-parse --abbrev-ref HEAD", {
2319
2305
  encoding: "utf-8",
2320
2306
  stdio: ["pipe", "pipe", "ignore"]
2321
2307
  }).trim();
@@ -2359,7 +2345,7 @@ function matchBranchPattern(branch, pattern) {
2359
2345
  }
2360
2346
 
2361
2347
  // src/utils/config.ts
2362
- import * as p7 from "@clack/prompts";
2348
+ import * as p13 from "@clack/prompts";
2363
2349
  import { config as loadEnv2 } from "dotenv";
2364
2350
  loadEnv2();
2365
2351
  function extractShortCodeFromApiKey(apiKey) {
@@ -2415,7 +2401,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2415
2401
  };
2416
2402
  const fileConfig = loadVocoderConfig(process.cwd());
2417
2403
  if (!fileConfig) {
2418
- p7.log.warn(
2404
+ p13.log.warn(
2419
2405
  `No ${highlight("vocoder.config.ts")} found \u2014 run ${highlight("npx @vocoder/cli init")} to generate one.`
2420
2406
  );
2421
2407
  }
@@ -2446,7 +2432,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2446
2432
  excludePattern = fileConfig.exclude;
2447
2433
  configSources.excludePattern = "vocoder.config";
2448
2434
  } else if (envExcludePattern) {
2449
- excludePattern = envExcludePattern.split(",").map((p15) => p15.trim()).filter(Boolean);
2435
+ excludePattern = envExcludePattern.split(",").map((p21) => p21.trim()).filter(Boolean);
2450
2436
  configSources.excludePattern = "environment";
2451
2437
  } else {
2452
2438
  excludePattern = defaults.excludePattern;
@@ -2503,7 +2489,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2503
2489
  ...maxWaitMs ? [`Max wait: ${highlight(String(configSources.maxWaitMs))}`] : [],
2504
2490
  `No fallback: ${highlight(String(configSources.noFallback))}`
2505
2491
  ];
2506
- p7.note(lines.join("\n"), "Configuration sources");
2492
+ p13.note(lines.join("\n"), "Configuration sources");
2507
2493
  }
2508
2494
  return {
2509
2495
  includePattern,
@@ -2757,22 +2743,22 @@ async function fetchApiSnapshot(api, params) {
2757
2743
  async function sync(options = {}) {
2758
2744
  const startTime = Date.now();
2759
2745
  const projectRoot = process.cwd();
2760
- p8.intro(chalk6.bold("Vocoder Sync"));
2746
+ p14.intro(chalk12.bold("Vocoder Sync"));
2761
2747
  const mergedConfig = await getMergedConfig(options, options.verbose);
2762
2748
  if (!mergedConfig.apiKey) {
2763
- p8.log.warn("No API key found. Run init to get started:");
2764
- p8.log.info(" npx @vocoder/cli init");
2765
- p8.log.info("");
2766
- p8.log.info(
2749
+ p14.log.warn("No API key found. Run init to get started:");
2750
+ p14.log.info(" npx @vocoder/cli init");
2751
+ p14.log.info("");
2752
+ p14.log.info(
2767
2753
  " Or add your key to .env: VOCODER_API_KEY=vca_..."
2768
2754
  );
2769
- p8.outro("Run `npx @vocoder/cli init` to set up your project.");
2755
+ p14.outro("Run `npx @vocoder/cli init` to set up your project.");
2770
2756
  return 1;
2771
2757
  }
2772
- const spinner7 = p8.spinner();
2758
+ const spinner9 = p14.spinner();
2773
2759
  try {
2774
2760
  const branch = detectBranch(options.branch);
2775
- spinner7.start("Loading project configuration");
2761
+ spinner9.start("Loading project configuration");
2776
2762
  const localConfig = {
2777
2763
  apiKey: mergedConfig.apiKey,
2778
2764
  apiUrl: mergedConfig.apiUrl || "https://vocoder.app"
@@ -2796,18 +2782,18 @@ async function sync(options = {}) {
2796
2782
  ...fileConfig?.industry ?? fileConfig?.appIndustry ? { industry: fileConfig?.industry ?? fileConfig?.appIndustry } : {},
2797
2783
  ...fileConfig?.formality ? { formality: fileConfig.formality } : {}
2798
2784
  };
2799
- spinner7.stop(`Branch: ${highlight(branch)}`);
2785
+ spinner9.stop(`Branch: ${highlight(branch)}`);
2800
2786
  if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
2801
- p8.log.warn(
2787
+ p14.log.warn(
2802
2788
  `Skipping translations (${highlight(branch)} is not a target branch)`
2803
2789
  );
2804
- p8.log.info(`Target branches: ${config.targetBranches.map((b) => highlight(b)).join(", ")}`);
2805
- p8.log.info("Use --force to translate anyway");
2806
- p8.outro("");
2790
+ p14.log.info(`Target branches: ${config.targetBranches.map((b) => highlight(b)).join(", ")}`);
2791
+ p14.log.info("Use --force to translate anyway");
2792
+ p14.outro("");
2807
2793
  return 0;
2808
2794
  }
2809
2795
  const patternsDisplay = Array.isArray(config.includePattern) ? config.includePattern.join(", ") : config.includePattern;
2810
- spinner7.start(`Extracting strings from ${patternsDisplay}`);
2796
+ spinner9.start(`Extracting strings from ${patternsDisplay}`);
2811
2797
  const extractor = new StringExtractor();
2812
2798
  const extractedStrings = await extractor.extractFromProject(
2813
2799
  config.includePattern,
@@ -2815,14 +2801,14 @@ async function sync(options = {}) {
2815
2801
  config.excludePattern
2816
2802
  );
2817
2803
  if (extractedStrings.length === 0) {
2818
- spinner7.stop("No translatable strings found");
2819
- p8.log.warn(
2804
+ spinner9.stop("No translatable strings found");
2805
+ p14.log.warn(
2820
2806
  "Make sure you are wrapping translatable strings with Vocoder"
2821
2807
  );
2822
- p8.outro("");
2808
+ p14.outro("");
2823
2809
  return 0;
2824
2810
  }
2825
- spinner7.stop(
2811
+ spinner9.stop(
2826
2812
  `Extracted ${highlight(extractedStrings.length)} strings from ${highlight(patternsDisplay)}`
2827
2813
  );
2828
2814
  if (options.verbose) {
@@ -2830,10 +2816,10 @@ async function sync(options = {}) {
2830
2816
  if (extractedStrings.length > 5) {
2831
2817
  sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
2832
2818
  }
2833
- p8.note(sampleLines.join("\n"), "Sample strings");
2819
+ p14.note(sampleLines.join("\n"), "Sample strings");
2834
2820
  }
2835
2821
  if (options.dryRun) {
2836
- p8.note(
2822
+ p14.note(
2837
2823
  [
2838
2824
  `Strings: ${extractedStrings.length}`,
2839
2825
  `Branch: ${branch}`,
@@ -2844,19 +2830,19 @@ async function sync(options = {}) {
2844
2830
  ].join("\n"),
2845
2831
  "Dry run - would translate"
2846
2832
  );
2847
- p8.outro("No API calls made.");
2833
+ p14.outro("No API calls made.");
2848
2834
  return 0;
2849
2835
  }
2850
2836
  const repoIdentity = resolveGitRepositoryIdentity();
2851
2837
  if (!repoIdentity && options.verbose) {
2852
- p8.log.warn(
2838
+ p14.log.warn(
2853
2839
  "Could not detect git remote origin. Sync will continue without repo metadata."
2854
2840
  );
2855
2841
  }
2856
2842
  const commitSha = detectCommitSha() ?? void 0;
2857
2843
  const stringEntries = buildStringEntries(extractedStrings);
2858
2844
  if (options.verbose && stringEntries.length !== extractedStrings.length) {
2859
- p8.log.info(
2845
+ p14.log.info(
2860
2846
  `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique strings`
2861
2847
  );
2862
2848
  }
@@ -2866,17 +2852,17 @@ async function sync(options = {}) {
2866
2852
  const cacheFile = getCacheFilePath(projectRoot, fingerprint);
2867
2853
  if (existsSync4(cacheFile)) {
2868
2854
  if (options.verbose) {
2869
- p8.log.info(`Cache hit: ${chalk6.dim(cacheFile)} (fingerprint ${highlight(fingerprint)})`);
2855
+ p14.log.info(`Cache hit: ${chalk12.dim(cacheFile)} (fingerprint ${highlight(fingerprint)})`);
2870
2856
  }
2871
2857
  const duration2 = ((Date.now() - startTime) / 1e3).toFixed(1);
2872
- p8.outro(`Up to date (${duration2}s)`);
2858
+ p14.outro(`Up to date (${duration2}s)`);
2873
2859
  return 0;
2874
2860
  }
2875
2861
  if (options.verbose) {
2876
- p8.log.info(`No cache for fingerprint ${highlight(fingerprint)} \u2014 will submit to API`);
2862
+ p14.log.info(`No cache for fingerprint ${highlight(fingerprint)} \u2014 will submit to API`);
2877
2863
  }
2878
2864
  }
2879
- spinner7.start("Submitting strings to Vocoder API");
2865
+ spinner9.start("Submitting strings to Vocoder API");
2880
2866
  const batchResponse = await api.submitTranslation(
2881
2867
  branch,
2882
2868
  stringEntries,
@@ -2890,33 +2876,33 @@ async function sync(options = {}) {
2890
2876
  },
2891
2877
  repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
2892
2878
  );
2893
- spinner7.stop("Strings submitted");
2879
+ spinner9.stop("Strings submitted");
2894
2880
  const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
2895
2881
  branch,
2896
2882
  requestedMode,
2897
2883
  policy: config.syncPolicy
2898
2884
  });
2899
2885
  if (options.verbose) {
2900
- p8.log.info(`Batch: ${chalk6.dim(batchResponse.batchId)}`);
2901
- p8.log.info(`Requested mode: ${requestedMode}`);
2902
- p8.log.info(`Effective mode: ${effectiveMode}`);
2903
- p8.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2886
+ p14.log.info(`Batch: ${chalk12.dim(batchResponse.batchId)}`);
2887
+ p14.log.info(`Requested mode: ${requestedMode}`);
2888
+ p14.log.info(`Effective mode: ${effectiveMode}`);
2889
+ p14.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2904
2890
  if (batchResponse.queueStatus) {
2905
- p8.log.info(`Queue status: ${batchResponse.queueStatus}`);
2891
+ p14.log.info(`Queue status: ${batchResponse.queueStatus}`);
2906
2892
  }
2907
2893
  }
2908
2894
  if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
2909
- p8.log.success(`Up to date \u2014 ${highlight(batchResponse.totalStrings)} strings, no changes`);
2895
+ p14.log.success(`Up to date \u2014 ${highlight(batchResponse.totalStrings)} strings, no changes`);
2910
2896
  } else if (batchResponse.newStrings === 0) {
2911
- const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk6.yellow(batchResponse.deletedStrings)} archived` : "";
2912
- p8.log.success(`No new strings \u2014 ${highlight(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2897
+ const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk12.yellow(batchResponse.deletedStrings)} archived` : "";
2898
+ p14.log.success(`No new strings \u2014 ${highlight(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2913
2899
  } else {
2914
2900
  const statParts = [`${highlight(batchResponse.newStrings)} new, ${highlight(batchResponse.totalStrings)} total`];
2915
2901
  if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
2916
- statParts.push(`${chalk6.yellow(batchResponse.deletedStrings)} archived`);
2902
+ statParts.push(`${chalk12.yellow(batchResponse.deletedStrings)} archived`);
2917
2903
  }
2918
2904
  const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
2919
- p8.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.map((l) => highlight(l)).join(", ")}${estTime}`);
2905
+ p14.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.map((l) => highlight(l)).join(", ")}${estTime}`);
2920
2906
  }
2921
2907
  let artifacts = null;
2922
2908
  if (batchResponse.translations) {
@@ -2928,7 +2914,7 @@ async function sync(options = {}) {
2928
2914
  let waitError = null;
2929
2915
  if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
2930
2916
  const waitTimeoutSecs = Math.round(waitTimeoutMs / 1e3);
2931
- spinner7.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
2917
+ spinner9.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
2932
2918
  let lastProgress = 0;
2933
2919
  try {
2934
2920
  const completion = await api.waitForCompletion(
@@ -2937,7 +2923,7 @@ async function sync(options = {}) {
2937
2923
  (progress) => {
2938
2924
  const percent = Math.round(progress * 100);
2939
2925
  if (percent > lastProgress) {
2940
- spinner7.message(`Translating... ${percent}%`);
2926
+ spinner9.message(`Translating... ${percent}%`);
2941
2927
  lastProgress = percent;
2942
2928
  }
2943
2929
  }
@@ -2947,14 +2933,14 @@ async function sync(options = {}) {
2947
2933
  translations: completion.translations,
2948
2934
  localeMetadata: completion.localeMetadata
2949
2935
  };
2950
- spinner7.stop("Translations complete");
2936
+ spinner9.stop("Translations complete");
2951
2937
  } catch (error) {
2952
- spinner7.stop("Translation wait incomplete");
2938
+ spinner9.stop("Translation wait incomplete");
2953
2939
  waitError = error instanceof Error ? error : new Error(String(error));
2954
2940
  if (effectiveMode === "required") {
2955
2941
  throw waitError;
2956
2942
  }
2957
- p8.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2943
+ p14.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2958
2944
  }
2959
2945
  }
2960
2946
  if (!artifacts) {
@@ -2963,14 +2949,14 @@ async function sync(options = {}) {
2963
2949
  "Fresh translations are not available and fallback is disabled (--no-fallback)."
2964
2950
  );
2965
2951
  }
2966
- spinner7.start("Loading fallback translations");
2952
+ spinner9.start("Loading fallback translations");
2967
2953
  const localFallback = readLocalCache({
2968
2954
  projectRoot,
2969
2955
  fingerprint
2970
2956
  });
2971
2957
  if (localFallback) {
2972
2958
  artifacts = localFallback;
2973
- spinner7.stop(`Using local cached snapshot (${fingerprint})`);
2959
+ spinner9.stop(`Using local cached snapshot (${fingerprint})`);
2974
2960
  } else {
2975
2961
  try {
2976
2962
  const apiSnapshot = await fetchApiSnapshot(api, {
@@ -2979,15 +2965,15 @@ async function sync(options = {}) {
2979
2965
  });
2980
2966
  if (apiSnapshot) {
2981
2967
  artifacts = apiSnapshot;
2982
- spinner7.stop("Using latest completed API snapshot");
2968
+ spinner9.stop("Using latest completed API snapshot");
2983
2969
  } else {
2984
- spinner7.stop("No completed API snapshot available");
2970
+ spinner9.stop("No completed API snapshot available");
2985
2971
  }
2986
2972
  } catch (error) {
2987
- spinner7.stop("Failed to fetch API snapshot");
2973
+ spinner9.stop("Failed to fetch API snapshot");
2988
2974
  if (options.verbose) {
2989
2975
  const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
2990
- p8.log.warn(`Snapshot fetch error: ${message}`);
2976
+ p14.log.warn(`Snapshot fetch error: ${message}`);
2991
2977
  }
2992
2978
  }
2993
2979
  }
@@ -3018,58 +3004,58 @@ async function sync(options = {}) {
3018
3004
  });
3019
3005
  const cachePath = writeCache({ projectRoot, fingerprint, data });
3020
3006
  if (options.verbose) {
3021
- p8.log.info(`Cache written: ${highlight(cachePath)}`);
3007
+ p14.log.info(`Cache written: ${highlight(cachePath)}`);
3022
3008
  }
3023
3009
  } catch (error) {
3024
3010
  if (options.verbose) {
3025
3011
  const message = error instanceof Error ? error.message : "Unknown cache write error";
3026
- p8.log.warn(`Failed to write cache: ${message}`);
3012
+ p14.log.warn(`Failed to write cache: ${message}`);
3027
3013
  }
3028
3014
  }
3029
3015
  if (artifacts.source !== "fresh") {
3030
3016
  const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
3031
- p8.log.warn(
3017
+ p14.log.warn(
3032
3018
  `Using ${sourceLabel}. New strings may appear after the background sync completes.`
3033
3019
  );
3034
3020
  }
3035
3021
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
3036
- p8.outro(`Sync complete! (${duration}s)`);
3022
+ p14.outro(`Sync complete! (${duration}s)`);
3037
3023
  return 0;
3038
3024
  } catch (error) {
3039
- spinner7.stop();
3025
+ spinner9.stop();
3040
3026
  if (error instanceof VocoderAPIError && error.syncPolicyError) {
3041
- p8.log.error(error.syncPolicyError.message);
3027
+ p14.log.error(error.syncPolicyError.message);
3042
3028
  const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
3043
3029
  for (const line of guidance) {
3044
- p8.log.info(line);
3030
+ p14.log.info(line);
3045
3031
  }
3046
3032
  return 1;
3047
3033
  }
3048
3034
  if (error instanceof VocoderAPIError && error.limitError) {
3049
3035
  const { limitError } = error;
3050
- p8.log.error(limitError.message);
3036
+ p14.log.error(limitError.message);
3051
3037
  const guidance = getLimitErrorGuidance(limitError);
3052
3038
  for (const line of guidance) {
3053
- p8.log.info(line);
3039
+ p14.log.info(line);
3054
3040
  }
3055
3041
  return 1;
3056
3042
  }
3057
3043
  if (error instanceof Error) {
3058
- p8.log.error(error.message);
3044
+ p14.log.error(error.message);
3059
3045
  const isInvalidKey = error.message.toLowerCase().includes("invalid api key") || error instanceof VocoderAPIError && error.status === 401;
3060
3046
  if (isInvalidKey) {
3061
- p8.log.warn(
3047
+ p14.log.warn(
3062
3048
  "API key rejected \u2014 the project may have been deleted or the key revoked."
3063
3049
  );
3064
- p8.log.info(
3050
+ p14.log.info(
3065
3051
  " Run `npx @vocoder/cli init` to create a new project and key."
3066
3052
  );
3067
3053
  } else if (error.message.includes("git branch")) {
3068
- p8.log.warn("Run from a git repository, or use:");
3069
- p8.log.info(" vocoder sync --branch main");
3054
+ p14.log.warn("Run from a git repository, or use:");
3055
+ p14.log.info(" vocoder sync --branch main");
3070
3056
  }
3071
3057
  if (options.verbose) {
3072
- p8.log.info(`Full error: ${error.stack ?? error}`);
3058
+ p14.log.info(`Full error: ${error.stack ?? error}`);
3073
3059
  }
3074
3060
  }
3075
3061
  return 1;
@@ -3092,7 +3078,7 @@ function readLocalAppId() {
3092
3078
  function getApiConfig(options) {
3093
3079
  const apiKey = process.env.VOCODER_API_KEY;
3094
3080
  if (!apiKey) {
3095
- p9.log.error(
3081
+ p15.log.error(
3096
3082
  "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3097
3083
  );
3098
3084
  return null;
@@ -3108,19 +3094,19 @@ async function listProjectLocales(options = {}) {
3108
3094
  const api = new VocoderAPI(config);
3109
3095
  try {
3110
3096
  const projectConfig = await api.getAppConfig();
3111
- p9.log.info(
3097
+ p15.log.info(
3112
3098
  `Source locale: ${highlight(projectConfig.sourceLocale)}`
3113
3099
  );
3114
3100
  if (projectConfig.targetLocales.length === 0) {
3115
- p9.log.info("Target locales: (none configured)");
3101
+ p15.log.info("Target locales: (none configured)");
3116
3102
  } else {
3117
- p9.log.info(
3103
+ p15.log.info(
3118
3104
  `Target locales: ${projectConfig.targetLocales.map((l) => highlight(l)).join(", ")}`
3119
3105
  );
3120
3106
  }
3121
3107
  return 0;
3122
3108
  } catch (error) {
3123
- p9.log.error(
3109
+ p15.log.error(
3124
3110
  error instanceof Error ? error.message : "Failed to fetch project locales."
3125
3111
  );
3126
3112
  return 1;
@@ -3128,7 +3114,7 @@ async function listProjectLocales(options = {}) {
3128
3114
  }
3129
3115
  async function addLocales(locales, options = {}) {
3130
3116
  if (locales.length === 0) {
3131
- p9.log.error("No locale codes provided.");
3117
+ p15.log.error("No locale codes provided.");
3132
3118
  return 1;
3133
3119
  }
3134
3120
  const config = getApiConfig(options);
@@ -3138,30 +3124,30 @@ async function addLocales(locales, options = {}) {
3138
3124
  let lastTargetLocales = [];
3139
3125
  let hadError = false;
3140
3126
  for (const locale of locales) {
3141
- const spinner7 = p9.spinner();
3142
- spinner7.start(`Adding ${locale}\u2026`);
3127
+ const spinner9 = p15.spinner();
3128
+ spinner9.start(`Adding ${locale}\u2026`);
3143
3129
  try {
3144
3130
  const result = await api.addLocale(locale, void 0, appId);
3145
3131
  lastTargetLocales = result.targetLocales;
3146
- spinner7.stop(`Added ${highlight(locale)}`);
3132
+ spinner9.stop(`Added ${highlight(locale)}`);
3147
3133
  } catch (error) {
3148
- spinner7.stop(`Failed to add ${chalk7.red(locale)}`);
3134
+ spinner9.stop(`Failed to add ${chalk13.red(locale)}`);
3149
3135
  hadError = true;
3150
3136
  if (error instanceof VocoderAPIError && error.limitError) {
3151
3137
  const { limitError } = error;
3152
- p9.log.error(limitError.message);
3138
+ p15.log.error(limitError.message);
3153
3139
  for (const line of getLimitErrorGuidance(limitError)) {
3154
- p9.log.info(line);
3140
+ p15.log.info(line);
3155
3141
  }
3156
3142
  break;
3157
3143
  }
3158
- p9.log.error(
3144
+ p15.log.error(
3159
3145
  error instanceof Error ? error.message : "Unknown error"
3160
3146
  );
3161
3147
  }
3162
3148
  }
3163
3149
  if (lastTargetLocales.length > 0) {
3164
- p9.log.info(
3150
+ p15.log.info(
3165
3151
  `Target locales now: ${lastTargetLocales.map((l) => highlight(l)).join(", ")}`
3166
3152
  );
3167
3153
  }
@@ -3169,7 +3155,7 @@ async function addLocales(locales, options = {}) {
3169
3155
  }
3170
3156
  async function removeLocales(locales, options = {}) {
3171
3157
  if (locales.length === 0) {
3172
- p9.log.error("No locale codes provided.");
3158
+ p15.log.error("No locale codes provided.");
3173
3159
  return 1;
3174
3160
  }
3175
3161
  const config = getApiConfig(options);
@@ -3179,26 +3165,26 @@ async function removeLocales(locales, options = {}) {
3179
3165
  let lastTargetLocales = [];
3180
3166
  let hadError = false;
3181
3167
  for (const locale of locales) {
3182
- const spinner7 = p9.spinner();
3183
- spinner7.start(`Removing ${locale}\u2026`);
3168
+ const spinner9 = p15.spinner();
3169
+ spinner9.start(`Removing ${locale}\u2026`);
3184
3170
  try {
3185
3171
  const result = await api.removeLocale(locale, void 0, appId);
3186
3172
  lastTargetLocales = result.targetLocales;
3187
- spinner7.stop(`Removed ${highlight(locale)}`);
3173
+ spinner9.stop(`Removed ${highlight(locale)}`);
3188
3174
  } catch (error) {
3189
- spinner7.stop(`Failed to remove ${chalk7.red(locale)}`);
3175
+ spinner9.stop(`Failed to remove ${chalk13.red(locale)}`);
3190
3176
  hadError = true;
3191
- p9.log.error(
3177
+ p15.log.error(
3192
3178
  error instanceof Error ? error.message : "Unknown error"
3193
3179
  );
3194
3180
  }
3195
3181
  }
3196
3182
  if (lastTargetLocales.length > 0) {
3197
- p9.log.info(
3183
+ p15.log.info(
3198
3184
  `Target locales now: ${lastTargetLocales.map((l) => highlight(l)).join(", ")}`
3199
3185
  );
3200
3186
  } else if (!hadError) {
3201
- p9.log.info("Target locales now: (none configured)");
3187
+ p15.log.info("Target locales now: (none configured)");
3202
3188
  }
3203
3189
  return hadError ? 1 : 0;
3204
3190
  }
@@ -3208,14 +3194,14 @@ async function listSupportedLocales(options = {}) {
3208
3194
  const api = new VocoderAPI(config);
3209
3195
  try {
3210
3196
  const result = await api.listLocales(config.apiKey);
3211
- p9.log.info(chalk7.bold("Source locales:"));
3197
+ p15.log.info(chalk13.bold("Source locales:"));
3212
3198
  printLocaleTable(result.sourceLocales);
3213
- p9.log.info("");
3214
- p9.log.info(chalk7.bold("Target locales:"));
3199
+ p15.log.info("");
3200
+ p15.log.info(chalk13.bold("Target locales:"));
3215
3201
  printLocaleTable(result.targetLocales);
3216
3202
  return 0;
3217
3203
  } catch (error) {
3218
- p9.log.error(
3204
+ p15.log.error(
3219
3205
  error instanceof Error ? error.message : "Failed to fetch supported locales."
3220
3206
  );
3221
3207
  return 1;
@@ -3224,16 +3210,16 @@ async function listSupportedLocales(options = {}) {
3224
3210
  function printLocaleTable(locales) {
3225
3211
  for (const locale of locales) {
3226
3212
  const native = locale.nativeName && locale.nativeName !== locale.name ? ` (${locale.nativeName})` : "";
3227
- p9.log.info(` ${highlight(locale.code.padEnd(10))} ${locale.name}${native}`);
3213
+ p15.log.info(` ${highlight(locale.code.padEnd(10))} ${locale.name}${native}`);
3228
3214
  }
3229
3215
  }
3230
3216
 
3231
3217
  // src/commands/logout.ts
3232
- import * as p10 from "@clack/prompts";
3218
+ import * as p16 from "@clack/prompts";
3233
3219
  async function logout(options = {}) {
3234
3220
  const stored = readAuthData();
3235
3221
  if (!stored) {
3236
- p10.log.info("Not currently authenticated.");
3222
+ p16.log.info("Not currently authenticated.");
3237
3223
  return 0;
3238
3224
  }
3239
3225
  const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
@@ -3243,19 +3229,19 @@ async function logout(options = {}) {
3243
3229
  } catch {
3244
3230
  }
3245
3231
  clearAuthData();
3246
- p10.log.success(`Logged out (was ${stored.email})`);
3232
+ p16.log.success(`Logged out (was ${stored.email})`);
3247
3233
  return 0;
3248
3234
  }
3249
3235
 
3250
3236
  // src/commands/app-config.ts
3251
- import * as p11 from "@clack/prompts";
3252
- import chalk8 from "chalk";
3237
+ import * as p17 from "@clack/prompts";
3238
+ import chalk14 from "chalk";
3253
3239
  import { config as loadEnv4 } from "dotenv";
3254
3240
  loadEnv4();
3255
3241
  async function appConfig(options = {}) {
3256
3242
  const apiKey = process.env.VOCODER_API_KEY;
3257
3243
  if (!apiKey) {
3258
- p11.log.error(
3244
+ p17.log.error(
3259
3245
  "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3260
3246
  );
3261
3247
  return 1;
@@ -3265,10 +3251,10 @@ async function appConfig(options = {}) {
3265
3251
  try {
3266
3252
  const config = await api.getAppConfig();
3267
3253
  const lines = [
3268
- `App: ${chalk8.bold(config.projectName)}`,
3254
+ `App: ${chalk14.bold(config.projectName)}`,
3269
3255
  `Organization: ${config.organizationName}`,
3270
3256
  `Source locale: ${highlight(config.sourceLocale)}`,
3271
- `Target locales: ${config.targetLocales.length > 0 ? config.targetLocales.map((l) => highlight(l)).join(", ") : chalk8.dim("(none)")}`,
3257
+ `Target locales: ${config.targetLocales.length > 0 ? config.targetLocales.map((l) => highlight(l)).join(", ") : chalk14.dim("(none)")}`,
3272
3258
  `Target branches: ${config.targetBranches.map((b) => highlight(b)).join(", ")}`,
3273
3259
  ...config.primaryBranch ? [`Primary branch: ${highlight(config.primaryBranch)}`] : [],
3274
3260
  `Sync policy:`,
@@ -3277,10 +3263,10 @@ async function appConfig(options = {}) {
3277
3263
  ` Non-blocking mode: ${highlight(config.syncPolicy.nonBlockingMode)}`,
3278
3264
  ` Max wait: ${highlight(String(config.syncPolicy.defaultMaxWaitMs))} ms`
3279
3265
  ];
3280
- p11.note(lines.join("\n"), `${config.projectName} \u2014 app config`);
3266
+ p17.note(lines.join("\n"), `${config.projectName} \u2014 app config`);
3281
3267
  return 0;
3282
3268
  } catch (error) {
3283
- p11.log.error(
3269
+ p17.log.error(
3284
3270
  error instanceof Error ? error.message : "Failed to fetch project config."
3285
3271
  );
3286
3272
  return 1;
@@ -3290,13 +3276,13 @@ async function appConfig(options = {}) {
3290
3276
  // src/commands/translations.ts
3291
3277
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
3292
3278
  import { join as join4 } from "path";
3293
- import * as p12 from "@clack/prompts";
3279
+ import * as p18 from "@clack/prompts";
3294
3280
  import { config as loadEnv5 } from "dotenv";
3295
3281
  loadEnv5();
3296
3282
  async function getTranslations(options = {}) {
3297
3283
  const apiKey = process.env.VOCODER_API_KEY;
3298
3284
  if (!apiKey) {
3299
- p12.log.error(
3285
+ p18.log.error(
3300
3286
  "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3301
3287
  );
3302
3288
  return 1;
@@ -3307,25 +3293,25 @@ async function getTranslations(options = {}) {
3307
3293
  try {
3308
3294
  branch = detectBranch(options.branch);
3309
3295
  } catch (error) {
3310
- p12.log.error(
3296
+ p18.log.error(
3311
3297
  error instanceof Error ? error.message : "Failed to detect branch."
3312
3298
  );
3313
3299
  return 1;
3314
3300
  }
3315
- const spinner7 = p12.spinner();
3316
- spinner7.start(`Fetching translations for ${highlight(branch)}\u2026`);
3301
+ const spinner9 = p18.spinner();
3302
+ spinner9.start(`Fetching translations for ${highlight(branch)}\u2026`);
3317
3303
  try {
3318
3304
  const projectConfig = await api.getAppConfig();
3319
3305
  const targetLocales = options.locale ? [options.locale] : projectConfig.targetLocales;
3320
3306
  if (targetLocales.length === 0) {
3321
- spinner7.stop("No target locales configured.");
3322
- p12.log.info("Add target locales with `vocoder locales add <code>`.");
3307
+ spinner9.stop("No target locales configured.");
3308
+ p18.log.info("Add target locales with `vocoder locales add <code>`.");
3323
3309
  return 1;
3324
3310
  }
3325
3311
  const snapshot = await api.getTranslationSnapshot({ branch, targetLocales });
3326
- spinner7.stop(`Fetched translations for ${highlight(branch)}`);
3312
+ spinner9.stop(`Fetched translations for ${highlight(branch)}`);
3327
3313
  if (snapshot.status === "NOT_FOUND") {
3328
- p12.log.warn(
3314
+ p18.log.warn(
3329
3315
  `No translation snapshot found for branch "${branch}". Run \`vocoder sync\` to generate one.`
3330
3316
  );
3331
3317
  return 1;
@@ -3339,8 +3325,8 @@ async function getTranslations(options = {}) {
3339
3325
  }
3340
3326
  return 0;
3341
3327
  } catch (error) {
3342
- spinner7.stop("Failed to fetch translations.");
3343
- p12.log.error(
3328
+ spinner9.stop("Failed to fetch translations.");
3329
+ p18.log.error(
3344
3330
  error instanceof Error ? error.message : "Unknown error."
3345
3331
  );
3346
3332
  return 1;
@@ -3351,19 +3337,19 @@ function writeLocaleFiles(translations, outputDir) {
3351
3337
  for (const [locale, strings] of Object.entries(translations)) {
3352
3338
  const filePath = join4(outputDir, `${locale}.json`);
3353
3339
  writeFileSync4(filePath, JSON.stringify(strings, null, 2) + "\n", "utf-8");
3354
- p12.log.success(`Wrote ${highlight(filePath)}`);
3340
+ p18.log.success(`Wrote ${highlight(filePath)}`);
3355
3341
  }
3356
3342
  }
3357
3343
 
3358
3344
  // src/commands/create-app.ts
3359
- import * as p13 from "@clack/prompts";
3360
- import chalk9 from "chalk";
3345
+ import * as p19 from "@clack/prompts";
3346
+ import chalk15 from "chalk";
3361
3347
  import { config as loadEnv6 } from "dotenv";
3362
3348
  loadEnv6();
3363
3349
  async function createApp(options) {
3364
3350
  const authData = readAuthData();
3365
3351
  if (!authData) {
3366
- p13.log.error(
3352
+ p19.log.error(
3367
3353
  "Not logged in. Run `npx @vocoder/cli init` to authenticate first."
3368
3354
  );
3369
3355
  return 1;
@@ -3382,15 +3368,15 @@ async function createApp(options) {
3382
3368
  appDir = identity.repoAppDir;
3383
3369
  }
3384
3370
  } else {
3385
- p13.log.warn(
3371
+ p19.log.warn(
3386
3372
  "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."
3387
3373
  );
3388
3374
  }
3389
3375
  }
3390
3376
  const targetLocales = options.targetLocales ? options.targetLocales.split(",").map((l) => l.trim()).filter(Boolean) : [];
3391
3377
  const targetBranches = options.targetBranches ? options.targetBranches.split(",").map((b) => b.trim()).filter(Boolean) : ["main"];
3392
- const spinner7 = p13.spinner();
3393
- spinner7.start(`Creating app "${options.name}"\u2026`);
3378
+ const spinner9 = p19.spinner();
3379
+ spinner9.start(`Creating app "${options.name}"\u2026`);
3394
3380
  try {
3395
3381
  const result = await api.createProject(authData.token, {
3396
3382
  organizationId: options.organization,
@@ -3401,35 +3387,35 @@ async function createApp(options) {
3401
3387
  appDirs: [appDir],
3402
3388
  ...repoCanonical ? { repoCanonical } : {}
3403
3389
  });
3404
- spinner7.stop(`Created app ${chalk9.bold(result.projectName)}`);
3390
+ spinner9.stop(`Created app ${chalk15.bold(result.projectName)}`);
3405
3391
  const lines = [
3406
3392
  `Project ID: ${result.projectId}`,
3407
3393
  `Source locale: ${highlight(result.sourceLocale)}`,
3408
- `Target locales: ${result.targetLocales.length > 0 ? result.targetLocales.map((l) => highlight(l)).join(", ") : chalk9.dim("(none)")}`,
3394
+ `Target locales: ${result.targetLocales.length > 0 ? result.targetLocales.map((l) => highlight(l)).join(", ") : chalk15.dim("(none)")}`,
3409
3395
  `Branches: ${result.targetBranches.map((b) => highlight(b)).join(", ")}`,
3410
3396
  ...repoCanonical ? [`Repository: ${highlight(repoCanonical)}${appDir !== "." ? ` (${appDir})` : ""}`] : [],
3411
3397
  "",
3412
3398
  `Add this to your .env file:`,
3413
- ` ${chalk9.bold("VOCODER_API_KEY")}=${highlight(result.apiKey)}`
3399
+ ` ${chalk15.bold("VOCODER_API_KEY")}=${highlight(result.apiKey)}`
3414
3400
  ];
3415
- p13.note(lines.join("\n"), "Project created");
3401
+ p19.note(lines.join("\n"), "Project created");
3416
3402
  if (!result.repositoryBound && repoCanonical) {
3417
- p13.log.warn(
3403
+ p19.log.warn(
3418
3404
  `Repository "${repoCanonical}" was not automatically connected. Ensure your GitHub App installation covers this repository.`
3419
3405
  );
3420
3406
  }
3421
3407
  return 0;
3422
3408
  } catch (error) {
3423
- spinner7.stop("Failed to create project.");
3409
+ spinner9.stop("Failed to create project.");
3424
3410
  if (error instanceof VocoderAPIError && error.limitError) {
3425
3411
  const { limitError } = error;
3426
- p13.log.error(limitError.message);
3412
+ p19.log.error(limitError.message);
3427
3413
  for (const line of getLimitErrorGuidance(limitError)) {
3428
- p13.log.info(line);
3414
+ p19.log.info(line);
3429
3415
  }
3430
3416
  return 1;
3431
3417
  }
3432
- p13.log.error(
3418
+ p19.log.error(
3433
3419
  error instanceof Error ? error.message : "Unknown error."
3434
3420
  );
3435
3421
  return 1;
@@ -3437,26 +3423,26 @@ async function createApp(options) {
3437
3423
  }
3438
3424
 
3439
3425
  // src/commands/whoami.ts
3440
- import * as p14 from "@clack/prompts";
3441
- import chalk10 from "chalk";
3426
+ import * as p20 from "@clack/prompts";
3427
+ import chalk16 from "chalk";
3442
3428
  async function whoami(options = {}) {
3443
3429
  const stored = readAuthData();
3444
3430
  if (!stored) {
3445
- p14.log.info("Not logged in. Run `vocoder init` to authenticate.");
3431
+ p20.log.info("Not logged in. Run `vocoder init` to authenticate.");
3446
3432
  return 1;
3447
3433
  }
3448
3434
  const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
3449
3435
  const api = new VocoderAPI({ apiUrl, apiKey: "" });
3450
3436
  try {
3451
3437
  const info2 = await api.getCliUserInfo(stored.token);
3452
- p14.log.info(`Logged in as ${chalk10.bold(info2.email)}`);
3438
+ p20.log.info(`Logged in as ${chalk16.bold(info2.email)}`);
3453
3439
  if (info2.name) {
3454
- p14.log.info(`Name: ${info2.name}`);
3440
+ p20.log.info(`Name: ${info2.name}`);
3455
3441
  }
3456
- p14.log.info(`API: ${apiUrl}`);
3442
+ p20.log.info(`API: ${apiUrl}`);
3457
3443
  return 0;
3458
3444
  } catch {
3459
- p14.log.error(
3445
+ p20.log.error(
3460
3446
  "Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate."
3461
3447
  );
3462
3448
  return 1;