@vocoder/cli 0.16.0 → 0.16.2

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