@vocoder/cli 0.15.0 → 0.16.1

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