@vocoder/cli 0.16.2 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  readAuthData,
14
14
  verifyStoredAuth,
15
15
  writeAuthData
16
- } from "./chunk-2JERZ6DL.mjs";
16
+ } from "./chunk-OQWNYACE.mjs";
17
17
 
18
18
  // src/bin.ts
19
19
  import { Command } from "commander";
@@ -21,74 +21,10 @@ import { Command } from "commander";
21
21
  // src/commands/init.ts
22
22
  import * as p12 from "@clack/prompts";
23
23
 
24
- // src/utils/auth-flow.ts
24
+ // src/utils/plan-check.ts
25
25
  import * as p from "@clack/prompts";
26
26
  import chalk from "chalk";
27
27
 
28
- // src/utils/local-server.ts
29
- import { createServer } from "http";
30
- import { URL as URL2 } from "url";
31
- function startCallbackServer() {
32
- return new Promise((resolve3, reject) => {
33
- let settled = false;
34
- let callbackResolve = null;
35
- let callbackReject = null;
36
- const callbackPromise = new Promise((res, rej) => {
37
- callbackResolve = res;
38
- callbackReject = rej;
39
- });
40
- const server = createServer((req, res) => {
41
- if (!req.url) {
42
- res.writeHead(400);
43
- res.end();
44
- return;
45
- }
46
- let pathname;
47
- let params;
48
- try {
49
- const parsed = new URL2(req.url, "http://localhost");
50
- pathname = parsed.pathname;
51
- params = Object.fromEntries(parsed.searchParams.entries());
52
- } catch {
53
- res.writeHead(400);
54
- res.end("Bad request");
55
- return;
56
- }
57
- if (pathname !== "/callback") {
58
- res.writeHead(404);
59
- res.end("Not found");
60
- return;
61
- }
62
- res.writeHead(200, { "Content-Type": "text/html" });
63
- res.end(
64
- '<!DOCTYPE html><html><head><title>Authenticated</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;"><h2>Authenticated</h2><p>Return to your terminal to continue. You can close this tab.</p></body></html>'
65
- );
66
- if (callbackResolve) {
67
- callbackResolve(params);
68
- callbackResolve = null;
69
- }
70
- setImmediate(() => server.close());
71
- });
72
- server.on("error", (err) => {
73
- if (!settled) {
74
- settled = true;
75
- if (callbackReject) callbackReject(err);
76
- reject(err);
77
- }
78
- });
79
- server.listen(0, "127.0.0.1", () => {
80
- if (settled) return;
81
- settled = true;
82
- const port = server.address().port;
83
- resolve3({
84
- port,
85
- waitForCallback: () => callbackPromise,
86
- close: () => server.close()
87
- });
88
- });
89
- });
90
- }
91
-
92
28
  // src/utils/browser.ts
93
29
  import { spawn } from "child_process";
94
30
  async function tryOpenBrowser(url) {
@@ -137,1917 +73,1990 @@ async function tryOpenBrowser(url) {
137
73
  });
138
74
  }
139
75
 
140
- // src/utils/auth-flow.ts
141
- async function sleep(ms) {
142
- await new Promise((resolve3) => setTimeout(resolve3, ms));
76
+ // src/utils/plan-check.ts
77
+ var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
78
+ function getSubscriptionSettingsUrl(apiUrl) {
79
+ return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
143
80
  }
144
- async function runAuthFlow(api, options, reauth = false, repoCanonical) {
145
- let server = null;
146
- if (!options.ci) {
147
- try {
148
- server = await startCallbackServer();
149
- } catch {
150
- }
151
- }
152
- const session = await api.startCliAuthSession(server?.port, repoCanonical);
153
- const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
154
- const expiresAt = new Date(session.expiresAt).getTime();
155
- if (options.ci) {
156
- process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
157
- `);
158
- process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
159
- `);
160
- } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
161
- if (reauth) {
162
- if (!options.yes) {
163
- const shouldOpen = await p.confirm({
164
- message: "Open your browser to sign in again?"
165
- });
166
- if (p.isCancel(shouldOpen)) {
167
- server?.close();
168
- p.cancel("Setup cancelled.");
169
- return null;
170
- }
171
- if (!shouldOpen) {
172
- server?.close();
173
- p.cancel("Setup cancelled.");
174
- return null;
175
- }
176
- const opened = await tryOpenBrowser(browserUrl);
177
- if (!opened) {
178
- p.note(browserUrl, "Sign In");
179
- p.log.info("Open the URL above manually to continue.");
180
- }
181
- } else {
182
- await tryOpenBrowser(browserUrl);
183
- }
184
- } else {
185
- let isLinkFlow = false;
186
- if (!options.yes) {
187
- const connectChoice = await p.select({
188
- message: "Vocoder needs to be installed on your GitHub account to get started",
189
- options: [
190
- {
191
- value: "install",
192
- label: "Install GitHub App",
193
- hint: "new user"
194
- },
195
- {
196
- value: "link",
197
- label: "Already installed? Link your account",
198
- hint: "returning user"
199
- }
200
- ]
201
- });
202
- if (p.isCancel(connectChoice)) {
203
- server?.close();
204
- p.cancel("Setup cancelled.");
205
- return null;
206
- }
207
- isLinkFlow = connectChoice === "link";
208
- }
209
- let urlToOpen = browserUrl;
210
- if (isLinkFlow) {
211
- try {
212
- const linkSession = await api.startCliGitHubLinkSession(
213
- session.sessionId,
214
- server?.port
215
- );
216
- urlToOpen = linkSession.oauthUrl;
217
- } catch {
218
- urlToOpen = browserUrl;
219
- }
220
- }
221
- const opened = await tryOpenBrowser(urlToOpen);
222
- if (!opened) {
223
- p.log.warn("Could not open your browser automatically.");
224
- p.note(urlToOpen, "GitHub");
225
- p.log.info("Open the URL above to continue.");
226
- }
81
+ function isPlanLimitFailure(message) {
82
+ if (!message) return false;
83
+ return /limit|upgrade/i.test(message);
84
+ }
85
+ function printPlanLimitMessage(apiUrl, message) {
86
+ p.log.error(`You are over your plan limits.
87
+ ${message}`);
88
+ p.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
89
+ }
90
+ async function checkPlanLimits(api, userToken, organizationId, apiUrl) {
91
+ try {
92
+ const { organizations } = await api.listOrganizations(userToken);
93
+ const organization = organizations.find((o) => o.id === organizationId);
94
+ if (!organization) {
95
+ return { atLimit: false };
227
96
  }
228
- }
229
- const authSpinner = p.spinner();
230
- authSpinner.start("Waiting for GitHub authorization...");
231
- let rawToken = null;
232
- let callbackOrganizationId;
233
- let callbackDiscoveryReady = false;
234
- const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
235
- let stopPolling = false;
236
- const serverCallback = server ? server.waitForCallback().catch(() => null) : Promise.resolve(null);
237
- const sessionPoll = (async () => {
238
- while (!stopPolling && Date.now() < expiresAt) {
239
- try {
240
- const result = await api.pollCliAuthSession(session.sessionId);
241
- if (result.status === "complete" || result.status === "failed") {
242
- return result;
243
- }
244
- } catch {
97
+ if (organization.maxApps !== -1 && organization.appCount >= organization.maxApps) {
98
+ p.log.warn(
99
+ `App limit reached \u2014 ${organization.appCount}/${organization.maxApps} on your ${chalk.bold(organization.planId)} plan.`
100
+ );
101
+ const limitAction = await p.select({
102
+ message: "What would you like to do?",
103
+ options: [
104
+ { value: "upgrade", label: "Upgrade plan" },
105
+ { value: "cancel", label: "Cancel" }
106
+ ]
107
+ });
108
+ if (p.isCancel(limitAction) || limitAction === "cancel") {
109
+ p.cancel("Setup cancelled.");
110
+ return { atLimit: true };
245
111
  }
246
- if (!stopPolling) await sleep(2e3);
112
+ await tryOpenBrowser(getSubscriptionSettingsUrl(apiUrl));
113
+ p.cancel("Upgrade your plan in the browser, then re-run `vocoder init`.");
114
+ return { atLimit: true };
247
115
  }
248
- return null;
249
- })();
250
- const winner = await new Promise((resolve3) => {
251
- let done = false;
252
- serverCallback.then((params) => {
253
- if (done || params === null || typeof params.token !== "string") return;
254
- done = true;
255
- resolve3({ kind: "server", params });
256
- }).catch(() => {
257
- });
258
- sessionPoll.then((result) => {
259
- if (done || result === null) return;
260
- if (result.status === "complete" || result.status === "failed") {
261
- done = true;
262
- resolve3({
263
- kind: "poll",
264
- result
265
- });
266
- }
267
- }).catch(() => {
268
- });
269
- setTimeout(
270
- () => {
271
- if (!done) {
272
- done = true;
273
- resolve3(null);
274
- }
275
- },
276
- Math.max(0, deadline - Date.now())
116
+ const remaining = organization.maxApps === -1 ? void 0 : Math.max(0, organization.maxApps - organization.appCount);
117
+ return { atLimit: false, remaining };
118
+ } catch {
119
+ p.log.warn(
120
+ "Could not verify plan limits \u2014 proceeding, the server will enforce them."
277
121
  );
278
- });
279
- stopPolling = true;
280
- server?.close();
281
- if (winner !== null) {
282
- if (winner.kind === "server") {
283
- rawToken = winner.params.token;
284
- if (typeof winner.params.organizationId === "string" && winner.params.organizationId) {
285
- callbackOrganizationId = winner.params.organizationId;
286
- }
287
- if (winner.params.discovery_ready === "1") {
288
- callbackDiscoveryReady = true;
289
- }
290
- } else if (winner.result.status === "complete") {
291
- rawToken = winner.result.token;
292
- if (winner.result.organizationId) {
293
- callbackOrganizationId = winner.result.organizationId;
294
- }
295
- } else {
296
- authSpinner.stop();
297
- p.log.error(winner.result.reason);
298
- return null;
299
- }
300
- }
301
- if (!rawToken) {
302
- authSpinner.stop();
303
- p.log.error("The authentication link expired. Run `vocoder init` again.");
304
- return null;
122
+ return { atLimit: false };
305
123
  }
306
- const userInfo = await api.getCliUserInfo(rawToken);
307
- authSpinner.stop(`Authenticated as ${chalk.bold(userInfo.email)}`);
308
- return {
309
- token: rawToken,
310
- ...userInfo,
311
- organizationId: callbackOrganizationId,
312
- discoveryReady: callbackDiscoveryReady
313
- };
314
124
  }
315
125
 
316
- // src/utils/output.ts
317
- import * as p2 from "@clack/prompts";
318
- import chalk2 from "chalk";
319
- import { execSync } from "child_process";
320
- import { existsSync, readFileSync, writeFileSync } from "fs";
321
- import { join } from "path";
322
- function tryClipboard(text2) {
323
- const tools = [
324
- { cmd: "pbcopy" },
325
- { cmd: "xclip", args: ["-selection", "clipboard"] },
326
- { cmd: "xsel", args: ["--clipboard", "--input"] },
327
- { cmd: "wl-copy" },
328
- { cmd: "clip" }
329
- ];
330
- for (const { cmd, args = [] } of tools) {
331
- try {
332
- execSync([cmd, ...args].join(" "), {
333
- input: text2,
334
- stdio: ["pipe", "ignore", "ignore"]
335
- });
336
- return true;
337
- } catch {
338
- continue;
339
- }
340
- }
341
- return false;
342
- }
343
- function printCommand(cmd) {
344
- const copied = tryClipboard(cmd);
345
- process.stdout.write("\n");
346
- process.stdout.write(` ${chalk2.dim("$")} ${chalk2.cyan(cmd)}
347
- `);
348
- if (copied) process.stdout.write(` ${chalk2.dim("\u2191 copied to clipboard")}
349
- `);
350
- process.stdout.write("\n");
351
- }
352
- function printCodeBlock(code) {
353
- process.stdout.write("\n");
354
- for (const line of code.split("\n")) {
355
- process.stdout.write(` ${line}
356
- `);
357
- }
358
- process.stdout.write("\n");
359
- }
360
- function writeApiKeyToEnv(apiKey, repoRoot) {
361
- const envPath = join(repoRoot ?? process.cwd(), ".env");
362
- if (!existsSync(envPath)) return false;
363
- try {
364
- const content = readFileSync(envPath, "utf-8");
365
- const keyLine = `VOCODER_API_KEY=${apiKey}`;
366
- let updated;
367
- if (/^VOCODER_API_KEY=/m.test(content)) {
368
- updated = content.replace(/^VOCODER_API_KEY=.*/m, keyLine);
369
- } else {
370
- const sep = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
371
- updated = `${content}${sep}${keyLine}
372
- `;
373
- }
374
- writeFileSync(envPath, updated);
375
- return true;
376
- } catch {
377
- return false;
378
- }
379
- }
380
- function printApiKey(apiKey, repoRoot) {
381
- const saved = writeApiKeyToEnv(apiKey, repoRoot);
382
- p2.log.message("");
383
- p2.log.message(chalk2.bold("Your API Key"));
384
- printCodeBlock(`VOCODER_API_KEY=${apiKey}`);
385
- if (saved) {
386
- p2.log.success(chalk2.dim("Saved to .env"));
387
- } else {
388
- p2.log.message(chalk2.dim(" Add the above to your .env file"));
389
- }
390
- }
126
+ // src/utils/project-create.ts
127
+ import * as p4 from "@clack/prompts";
128
+ import chalk3 from "chalk";
391
129
 
392
- // src/utils/scaffold.ts
393
- import * as p3 from "@clack/prompts";
394
- import chalk4 from "chalk";
395
- import { execSync as execSync2 } from "child_process";
130
+ // src/utils/app-dir-select.ts
131
+ import { existsSync, statSync } from "fs";
396
132
  import { resolve } from "path";
133
+ import { isCancel as isCancel2, Prompt } from "@clack/core";
134
+ import * as p2 from "@clack/prompts";
397
135
 
398
136
  // src/utils/theme.ts
399
- import chalk3 from "chalk";
137
+ import chalk2 from "chalk";
400
138
  var ORANGE = "#FC5206";
401
139
  var PINK = "#D51977";
402
140
  var BLUE = "#2450A9";
403
141
  var noColor = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
404
- var hex = (color) => (s) => noColor ? s : chalk3.hex(color)(s);
405
- var dim = (s) => noColor ? s : chalk3.dim(s);
406
- var bld = (s) => noColor ? s : chalk3.bold(s);
407
- var grn = (s) => noColor ? s : chalk3.green(s);
408
- var ylw = (s) => noColor ? s : chalk3.yellow(s);
409
- var red = (s) => noColor ? s : chalk3.red(s);
142
+ var hex = (color) => (s) => noColor ? s : chalk2.hex(color)(s);
143
+ var dim = (s) => noColor ? s : chalk2.dim(s);
144
+ var bld = (s) => noColor ? s : chalk2.bold(s);
145
+ var grn = (s) => noColor ? s : chalk2.green(s);
146
+ var ylw = (s) => noColor ? s : chalk2.yellow(s);
147
+ var red = (s) => noColor ? s : chalk2.red(s);
410
148
  var highlight = hex(PINK);
411
149
  var info = hex(BLUE);
412
150
  var active = hex(ORANGE);
413
151
 
414
- // src/utils/write-config.ts
415
- import { existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
416
- import { join as join2 } from "path";
417
- function findExistingConfig(cwd = process.cwd()) {
418
- for (const name of [
419
- "vocoder.config.ts",
420
- "vocoder.config.js",
421
- "vocoder.config.json"
422
- ]) {
423
- const candidate = join2(cwd, name);
424
- if (existsSync2(candidate)) return candidate;
152
+ // src/utils/app-dir-select.ts
153
+ var S_BAR = "\u2502";
154
+ var S_BAR_END = "\u2514";
155
+ var S_ACTIVE = "\u25C6";
156
+ var S_SUBMIT = "\u25C6";
157
+ var S_CANCEL = "\u25A0";
158
+ var S_ERROR = "\u25B2";
159
+ function symbol(state) {
160
+ switch (state) {
161
+ case "submit":
162
+ return grn(S_SUBMIT);
163
+ case "cancel":
164
+ return red(S_CANCEL);
165
+ case "error":
166
+ return ylw(S_ERROR);
167
+ default:
168
+ return active(S_ACTIVE);
425
169
  }
426
- return null;
427
170
  }
428
- function writeVocoderConfig(options) {
429
- const {
430
- targetBranches = ["main"],
431
- useTypeScript = true,
432
- cwd = process.cwd(),
433
- appId
434
- } = options;
435
- if (findExistingConfig(cwd)) return null;
436
- const ext = useTypeScript ? "ts" : "js";
437
- const configPath = join2(cwd, `vocoder.config.${ext}`);
438
- const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
439
- const includes = ["**/*.{tsx,jsx,ts,js}"];
440
- const includesStr = includes.map((p21) => `'${p21}'`).join(", ");
441
- const appIdLine = appId ? ` appId: '${appId}',
442
- ` : "";
443
- const content = `import { defineConfig } from '@vocoder/config'
444
-
445
- export default defineConfig({
446
- ${appIdLine} targetBranches: [${branchesStr}],
447
- include: [${includesStr}],
448
- })
449
- `;
450
- try {
451
- writeFileSync2(configPath, content, "utf-8");
452
- return `vocoder.config.${ext}`;
453
- } catch {
454
- return null;
171
+ function validateAppDirPath(val, existing, opts = {}) {
172
+ if (val.startsWith("/")) return "Must be a relative path (e.g. apps/web)";
173
+ if (val.includes("..")) return "Path traversal not allowed";
174
+ const hasWholeRepo = existing.includes("");
175
+ const hasScoped = existing.some((d) => d !== "");
176
+ if (val === "" && hasScoped) return "Cannot add whole-repo scope to a monorepo project";
177
+ if (val !== "" && hasWholeRepo) return "Cannot add a scoped directory to a whole-repo project";
178
+ if (existing.includes(val)) return `Already added: ${val}`;
179
+ const nested = existing.find(
180
+ (d) => d !== "" && (val.startsWith(d + "/") || d.startsWith(val + "/"))
181
+ );
182
+ if (nested) return `"${val}" overlaps with already-added "${nested}"`;
183
+ if (val !== "") {
184
+ const abs = resolve(opts.cwd ?? process.cwd(), val);
185
+ if (!existsSync(abs)) return `Directory not found: ${val}`;
186
+ if (!statSync(abs).isDirectory()) return `Not a directory: ${val}`;
455
187
  }
188
+ return null;
456
189
  }
457
-
458
- // src/utils/scaffold.ts
459
- function runScaffold(params) {
460
- const { targetBranches } = params;
461
- const detection = detectLocalEcosystem();
462
- if (detection.ecosystem) {
463
- const frameworkLabel = detection.framework ?? detection.ecosystem;
464
- const pmLabel = detection.packageManager;
465
- p3.log.info(`Detected: ${chalk4.bold(frameworkLabel)} (${pmLabel})`);
466
- }
467
- const { devPackages, runtimePackages } = getPackagesToInstall(detection);
468
- const allPackages = [...devPackages, ...runtimePackages];
469
- if (allPackages.length > 0) {
470
- p3.log.info("");
471
- const installSpinner = p3.spinner();
472
- installSpinner.start(`Installing ${allPackages.join(", ")}...`);
473
- try {
474
- if (devPackages.length > 0) {
475
- execSync2(
476
- buildInstallCommand(detection.packageManager, devPackages, true),
477
- { stdio: "pipe", cwd: process.cwd() }
478
- );
479
- }
480
- if (runtimePackages.length > 0) {
481
- execSync2(
482
- buildInstallCommand(detection.packageManager, runtimePackages, false),
483
- { stdio: "pipe", cwd: process.cwd() }
484
- );
190
+ async function collectAppDirs(opts = {}) {
191
+ const added = [];
192
+ let filter = "";
193
+ let cursor = 0;
194
+ let addCursor = false;
195
+ const isNewDir = () => {
196
+ const t = filter.trim();
197
+ return t.length > 0 && !added.includes(t);
198
+ };
199
+ const clampCursor = () => {
200
+ const max = added.length - 1;
201
+ if (cursor > max) cursor = Math.max(0, max);
202
+ };
203
+ const prompt = new Prompt(
204
+ {
205
+ validate() {
206
+ return void 0;
207
+ },
208
+ render() {
209
+ const trimmed = filter.trim();
210
+ const hdr = `${dim(S_BAR)}
211
+ ${symbol(this.state)} App directories
212
+ `;
213
+ switch (this.state) {
214
+ case "submit": {
215
+ const summary = added.length > 0 ? bld(added.join(", ")) : dim("none (single-app project)");
216
+ return `${hdr}${dim(S_BAR)} ${summary}`;
217
+ }
218
+ case "cancel":
219
+ return `${hdr}${dim(S_BAR)}`;
220
+ default: {
221
+ const inputHint = filter.length > 0 ? filter : added.length === 0 ? dim("e.g. apps/web") : dim("e.g. apps/api");
222
+ const lines = [
223
+ hdr.trimEnd(),
224
+ `${info(S_BAR)} ${dim("/")} ${inputHint}`,
225
+ info(S_BAR)
226
+ ];
227
+ for (let i = 0; i < added.length; i++) {
228
+ const isCursor = i === cursor && !addCursor;
229
+ const icon = active("\u25FC");
230
+ const label = isCursor ? bld(added[i]) : added[i];
231
+ lines.push(`${info(S_BAR)} ${icon} ${label}`);
232
+ }
233
+ const atLimit = opts.maxDirs !== void 0 && added.length >= opts.maxDirs;
234
+ if (atLimit) {
235
+ lines.push(`${info(S_BAR)} ${dim(`App limit reached (${added.length}/${opts.maxDirs} on your plan)`)}`);
236
+ } else if (isNewDir()) {
237
+ const err = validateAppDirPath(trimmed, added, opts);
238
+ const icon = addCursor ? active("\u25FB") : dim("\u25FB");
239
+ const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}"`;
240
+ lines.push(`${info(S_BAR)} ${icon} ${label}`);
241
+ }
242
+ lines.push(info(S_BAR));
243
+ if (atLimit) {
244
+ lines.push(dim(`${S_BAR} \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
245
+ } else if (added.length === 0 && !isNewDir()) {
246
+ lines.push(dim(`${S_BAR} Monorepo? Type each app's subdirectory path and press Space.`));
247
+ lines.push(dim(`${S_BAR} Single app? Press Enter to skip this step.`));
248
+ } else if (added.length > 0) {
249
+ lines.push(dim(`${S_BAR} ${added.length} added \xB7 \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
250
+ }
251
+ const barEnd = this.state === "error" ? ylw(S_BAR_END) : info(S_BAR_END);
252
+ if (this.state === "error") {
253
+ lines.push(`${ylw(S_BAR_END)} ${ylw(this.error)}`);
254
+ } else {
255
+ lines.push(barEnd);
256
+ }
257
+ lines.push("");
258
+ return lines.join("\n");
259
+ }
260
+ }
485
261
  }
486
- installSpinner.stop(`Installed ${allPackages.join(", ")}`);
487
- } catch {
488
- installSpinner.stop("Package installation failed");
489
- const cmds = [
490
- devPackages.length > 0 ? buildInstallCommand(detection.packageManager, devPackages, true) : null,
491
- runtimePackages.length > 0 ? buildInstallCommand(detection.packageManager, runtimePackages, false) : null
492
- ].filter(Boolean).join(" && ");
493
- p3.log.warn(`Run manually: ${highlight(cmds)}`);
262
+ },
263
+ false
264
+ );
265
+ prompt.on("key", (key) => {
266
+ if (!key || key === " ") return;
267
+ const cp = key.codePointAt(0) ?? 0;
268
+ if (cp === 127 || cp === 8) {
269
+ filter = filter.slice(0, -1);
270
+ addCursor = false;
271
+ } else if (cp >= 32 && cp !== 127) {
272
+ filter += key;
273
+ cursor = 0;
274
+ addCursor = false;
494
275
  }
495
- } else if (detection.ecosystem) {
496
- p3.log.info(`Packages: ${chalk4.green("already installed")}`);
497
- }
498
- const branchList = targetBranches.length > 0 ? targetBranches.map((b) => highlight(b)).join(" or ") : highlight("your target branch");
499
- p3.log.message("");
500
- p3.log.success(`Push to ${branchList} to trigger your first translation run.`);
501
- p3.log.message(info(" Docs: https://vocoder.app/docs/getting-started"));
502
- }
503
- function writeAppConfigs(apps, targetBranches, useTypeScript, repoRoot) {
504
- const base = repoRoot ?? process.cwd();
505
- for (const app of apps) {
506
- const dir = app.appDir ? resolve(base, app.appDir) : base;
507
- const written = writeVocoderConfig({
508
- targetBranches,
509
- appId: app.appId,
510
- cwd: dir,
511
- useTypeScript
512
- });
513
- if (written) {
514
- const displayPath = app.appDir ? `${app.appDir}/${written}` : written;
515
- p3.log.success(`Created ${highlight(displayPath)}`);
516
- } else if (!findExistingConfig(dir)) {
517
- const ext = useTypeScript ? "ts" : "js";
518
- p3.log.warn(
519
- `Could not write ${app.appDir ? `${app.appDir}/` : ""}vocoder.config.${ext} \u2014 create it manually.`
520
- );
521
- }
522
- }
523
- }
524
-
525
- // src/utils/mcp-setup.ts
526
- import * as p4 from "@clack/prompts";
527
- import chalk5 from "chalk";
528
- import { execSync as execSync3 } from "child_process";
529
- var MCP_DOCS_URL = "https://vocoder.app/docs/mcp";
530
- function mcpServerJson(apiKey) {
531
- return JSON.stringify(
532
- {
533
- mcpServers: {
534
- vocoder: {
535
- type: "stdio",
536
- command: "npx",
537
- args: ["-y", "@vocoder/mcp"],
538
- env: { VOCODER_API_KEY: apiKey }
276
+ });
277
+ prompt.on("cursor", (action) => {
278
+ switch (action) {
279
+ case "up":
280
+ if (addCursor) {
281
+ addCursor = false;
282
+ cursor = Math.max(0, added.length - 1);
283
+ } else {
284
+ cursor = Math.max(0, cursor - 1);
285
+ }
286
+ break;
287
+ case "down":
288
+ if (!addCursor && cursor >= added.length - 1 && isNewDir()) {
289
+ addCursor = true;
290
+ } else if (!addCursor) {
291
+ cursor = Math.min(added.length - 1, cursor + 1);
292
+ }
293
+ break;
294
+ case "space": {
295
+ if (addCursor || filter.trim().length > 0 && isNewDir()) {
296
+ if (opts.maxDirs !== void 0 && added.length >= opts.maxDirs) break;
297
+ const trimmed = filter.trim();
298
+ const err = validateAppDirPath(trimmed, added, opts);
299
+ if (!err) {
300
+ added.push(trimmed);
301
+ filter = "";
302
+ addCursor = false;
303
+ cursor = 0;
304
+ }
305
+ } else if (added.length > 0 && !isNewDir()) {
306
+ clampCursor();
307
+ added.splice(cursor, 1);
308
+ if (cursor >= added.length) cursor = Math.max(0, added.length - 1);
539
309
  }
310
+ break;
540
311
  }
541
- },
542
- null,
543
- 2
544
- );
545
- }
546
- async function runMcpSetup(apiKey) {
547
- const tool = await p4.select({
548
- message: "Which AI editor?",
549
- options: [
550
- { value: "claude", label: "Claude Code" },
551
- { value: "cursor", label: "Cursor" },
552
- { value: "windsurf", label: "Windsurf" },
553
- { value: "vscode", label: "VS Code (GitHub Copilot)" },
554
- { value: "other", label: "Other \u2014 show the config JSON" }
555
- ]
312
+ }
556
313
  });
557
- if (p4.isCancel(tool)) return;
558
- if (tool === "claude") {
559
- try {
560
- execSync3(
561
- `claude mcp add --scope user --transport stdio -e VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`,
562
- { stdio: "pipe" }
563
- );
564
- p4.log.success("Vocoder MCP server registered in Claude Code.");
565
- } catch {
566
- p4.log.message("Run this to register the MCP server:");
567
- printCommand(
568
- `claude mcp add --scope user --transport stdio -e VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`
569
- );
570
- p4.log.message(info(` Docs: ${MCP_DOCS_URL}`));
314
+ prompt.on("finalize", () => {
315
+ if (prompt.state === "submit") {
316
+ prompt.value = [...added];
571
317
  }
572
- return;
573
- }
574
- const configPaths = {
575
- cursor: { path: "~/.cursor/mcp.json", merge: true },
576
- windsurf: { path: "~/.codeium/windsurf/mcp_config.json", merge: true },
577
- vscode: { path: ".vscode/mcp.json", merge: true },
578
- other: { path: ".mcp.json", merge: false }
579
- };
580
- const { path: configPath, merge } = configPaths[tool];
581
- const mergeNote = merge ? chalk5.dim(` Merge into ${configPath} (create if missing):`) : chalk5.dim(` Add to ${configPath}:`);
582
- p4.log.message(mergeNote);
583
- printCodeBlock(mcpServerJson(apiKey));
584
- p4.log.message(info(` Docs: ${MCP_DOCS_URL}`));
585
- }
586
-
587
- // src/utils/plan-check.ts
588
- import * as p5 from "@clack/prompts";
589
- import chalk6 from "chalk";
590
- var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
591
- function getSubscriptionSettingsUrl(apiUrl) {
592
- return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
318
+ });
319
+ const result = await prompt.prompt();
320
+ if (isCancel2(result)) return null;
321
+ return result;
593
322
  }
594
- function isPlanLimitFailure(message) {
595
- if (!message) return false;
596
- return /limit|upgrade/i.test(message);
323
+ async function promptSingleAppDir(params) {
324
+ const { existingDirs, cwd } = params;
325
+ const input = await p2.text({
326
+ message: "App directory to add",
327
+ placeholder: "apps/web",
328
+ validate(val) {
329
+ const err = validateAppDirPath(val ?? "", existingDirs, { cwd });
330
+ if (err) return err;
331
+ if (!val) return "Directory is required";
332
+ return void 0;
333
+ }
334
+ });
335
+ if (p2.isCancel(input)) return null;
336
+ return input;
597
337
  }
598
- function printPlanLimitMessage(apiUrl, message) {
599
- p5.log.error(`You are over your plan limits.
600
- ${message}`);
601
- p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
338
+
339
+ // src/utils/branch-select.ts
340
+ import { execSync } from "child_process";
341
+ import { isCancel as isCancel4, Prompt as Prompt2 } from "@clack/core";
342
+ var S_BAR2 = "\u2502";
343
+ var S_BAR_END2 = "\u2514";
344
+ var S_ACTIVE2 = "\u25C6";
345
+ var S_SUBMIT2 = "\u25C6";
346
+ var S_CANCEL2 = "\u25A0";
347
+ var S_ERROR2 = "\u25B2";
348
+ function symbol2(state) {
349
+ switch (state) {
350
+ case "submit":
351
+ return grn(S_SUBMIT2);
352
+ case "cancel":
353
+ return red(S_CANCEL2);
354
+ case "error":
355
+ return ylw(S_ERROR2);
356
+ default:
357
+ return active(S_ACTIVE2);
358
+ }
602
359
  }
603
- async function checkPlanLimits(api, userToken, organizationId, apiUrl) {
360
+ function detectGitBranches(cwd) {
361
+ const workDir = cwd ?? process.cwd();
604
362
  try {
605
- const { organizations } = await api.listOrganizations(userToken);
606
- const organization = organizations.find((o) => o.id === organizationId);
607
- if (!organization) {
608
- return { atLimit: false };
363
+ const localOut = execSync("git branch", {
364
+ cwd: workDir,
365
+ stdio: "pipe"
366
+ }).toString();
367
+ const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
368
+ let remoteBranches = [];
369
+ try {
370
+ const remoteOut = execSync("git branch -r", {
371
+ cwd: workDir,
372
+ stdio: "pipe"
373
+ }).toString();
374
+ remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
375
+ } catch {
609
376
  }
610
- if (organization.maxApps !== -1 && organization.appCount >= organization.maxApps) {
611
- p5.log.warn(
612
- `App limit reached \u2014 ${organization.appCount}/${organization.maxApps} on your ${chalk6.bold(organization.planId)} plan.`
613
- );
614
- const limitAction = await p5.select({
615
- message: "What would you like to do?",
616
- options: [
617
- { value: "upgrade", label: "Upgrade plan" },
618
- { value: "cancel", label: "Cancel" }
619
- ]
620
- });
621
- if (p5.isCancel(limitAction) || limitAction === "cancel") {
622
- p5.cancel("Setup cancelled.");
623
- return { atLimit: true };
624
- }
625
- await tryOpenBrowser(getSubscriptionSettingsUrl(apiUrl));
626
- p5.cancel("Upgrade your plan in the browser, then re-run `vocoder init`.");
627
- return { atLimit: true };
377
+ const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
378
+ let defaultBranch = "main";
379
+ try {
380
+ const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
381
+ cwd: workDir,
382
+ stdio: "pipe"
383
+ }).toString().trim();
384
+ defaultBranch = ref.split("/").pop() ?? "main";
385
+ } catch {
628
386
  }
629
- const remaining = organization.maxApps === -1 ? void 0 : Math.max(0, organization.maxApps - organization.appCount);
630
- return { atLimit: false, remaining };
387
+ return {
388
+ branches: branches.length > 0 ? branches : [defaultBranch],
389
+ defaultBranch
390
+ };
631
391
  } catch {
632
- p5.log.warn(
633
- "Could not verify plan limits \u2014 proceeding, the server will enforce them."
634
- );
635
- return { atLimit: false };
392
+ return { branches: ["main"], defaultBranch: "main" };
636
393
  }
637
394
  }
638
-
639
- // src/utils/organization-select.ts
640
- import * as p8 from "@clack/prompts";
641
- import chalk9 from "chalk";
642
-
643
- // src/utils/github-connect.ts
644
- import * as p6 from "@clack/prompts";
645
- import chalk7 from "chalk";
646
- async function runGitHubInstallFlow(params) {
647
- let server = null;
648
- try {
649
- server = await startCallbackServer();
650
- } catch {
651
- }
652
- const { installUrl } = await params.api.startCliGitHubInstall(
653
- params.userToken,
654
- {
655
- organizationId: params.organizationId,
656
- callbackPort: server?.port
657
- }
658
- );
659
- p6.log.info("Opening GitHub to install the Vocoder App...");
660
- if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
661
- const shouldOpen = params.yes ? true : await p6.confirm({ message: "Open in your browser?" });
662
- if (p6.isCancel(shouldOpen)) {
663
- server?.close();
664
- return null;
665
- }
666
- if (shouldOpen) {
667
- const opened = await tryOpenBrowser(installUrl);
668
- if (!opened) {
669
- p6.log.info(
670
- "Could not open a browser automatically. Use the URL above."
671
- );
672
- }
395
+ var INVALID_CHARS = /[\s?^~:[\]\\]/;
396
+ function validateBranchPattern(pattern) {
397
+ const t = pattern.trim();
398
+ if (!t) return "Pattern cannot be empty";
399
+ if (INVALID_CHARS.test(t))
400
+ return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
401
+ if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
402
+ if (t.includes("//")) return "Cannot contain //";
403
+ return null;
404
+ }
405
+ var MAX_VISIBLE = 10;
406
+ function buildItems(branches, defaultBranch, customPatterns) {
407
+ const items = branches.map((b) => ({
408
+ value: b,
409
+ label: b === defaultBranch ? `${b} (default branch)` : b
410
+ }));
411
+ for (const pt of customPatterns) {
412
+ if (!branches.includes(pt)) {
413
+ items.push({ value: pt, label: pt, isCustom: true });
673
414
  }
674
415
  }
675
- const connectSpinner = p6.spinner();
676
- connectSpinner.start("Waiting for GitHub App installation...");
677
- if (server) {
678
- try {
679
- const params_timeout = 15 * 60 * 1e3;
680
- const callbackParams = await Promise.race([
681
- server.waitForCallback(),
682
- new Promise(
683
- (resolve3) => setTimeout(() => resolve3(null), params_timeout)
684
- )
685
- ]);
686
- server.close();
687
- if (!callbackParams) {
688
- connectSpinner.stop("GitHub App installation timed out");
689
- p6.log.error(
690
- "The installation flow timed out. Run `vocoder init` again."
691
- );
692
- return null;
693
- }
694
- if (callbackParams.error) {
695
- connectSpinner.stop("GitHub App installation failed");
696
- p6.log.error(callbackParams.error);
697
- return null;
698
- }
699
- const { organizationId, connectionLabel, workspace_created } = callbackParams;
700
- if (!organizationId || !connectionLabel) {
701
- connectSpinner.stop("GitHub App installation incomplete");
702
- p6.log.error("Missing organization or connection data from callback.");
703
- return null;
704
- }
705
- connectSpinner.stop(
706
- `Connected to GitHub as ${chalk7.bold(connectionLabel)}`
707
- );
708
- const orgName = workspace_created ? connectionLabel : organizationId;
709
- return {
710
- organizationId,
711
- organizationName: orgName,
712
- connectionLabel
713
- };
714
- } catch {
715
- server.close();
716
- connectSpinner.stop("GitHub App installation failed");
717
- return null;
718
- }
719
- }
720
- connectSpinner.stop("Could not detect GitHub App installation automatically");
721
- p6.log.warn(
722
- "Complete the installation in your browser, then run `vocoder init` again."
723
- );
724
- return null;
416
+ return items;
725
417
  }
726
- async function runGitHubDiscoveryFlow(params) {
727
- let server = null;
728
- try {
729
- server = await startCallbackServer();
730
- } catch {
418
+ function filterItems(items, query) {
419
+ if (!query.trim()) return items;
420
+ const lower = query.toLowerCase();
421
+ return items.filter((i) => i.value.toLowerCase().includes(lower));
422
+ }
423
+ function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedPatterns = /* @__PURE__ */ new Set()) {
424
+ const lines = [info(S_BAR2)];
425
+ const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
426
+ for (let i = scrollOffset; i < end; i++) {
427
+ const item = filtered[i];
428
+ const isCursor = i === cursor && !addCursor;
429
+ const isChecked = selected.has(item.value);
430
+ const icon = isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB");
431
+ let label = item.isCustom ? `${item.label} ${dim("(custom)")}` : item.label;
432
+ if (isCursor) label = bld(label);
433
+ lines.push(`${info(S_BAR2)} ${icon} ${label}`);
731
434
  }
732
- const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
733
- organizationId: params.organizationId,
734
- callbackPort: server?.port
735
- });
736
- p6.log.info("Opening GitHub to authorize your account...");
737
- if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
738
- const shouldOpen = params.yes ? true : await p6.confirm({ message: "Open in your browser?" });
739
- if (p6.isCancel(shouldOpen)) {
740
- server?.close();
741
- return null;
435
+ const trimmed = filter.trim();
436
+ const isNewPattern = trimmed.length > 0 && !filtered.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
437
+ if (isNewPattern) {
438
+ const err = validateBranchPattern(trimmed) ?? (excludedPatterns.has(trimmed) ? "Already used for automatic translation" : null);
439
+ const icon = addCursor ? active("\u25FB") : dim("\u25FB");
440
+ const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}" as branch pattern`;
441
+ lines.push(`${info(S_BAR2)} ${icon} ${label}`);
442
+ } else if (filtered.length === 0 && trimmed.length === 0) {
443
+ lines.push(dim(`${S_BAR2} No branches detected`));
444
+ }
445
+ const hidden = filtered.length - (end - scrollOffset);
446
+ if (hidden > 0) lines.push(dim(`${S_BAR2} ${hidden} more \u2014 keep typing to narrow`));
447
+ return lines.join("\n");
448
+ }
449
+ async function filterableBranchSelect(params) {
450
+ const { message, branches, defaultBranch } = params;
451
+ const optional = params.optional ?? false;
452
+ const excludedSet = new Set(params.excludedPatterns ?? []);
453
+ let filter = "";
454
+ let cursor = 0;
455
+ let scrollOffset = 0;
456
+ let addCursor = false;
457
+ const customPatterns = [];
458
+ const selected = new Set(params.initialValues ?? [defaultBranch]);
459
+ const getItems = () => buildItems(branches, defaultBranch, customPatterns);
460
+ const getFiltered = () => filterItems(getItems(), filter);
461
+ const isNewPattern = () => {
462
+ const t = filter.trim();
463
+ if (!t) return false;
464
+ return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
465
+ };
466
+ const clampCursor = (filtered) => {
467
+ const hasAdd = isNewPattern();
468
+ const max = filtered.length - 1 + (hasAdd ? 1 : 0);
469
+ if (cursor > max && !addCursor) cursor = Math.max(0, max);
470
+ if (!addCursor) {
471
+ if (cursor < scrollOffset) scrollOffset = cursor;
472
+ if (cursor >= scrollOffset + MAX_VISIBLE)
473
+ scrollOffset = cursor - MAX_VISIBLE + 1;
474
+ if (scrollOffset < 0) scrollOffset = 0;
742
475
  }
743
- if (shouldOpen) {
744
- const opened = await tryOpenBrowser(oauthUrl);
745
- if (!opened) {
746
- p6.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
476
+ };
477
+ const prompt = new Prompt2(
478
+ {
479
+ validate() {
480
+ if (!optional && selected.size === 0)
481
+ return "At least one branch is required.";
482
+ return void 0;
483
+ },
484
+ render() {
485
+ const filtered = getFiltered();
486
+ clampCursor(filtered);
487
+ const hdr = `${dim(S_BAR2)}
488
+ ${symbol2(this.state)} ${message}
489
+ `;
490
+ const inputHint = filter.length > 0 ? filter : dim("type to filter \xB7 type a custom pattern to add it");
491
+ const trimmedFilter = filter.trim();
492
+ const footer = (() => {
493
+ if (trimmedFilter.length > 0 && isNewPattern()) {
494
+ return dim(`${S_BAR2} Space to add "${trimmedFilter}" \xB7 \u2191\u2193 navigate \xB7 Enter to confirm`);
495
+ }
496
+ if (selected.size > 0) {
497
+ return dim(`${S_BAR2} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
498
+ }
499
+ 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`);
500
+ })();
501
+ switch (this.state) {
502
+ case "submit": {
503
+ const summary = selected.size > 0 ? bld(Array.from(selected).join(", ")) : dim("none");
504
+ return `${hdr}${dim(S_BAR2)} ${summary}`;
505
+ }
506
+ case "cancel":
507
+ return `${hdr}${dim(S_BAR2)}`;
508
+ case "error":
509
+ return [
510
+ hdr.trimEnd(),
511
+ `${ylw(S_BAR2)} ${dim("/")} ${inputHint}`,
512
+ buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
513
+ footer,
514
+ `${ylw(S_BAR_END2)} ${ylw(this.error)}`,
515
+ ""
516
+ ].join("\n");
517
+ default:
518
+ return [
519
+ hdr.trimEnd(),
520
+ `${info(S_BAR2)} ${dim("/")} ${inputHint}`,
521
+ buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
522
+ footer,
523
+ `${info(S_BAR_END2)}`,
524
+ ""
525
+ ].join("\n");
526
+ }
747
527
  }
528
+ },
529
+ false
530
+ );
531
+ prompt.on("key", (key) => {
532
+ if (!key || key === " ") return;
533
+ const cp = key.codePointAt(0) ?? 0;
534
+ if (cp === 127 || cp === 8) {
535
+ filter = filter.slice(0, -1);
536
+ cursor = 0;
537
+ scrollOffset = 0;
538
+ addCursor = false;
539
+ } else if (cp >= 32 && cp !== 127) {
540
+ filter += key;
541
+ cursor = 0;
542
+ scrollOffset = 0;
543
+ addCursor = false;
748
544
  }
749
- }
750
- const oauthSpinner = p6.spinner();
751
- oauthSpinner.start("Waiting for GitHub authorization...");
752
- if (server) {
753
- try {
754
- const timeoutMs = 10 * 60 * 1e3;
755
- const callbackParams = await Promise.race([
756
- server.waitForCallback(),
757
- new Promise(
758
- (resolve3) => setTimeout(() => resolve3(null), timeoutMs)
759
- )
760
- ]);
761
- server.close();
762
- if (!callbackParams) {
763
- oauthSpinner.stop("GitHub authorization timed out");
764
- return null;
765
- }
766
- if (callbackParams.error) {
767
- oauthSpinner.stop("GitHub authorization failed");
768
- p6.log.error(callbackParams.error);
769
- return null;
770
- }
771
- } catch {
772
- server.close();
773
- oauthSpinner.stop("GitHub authorization failed");
774
- return null;
545
+ if (isNewPattern()) {
546
+ addCursor = true;
775
547
  }
776
- }
777
- oauthSpinner.stop("GitHub account authorized");
778
- const discoveryResult = await params.api.getCliGitHubDiscovery(
779
- params.userToken
780
- );
781
- return discoveryResult.installations;
782
- }
783
- async function selectGitHubInstallation(installations, canInstallNew) {
784
- const options = installations.map((inst) => ({
785
- value: String(inst.installationId),
786
- label: inst.accountLogin,
787
- hint: [
788
- inst.accountType === "Organization" ? "GitHub org" : "personal account",
789
- inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
790
- inst.isSuspended ? "suspended" : ""
791
- ].filter(Boolean).join(" \xB7 ") || void 0
792
- }));
793
- if (canInstallNew) {
794
- options.push({
795
- value: "install_new",
796
- label: `Install on a new account ${chalk7.dim("(creates a new workspace)")}`
797
- });
798
- }
799
- const selected = await p6.select({
800
- message: "Which GitHub account should this workspace connect to?",
801
- options
802
548
  });
803
- if (p6.isCancel(selected)) return null;
804
- if (selected === "install_new") return "install_new";
805
- return Number(selected);
806
- }
807
-
808
- // src/utils/organization.ts
809
- import * as p7 from "@clack/prompts";
810
- import chalk8 from "chalk";
811
- async function selectOrganization(result) {
812
- const { organizations, canCreateOrganization } = result;
813
- if (organizations.length === 0) {
814
- return { action: "create" };
815
- }
816
- const options = organizations.map((org) => {
817
- const atLimit = org.maxApps !== -1 && org.appCount >= org.maxApps;
818
- const hint = [
819
- org.projectCount > 0 ? `${org.projectCount} project${org.projectCount !== 1 ? "s" : ""}` : "",
820
- org.connectionLabel ? `GitHub: ${org.connectionLabel}` : "",
821
- atLimit ? chalk8.yellow(`${org.appCount}/${org.maxApps} apps \u2014 upgrade for more`) : ""
822
- ].filter(Boolean).join(" \xB7 ") || void 0;
823
- return { value: org.id, label: org.name, hint };
549
+ prompt.on("cursor", (action) => {
550
+ const filtered = getFiltered();
551
+ const hasAdd = isNewPattern();
552
+ switch (action) {
553
+ case "up":
554
+ if (addCursor) {
555
+ addCursor = false;
556
+ cursor = Math.max(0, filtered.length - 1);
557
+ } else cursor = Math.max(0, cursor - 1);
558
+ break;
559
+ case "down":
560
+ if (!addCursor && cursor >= filtered.length - 1 && hasAdd)
561
+ addCursor = true;
562
+ else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
563
+ break;
564
+ case "space": {
565
+ const t = filter.trim();
566
+ if (addCursor || t.length > 0 && isNewPattern()) {
567
+ const err = validateBranchPattern(t) ?? (excludedSet.has(t) ? "Already used for automatic translation" : null);
568
+ if (!err) {
569
+ customPatterns.push(t);
570
+ selected.add(t);
571
+ filter = "";
572
+ cursor = 0;
573
+ scrollOffset = 0;
574
+ addCursor = false;
575
+ }
576
+ } else {
577
+ const item = filtered[cursor];
578
+ if (item) {
579
+ if (selected.has(item.value)) selected.delete(item.value);
580
+ else selected.add(item.value);
581
+ }
582
+ }
583
+ break;
584
+ }
585
+ }
824
586
  });
825
- if (canCreateOrganization) {
826
- options.push({ value: "create", label: "Create new workspace" });
827
- }
828
- const selected = await p7.select({
829
- message: "Select workspace",
830
- options
587
+ prompt.on("finalize", () => {
588
+ if (prompt.state === "submit") {
589
+ prompt.value = Array.from(selected);
590
+ }
831
591
  });
832
- if (p7.isCancel(selected)) {
833
- return { action: "cancelled" };
834
- }
835
- if (selected === "create") {
836
- return { action: "create" };
837
- }
838
- const organization = organizations.find((org) => org.id === selected);
839
- if (!organization) {
840
- return { action: "cancelled" };
841
- }
842
- return { action: "use", organization };
592
+ const result = await prompt.prompt();
593
+ if (isCancel4(result)) return null;
594
+ return result;
843
595
  }
844
596
 
845
- // src/utils/organization-select.ts
846
- async function selectOrganizationForInit(params) {
847
- const { api, userToken, userEmail, identity, lookup, repoProjectId, options } = params;
848
- if (params.authOrganizationId) {
849
- const organizationData2 = await api.listOrganizations(userToken);
850
- const organization = organizationData2.organizations.find(
851
- (o) => o.id === params.authOrganizationId
852
- );
853
- const organizationName = organization?.name ?? userEmail;
854
- p8.log.success(
855
- `Connected as ${chalk9.bold(userEmail)} \u2014 workspace: ${chalk9.bold(organizationName)}`
856
- );
857
- return { organizationId: params.authOrganizationId, organizationName };
858
- }
859
- const repoOrgContext = identity ? lookup?.organizationContext ?? null : null;
860
- if (repoOrgContext && !repoProjectId) {
861
- p8.log.success(`Workspace: ${chalk9.bold(repoOrgContext.organizationName)}`);
862
- return {
863
- organizationId: repoOrgContext.organizationId,
864
- organizationName: repoOrgContext.organizationName
865
- };
597
+ // src/utils/locale-search.ts
598
+ import { isCancel as isCancel5, Prompt as Prompt3 } from "@clack/core";
599
+ import * as p3 from "@clack/prompts";
600
+ var S_BAR3 = "\u2502";
601
+ var S_BAR_END3 = "\u2514";
602
+ var S_ACTIVE3 = "\u25C6";
603
+ var S_SUBMIT3 = "\u25C6";
604
+ var S_CANCEL3 = "\u25A0";
605
+ var S_ERROR3 = "\u25B2";
606
+ function symbol3(state) {
607
+ switch (state) {
608
+ case "submit":
609
+ return grn(S_SUBMIT3);
610
+ case "cancel":
611
+ return red(S_CANCEL3);
612
+ case "error":
613
+ return ylw(S_ERROR3);
614
+ default:
615
+ return active(S_ACTIVE3);
866
616
  }
867
- const organizationData = await api.listOrganizations(userToken, {
868
- repo: identity?.repoCanonical
869
- });
870
- const repoCanonical = identity?.repoCanonical ?? null;
871
- const covering = repoCanonical ? organizationData.organizations.filter((o) => o.coversRepo === true) : [];
872
- const connected = organizationData.organizations.filter(
873
- (o) => o.hasGitHubConnection
617
+ }
618
+ var MAX_VISIBLE2 = 12;
619
+ function filterLocales(options, query) {
620
+ if (!query.trim()) return options;
621
+ const lower = query.toLowerCase();
622
+ return options.filter(
623
+ (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
874
624
  );
875
- if (repoCanonical && covering.length === 1) {
876
- const organization = covering[0];
877
- p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
878
- return { organizationId: organization.id, organizationName: organization.name };
879
- }
880
- if (repoCanonical && covering.length > 1) {
881
- const choice = await p8.select({
882
- message: "Select workspace for this repo",
883
- options: covering.map((o) => ({
884
- value: o.id,
885
- label: `${o.name} ${chalk9.dim(`(${o.appCount} app${o.appCount !== 1 ? "s" : ""})`)}`
886
- }))
887
- });
888
- if (p8.isCancel(choice)) {
889
- p8.cancel("Setup cancelled.");
890
- return null;
891
- }
892
- const organization = covering.find((o) => o.id === choice);
893
- p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
894
- return { organizationId: organization.id, organizationName: organization.name };
895
- }
896
- if (repoCanonical && covering.length === 0 && connected.length > 0) {
897
- const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
898
- p8.log.warn(
899
- `${chalk9.bold(shortRepo)} isn't accessible from your Vocoder installation.
900
- Grant access to this repository or install on the account that owns it.`
625
+ }
626
+ function buildList2(filtered, cursor, scrollOffset, selected) {
627
+ const isMulti = selected !== null;
628
+ const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
629
+ const visibleLines = [info(S_BAR3)];
630
+ for (let i = scrollOffset; i < end; i++) {
631
+ const opt = filtered[i];
632
+ const isCursor = i === cursor;
633
+ const isChecked = isMulti && selected.has(opt.bcp47);
634
+ const icon = isMulti ? isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB") : isCursor ? active("\u25CF") : dim("\u25CB");
635
+ visibleLines.push(
636
+ `${info(S_BAR3)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`
901
637
  );
902
- const fixOptions = [];
903
- for (const organization of connected) {
904
- if (organization.installationConfigureUrl) {
905
- fixOptions.push({
906
- value: `grant:${organization.id}`,
907
- label: `Configure ${chalk9.bold(organization.connectionLabel ?? organization.name)}'s GitHub App installation`
908
- });
909
- }
910
- }
911
- fixOptions.push({
912
- value: "install_new",
913
- label: `Install on a different GitHub account ${chalk9.dim("(creates a new personal workspace)")}`
914
- });
915
- fixOptions.push({ value: "cancel", label: "Cancel" });
916
- const fix = await p8.select({
917
- message: "How would you like to fix this?",
918
- options: fixOptions
919
- });
920
- if (p8.isCancel(fix) || fix === "cancel") {
921
- p8.cancel("Setup cancelled.");
922
- return null;
923
- }
924
- if (fix.startsWith("grant:")) {
925
- const organization = connected.find((o) => `grant:${o.id}` === fix);
926
- await tryOpenBrowser(organization.installationConfigureUrl);
927
- p8.cancel(
928
- `Grant access to ${chalk9.bold(shortRepo)} in your browser,
929
- then re-run ${chalk9.bold("vocoder init")}.`
930
- );
931
- return null;
932
- }
933
- const connectResult = await runGitHubInstallFlow({
934
- api,
935
- userToken,
936
- yes: options.yes
937
- });
938
- if (!connectResult) {
939
- p8.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
940
- return null;
941
- }
942
- p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
943
- return {
944
- organizationId: connectResult.organizationId,
945
- organizationName: connectResult.organizationName
946
- };
947
638
  }
948
- const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
949
- const cachedInstallations = discoveryResult?.installations ?? [];
950
- if (cachedInstallations.length > 0) {
951
- if (repoCanonical) {
952
- const repoOwner = repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
953
- if (repoOwner) {
954
- const hasMatchingAccount = cachedInstallations.some(
955
- (i) => i.accountLogin.toLowerCase() === repoOwner
956
- );
957
- if (!hasMatchingAccount) {
958
- p8.log.warn(
959
- `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
960
- The project will be created but translations won't trigger automatically.
961
- To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
962
- );
639
+ const hidden = filtered.length - (end - scrollOffset);
640
+ if (hidden > 0) visibleLines.push(dim(`${S_BAR3} ${hidden} more \u2014 keep typing to narrow`));
641
+ if (filtered.length === 0) visibleLines.push(dim(`${S_BAR3} No matches`));
642
+ return visibleLines.join("\n");
643
+ }
644
+ async function runFilterablePrompt(opts) {
645
+ const { message, options, multi } = opts;
646
+ let filter = "";
647
+ let cursor = 0;
648
+ let scrollOffset = 0;
649
+ const selected = new Set(multi ? opts.initialValues ?? [] : []);
650
+ if (!multi && opts.initialValue) {
651
+ const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
652
+ if (idx >= 0) cursor = idx;
653
+ }
654
+ const getFiltered = () => filterLocales(options, filter);
655
+ const clampCursor = (filtered) => {
656
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
657
+ if (cursor < scrollOffset) scrollOffset = cursor;
658
+ if (cursor >= scrollOffset + MAX_VISIBLE2)
659
+ scrollOffset = cursor - MAX_VISIBLE2 + 1;
660
+ if (scrollOffset < 0) scrollOffset = 0;
661
+ };
662
+ const prompt = new Prompt3(
663
+ {
664
+ initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
665
+ validate() {
666
+ const f = getFiltered();
667
+ if (multi && selected.size === 0)
668
+ return "At least one target language is required.";
669
+ if (!multi && !f[cursor]) return "Please select a language.";
670
+ return void 0;
671
+ },
672
+ render() {
673
+ const filtered = getFiltered();
674
+ clampCursor(filtered);
675
+ const hdr = `${dim(S_BAR3)}
676
+ ${symbol3(this.state)} ${message}
677
+ `;
678
+ const inputHint = filter.length > 0 ? filter : dim("type to filter");
679
+ 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`);
680
+ switch (this.state) {
681
+ case "submit": {
682
+ 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 ?? "";
683
+ return `${hdr}${dim(S_BAR3)} ${bld(val || dim("none"))}`;
684
+ }
685
+ case "cancel":
686
+ return `${hdr}${dim(S_BAR3)}`;
687
+ case "error":
688
+ return [
689
+ hdr.trimEnd(),
690
+ `${ylw(S_BAR3)} ${dim("/")} ${inputHint}`,
691
+ buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
692
+ footer,
693
+ `${ylw(S_BAR_END3)} ${ylw(this.error)}`,
694
+ ""
695
+ ].join("\n");
696
+ default:
697
+ return [
698
+ hdr.trimEnd(),
699
+ `${info(S_BAR3)} ${dim("/")} ${inputHint}`,
700
+ buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
701
+ footer,
702
+ `${info(S_BAR_END3)}`,
703
+ ""
704
+ ].join("\n");
963
705
  }
964
706
  }
707
+ },
708
+ false
709
+ // trackValue=false — we manage value manually
710
+ );
711
+ prompt.on("key", (key) => {
712
+ if (!key || key === " ") return;
713
+ const cp = key.codePointAt(0) ?? 0;
714
+ if (cp === 127 || cp === 8) {
715
+ filter = filter.slice(0, -1);
716
+ cursor = 0;
717
+ scrollOffset = 0;
718
+ } else if (cp >= 32 && cp !== 127) {
719
+ filter += key;
720
+ cursor = 0;
721
+ scrollOffset = 0;
965
722
  }
966
- const validInstallations = cachedInstallations.filter(
967
- (i) => !i.isSuspended && !i.conflictLabel
968
- );
969
- let selectedInstallationId2 = null;
970
- if (validInstallations.length === 1 && cachedInstallations.length === 1) {
971
- selectedInstallationId2 = validInstallations[0].installationId;
972
- } else {
973
- selectedInstallationId2 = await selectGitHubInstallation(
974
- cachedInstallations.map((inst) => ({
975
- installationId: inst.installationId,
976
- accountLogin: inst.accountLogin,
977
- accountType: inst.accountType,
978
- isSuspended: inst.isSuspended,
979
- conflictLabel: inst.conflictLabel
980
- })),
981
- false
982
- );
723
+ });
724
+ prompt.on("cursor", (action) => {
725
+ const filtered = getFiltered();
726
+ switch (action) {
727
+ case "up":
728
+ cursor = Math.max(0, cursor - 1);
729
+ break;
730
+ case "down":
731
+ cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
732
+ break;
733
+ case "space":
734
+ if (multi) {
735
+ const opt = filtered[cursor];
736
+ if (opt) {
737
+ if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
738
+ else selected.add(opt.bcp47);
739
+ }
740
+ }
741
+ break;
983
742
  }
984
- if (selectedInstallationId2 === null || selectedInstallationId2 === "install_new") {
985
- p8.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
986
- return null;
743
+ if (!multi) {
744
+ const opt = getFiltered()[cursor];
745
+ prompt.value = opt?.bcp47 ?? null;
987
746
  }
988
- const claimResult2 = await api.claimCliGitHubInstallation(userToken, {
989
- installationId: String(selectedInstallationId2),
990
- organizationId: null
991
- });
992
- p8.log.success(`Workspace: ${chalk9.bold(claimResult2.organizationName)}`);
993
- return {
994
- organizationId: claimResult2.organizationId,
995
- organizationName: claimResult2.organizationName
996
- };
747
+ });
748
+ prompt.on("finalize", () => {
749
+ if (prompt.state === "submit") {
750
+ if (multi) {
751
+ prompt.value = Array.from(selected);
752
+ } else {
753
+ const f = getFiltered();
754
+ prompt.value = f[cursor]?.bcp47 ?? null;
755
+ }
756
+ }
757
+ });
758
+ const result = await prompt.prompt();
759
+ if (isCancel5(result)) return null;
760
+ return result;
761
+ }
762
+ async function searchSelectLocale(options, message, initialValue) {
763
+ const result = await runFilterablePrompt({
764
+ message,
765
+ options,
766
+ multi: false,
767
+ initialValue
768
+ });
769
+ return typeof result === "string" ? result : null;
770
+ }
771
+ async function searchMultiSelectLocales(options, message, initialValues) {
772
+ const result = await runFilterablePrompt({
773
+ message,
774
+ options,
775
+ multi: true,
776
+ initialValues
777
+ });
778
+ if (result === null) return null;
779
+ const picks = result;
780
+ if (picks.length === 0) {
781
+ p3.log.warn(
782
+ "At least one target language is required. Please select at least one."
783
+ );
784
+ return searchMultiSelectLocales(options, message, initialValues);
997
785
  }
998
- if (organizationData.organizations.length === 1 && !organizationData.canCreateOrganization) {
999
- const organization = organizationData.organizations[0];
1000
- p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
1001
- return { organizationId: organization.id, organizationName: organization.name };
786
+ return picks;
787
+ }
788
+
789
+ // src/utils/project-create.ts
790
+ function buildLocaleOptions(locales) {
791
+ return locales.map((l) => ({
792
+ bcp47: l.code,
793
+ label: `${l.name} \u2014 ${l.code}`
794
+ }));
795
+ }
796
+ function buildLanguageOptions(locales) {
797
+ const byFamily = /* @__PURE__ */ new Map();
798
+ for (const l of locales) {
799
+ const family = l.code.split("-")[0].toLowerCase();
800
+ const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
801
+ const existing = byFamily.get(family);
802
+ if (!existing || l.code.length < existing.bcp47.length) {
803
+ byFamily.set(family, opt);
804
+ }
1002
805
  }
1003
- const organizationResult = await selectOrganization(organizationData);
1004
- if (organizationResult.action === "cancelled") {
1005
- p8.cancel("Setup cancelled.");
806
+ return Array.from(byFamily.values());
807
+ }
808
+ async function runProjectCreate(params) {
809
+ const { api, userToken, organizationId, repoCanonical, repoRoot } = params;
810
+ const projectName = (params.defaultName ?? "my-project").trim();
811
+ p4.log.success(`Project: ${chalk3.bold(projectName)}`);
812
+ let sourceLocales;
813
+ try {
814
+ ({ sourceLocales } = await api.listLocales(userToken));
815
+ } catch {
816
+ p4.log.error(
817
+ "Failed to fetch supported locales. Check your connection and try again."
818
+ );
1006
819
  return null;
1007
820
  }
1008
- if (organizationResult.action === "use") {
1009
- const { organization } = organizationResult;
1010
- p8.log.success(`Workspace: ${chalk9.bold(organization.name)}`);
1011
- return { organizationId: organization.id, organizationName: organization.name };
821
+ const languageOptions = buildLanguageOptions(sourceLocales);
822
+ const appDirs = await collectAppDirs({ cwd: repoRoot, maxDirs: params.maxAppDirs });
823
+ if (appDirs === null) return null;
824
+ if (appDirs.length > 0) {
825
+ p4.log.success(`App directories: ${appDirs.map((d) => chalk3.bold(d)).join(", ")}`);
1012
826
  }
1013
- const connectChoice = await p8.select({
1014
- message: "Connect your new workspace to GitHub",
1015
- options: [
1016
- { value: "install", label: "Install the Vocoder GitHub App" },
1017
- { value: "link", label: "Link an existing installation" }
1018
- ]
1019
- });
1020
- if (p8.isCancel(connectChoice)) {
1021
- p8.cancel("Setup cancelled.");
827
+ const sourceLocale = await searchSelectLocale(
828
+ languageOptions,
829
+ "Source language (the language your code is written in)",
830
+ params.defaultSourceLocale ?? "en"
831
+ );
832
+ if (sourceLocale === null) return null;
833
+ let compatibleTargets;
834
+ try {
835
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
836
+ } catch {
837
+ p4.log.error(
838
+ "Failed to fetch compatible target locales. Check your connection and try again."
839
+ );
1022
840
  return null;
1023
841
  }
1024
- if (connectChoice === "install") {
1025
- const connectResult = await runGitHubInstallFlow({
1026
- api,
1027
- userToken,
1028
- yes: options.yes
1029
- });
1030
- if (!connectResult) {
1031
- p8.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1032
- return null;
842
+ const localeOptions = buildLocaleOptions(compatibleTargets);
843
+ const targetOptions = localeOptions.filter(
844
+ (opt) => opt.bcp47 !== sourceLocale
845
+ );
846
+ const targetLocales = await searchMultiSelectLocales(
847
+ targetOptions,
848
+ "Target languages (languages to translate into)"
849
+ );
850
+ if (targetLocales === null) return null;
851
+ if (targetLocales.length === 0) {
852
+ p4.log.warn(
853
+ "No target languages selected \u2014 you can add them later from the dashboard."
854
+ );
855
+ }
856
+ const detected = detectGitBranches();
857
+ const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
858
+ let pushBranches = [];
859
+ {
860
+ let initial = initialBranches;
861
+ while (pushBranches.length === 0) {
862
+ const result2 = await filterableBranchSelect({
863
+ message: "Which branches should trigger translations?",
864
+ branches: detected.branches,
865
+ defaultBranch: detected.defaultBranch,
866
+ initialValues: initial
867
+ });
868
+ if (result2 === null) return null;
869
+ if (result2.length === 0) {
870
+ p4.log.warn(
871
+ "At least one branch is required. Please select at least one."
872
+ );
873
+ initial = [detected.defaultBranch];
874
+ } else {
875
+ pushBranches = result2;
876
+ }
1033
877
  }
1034
- p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
1035
- return {
1036
- organizationId: connectResult.organizationId,
1037
- organizationName: connectResult.organizationName
1038
- };
1039
878
  }
1040
- const installations = await runGitHubDiscoveryFlow({
1041
- api,
1042
- userToken,
1043
- yes: options.yes
879
+ const targetBranches = pushBranches;
880
+ const result = await api.createProject(userToken, {
881
+ organizationId,
882
+ name: projectName,
883
+ sourceLocale,
884
+ targetLocales,
885
+ targetBranches,
886
+ appDirs,
887
+ repoCanonical
1044
888
  });
1045
- if (!installations) return null;
1046
- if (installations.length === 0) {
1047
- p8.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
1048
- const installNow = await p8.confirm({
1049
- message: "Open GitHub to install the App?"
1050
- });
1051
- if (p8.isCancel(installNow) || !installNow) return null;
1052
- const connectResult = await runGitHubInstallFlow({
1053
- api,
1054
- userToken,
1055
- yes: options.yes
1056
- });
1057
- if (!connectResult) return null;
1058
- p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
1059
- return {
1060
- organizationId: connectResult.organizationId,
1061
- organizationName: connectResult.organizationName
1062
- };
889
+ p4.log.success(`Project ${chalk3.bold(result.projectName)} created!`);
890
+ return {
891
+ projectId: result.projectId,
892
+ projectName: result.projectName,
893
+ apiKey: result.apiKey,
894
+ sourceLocale,
895
+ targetLocales,
896
+ targetBranches,
897
+ repositoryBound: result.repositoryBound,
898
+ configureUrl: result.configureUrl,
899
+ apps: result.apps
900
+ };
901
+ }
902
+ async function runAppCreate(params) {
903
+ const { api, userToken, projectId, projectName, repoCanonical } = params;
904
+ const existingDirs = params.existingApps.map((a) => a.appDir);
905
+ const appDir = await promptSingleAppDir({ existingDirs });
906
+ if (appDir === null) return null;
907
+ if (appDir) {
908
+ p4.log.success(`App directory: ${chalk3.bold(appDir)}`);
1063
909
  }
1064
- const selectedInstallationId = await selectGitHubInstallation(
1065
- installations.map((inst) => ({
1066
- installationId: inst.installationId,
1067
- accountLogin: inst.accountLogin,
1068
- accountType: inst.accountType,
1069
- isSuspended: inst.isSuspended,
1070
- conflictLabel: inst.conflictLabel
1071
- })),
1072
- true
910
+ let sourceLocales;
911
+ try {
912
+ ({ sourceLocales } = await api.listLocales(userToken));
913
+ } catch {
914
+ p4.log.error(
915
+ "Failed to fetch supported locales. Check your connection and try again."
916
+ );
917
+ return null;
918
+ }
919
+ const languageOptions = buildLanguageOptions(sourceLocales);
920
+ const sourceLocale = await searchSelectLocale(
921
+ languageOptions,
922
+ "Source language",
923
+ "en"
1073
924
  );
1074
- if (selectedInstallationId === null) {
1075
- p8.cancel("Setup cancelled.");
925
+ if (sourceLocale === null) return null;
926
+ let compatibleTargets;
927
+ try {
928
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
929
+ } catch {
930
+ p4.log.error(
931
+ "Failed to fetch compatible target locales. Check your connection and try again."
932
+ );
1076
933
  return null;
1077
934
  }
1078
- if (selectedInstallationId === "install_new") {
1079
- const connectResult = await runGitHubInstallFlow({
1080
- api,
1081
- userToken,
1082
- yes: options.yes
1083
- });
1084
- if (!connectResult) return null;
1085
- p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
1086
- return {
1087
- organizationId: connectResult.organizationId,
1088
- organizationName: connectResult.organizationName
1089
- };
935
+ const targetOptions = buildLocaleOptions(compatibleTargets).filter(
936
+ (opt) => opt.bcp47 !== sourceLocale
937
+ );
938
+ const targetLocales = await searchMultiSelectLocales(
939
+ targetOptions,
940
+ "Target languages"
941
+ );
942
+ if (targetLocales === null) return null;
943
+ if (targetLocales.length === 0) {
944
+ p4.log.warn(
945
+ "No target languages selected \u2014 you can add them later from the dashboard."
946
+ );
1090
947
  }
1091
- const claimResult = await api.claimCliGitHubInstallation(userToken, {
1092
- installationId: String(selectedInstallationId),
1093
- organizationId: null
948
+ const detectedApp = detectGitBranches();
949
+ let appPushBranches = [];
950
+ {
951
+ let initial = [detectedApp.defaultBranch];
952
+ while (appPushBranches.length === 0) {
953
+ const result2 = await filterableBranchSelect({
954
+ message: "Which branches should trigger translations?",
955
+ branches: detectedApp.branches,
956
+ defaultBranch: detectedApp.defaultBranch,
957
+ initialValues: initial
958
+ });
959
+ if (result2 === null) return null;
960
+ if (result2.length === 0) {
961
+ p4.log.warn("At least one branch is required.");
962
+ initial = [detectedApp.defaultBranch];
963
+ } else {
964
+ appPushBranches = result2;
965
+ }
966
+ }
967
+ }
968
+ const targetBranches = appPushBranches;
969
+ const result = await api.createApp(userToken, {
970
+ projectId,
971
+ appDir,
972
+ sourceLocale,
973
+ targetLocales,
974
+ targetBranches,
975
+ repoCanonical: repoCanonical ?? ""
1094
976
  });
1095
- p8.log.success(`Workspace: ${chalk9.bold(claimResult.organizationName)}`);
977
+ p4.log.success(
978
+ `App ${chalk3.bold(appDir || "(root)")} added to ${chalk3.bold(projectName)}!`
979
+ );
1096
980
  return {
1097
- organizationId: claimResult.organizationId,
1098
- organizationName: claimResult.organizationName
981
+ projectId: result.projectId,
982
+ projectName: result.projectName,
983
+ appDir: result.appDir,
984
+ appId: result.appId,
985
+ sourceLocale,
986
+ targetLocales,
987
+ targetBranches
1099
988
  };
1100
989
  }
1101
990
 
1102
- // src/utils/project-create.ts
1103
- import * as p11 from "@clack/prompts";
1104
- import chalk10 from "chalk";
1105
-
1106
- // src/utils/app-dir-select.ts
1107
- import { existsSync as existsSync3, statSync } from "fs";
991
+ // src/utils/scaffold.ts
992
+ import * as p5 from "@clack/prompts";
993
+ import chalk4 from "chalk";
994
+ import { execSync as execSync2 } from "child_process";
1108
995
  import { resolve as resolve2 } from "path";
1109
- import { isCancel as isCancel7, Prompt } from "@clack/core";
1110
- import * as p9 from "@clack/prompts";
1111
- var S_BAR = "\u2502";
1112
- var S_BAR_END = "\u2514";
1113
- var S_ACTIVE = "\u25C6";
1114
- var S_SUBMIT = "\u25C6";
1115
- var S_CANCEL = "\u25A0";
1116
- var S_ERROR = "\u25B2";
1117
- function symbol(state) {
1118
- switch (state) {
1119
- case "submit":
1120
- return grn(S_SUBMIT);
1121
- case "cancel":
1122
- return red(S_CANCEL);
1123
- case "error":
1124
- return ylw(S_ERROR);
1125
- default:
1126
- return active(S_ACTIVE);
1127
- }
1128
- }
1129
- function validateAppDirPath(val, existing, opts = {}) {
1130
- if (val.startsWith("/")) return "Must be a relative path (e.g. apps/web)";
1131
- if (val.includes("..")) return "Path traversal not allowed";
1132
- const hasWholeRepo = existing.includes("");
1133
- const hasScoped = existing.some((d) => d !== "");
1134
- if (val === "" && hasScoped) return "Cannot add whole-repo scope to a monorepo project";
1135
- if (val !== "" && hasWholeRepo) return "Cannot add a scoped directory to a whole-repo project";
1136
- if (existing.includes(val)) return `Already added: ${val}`;
1137
- const nested = existing.find(
1138
- (d) => d !== "" && (val.startsWith(d + "/") || d.startsWith(val + "/"))
1139
- );
1140
- if (nested) return `"${val}" overlaps with already-added "${nested}"`;
1141
- if (val !== "") {
1142
- const abs = resolve2(opts.cwd ?? process.cwd(), val);
1143
- if (!existsSync3(abs)) return `Directory not found: ${val}`;
1144
- if (!statSync(abs).isDirectory()) return `Not a directory: ${val}`;
996
+
997
+ // src/utils/write-config.ts
998
+ import { existsSync as existsSync2, writeFileSync } from "fs";
999
+ import { join } from "path";
1000
+ function findExistingConfig(cwd = process.cwd()) {
1001
+ for (const name of [
1002
+ "vocoder.config.ts",
1003
+ "vocoder.config.js",
1004
+ "vocoder.config.json"
1005
+ ]) {
1006
+ const candidate = join(cwd, name);
1007
+ if (existsSync2(candidate)) return candidate;
1145
1008
  }
1146
1009
  return null;
1147
1010
  }
1148
- async function collectAppDirs(opts = {}) {
1149
- const added = [];
1150
- let filter = "";
1151
- let cursor = 0;
1152
- let addCursor = false;
1153
- const isNewDir = () => {
1154
- const t = filter.trim();
1155
- return t.length > 0 && !added.includes(t);
1156
- };
1157
- const clampCursor = () => {
1158
- const max = added.length - 1;
1159
- if (cursor > max) cursor = Math.max(0, max);
1160
- };
1161
- const prompt = new Prompt(
1162
- {
1163
- validate() {
1164
- return void 0;
1165
- },
1166
- render() {
1167
- const trimmed = filter.trim();
1168
- const hdr = `${dim(S_BAR)}
1169
- ${symbol(this.state)} App directories
1011
+ function writeVocoderConfig(options) {
1012
+ const {
1013
+ targetBranches = ["main"],
1014
+ useTypeScript = true,
1015
+ cwd = process.cwd(),
1016
+ appId
1017
+ } = options;
1018
+ if (findExistingConfig(cwd)) return null;
1019
+ const ext = useTypeScript ? "ts" : "js";
1020
+ const configPath = join(cwd, `vocoder.config.${ext}`);
1021
+ const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
1022
+ const includes = ["**/*.{tsx,jsx,ts,js}"];
1023
+ const includesStr = includes.map((p21) => `'${p21}'`).join(", ");
1024
+ const appIdLine = appId ? ` appId: '${appId}',
1025
+ ` : "";
1026
+ const content = `import { defineConfig } from '@vocoder/config'
1027
+
1028
+ export default defineConfig({
1029
+ ${appIdLine} targetBranches: [${branchesStr}],
1030
+ include: [${includesStr}],
1031
+ })
1170
1032
  `;
1171
- switch (this.state) {
1172
- case "submit": {
1173
- const summary = added.length > 0 ? bld(added.join(", ")) : dim("none (single-app project)");
1174
- return `${hdr}${dim(S_BAR)} ${summary}`;
1175
- }
1176
- case "cancel":
1177
- return `${hdr}${dim(S_BAR)}`;
1178
- default: {
1179
- const inputHint = filter.length > 0 ? filter : added.length === 0 ? dim("e.g. apps/web") : dim("e.g. apps/api");
1180
- const lines = [
1181
- hdr.trimEnd(),
1182
- `${info(S_BAR)} ${dim("/")} ${inputHint}`,
1183
- info(S_BAR)
1184
- ];
1185
- for (let i = 0; i < added.length; i++) {
1186
- const isCursor = i === cursor && !addCursor;
1187
- const icon = active("\u25FC");
1188
- const label = isCursor ? bld(added[i]) : added[i];
1189
- lines.push(`${info(S_BAR)} ${icon} ${label}`);
1190
- }
1191
- const atLimit = opts.maxDirs !== void 0 && added.length >= opts.maxDirs;
1192
- if (atLimit) {
1193
- lines.push(`${info(S_BAR)} ${dim(`App limit reached (${added.length}/${opts.maxDirs} on your plan)`)}`);
1194
- } else if (isNewDir()) {
1195
- const err = validateAppDirPath(trimmed, added, opts);
1196
- const icon = addCursor ? active("\u25FB") : dim("\u25FB");
1197
- const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}"`;
1198
- lines.push(`${info(S_BAR)} ${icon} ${label}`);
1199
- }
1200
- lines.push(info(S_BAR));
1201
- if (atLimit) {
1202
- lines.push(dim(`${S_BAR} \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
1203
- } else if (added.length === 0 && !isNewDir()) {
1204
- lines.push(dim(`${S_BAR} Monorepo? Type each app's subdirectory path and press Space.`));
1205
- lines.push(dim(`${S_BAR} Single app? Press Enter to skip this step.`));
1206
- } else if (added.length > 0) {
1207
- lines.push(dim(`${S_BAR} ${added.length} added \xB7 \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
1208
- }
1209
- const barEnd = this.state === "error" ? ylw(S_BAR_END) : info(S_BAR_END);
1210
- if (this.state === "error") {
1211
- lines.push(`${ylw(S_BAR_END)} ${ylw(this.error)}`);
1212
- } else {
1213
- lines.push(barEnd);
1214
- }
1215
- lines.push("");
1216
- return lines.join("\n");
1217
- }
1218
- }
1033
+ try {
1034
+ writeFileSync(configPath, content, "utf-8");
1035
+ return `vocoder.config.${ext}`;
1036
+ } catch {
1037
+ return null;
1038
+ }
1039
+ }
1040
+
1041
+ // src/utils/scaffold.ts
1042
+ function runScaffold(params) {
1043
+ const { targetBranches } = params;
1044
+ const detection = detectLocalEcosystem();
1045
+ if (detection.ecosystem) {
1046
+ const frameworkLabel = detection.framework ?? detection.ecosystem;
1047
+ const pmLabel = detection.packageManager;
1048
+ p5.log.info(`Detected: ${chalk4.bold(frameworkLabel)} (${pmLabel})`);
1049
+ }
1050
+ const { devPackages, runtimePackages } = getPackagesToInstall(detection);
1051
+ const allPackages = [...devPackages, ...runtimePackages];
1052
+ if (allPackages.length > 0) {
1053
+ p5.log.info("");
1054
+ const installSpinner = p5.spinner();
1055
+ installSpinner.start(`Installing ${allPackages.join(", ")}...`);
1056
+ try {
1057
+ if (devPackages.length > 0) {
1058
+ execSync2(
1059
+ buildInstallCommand(detection.packageManager, devPackages, true),
1060
+ { stdio: "pipe", cwd: process.cwd() }
1061
+ );
1219
1062
  }
1220
- },
1221
- false
1222
- );
1223
- prompt.on("key", (key) => {
1224
- if (!key || key === " ") return;
1225
- const cp = key.codePointAt(0) ?? 0;
1226
- if (cp === 127 || cp === 8) {
1227
- filter = filter.slice(0, -1);
1228
- addCursor = false;
1229
- } else if (cp >= 32 && cp !== 127) {
1230
- filter += key;
1231
- cursor = 0;
1232
- addCursor = false;
1233
- }
1234
- });
1235
- prompt.on("cursor", (action) => {
1236
- switch (action) {
1237
- case "up":
1238
- if (addCursor) {
1239
- addCursor = false;
1240
- cursor = Math.max(0, added.length - 1);
1241
- } else {
1242
- cursor = Math.max(0, cursor - 1);
1243
- }
1244
- break;
1245
- case "down":
1246
- if (!addCursor && cursor >= added.length - 1 && isNewDir()) {
1247
- addCursor = true;
1248
- } else if (!addCursor) {
1249
- cursor = Math.min(added.length - 1, cursor + 1);
1250
- }
1251
- break;
1252
- case "space": {
1253
- if (addCursor || filter.trim().length > 0 && isNewDir()) {
1254
- if (opts.maxDirs !== void 0 && added.length >= opts.maxDirs) break;
1255
- const trimmed = filter.trim();
1256
- const err = validateAppDirPath(trimmed, added, opts);
1257
- if (!err) {
1258
- added.push(trimmed);
1259
- filter = "";
1260
- addCursor = false;
1261
- cursor = 0;
1262
- }
1263
- } else if (added.length > 0 && !isNewDir()) {
1264
- clampCursor();
1265
- added.splice(cursor, 1);
1266
- if (cursor >= added.length) cursor = Math.max(0, added.length - 1);
1267
- }
1268
- break;
1063
+ if (runtimePackages.length > 0) {
1064
+ execSync2(
1065
+ buildInstallCommand(detection.packageManager, runtimePackages, false),
1066
+ { stdio: "pipe", cwd: process.cwd() }
1067
+ );
1269
1068
  }
1069
+ installSpinner.stop(`Installed ${allPackages.join(", ")}`);
1070
+ } catch {
1071
+ installSpinner.stop("Package installation failed");
1072
+ const cmds = [
1073
+ devPackages.length > 0 ? buildInstallCommand(detection.packageManager, devPackages, true) : null,
1074
+ runtimePackages.length > 0 ? buildInstallCommand(detection.packageManager, runtimePackages, false) : null
1075
+ ].filter(Boolean).join(" && ");
1076
+ p5.log.warn(`Run manually: ${highlight(cmds)}`);
1270
1077
  }
1271
- });
1272
- prompt.on("finalize", () => {
1273
- if (prompt.state === "submit") {
1274
- prompt.value = [...added];
1078
+ } else if (detection.ecosystem) {
1079
+ p5.log.info(`Packages: ${chalk4.green("already installed")}`);
1080
+ }
1081
+ const branchList = targetBranches.length > 0 ? targetBranches.map((b) => highlight(b)).join(" or ") : highlight("your target branch");
1082
+ p5.log.message("");
1083
+ p5.log.success(`Push to ${branchList} to trigger your first translation run.`);
1084
+ p5.log.message(info(" Docs: https://vocoder.app/docs/getting-started"));
1085
+ }
1086
+ function writeAppConfigs(apps, targetBranches, useTypeScript, repoRoot) {
1087
+ const base = repoRoot ?? process.cwd();
1088
+ for (const app of apps) {
1089
+ const dir = app.appDir ? resolve2(base, app.appDir) : base;
1090
+ const written = writeVocoderConfig({
1091
+ targetBranches,
1092
+ appId: app.appId,
1093
+ cwd: dir,
1094
+ useTypeScript
1095
+ });
1096
+ if (written) {
1097
+ const displayPath = app.appDir ? `${app.appDir}/${written}` : written;
1098
+ p5.log.success(`Created ${highlight(displayPath)}`);
1099
+ } else if (!findExistingConfig(dir)) {
1100
+ const ext = useTypeScript ? "ts" : "js";
1101
+ p5.log.warn(
1102
+ `Could not write ${app.appDir ? `${app.appDir}/` : ""}vocoder.config.${ext} \u2014 create it manually.`
1103
+ );
1275
1104
  }
1276
- });
1277
- const result = await prompt.prompt();
1278
- if (isCancel7(result)) return null;
1279
- return result;
1105
+ }
1280
1106
  }
1281
- async function promptSingleAppDir(params) {
1282
- const { existingDirs, cwd } = params;
1283
- const input = await p9.text({
1284
- message: "App directory to add",
1285
- placeholder: "apps/web",
1286
- validate(val) {
1287
- const err = validateAppDirPath(val ?? "", existingDirs, { cwd });
1288
- if (err) return err;
1289
- if (!val) return "Directory is required";
1290
- return void 0;
1107
+
1108
+ // src/commands/init.ts
1109
+ import chalk11 from "chalk";
1110
+ import { config as loadEnv } from "dotenv";
1111
+
1112
+ // src/utils/output.ts
1113
+ import * as p6 from "@clack/prompts";
1114
+ import chalk5 from "chalk";
1115
+ import { execSync as execSync3 } from "child_process";
1116
+ import { existsSync as existsSync3, readFileSync, writeFileSync as writeFileSync2 } from "fs";
1117
+ import { join as join2 } from "path";
1118
+ function tryClipboard(text2) {
1119
+ const tools = [
1120
+ { cmd: "pbcopy" },
1121
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
1122
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
1123
+ { cmd: "wl-copy" },
1124
+ { cmd: "clip" }
1125
+ ];
1126
+ for (const { cmd, args = [] } of tools) {
1127
+ try {
1128
+ execSync3([cmd, ...args].join(" "), {
1129
+ input: text2,
1130
+ stdio: ["pipe", "ignore", "ignore"]
1131
+ });
1132
+ return true;
1133
+ } catch {
1134
+ continue;
1291
1135
  }
1292
- });
1293
- if (p9.isCancel(input)) return null;
1294
- return input;
1136
+ }
1137
+ return false;
1138
+ }
1139
+ function printCommand(cmd) {
1140
+ const copied = tryClipboard(cmd);
1141
+ process.stdout.write("\n");
1142
+ process.stdout.write(` ${chalk5.dim("$")} ${chalk5.cyan(cmd)}
1143
+ `);
1144
+ if (copied) process.stdout.write(` ${chalk5.dim("\u2191 copied to clipboard")}
1145
+ `);
1146
+ process.stdout.write("\n");
1147
+ }
1148
+ function printCodeBlock(code) {
1149
+ process.stdout.write("\n");
1150
+ for (const line of code.split("\n")) {
1151
+ process.stdout.write(` ${line}
1152
+ `);
1153
+ }
1154
+ process.stdout.write("\n");
1155
+ }
1156
+ function writeApiKeyToEnv(apiKey, repoRoot) {
1157
+ const envPath = join2(repoRoot ?? process.cwd(), ".env");
1158
+ if (!existsSync3(envPath)) return false;
1159
+ try {
1160
+ const content = readFileSync(envPath, "utf-8");
1161
+ const keyLine = `VOCODER_API_KEY=${apiKey}`;
1162
+ let updated;
1163
+ if (/^VOCODER_API_KEY=/m.test(content)) {
1164
+ updated = content.replace(/^VOCODER_API_KEY=.*/m, keyLine);
1165
+ } else {
1166
+ const sep = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
1167
+ updated = `${content}${sep}${keyLine}
1168
+ `;
1169
+ }
1170
+ writeFileSync2(envPath, updated);
1171
+ return true;
1172
+ } catch {
1173
+ return false;
1174
+ }
1175
+ }
1176
+ function printApiKey(apiKey, repoRoot) {
1177
+ const saved = writeApiKeyToEnv(apiKey, repoRoot);
1178
+ p6.log.message("");
1179
+ p6.log.message(chalk5.bold("Your API Key"));
1180
+ printCodeBlock(`VOCODER_API_KEY=${apiKey}`);
1181
+ if (saved) {
1182
+ p6.log.success(chalk5.dim("Saved to .env"));
1183
+ } else {
1184
+ p6.log.message(chalk5.dim(" Add the above to your .env file"));
1185
+ }
1295
1186
  }
1296
1187
 
1297
- // src/utils/branch-select.ts
1188
+ // src/utils/git-identity.ts
1298
1189
  import { execSync as execSync4 } from "child_process";
1299
- import { isCancel as isCancel9, Prompt as Prompt2 } from "@clack/core";
1300
- var S_BAR2 = "\u2502";
1301
- var S_BAR_END2 = "\u2514";
1302
- var S_ACTIVE2 = "\u25C6";
1303
- var S_SUBMIT2 = "\u25C6";
1304
- var S_CANCEL2 = "\u25A0";
1305
- var S_ERROR2 = "\u25B2";
1306
- function symbol2(state) {
1307
- switch (state) {
1308
- case "submit":
1309
- return grn(S_SUBMIT2);
1310
- case "cancel":
1311
- return red(S_CANCEL2);
1312
- case "error":
1313
- return ylw(S_ERROR2);
1314
- default:
1315
- return active(S_ACTIVE2);
1190
+ var SHA_REGEX = /^[0-9a-f]{40}$/i;
1191
+ function detectCommitSha() {
1192
+ if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1193
+ return process.env.VOCODER_COMMIT_SHA;
1316
1194
  }
1195
+ 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;
1196
+ if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1197
+ return safeExec("git rev-parse HEAD");
1317
1198
  }
1318
- function detectGitBranches(cwd) {
1319
- const workDir = cwd ?? process.cwd();
1199
+ function safeExec(command) {
1320
1200
  try {
1321
- const localOut = execSync4("git branch", {
1322
- cwd: workDir,
1323
- stdio: "pipe"
1324
- }).toString();
1325
- const localBranches = localOut.split("\n").filter(Boolean).map((b) => b.replace(/^\*?\s*/, "").trim()).filter(Boolean);
1326
- let remoteBranches = [];
1327
- try {
1328
- const remoteOut = execSync4("git branch -r", {
1329
- cwd: workDir,
1330
- stdio: "pipe"
1331
- }).toString();
1332
- remoteBranches = remoteOut.split("\n").map((b) => b.trim()).filter((b) => b && !b.includes("HEAD")).map((b) => b.replace(/^[^/]+\//, ""));
1333
- } catch {
1201
+ const output = execSync4(command, {
1202
+ encoding: "utf-8",
1203
+ stdio: ["pipe", "pipe", "ignore"]
1204
+ }).trim();
1205
+ return output.length > 0 ? output : null;
1206
+ } catch {
1207
+ return null;
1208
+ }
1209
+ }
1210
+ function normalizePath(pathname) {
1211
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1212
+ if (!cleaned || !cleaned.includes("/")) {
1213
+ return null;
1214
+ }
1215
+ return cleaned;
1216
+ }
1217
+ function parseRemoteUrl(remoteUrl) {
1218
+ const trimmed = remoteUrl.trim();
1219
+ if (!trimmed) {
1220
+ return null;
1221
+ }
1222
+ if (!trimmed.includes("://")) {
1223
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1224
+ if (scpMatch) {
1225
+ const host = (scpMatch[1] || "").toLowerCase();
1226
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
1227
+ if (!host || !ownerRepoPath) {
1228
+ return null;
1229
+ }
1230
+ return { host, ownerRepoPath };
1334
1231
  }
1335
- const branches = [.../* @__PURE__ */ new Set([...localBranches, ...remoteBranches])].sort();
1336
- let defaultBranch = "main";
1337
- try {
1338
- const ref = execSync4("git symbolic-ref refs/remotes/origin/HEAD", {
1339
- cwd: workDir,
1340
- stdio: "pipe"
1341
- }).toString().trim();
1342
- defaultBranch = ref.split("/").pop() ?? "main";
1343
- } catch {
1232
+ return null;
1233
+ }
1234
+ try {
1235
+ const parsed = new URL(trimmed);
1236
+ const host = parsed.hostname.toLowerCase();
1237
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
1238
+ if (!host || !ownerRepoPath) {
1239
+ return null;
1344
1240
  }
1345
- return {
1346
- branches: branches.length > 0 ? branches : [defaultBranch],
1347
- defaultBranch
1348
- };
1241
+ return { host, ownerRepoPath };
1349
1242
  } catch {
1350
- return { branches: ["main"], defaultBranch: "main" };
1243
+ return null;
1351
1244
  }
1352
1245
  }
1353
- var INVALID_CHARS = /[\s?^~:[\]\\]/;
1354
- function validateBranchPattern(pattern) {
1355
- const t = pattern.trim();
1356
- if (!t) return "Pattern cannot be empty";
1357
- if (INVALID_CHARS.test(t))
1358
- return "Invalid characters \u2014 avoid spaces, ?, ^, ~, :, [, ], \\";
1359
- if (t.startsWith("/") || t.endsWith("/")) return "Cannot start or end with /";
1360
- if (t.includes("//")) return "Cannot contain //";
1361
- return null;
1362
- }
1363
- var MAX_VISIBLE = 10;
1364
- function buildItems(branches, defaultBranch, customPatterns) {
1365
- const items = branches.map((b) => ({
1366
- value: b,
1367
- label: b === defaultBranch ? `${b} (default branch)` : b
1368
- }));
1369
- for (const pt of customPatterns) {
1370
- if (!branches.includes(pt)) {
1371
- items.push({ value: pt, label: pt, isCustom: true });
1372
- }
1246
+ function toCanonical(host, ownerRepoPath) {
1247
+ if (host.includes("github.com")) {
1248
+ return `github:${ownerRepoPath.toLowerCase()}`;
1373
1249
  }
1374
- return items;
1375
- }
1376
- function filterItems(items, query) {
1377
- if (!query.trim()) return items;
1378
- const lower = query.toLowerCase();
1379
- return items.filter((i) => i.value.toLowerCase().includes(lower));
1380
- }
1381
- function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedPatterns = /* @__PURE__ */ new Set()) {
1382
- const lines = [info(S_BAR2)];
1383
- const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
1384
- for (let i = scrollOffset; i < end; i++) {
1385
- const item = filtered[i];
1386
- const isCursor = i === cursor && !addCursor;
1387
- const isChecked = selected.has(item.value);
1388
- const icon = isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB");
1389
- let label = item.isCustom ? `${item.label} ${dim("(custom)")}` : item.label;
1390
- if (isCursor) label = bld(label);
1391
- lines.push(`${info(S_BAR2)} ${icon} ${label}`);
1250
+ if (host.includes("gitlab.com")) {
1251
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
1392
1252
  }
1393
- const trimmed = filter.trim();
1394
- const isNewPattern = trimmed.length > 0 && !filtered.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
1395
- if (isNewPattern) {
1396
- const err = validateBranchPattern(trimmed) ?? (excludedPatterns.has(trimmed) ? "Already used for automatic translation" : null);
1397
- const icon = addCursor ? active("\u25FB") : dim("\u25FB");
1398
- const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}" as branch pattern`;
1399
- lines.push(`${info(S_BAR2)} ${icon} ${label}`);
1400
- } else if (filtered.length === 0 && trimmed.length === 0) {
1401
- lines.push(dim(`${S_BAR2} No branches detected`));
1253
+ if (host.includes("bitbucket.org")) {
1254
+ return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1402
1255
  }
1403
- const hidden = filtered.length - (end - scrollOffset);
1404
- if (hidden > 0) lines.push(dim(`${S_BAR2} ${hidden} more \u2014 keep typing to narrow`));
1405
- return lines.join("\n");
1256
+ return `git:${host}/${ownerRepoPath.toLowerCase()}`;
1406
1257
  }
1407
- async function filterableBranchSelect(params) {
1408
- const { message, branches, defaultBranch } = params;
1409
- const optional = params.optional ?? false;
1410
- const excludedSet = new Set(params.excludedPatterns ?? []);
1411
- let filter = "";
1412
- let cursor = 0;
1413
- let scrollOffset = 0;
1414
- let addCursor = false;
1415
- const customPatterns = [];
1416
- const selected = new Set(params.initialValues ?? [defaultBranch]);
1417
- const getItems = () => buildItems(branches, defaultBranch, customPatterns);
1418
- const getFiltered = () => filterItems(getItems(), filter);
1419
- const isNewPattern = () => {
1420
- const t = filter.trim();
1421
- if (!t) return false;
1422
- return !getItems().some((i) => i.value === t) && !customPatterns.includes(t);
1423
- };
1424
- const clampCursor = (filtered) => {
1425
- const hasAdd = isNewPattern();
1426
- const max = filtered.length - 1 + (hasAdd ? 1 : 0);
1427
- if (cursor > max && !addCursor) cursor = Math.max(0, max);
1428
- if (!addCursor) {
1429
- if (cursor < scrollOffset) scrollOffset = cursor;
1430
- if (cursor >= scrollOffset + MAX_VISIBLE)
1431
- scrollOffset = cursor - MAX_VISIBLE + 1;
1432
- if (scrollOffset < 0) scrollOffset = 0;
1433
- }
1258
+ function resolveGitRepositoryIdentity() {
1259
+ const remoteUrl = safeExec("git config --get remote.origin.url");
1260
+ if (!remoteUrl) {
1261
+ return null;
1262
+ }
1263
+ const parsed = parseRemoteUrl(remoteUrl);
1264
+ if (!parsed) {
1265
+ return null;
1266
+ }
1267
+ const repoRoot = safeExec("git rev-parse --show-toplevel");
1268
+ if (!repoRoot) {
1269
+ return null;
1270
+ }
1271
+ return {
1272
+ repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
1273
+ repoRoot
1434
1274
  };
1435
- const prompt = new Prompt2(
1436
- {
1437
- validate() {
1438
- if (!optional && selected.size === 0)
1439
- return "At least one branch is required.";
1440
- return void 0;
1441
- },
1442
- render() {
1443
- const filtered = getFiltered();
1444
- clampCursor(filtered);
1445
- const hdr = `${dim(S_BAR2)}
1446
- ${symbol2(this.state)} ${message}
1447
- `;
1448
- const inputHint = filter.length > 0 ? filter : dim("type to filter \xB7 type a custom pattern to add it");
1449
- const trimmedFilter = filter.trim();
1450
- const footer = (() => {
1451
- if (trimmedFilter.length > 0 && isNewPattern()) {
1452
- return dim(`${S_BAR2} Space to add "${trimmedFilter}" \xB7 \u2191\u2193 navigate \xB7 Enter to confirm`);
1453
- }
1454
- if (selected.size > 0) {
1455
- return dim(`${S_BAR2} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
1456
- }
1457
- return optional ? dim(`${S_BAR2} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to skip`) : dim(`${S_BAR2} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`);
1458
- })();
1459
- switch (this.state) {
1460
- case "submit": {
1461
- const summary = selected.size > 0 ? bld(Array.from(selected).join(", ")) : dim("none");
1462
- return `${hdr}${dim(S_BAR2)} ${summary}`;
1463
- }
1464
- case "cancel":
1465
- return `${hdr}${dim(S_BAR2)}`;
1466
- case "error":
1467
- return [
1468
- hdr.trimEnd(),
1469
- `${ylw(S_BAR2)} ${dim("/")} ${inputHint}`,
1470
- buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
1471
- footer,
1472
- `${ylw(S_BAR_END2)} ${ylw(this.error)}`,
1473
- ""
1474
- ].join("\n");
1475
- default:
1476
- return [
1477
- hdr.trimEnd(),
1478
- `${info(S_BAR2)} ${dim("/")} ${inputHint}`,
1479
- buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
1480
- footer,
1481
- `${info(S_BAR_END2)}`,
1482
- ""
1483
- ].join("\n");
1484
- }
1275
+ }
1276
+ function resolveGitContext() {
1277
+ const warnings = [];
1278
+ const identity = resolveGitRepositoryIdentity();
1279
+ if (!identity) {
1280
+ warnings.push(
1281
+ "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
1282
+ );
1283
+ }
1284
+ return { identity, warnings };
1285
+ }
1286
+
1287
+ // src/utils/auth-flow.ts
1288
+ import * as p7 from "@clack/prompts";
1289
+ import chalk6 from "chalk";
1290
+
1291
+ // src/utils/local-server.ts
1292
+ import { createServer } from "http";
1293
+ import { URL as URL2 } from "url";
1294
+ function startCallbackServer() {
1295
+ return new Promise((resolve3, reject) => {
1296
+ let settled = false;
1297
+ let callbackResolve = null;
1298
+ let callbackReject = null;
1299
+ const callbackPromise = new Promise((res, rej) => {
1300
+ callbackResolve = res;
1301
+ callbackReject = rej;
1302
+ });
1303
+ const server = createServer((req, res) => {
1304
+ if (!req.url) {
1305
+ res.writeHead(400);
1306
+ res.end();
1307
+ return;
1485
1308
  }
1486
- },
1487
- false
1488
- );
1489
- prompt.on("key", (key) => {
1490
- if (!key || key === " ") return;
1491
- const cp = key.codePointAt(0) ?? 0;
1492
- if (cp === 127 || cp === 8) {
1493
- filter = filter.slice(0, -1);
1494
- cursor = 0;
1495
- scrollOffset = 0;
1496
- addCursor = false;
1497
- } else if (cp >= 32 && cp !== 127) {
1498
- filter += key;
1499
- cursor = 0;
1500
- scrollOffset = 0;
1501
- addCursor = false;
1502
- }
1503
- if (isNewPattern()) {
1504
- addCursor = true;
1505
- }
1506
- });
1507
- prompt.on("cursor", (action) => {
1508
- const filtered = getFiltered();
1509
- const hasAdd = isNewPattern();
1510
- switch (action) {
1511
- case "up":
1512
- if (addCursor) {
1513
- addCursor = false;
1514
- cursor = Math.max(0, filtered.length - 1);
1515
- } else cursor = Math.max(0, cursor - 1);
1516
- break;
1517
- case "down":
1518
- if (!addCursor && cursor >= filtered.length - 1 && hasAdd)
1519
- addCursor = true;
1520
- else if (!addCursor) cursor = Math.min(filtered.length - 1, cursor + 1);
1521
- break;
1522
- case "space": {
1523
- const t = filter.trim();
1524
- if (addCursor || t.length > 0 && isNewPattern()) {
1525
- const err = validateBranchPattern(t) ?? (excludedSet.has(t) ? "Already used for automatic translation" : null);
1526
- if (!err) {
1527
- customPatterns.push(t);
1528
- selected.add(t);
1529
- filter = "";
1530
- cursor = 0;
1531
- scrollOffset = 0;
1532
- addCursor = false;
1533
- }
1534
- } else {
1535
- const item = filtered[cursor];
1536
- if (item) {
1537
- if (selected.has(item.value)) selected.delete(item.value);
1538
- else selected.add(item.value);
1539
- }
1540
- }
1541
- break;
1309
+ let pathname;
1310
+ let params;
1311
+ try {
1312
+ const parsed = new URL2(req.url, "http://localhost");
1313
+ pathname = parsed.pathname;
1314
+ params = Object.fromEntries(parsed.searchParams.entries());
1315
+ } catch {
1316
+ res.writeHead(400);
1317
+ res.end("Bad request");
1318
+ return;
1542
1319
  }
1543
- }
1544
- });
1545
- prompt.on("finalize", () => {
1546
- if (prompt.state === "submit") {
1547
- prompt.value = Array.from(selected);
1548
- }
1320
+ if (pathname !== "/callback") {
1321
+ res.writeHead(404);
1322
+ res.end("Not found");
1323
+ return;
1324
+ }
1325
+ res.writeHead(200, { "Content-Type": "text/html" });
1326
+ res.end(
1327
+ '<!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>'
1328
+ );
1329
+ if (callbackResolve) {
1330
+ callbackResolve(params);
1331
+ callbackResolve = null;
1332
+ }
1333
+ setImmediate(() => server.close());
1334
+ });
1335
+ server.on("error", (err) => {
1336
+ if (!settled) {
1337
+ settled = true;
1338
+ if (callbackReject) callbackReject(err);
1339
+ reject(err);
1340
+ }
1341
+ });
1342
+ server.listen(0, "127.0.0.1", () => {
1343
+ if (settled) return;
1344
+ settled = true;
1345
+ const port = server.address().port;
1346
+ resolve3({
1347
+ port,
1348
+ waitForCallback: () => callbackPromise,
1349
+ close: () => server.close()
1350
+ });
1351
+ });
1549
1352
  });
1550
- const result = await prompt.prompt();
1551
- if (isCancel9(result)) return null;
1552
- return result;
1553
1353
  }
1554
1354
 
1555
- // src/utils/locale-search.ts
1556
- import { isCancel as isCancel10, Prompt as Prompt3 } from "@clack/core";
1557
- import * as p10 from "@clack/prompts";
1558
- var S_BAR3 = "\u2502";
1559
- var S_BAR_END3 = "\u2514";
1560
- var S_ACTIVE3 = "\u25C6";
1561
- var S_SUBMIT3 = "\u25C6";
1562
- var S_CANCEL3 = "\u25A0";
1563
- var S_ERROR3 = "\u25B2";
1564
- function symbol3(state) {
1565
- switch (state) {
1566
- case "submit":
1567
- return grn(S_SUBMIT3);
1568
- case "cancel":
1569
- return red(S_CANCEL3);
1570
- case "error":
1571
- return ylw(S_ERROR3);
1572
- default:
1573
- return active(S_ACTIVE3);
1574
- }
1575
- }
1576
- var MAX_VISIBLE2 = 12;
1577
- function filterLocales(options, query) {
1578
- if (!query.trim()) return options;
1579
- const lower = query.toLowerCase();
1580
- return options.filter(
1581
- (o) => o.bcp47.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
1582
- );
1583
- }
1584
- function buildList2(filtered, cursor, scrollOffset, selected) {
1585
- const isMulti = selected !== null;
1586
- const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
1587
- const visibleLines = [info(S_BAR3)];
1588
- for (let i = scrollOffset; i < end; i++) {
1589
- const opt = filtered[i];
1590
- const isCursor = i === cursor;
1591
- const isChecked = isMulti && selected.has(opt.bcp47);
1592
- const icon = isMulti ? isChecked ? active("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB") : isCursor ? active("\u25CF") : dim("\u25CB");
1593
- visibleLines.push(
1594
- `${info(S_BAR3)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`
1595
- );
1596
- }
1597
- const hidden = filtered.length - (end - scrollOffset);
1598
- if (hidden > 0) visibleLines.push(dim(`${S_BAR3} ${hidden} more \u2014 keep typing to narrow`));
1599
- if (filtered.length === 0) visibleLines.push(dim(`${S_BAR3} No matches`));
1600
- return visibleLines.join("\n");
1355
+ // src/utils/auth-flow.ts
1356
+ async function sleep(ms) {
1357
+ await new Promise((resolve3) => setTimeout(resolve3, ms));
1601
1358
  }
1602
- async function runFilterablePrompt(opts) {
1603
- const { message, options, multi } = opts;
1604
- let filter = "";
1605
- let cursor = 0;
1606
- let scrollOffset = 0;
1607
- const selected = new Set(multi ? opts.initialValues ?? [] : []);
1608
- if (!multi && opts.initialValue) {
1609
- const idx = options.findIndex((o) => o.bcp47 === opts.initialValue);
1610
- if (idx >= 0) cursor = idx;
1359
+ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1360
+ let server = null;
1361
+ if (!options.ci) {
1362
+ try {
1363
+ server = await startCallbackServer();
1364
+ } catch {
1365
+ }
1611
1366
  }
1612
- const getFiltered = () => filterLocales(options, filter);
1613
- const clampCursor = (filtered) => {
1614
- if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
1615
- if (cursor < scrollOffset) scrollOffset = cursor;
1616
- if (cursor >= scrollOffset + MAX_VISIBLE2)
1617
- scrollOffset = cursor - MAX_VISIBLE2 + 1;
1618
- if (scrollOffset < 0) scrollOffset = 0;
1619
- };
1620
- const prompt = new Prompt3(
1621
- {
1622
- initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
1623
- validate() {
1624
- const f = getFiltered();
1625
- if (multi && selected.size === 0)
1626
- return "At least one target language is required.";
1627
- if (!multi && !f[cursor]) return "Please select a language.";
1628
- return void 0;
1629
- },
1630
- render() {
1631
- const filtered = getFiltered();
1632
- clampCursor(filtered);
1633
- const hdr = `${dim(S_BAR3)}
1634
- ${symbol3(this.state)} ${message}
1635
- `;
1636
- const inputHint = filter.length > 0 ? filter : dim("type to filter");
1637
- const footer = multi ? selected.size > 0 ? dim(`${S_BAR3} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : dim(`${S_BAR3} \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : dim(`${S_BAR3} \u2191\u2193 navigate \xB7 Enter to confirm`);
1638
- switch (this.state) {
1639
- case "submit": {
1640
- const val = multi ? Array.from(selected).map((id) => options.find((o) => o.bcp47 === id)?.label ?? id).join(", ") : options.find((o) => o.bcp47 === this.value)?.label ?? "";
1641
- return `${hdr}${dim(S_BAR3)} ${bld(val || dim("none"))}`;
1642
- }
1643
- case "cancel":
1644
- return `${hdr}${dim(S_BAR3)}`;
1645
- case "error":
1646
- return [
1647
- hdr.trimEnd(),
1648
- `${ylw(S_BAR3)} ${dim("/")} ${inputHint}`,
1649
- buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
1650
- footer,
1651
- `${ylw(S_BAR_END3)} ${ylw(this.error)}`,
1652
- ""
1653
- ].join("\n");
1654
- default:
1655
- return [
1656
- hdr.trimEnd(),
1657
- `${info(S_BAR3)} ${dim("/")} ${inputHint}`,
1658
- buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
1659
- footer,
1660
- `${info(S_BAR_END3)}`,
1661
- ""
1662
- ].join("\n");
1367
+ const session = await api.startCliAuthSession(server?.port, repoCanonical);
1368
+ const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
1369
+ const expiresAt = new Date(session.expiresAt).getTime();
1370
+ if (options.ci) {
1371
+ process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
1372
+ `);
1373
+ process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
1374
+ `);
1375
+ } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1376
+ if (reauth) {
1377
+ if (!options.yes) {
1378
+ const shouldOpen = await p7.confirm({
1379
+ message: "Open your browser to sign in again?"
1380
+ });
1381
+ if (p7.isCancel(shouldOpen)) {
1382
+ server?.close();
1383
+ p7.cancel("Setup cancelled.");
1384
+ return null;
1663
1385
  }
1386
+ if (!shouldOpen) {
1387
+ server?.close();
1388
+ p7.cancel("Setup cancelled.");
1389
+ return null;
1390
+ }
1391
+ const opened = await tryOpenBrowser(browserUrl);
1392
+ if (!opened) {
1393
+ p7.note(browserUrl, "Sign In");
1394
+ p7.log.info("Open the URL above manually to continue.");
1395
+ }
1396
+ } else {
1397
+ await tryOpenBrowser(browserUrl);
1398
+ }
1399
+ } else {
1400
+ let isLinkFlow = false;
1401
+ if (!options.yes) {
1402
+ const connectChoice = await p7.select({
1403
+ message: "Vocoder needs to be installed on your GitHub account to get started",
1404
+ options: [
1405
+ {
1406
+ value: "install",
1407
+ label: "Install GitHub App",
1408
+ hint: "new user"
1409
+ },
1410
+ {
1411
+ value: "link",
1412
+ label: "Already installed? Link your account",
1413
+ hint: "returning user"
1414
+ }
1415
+ ]
1416
+ });
1417
+ if (p7.isCancel(connectChoice)) {
1418
+ server?.close();
1419
+ p7.cancel("Setup cancelled.");
1420
+ return null;
1421
+ }
1422
+ isLinkFlow = connectChoice === "link";
1423
+ }
1424
+ let urlToOpen = browserUrl;
1425
+ if (isLinkFlow) {
1426
+ try {
1427
+ const linkSession = await api.startCliGitHubLinkSession(
1428
+ session.sessionId,
1429
+ server?.port
1430
+ );
1431
+ urlToOpen = linkSession.oauthUrl;
1432
+ } catch {
1433
+ urlToOpen = browserUrl;
1434
+ }
1435
+ }
1436
+ const opened = await tryOpenBrowser(urlToOpen);
1437
+ if (!opened) {
1438
+ p7.log.warn("Could not open your browser automatically.");
1439
+ p7.note(urlToOpen, "GitHub");
1440
+ p7.log.info("Open the URL above to continue.");
1664
1441
  }
1665
- },
1666
- false
1667
- // trackValue=false — we manage value manually
1668
- );
1669
- prompt.on("key", (key) => {
1670
- if (!key || key === " ") return;
1671
- const cp = key.codePointAt(0) ?? 0;
1672
- if (cp === 127 || cp === 8) {
1673
- filter = filter.slice(0, -1);
1674
- cursor = 0;
1675
- scrollOffset = 0;
1676
- } else if (cp >= 32 && cp !== 127) {
1677
- filter += key;
1678
- cursor = 0;
1679
- scrollOffset = 0;
1680
1442
  }
1681
- });
1682
- prompt.on("cursor", (action) => {
1683
- const filtered = getFiltered();
1684
- switch (action) {
1685
- case "up":
1686
- cursor = Math.max(0, cursor - 1);
1687
- break;
1688
- case "down":
1689
- cursor = Math.min(Math.max(filtered.length - 1, 0), cursor + 1);
1690
- break;
1691
- case "space":
1692
- if (multi) {
1693
- const opt = filtered[cursor];
1694
- if (opt) {
1695
- if (selected.has(opt.bcp47)) selected.delete(opt.bcp47);
1696
- else selected.add(opt.bcp47);
1697
- }
1443
+ }
1444
+ const authSpinner = p7.spinner();
1445
+ authSpinner.start("Waiting for GitHub authorization...");
1446
+ let rawToken = null;
1447
+ let callbackOrganizationId;
1448
+ let callbackDiscoveryReady = false;
1449
+ const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
1450
+ let stopPolling = false;
1451
+ const serverCallback = server ? server.waitForCallback().catch(() => null) : Promise.resolve(null);
1452
+ const sessionPoll = (async () => {
1453
+ while (!stopPolling && Date.now() < expiresAt) {
1454
+ try {
1455
+ const result = await api.pollCliAuthSession(session.sessionId);
1456
+ if (result.status === "complete" || result.status === "failed") {
1457
+ return result;
1698
1458
  }
1699
- break;
1700
- }
1701
- if (!multi) {
1702
- const opt = getFiltered()[cursor];
1703
- prompt.value = opt?.bcp47 ?? null;
1459
+ } catch {
1460
+ }
1461
+ if (!stopPolling) await sleep(2e3);
1704
1462
  }
1463
+ return null;
1464
+ })();
1465
+ const winner = await new Promise((resolve3) => {
1466
+ let done = false;
1467
+ serverCallback.then((params) => {
1468
+ if (done || params === null || typeof params.token !== "string") return;
1469
+ done = true;
1470
+ resolve3({ kind: "server", params });
1471
+ }).catch(() => {
1472
+ });
1473
+ sessionPoll.then((result) => {
1474
+ if (done || result === null) return;
1475
+ if (result.status === "complete" || result.status === "failed") {
1476
+ done = true;
1477
+ resolve3({
1478
+ kind: "poll",
1479
+ result
1480
+ });
1481
+ }
1482
+ }).catch(() => {
1483
+ });
1484
+ setTimeout(
1485
+ () => {
1486
+ if (!done) {
1487
+ done = true;
1488
+ resolve3(null);
1489
+ }
1490
+ },
1491
+ Math.max(0, deadline - Date.now())
1492
+ );
1705
1493
  });
1706
- prompt.on("finalize", () => {
1707
- if (prompt.state === "submit") {
1708
- if (multi) {
1709
- prompt.value = Array.from(selected);
1710
- } else {
1711
- const f = getFiltered();
1712
- prompt.value = f[cursor]?.bcp47 ?? null;
1494
+ stopPolling = true;
1495
+ server?.close();
1496
+ if (winner !== null) {
1497
+ if (winner.kind === "server") {
1498
+ rawToken = winner.params.token;
1499
+ if (typeof winner.params.organizationId === "string" && winner.params.organizationId) {
1500
+ callbackOrganizationId = winner.params.organizationId;
1713
1501
  }
1502
+ if (winner.params.discovery_ready === "1") {
1503
+ callbackDiscoveryReady = true;
1504
+ }
1505
+ } else if (winner.result.status === "complete") {
1506
+ rawToken = winner.result.token;
1507
+ if (winner.result.organizationId) {
1508
+ callbackOrganizationId = winner.result.organizationId;
1509
+ }
1510
+ } else {
1511
+ authSpinner.stop();
1512
+ p7.log.error(winner.result.reason);
1513
+ return null;
1714
1514
  }
1715
- });
1716
- const result = await prompt.prompt();
1717
- if (isCancel10(result)) return null;
1718
- return result;
1719
- }
1720
- async function searchSelectLocale(options, message, initialValue) {
1721
- const result = await runFilterablePrompt({
1722
- message,
1723
- options,
1724
- multi: false,
1725
- initialValue
1726
- });
1727
- return typeof result === "string" ? result : null;
1728
- }
1729
- async function searchMultiSelectLocales(options, message, initialValues) {
1730
- const result = await runFilterablePrompt({
1731
- message,
1732
- options,
1733
- multi: true,
1734
- initialValues
1735
- });
1736
- if (result === null) return null;
1737
- const picks = result;
1738
- if (picks.length === 0) {
1739
- p10.log.warn(
1740
- "At least one target language is required. Please select at least one."
1741
- );
1742
- return searchMultiSelectLocales(options, message, initialValues);
1743
1515
  }
1744
- return picks;
1516
+ if (!rawToken) {
1517
+ authSpinner.stop();
1518
+ p7.log.error("The authentication link expired. Run `vocoder init` again.");
1519
+ return null;
1520
+ }
1521
+ const userInfo = await api.getCliUserInfo(rawToken);
1522
+ authSpinner.stop(`Authenticated as ${chalk6.bold(userInfo.email)}`);
1523
+ return {
1524
+ token: rawToken,
1525
+ ...userInfo,
1526
+ organizationId: callbackOrganizationId,
1527
+ discoveryReady: callbackDiscoveryReady
1528
+ };
1745
1529
  }
1746
1530
 
1747
- // src/utils/project-create.ts
1748
- function buildLocaleOptions(locales) {
1749
- return locales.map((l) => ({
1750
- bcp47: l.code,
1751
- label: `${l.name} \u2014 ${l.code}`
1752
- }));
1531
+ // src/utils/mcp-setup.ts
1532
+ import * as p8 from "@clack/prompts";
1533
+ import chalk7 from "chalk";
1534
+ import { execSync as execSync5 } from "child_process";
1535
+ var MCP_DOCS_URL = "https://vocoder.app/docs/mcp";
1536
+ function mcpServerJson(apiKey) {
1537
+ return JSON.stringify(
1538
+ {
1539
+ mcpServers: {
1540
+ vocoder: {
1541
+ type: "stdio",
1542
+ command: "npx",
1543
+ args: ["-y", "@vocoder/mcp"],
1544
+ env: { VOCODER_API_KEY: apiKey }
1545
+ }
1546
+ }
1547
+ },
1548
+ null,
1549
+ 2
1550
+ );
1753
1551
  }
1754
- function buildLanguageOptions(locales) {
1755
- const byFamily = /* @__PURE__ */ new Map();
1756
- for (const l of locales) {
1757
- const family = l.code.split("-")[0].toLowerCase();
1758
- const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
1759
- const existing = byFamily.get(family);
1760
- if (!existing || l.code.length < existing.bcp47.length) {
1761
- byFamily.set(family, opt);
1552
+ async function runMcpSetup(apiKey) {
1553
+ p8.log.message(
1554
+ chalk7.dim(
1555
+ " The Vocoder MCP server lets your AI editor add/remove locales,\n check translation status, and scaffold i18n directly in your project."
1556
+ )
1557
+ );
1558
+ const tool = await p8.select({
1559
+ message: "Which AI editor?",
1560
+ options: [
1561
+ { value: "claude", label: "Claude Code" },
1562
+ { value: "cursor", label: "Cursor" },
1563
+ { value: "windsurf", label: "Windsurf" },
1564
+ { value: "vscode", label: "VS Code (GitHub Copilot)" },
1565
+ { value: "other", label: "Other \u2014 show the config JSON" }
1566
+ ]
1567
+ });
1568
+ if (p8.isCancel(tool)) return;
1569
+ if (tool === "claude") {
1570
+ try {
1571
+ execSync5(
1572
+ `claude mcp add --scope user --transport stdio -e VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`,
1573
+ { stdio: "pipe" }
1574
+ );
1575
+ p8.log.success("Vocoder MCP server registered in Claude Code.");
1576
+ } catch {
1577
+ p8.log.message(chalk7.dim("(automatic registration failed \u2014 run this command manually:)"));
1578
+ printCommand(
1579
+ `claude mcp add --scope user --transport stdio -e VOCODER_API_KEY=${apiKey} vocoder -- npx -y @vocoder/mcp`
1580
+ );
1581
+ p8.log.message(info(` Docs: ${MCP_DOCS_URL}`));
1762
1582
  }
1583
+ return;
1763
1584
  }
1764
- return Array.from(byFamily.values());
1585
+ const configPaths = {
1586
+ cursor: { path: "~/.cursor/mcp.json", merge: true },
1587
+ windsurf: { path: "~/.codeium/windsurf/mcp_config.json", merge: true },
1588
+ vscode: { path: ".vscode/mcp.json", merge: true },
1589
+ other: { path: ".mcp.json", merge: false }
1590
+ };
1591
+ const { path: configPath, merge } = configPaths[tool];
1592
+ const mergeNote = merge ? chalk7.dim(` Merge into ${configPath} (create if missing):`) : chalk7.dim(` Add to ${configPath}:`);
1593
+ p8.log.message(mergeNote);
1594
+ printCodeBlock(mcpServerJson(apiKey));
1595
+ p8.log.message(info(` Docs: ${MCP_DOCS_URL}`));
1765
1596
  }
1766
- async function runProjectCreate(params) {
1767
- const { api, userToken, organizationId, repoCanonical, repoRoot } = params;
1768
- const projectName = (params.defaultName ?? "my-project").trim();
1769
- p11.log.success(`Project: ${chalk10.bold(projectName)}`);
1770
- let sourceLocales;
1597
+
1598
+ // src/utils/organization-select.ts
1599
+ import * as p11 from "@clack/prompts";
1600
+ import chalk10 from "chalk";
1601
+
1602
+ // src/utils/github-connect.ts
1603
+ import * as p9 from "@clack/prompts";
1604
+ import chalk8 from "chalk";
1605
+ async function runGitHubInstallFlow(params) {
1606
+ let server = null;
1771
1607
  try {
1772
- ({ sourceLocales } = await api.listLocales(userToken));
1608
+ server = await startCallbackServer();
1773
1609
  } catch {
1774
- p11.log.error(
1775
- "Failed to fetch supported locales. Check your connection and try again."
1776
- );
1777
- return null;
1778
1610
  }
1779
- const languageOptions = buildLanguageOptions(sourceLocales);
1780
- const appDirs = await collectAppDirs({ cwd: repoRoot, maxDirs: params.maxAppDirs });
1781
- if (appDirs === null) return null;
1782
- if (appDirs.length > 0) {
1783
- p11.log.success(`App directories: ${appDirs.map((d) => chalk10.bold(d)).join(", ")}`);
1611
+ const { installUrl } = await params.api.startCliGitHubInstall(
1612
+ params.userToken,
1613
+ {
1614
+ organizationId: params.organizationId,
1615
+ callbackPort: server?.port
1616
+ }
1617
+ );
1618
+ p9.log.info("Opening GitHub to install the Vocoder App...");
1619
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1620
+ const shouldOpen = params.yes ? true : await p9.confirm({ message: "Open in your browser?" });
1621
+ if (p9.isCancel(shouldOpen)) {
1622
+ server?.close();
1623
+ return null;
1624
+ }
1625
+ if (shouldOpen) {
1626
+ const opened = await tryOpenBrowser(installUrl);
1627
+ if (!opened) {
1628
+ p9.log.info(
1629
+ "Could not open a browser automatically. Use the URL above."
1630
+ );
1631
+ }
1632
+ }
1633
+ }
1634
+ const connectSpinner = p9.spinner();
1635
+ connectSpinner.start("Waiting for GitHub App installation...");
1636
+ if (server) {
1637
+ try {
1638
+ const params_timeout = 15 * 60 * 1e3;
1639
+ const callbackParams = await Promise.race([
1640
+ server.waitForCallback(),
1641
+ new Promise(
1642
+ (resolve3) => setTimeout(() => resolve3(null), params_timeout)
1643
+ )
1644
+ ]);
1645
+ server.close();
1646
+ if (!callbackParams) {
1647
+ connectSpinner.stop("GitHub App installation timed out");
1648
+ p9.log.error(
1649
+ "The installation flow timed out. Run `vocoder init` again."
1650
+ );
1651
+ return null;
1652
+ }
1653
+ if (callbackParams.error) {
1654
+ connectSpinner.stop("GitHub App installation failed");
1655
+ p9.log.error(callbackParams.error);
1656
+ return null;
1657
+ }
1658
+ const { organizationId, connectionLabel, workspace_created } = callbackParams;
1659
+ if (!organizationId || !connectionLabel) {
1660
+ connectSpinner.stop("GitHub App installation incomplete");
1661
+ p9.log.error("Missing organization or connection data from callback.");
1662
+ return null;
1663
+ }
1664
+ connectSpinner.stop(
1665
+ `Connected to GitHub as ${chalk8.bold(connectionLabel)}`
1666
+ );
1667
+ const orgName = workspace_created ? connectionLabel : organizationId;
1668
+ return {
1669
+ organizationId,
1670
+ organizationName: orgName,
1671
+ connectionLabel
1672
+ };
1673
+ } catch {
1674
+ server.close();
1675
+ connectSpinner.stop("GitHub App installation failed");
1676
+ return null;
1677
+ }
1784
1678
  }
1785
- const sourceLocale = await searchSelectLocale(
1786
- languageOptions,
1787
- "Source language (the language your code is written in)",
1788
- params.defaultSourceLocale ?? "en"
1679
+ connectSpinner.stop("Could not detect GitHub App installation automatically");
1680
+ p9.log.warn(
1681
+ "Complete the installation in your browser, then run `vocoder init` again."
1789
1682
  );
1790
- if (sourceLocale === null) return null;
1791
- let compatibleTargets;
1683
+ return null;
1684
+ }
1685
+ async function runGitHubDiscoveryFlow(params) {
1686
+ let server = null;
1792
1687
  try {
1793
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
1688
+ server = await startCallbackServer();
1794
1689
  } catch {
1795
- p11.log.error(
1796
- "Failed to fetch compatible target locales. Check your connection and try again."
1797
- );
1798
- return null;
1799
1690
  }
1800
- const localeOptions = buildLocaleOptions(compatibleTargets);
1801
- const targetOptions = localeOptions.filter(
1802
- (opt) => opt.bcp47 !== sourceLocale
1803
- );
1804
- const targetLocales = await searchMultiSelectLocales(
1805
- targetOptions,
1806
- "Target languages (languages to translate into)"
1807
- );
1808
- if (targetLocales === null) return null;
1809
- if (targetLocales.length === 0) {
1810
- p11.log.warn(
1811
- "No target languages selected \u2014 you can add them later from the dashboard."
1812
- );
1691
+ const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
1692
+ organizationId: params.organizationId,
1693
+ callbackPort: server?.port
1694
+ });
1695
+ p9.log.info("Opening GitHub to authorize your account...");
1696
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1697
+ const shouldOpen = params.yes ? true : await p9.confirm({ message: "Open in your browser?" });
1698
+ if (p9.isCancel(shouldOpen)) {
1699
+ server?.close();
1700
+ return null;
1701
+ }
1702
+ if (shouldOpen) {
1703
+ const opened = await tryOpenBrowser(oauthUrl);
1704
+ if (!opened) {
1705
+ p9.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
1706
+ }
1707
+ }
1813
1708
  }
1814
- const detected = detectGitBranches();
1815
- const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
1816
- let pushBranches = [];
1817
- {
1818
- let initial = initialBranches;
1819
- while (pushBranches.length === 0) {
1820
- const result2 = await filterableBranchSelect({
1821
- message: "Which branches should trigger translations?",
1822
- branches: detected.branches,
1823
- defaultBranch: detected.defaultBranch,
1824
- initialValues: initial
1825
- });
1826
- if (result2 === null) return null;
1827
- if (result2.length === 0) {
1828
- p11.log.warn(
1829
- "At least one branch is required. Please select at least one."
1830
- );
1831
- initial = [detected.defaultBranch];
1832
- } else {
1833
- pushBranches = result2;
1709
+ const oauthSpinner = p9.spinner();
1710
+ oauthSpinner.start("Waiting for GitHub authorization...");
1711
+ if (server) {
1712
+ try {
1713
+ const timeoutMs = 10 * 60 * 1e3;
1714
+ const callbackParams = await Promise.race([
1715
+ server.waitForCallback(),
1716
+ new Promise(
1717
+ (resolve3) => setTimeout(() => resolve3(null), timeoutMs)
1718
+ )
1719
+ ]);
1720
+ server.close();
1721
+ if (!callbackParams) {
1722
+ oauthSpinner.stop("GitHub authorization timed out");
1723
+ return null;
1724
+ }
1725
+ if (callbackParams.error) {
1726
+ oauthSpinner.stop("GitHub authorization failed");
1727
+ p9.log.error(callbackParams.error);
1728
+ return null;
1834
1729
  }
1730
+ } catch {
1731
+ server.close();
1732
+ oauthSpinner.stop("GitHub authorization failed");
1733
+ return null;
1835
1734
  }
1836
1735
  }
1837
- const targetBranches = pushBranches;
1838
- const result = await api.createProject(userToken, {
1839
- organizationId,
1840
- name: projectName,
1841
- sourceLocale,
1842
- targetLocales,
1843
- targetBranches,
1844
- appDirs,
1845
- repoCanonical
1736
+ oauthSpinner.stop("GitHub account authorized");
1737
+ const discoveryResult = await params.api.getCliGitHubDiscovery(
1738
+ params.userToken
1739
+ );
1740
+ return discoveryResult.installations;
1741
+ }
1742
+ async function selectGitHubInstallation(installations, canInstallNew) {
1743
+ const options = installations.map((inst) => ({
1744
+ value: String(inst.installationId),
1745
+ label: inst.accountLogin,
1746
+ hint: [
1747
+ inst.accountType === "Organization" ? "GitHub org" : "personal account",
1748
+ inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
1749
+ inst.isSuspended ? "suspended" : ""
1750
+ ].filter(Boolean).join(" \xB7 ") || void 0
1751
+ }));
1752
+ if (canInstallNew) {
1753
+ options.push({
1754
+ value: "install_new",
1755
+ label: `Install on a new account ${chalk8.dim("(creates a new workspace)")}`
1756
+ });
1757
+ }
1758
+ const selected = await p9.select({
1759
+ message: "Which GitHub account should this workspace connect to?",
1760
+ options
1846
1761
  });
1847
- p11.log.success(`Project ${chalk10.bold(result.projectName)} created!`);
1848
- return {
1849
- projectId: result.projectId,
1850
- projectName: result.projectName,
1851
- apiKey: result.apiKey,
1852
- sourceLocale,
1853
- targetLocales,
1854
- targetBranches,
1855
- repositoryBound: result.repositoryBound,
1856
- configureUrl: result.configureUrl,
1857
- apps: result.apps
1858
- };
1762
+ if (p9.isCancel(selected)) return null;
1763
+ if (selected === "install_new") return "install_new";
1764
+ return Number(selected);
1859
1765
  }
1860
- async function runAppCreate(params) {
1861
- const { api, userToken, projectId, projectName, repoCanonical } = params;
1862
- const existingDirs = params.existingApps.map((a) => a.appDir);
1863
- const appDir = await promptSingleAppDir({ existingDirs });
1864
- if (appDir === null) return null;
1865
- if (appDir) {
1866
- p11.log.success(`App directory: ${chalk10.bold(appDir)}`);
1766
+
1767
+ // src/utils/organization.ts
1768
+ import * as p10 from "@clack/prompts";
1769
+ import chalk9 from "chalk";
1770
+ async function selectOrganization(result) {
1771
+ const { organizations, canCreateOrganization } = result;
1772
+ if (organizations.length === 0) {
1773
+ return { action: "create" };
1867
1774
  }
1868
- let sourceLocales;
1869
- try {
1870
- ({ sourceLocales } = await api.listLocales(userToken));
1871
- } catch {
1872
- p11.log.error(
1873
- "Failed to fetch supported locales. Check your connection and try again."
1874
- );
1875
- return null;
1775
+ const options = organizations.map((org) => {
1776
+ const atLimit = org.maxApps !== -1 && org.appCount >= org.maxApps;
1777
+ const hint = [
1778
+ org.projectCount > 0 ? `${org.projectCount} project${org.projectCount !== 1 ? "s" : ""}` : "",
1779
+ org.connectionLabel ? `GitHub: ${org.connectionLabel}` : "",
1780
+ atLimit ? chalk9.yellow(`${org.appCount}/${org.maxApps} apps \u2014 upgrade for more`) : ""
1781
+ ].filter(Boolean).join(" \xB7 ") || void 0;
1782
+ return { value: org.id, label: org.name, hint };
1783
+ });
1784
+ if (canCreateOrganization) {
1785
+ options.push({ value: "create", label: "Create new workspace" });
1876
1786
  }
1877
- const languageOptions = buildLanguageOptions(sourceLocales);
1878
- const sourceLocale = await searchSelectLocale(
1879
- languageOptions,
1880
- "Source language",
1881
- "en"
1882
- );
1883
- if (sourceLocale === null) return null;
1884
- let compatibleTargets;
1885
- try {
1886
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
1887
- } catch {
1888
- p11.log.error(
1889
- "Failed to fetch compatible target locales. Check your connection and try again."
1890
- );
1891
- return null;
1787
+ const selected = await p10.select({
1788
+ message: "Select workspace",
1789
+ options
1790
+ });
1791
+ if (p10.isCancel(selected)) {
1792
+ return { action: "cancelled" };
1892
1793
  }
1893
- const targetOptions = buildLocaleOptions(compatibleTargets).filter(
1894
- (opt) => opt.bcp47 !== sourceLocale
1895
- );
1896
- const targetLocales = await searchMultiSelectLocales(
1897
- targetOptions,
1898
- "Target languages"
1899
- );
1900
- if (targetLocales === null) return null;
1901
- if (targetLocales.length === 0) {
1902
- p11.log.warn(
1903
- "No target languages selected \u2014 you can add them later from the dashboard."
1904
- );
1794
+ if (selected === "create") {
1795
+ return { action: "create" };
1905
1796
  }
1906
- const detectedApp = detectGitBranches();
1907
- let appPushBranches = [];
1908
- {
1909
- let initial = [detectedApp.defaultBranch];
1910
- while (appPushBranches.length === 0) {
1911
- const result2 = await filterableBranchSelect({
1912
- message: "Which branches should trigger translations?",
1913
- branches: detectedApp.branches,
1914
- defaultBranch: detectedApp.defaultBranch,
1915
- initialValues: initial
1916
- });
1917
- if (result2 === null) return null;
1918
- if (result2.length === 0) {
1919
- p11.log.warn("At least one branch is required.");
1920
- initial = [detectedApp.defaultBranch];
1921
- } else {
1922
- appPushBranches = result2;
1923
- }
1924
- }
1797
+ const organization = organizations.find((org) => org.id === selected);
1798
+ if (!organization) {
1799
+ return { action: "cancelled" };
1925
1800
  }
1926
- const targetBranches = appPushBranches;
1927
- const result = await api.createApp(userToken, {
1928
- projectId,
1929
- appDir,
1930
- sourceLocale,
1931
- targetLocales,
1932
- targetBranches,
1933
- repoCanonical: repoCanonical ?? ""
1934
- });
1935
- p11.log.success(
1936
- `App ${chalk10.bold(appDir || "(root)")} added to ${chalk10.bold(projectName)}!`
1937
- );
1938
- return {
1939
- projectId: result.projectId,
1940
- projectName: result.projectName,
1941
- appDir: result.appDir,
1942
- appId: result.appId,
1943
- sourceLocale,
1944
- targetLocales,
1945
- targetBranches
1946
- };
1801
+ return { action: "use", organization };
1947
1802
  }
1948
1803
 
1949
- // src/commands/init.ts
1950
- import chalk11 from "chalk";
1951
- import { config as loadEnv } from "dotenv";
1952
-
1953
- // src/utils/git-identity.ts
1954
- import { execSync as execSync5 } from "child_process";
1955
- var SHA_REGEX = /^[0-9a-f]{40}$/i;
1956
- function detectCommitSha() {
1957
- if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1958
- return process.env.VOCODER_COMMIT_SHA;
1804
+ // src/utils/organization-select.ts
1805
+ async function selectOrganizationForInit(params) {
1806
+ const { api, userToken, userEmail, identity, lookup, repoProjectId, options } = params;
1807
+ if (params.authOrganizationId) {
1808
+ const organizationData2 = await api.listOrganizations(userToken);
1809
+ const organization = organizationData2.organizations.find(
1810
+ (o) => o.id === params.authOrganizationId
1811
+ );
1812
+ const organizationName = organization?.name ?? userEmail;
1813
+ p11.log.success(
1814
+ `Connected as ${chalk10.bold(userEmail)} \u2014 workspace: ${chalk10.bold(organizationName)}`
1815
+ );
1816
+ return { organizationId: params.authOrganizationId, organizationName };
1959
1817
  }
1960
- const knownSha = process.env.GITHUB_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.CI_COMMIT_SHA || process.env.BITBUCKET_COMMIT || process.env.CIRCLE_SHA1 || process.env.RENDER_GIT_COMMIT;
1961
- if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1962
- return safeExec("git rev-parse HEAD");
1963
- }
1964
- function safeExec(command) {
1965
- try {
1966
- const output = execSync5(command, {
1967
- encoding: "utf-8",
1968
- stdio: ["pipe", "pipe", "ignore"]
1969
- }).trim();
1970
- return output.length > 0 ? output : null;
1971
- } catch {
1972
- return null;
1818
+ const repoOrgContext = identity ? lookup?.organizationContext ?? null : null;
1819
+ if (repoOrgContext && !repoProjectId) {
1820
+ p11.log.success(`Workspace: ${chalk10.bold(repoOrgContext.organizationName)}`);
1821
+ return {
1822
+ organizationId: repoOrgContext.organizationId,
1823
+ organizationName: repoOrgContext.organizationName
1824
+ };
1973
1825
  }
1974
- }
1975
- function normalizePath(pathname) {
1976
- const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1977
- if (!cleaned || !cleaned.includes("/")) {
1978
- return null;
1826
+ const organizationData = await api.listOrganizations(userToken, {
1827
+ repo: identity?.repoCanonical
1828
+ });
1829
+ const repoCanonical = identity?.repoCanonical ?? null;
1830
+ const covering = repoCanonical ? organizationData.organizations.filter((o) => o.coversRepo === true) : [];
1831
+ const connected = organizationData.organizations.filter(
1832
+ (o) => o.hasGitHubConnection
1833
+ );
1834
+ if (repoCanonical && covering.length === 1) {
1835
+ const organization = covering[0];
1836
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1837
+ return { organizationId: organization.id, organizationName: organization.name };
1979
1838
  }
1980
- return cleaned;
1981
- }
1982
- function parseRemoteUrl(remoteUrl) {
1983
- const trimmed = remoteUrl.trim();
1984
- if (!trimmed) {
1985
- return null;
1839
+ if (repoCanonical && covering.length > 1) {
1840
+ const choice = await p11.select({
1841
+ message: "Select workspace for this repo",
1842
+ options: covering.map((o) => ({
1843
+ value: o.id,
1844
+ label: `${o.name} ${chalk10.dim(`(${o.appCount} app${o.appCount !== 1 ? "s" : ""})`)}`
1845
+ }))
1846
+ });
1847
+ if (p11.isCancel(choice)) {
1848
+ p11.cancel("Setup cancelled.");
1849
+ return null;
1850
+ }
1851
+ const organization = covering.find((o) => o.id === choice);
1852
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1853
+ return { organizationId: organization.id, organizationName: organization.name };
1986
1854
  }
1987
- if (!trimmed.includes("://")) {
1988
- const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1989
- if (scpMatch) {
1990
- const host = (scpMatch[1] || "").toLowerCase();
1991
- const ownerRepoPath = normalizePath(scpMatch[2] || "");
1992
- if (!host || !ownerRepoPath) {
1993
- return null;
1855
+ if (repoCanonical && covering.length === 0 && connected.length > 0) {
1856
+ const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
1857
+ p11.log.warn(
1858
+ `${chalk10.bold(shortRepo)} isn't accessible from your Vocoder installation.
1859
+ Grant access to this repository or install on the account that owns it.`
1860
+ );
1861
+ const fixOptions = [];
1862
+ for (const organization of connected) {
1863
+ if (organization.installationConfigureUrl) {
1864
+ fixOptions.push({
1865
+ value: `grant:${organization.id}`,
1866
+ label: `Configure ${chalk10.bold(organization.connectionLabel ?? organization.name)}'s GitHub App installation`
1867
+ });
1994
1868
  }
1995
- return { host, ownerRepoPath };
1996
1869
  }
1997
- return null;
1870
+ fixOptions.push({
1871
+ value: "install_new",
1872
+ label: `Install on a different GitHub account ${chalk10.dim("(creates a new personal workspace)")}`
1873
+ });
1874
+ fixOptions.push({ value: "cancel", label: "Cancel" });
1875
+ const fix = await p11.select({
1876
+ message: "How would you like to fix this?",
1877
+ options: fixOptions
1878
+ });
1879
+ if (p11.isCancel(fix) || fix === "cancel") {
1880
+ p11.cancel("Setup cancelled.");
1881
+ return null;
1882
+ }
1883
+ if (fix.startsWith("grant:")) {
1884
+ const organization = connected.find((o) => `grant:${o.id}` === fix);
1885
+ await tryOpenBrowser(organization.installationConfigureUrl);
1886
+ p11.cancel(
1887
+ `Grant access to ${chalk10.bold(shortRepo)} in your browser,
1888
+ then re-run ${chalk10.bold("vocoder init")}.`
1889
+ );
1890
+ return null;
1891
+ }
1892
+ const connectResult = await runGitHubInstallFlow({
1893
+ api,
1894
+ userToken,
1895
+ yes: options.yes
1896
+ });
1897
+ if (!connectResult) {
1898
+ p11.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1899
+ return null;
1900
+ }
1901
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
1902
+ return {
1903
+ organizationId: connectResult.organizationId,
1904
+ organizationName: connectResult.organizationName
1905
+ };
1998
1906
  }
1999
- try {
2000
- const parsed = new URL(trimmed);
2001
- const host = parsed.hostname.toLowerCase();
2002
- const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
2003
- if (!host || !ownerRepoPath) {
1907
+ const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
1908
+ const cachedInstallations = discoveryResult?.installations ?? [];
1909
+ if (cachedInstallations.length > 0) {
1910
+ if (repoCanonical) {
1911
+ const repoOwner = repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
1912
+ if (repoOwner) {
1913
+ const hasMatchingAccount = cachedInstallations.some(
1914
+ (i) => i.accountLogin.toLowerCase() === repoOwner
1915
+ );
1916
+ if (!hasMatchingAccount) {
1917
+ p11.log.warn(
1918
+ `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
1919
+ The project will be created but translations won't trigger automatically.
1920
+ To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
1921
+ );
1922
+ }
1923
+ }
1924
+ }
1925
+ const validInstallations = cachedInstallations.filter(
1926
+ (i) => !i.isSuspended && !i.conflictLabel
1927
+ );
1928
+ let selectedInstallationId2 = null;
1929
+ if (validInstallations.length === 1 && cachedInstallations.length === 1) {
1930
+ selectedInstallationId2 = validInstallations[0].installationId;
1931
+ } else {
1932
+ selectedInstallationId2 = await selectGitHubInstallation(
1933
+ cachedInstallations.map((inst) => ({
1934
+ installationId: inst.installationId,
1935
+ accountLogin: inst.accountLogin,
1936
+ accountType: inst.accountType,
1937
+ isSuspended: inst.isSuspended,
1938
+ conflictLabel: inst.conflictLabel
1939
+ })),
1940
+ false
1941
+ );
1942
+ }
1943
+ if (selectedInstallationId2 === null || selectedInstallationId2 === "install_new") {
1944
+ p11.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
2004
1945
  return null;
2005
1946
  }
2006
- return { host, ownerRepoPath };
2007
- } catch {
2008
- return null;
1947
+ const claimResult2 = await api.claimCliGitHubInstallation(userToken, {
1948
+ installationId: String(selectedInstallationId2),
1949
+ organizationId: null
1950
+ });
1951
+ p11.log.success(`Workspace: ${chalk10.bold(claimResult2.organizationName)}`);
1952
+ return {
1953
+ organizationId: claimResult2.organizationId,
1954
+ organizationName: claimResult2.organizationName
1955
+ };
2009
1956
  }
2010
- }
2011
- function toCanonical(host, ownerRepoPath) {
2012
- if (host.includes("github.com")) {
2013
- return `github:${ownerRepoPath.toLowerCase()}`;
1957
+ if (organizationData.organizations.length === 1 && !organizationData.canCreateOrganization) {
1958
+ const organization = organizationData.organizations[0];
1959
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1960
+ return { organizationId: organization.id, organizationName: organization.name };
2014
1961
  }
2015
- if (host.includes("gitlab.com")) {
2016
- return `gitlab:${ownerRepoPath.toLowerCase()}`;
1962
+ const organizationResult = await selectOrganization(organizationData);
1963
+ if (organizationResult.action === "cancelled") {
1964
+ p11.cancel("Setup cancelled.");
1965
+ return null;
2017
1966
  }
2018
- if (host.includes("bitbucket.org")) {
2019
- return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1967
+ if (organizationResult.action === "use") {
1968
+ const { organization } = organizationResult;
1969
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1970
+ return { organizationId: organization.id, organizationName: organization.name };
2020
1971
  }
2021
- return `git:${host}/${ownerRepoPath.toLowerCase()}`;
2022
- }
2023
- function resolveGitRepositoryIdentity() {
2024
- const remoteUrl = safeExec("git config --get remote.origin.url");
2025
- if (!remoteUrl) {
1972
+ const connectChoice = await p11.select({
1973
+ message: "Connect your new workspace to GitHub",
1974
+ options: [
1975
+ { value: "install", label: "Install the Vocoder GitHub App" },
1976
+ { value: "link", label: "Link an existing installation" }
1977
+ ]
1978
+ });
1979
+ if (p11.isCancel(connectChoice)) {
1980
+ p11.cancel("Setup cancelled.");
2026
1981
  return null;
2027
1982
  }
2028
- const parsed = parseRemoteUrl(remoteUrl);
2029
- if (!parsed) {
2030
- return null;
1983
+ if (connectChoice === "install") {
1984
+ const connectResult = await runGitHubInstallFlow({
1985
+ api,
1986
+ userToken,
1987
+ yes: options.yes
1988
+ });
1989
+ if (!connectResult) {
1990
+ p11.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1991
+ return null;
1992
+ }
1993
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
1994
+ return {
1995
+ organizationId: connectResult.organizationId,
1996
+ organizationName: connectResult.organizationName
1997
+ };
2031
1998
  }
2032
- const repoRoot = safeExec("git rev-parse --show-toplevel");
2033
- if (!repoRoot) {
1999
+ const installations = await runGitHubDiscoveryFlow({
2000
+ api,
2001
+ userToken,
2002
+ yes: options.yes
2003
+ });
2004
+ if (!installations) return null;
2005
+ if (installations.length === 0) {
2006
+ p11.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
2007
+ const installNow = await p11.confirm({
2008
+ message: "Open GitHub to install the App?"
2009
+ });
2010
+ if (p11.isCancel(installNow) || !installNow) return null;
2011
+ const connectResult = await runGitHubInstallFlow({
2012
+ api,
2013
+ userToken,
2014
+ yes: options.yes
2015
+ });
2016
+ if (!connectResult) return null;
2017
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
2018
+ return {
2019
+ organizationId: connectResult.organizationId,
2020
+ organizationName: connectResult.organizationName
2021
+ };
2022
+ }
2023
+ const selectedInstallationId = await selectGitHubInstallation(
2024
+ installations.map((inst) => ({
2025
+ installationId: inst.installationId,
2026
+ accountLogin: inst.accountLogin,
2027
+ accountType: inst.accountType,
2028
+ isSuspended: inst.isSuspended,
2029
+ conflictLabel: inst.conflictLabel
2030
+ })),
2031
+ true
2032
+ );
2033
+ if (selectedInstallationId === null) {
2034
+ p11.cancel("Setup cancelled.");
2034
2035
  return null;
2035
2036
  }
2037
+ if (selectedInstallationId === "install_new") {
2038
+ const connectResult = await runGitHubInstallFlow({
2039
+ api,
2040
+ userToken,
2041
+ yes: options.yes
2042
+ });
2043
+ if (!connectResult) return null;
2044
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
2045
+ return {
2046
+ organizationId: connectResult.organizationId,
2047
+ organizationName: connectResult.organizationName
2048
+ };
2049
+ }
2050
+ const claimResult = await api.claimCliGitHubInstallation(userToken, {
2051
+ installationId: String(selectedInstallationId),
2052
+ organizationId: null
2053
+ });
2054
+ p11.log.success(`Workspace: ${chalk10.bold(claimResult.organizationName)}`);
2036
2055
  return {
2037
- repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
2038
- repoRoot
2056
+ organizationId: claimResult.organizationId,
2057
+ organizationName: claimResult.organizationName
2039
2058
  };
2040
2059
  }
2041
- function resolveGitContext() {
2042
- const warnings = [];
2043
- const identity = resolveGitRepositoryIdentity();
2044
- if (!identity) {
2045
- warnings.push(
2046
- "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
2047
- );
2048
- }
2049
- return { identity, warnings };
2050
- }
2051
2060
 
2052
2061
  // src/commands/init.ts
2053
2062
  loadEnv();
@@ -2246,7 +2255,7 @@ Translations won't run automatically until you grant access.
2246
2255
  );
2247
2256
  printApiKey(projectResult.apiKey, identity?.repoRoot);
2248
2257
  const wantsMcp = await p12.confirm({
2249
- message: "Set up the Vocoder MCP server for your AI editor?"
2258
+ message: "Set up the Vocoder MCP server?"
2250
2259
  });
2251
2260
  if (!p12.isCancel(wantsMcp) && wantsMcp) {
2252
2261
  await runMcpSetup(projectResult.apiKey);