@vocoder/cli 0.16.2 → 0.16.4

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,1989 @@ 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 };
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}`
637
+ );
879
638
  }
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 };
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;
895
653
  }
896
- if (repoCanonical && covering.length === 0 && connected.length > 0) {
897
- const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
898
- p8.log.warn(
899
- `${chalk9.bold(shortRepo)} isn't accessible from your Vocoder installation.
900
- Grant access to this repository or install on the account that owns it.`
901
- );
902
- const fixOptions = [];
903
- for (const organization of connected) {
904
- if (organization.installationConfigureUrl) {
905
- fixOptions.push({
906
- value: `grant:${organization.id}`,
907
- label: `Configure ${chalk9.bold(organization.connectionLabel ?? organization.name)}'s GitHub App installation`
908
- });
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");
705
+ }
909
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;
910
722
  }
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;
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;
932
742
  }
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;
743
+ if (!multi) {
744
+ const opt = getFiltered()[cursor];
745
+ prompt.value = opt?.bcp47 ?? null;
941
746
  }
942
- p8.log.success(`Workspace: ${chalk9.bold(connectResult.organizationName)}`);
943
- return {
944
- organizationId: connectResult.organizationId,
945
- organizationName: connectResult.organizationName
946
- };
947
- }
948
- const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
949
- const cachedInstallations = discoveryResult?.installations ?? [];
950
- if (cachedInstallations.length > 0) {
951
- if (repoCanonical) {
952
- const repoOwner = repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
953
- if (repoOwner) {
954
- const hasMatchingAccount = cachedInstallations.some(
955
- (i) => i.accountLogin.toLowerCase() === repoOwner
956
- );
957
- if (!hasMatchingAccount) {
958
- p8.log.warn(
959
- `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
960
- The project will be created but translations won't trigger automatically.
961
- To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
962
- );
963
- }
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;
964
755
  }
965
756
  }
966
- const validInstallations = cachedInstallations.filter(
967
- (i) => !i.isSuspended && !i.conflictLabel
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."
968
783
  );
969
- let selectedInstallationId2 = null;
970
- if (validInstallations.length === 1 && cachedInstallations.length === 1) {
971
- selectedInstallationId2 = validInstallations[0].installationId;
972
- } else {
973
- selectedInstallationId2 = await selectGitHubInstallation(
974
- cachedInstallations.map((inst) => ({
975
- installationId: inst.installationId,
976
- accountLogin: inst.accountLogin,
977
- accountType: inst.accountType,
978
- isSuspended: inst.isSuspended,
979
- conflictLabel: inst.conflictLabel
980
- })),
981
- false
982
- );
983
- }
984
- if (selectedInstallationId2 === null || selectedInstallationId2 === "install_new") {
985
- p8.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
986
- return null;
987
- }
988
- const claimResult2 = await api.claimCliGitHubInstallation(userToken, {
989
- installationId: String(selectedInstallationId2),
990
- organizationId: null
991
- });
992
- p8.log.success(`Workspace: ${chalk9.bold(claimResult2.organizationName)}`);
993
- return {
994
- organizationId: claimResult2.organizationId,
995
- organizationName: claimResult2.organizationName
996
- };
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
+ if (saved) {
1179
+ p6.log.success("API key saved to .env");
1180
+ } else {
1181
+ p6.log.warn(
1182
+ "Could not write to .env \u2014 find your API key at https://vocoder.app/settings"
1183
+ );
1184
+ }
1295
1185
  }
1296
1186
 
1297
- // src/utils/branch-select.ts
1187
+ // src/utils/git-identity.ts
1298
1188
  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);
1189
+ var SHA_REGEX = /^[0-9a-f]{40}$/i;
1190
+ function detectCommitSha() {
1191
+ if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
1192
+ return process.env.VOCODER_COMMIT_SHA;
1316
1193
  }
1194
+ 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;
1195
+ if (knownSha && SHA_REGEX.test(knownSha)) return knownSha;
1196
+ return safeExec("git rev-parse HEAD");
1317
1197
  }
1318
- function detectGitBranches(cwd) {
1319
- const workDir = cwd ?? process.cwd();
1198
+ function safeExec(command) {
1320
1199
  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 {
1200
+ const output = execSync4(command, {
1201
+ encoding: "utf-8",
1202
+ stdio: ["pipe", "pipe", "ignore"]
1203
+ }).trim();
1204
+ return output.length > 0 ? output : null;
1205
+ } catch {
1206
+ return null;
1207
+ }
1208
+ }
1209
+ function normalizePath(pathname) {
1210
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1211
+ if (!cleaned || !cleaned.includes("/")) {
1212
+ return null;
1213
+ }
1214
+ return cleaned;
1215
+ }
1216
+ function parseRemoteUrl(remoteUrl) {
1217
+ const trimmed = remoteUrl.trim();
1218
+ if (!trimmed) {
1219
+ return null;
1220
+ }
1221
+ if (!trimmed.includes("://")) {
1222
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
1223
+ if (scpMatch) {
1224
+ const host = (scpMatch[1] || "").toLowerCase();
1225
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
1226
+ if (!host || !ownerRepoPath) {
1227
+ return null;
1228
+ }
1229
+ return { host, ownerRepoPath };
1334
1230
  }
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 {
1231
+ return null;
1232
+ }
1233
+ try {
1234
+ const parsed = new URL(trimmed);
1235
+ const host = parsed.hostname.toLowerCase();
1236
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
1237
+ if (!host || !ownerRepoPath) {
1238
+ return null;
1344
1239
  }
1345
- return {
1346
- branches: branches.length > 0 ? branches : [defaultBranch],
1347
- defaultBranch
1348
- };
1240
+ return { host, ownerRepoPath };
1349
1241
  } catch {
1350
- return { branches: ["main"], defaultBranch: "main" };
1242
+ return null;
1351
1243
  }
1352
1244
  }
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
- }
1245
+ function toCanonical(host, ownerRepoPath) {
1246
+ if (host.includes("github.com")) {
1247
+ return `github:${ownerRepoPath.toLowerCase()}`;
1373
1248
  }
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}`);
1249
+ if (host.includes("gitlab.com")) {
1250
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
1392
1251
  }
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`));
1252
+ if (host.includes("bitbucket.org")) {
1253
+ return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1402
1254
  }
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");
1255
+ return `git:${host}/${ownerRepoPath.toLowerCase()}`;
1406
1256
  }
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
- }
1257
+ function resolveGitRepositoryIdentity() {
1258
+ const remoteUrl = safeExec("git config --get remote.origin.url");
1259
+ if (!remoteUrl) {
1260
+ return null;
1261
+ }
1262
+ const parsed = parseRemoteUrl(remoteUrl);
1263
+ if (!parsed) {
1264
+ return null;
1265
+ }
1266
+ const repoRoot = safeExec("git rev-parse --show-toplevel");
1267
+ if (!repoRoot) {
1268
+ return null;
1269
+ }
1270
+ return {
1271
+ repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
1272
+ repoRoot
1434
1273
  };
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
- }
1274
+ }
1275
+ function resolveGitContext() {
1276
+ const warnings = [];
1277
+ const identity = resolveGitRepositoryIdentity();
1278
+ if (!identity) {
1279
+ warnings.push(
1280
+ "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
1281
+ );
1282
+ }
1283
+ return { identity, warnings };
1284
+ }
1285
+
1286
+ // src/utils/auth-flow.ts
1287
+ import * as p7 from "@clack/prompts";
1288
+ import chalk6 from "chalk";
1289
+
1290
+ // src/utils/local-server.ts
1291
+ import { createServer } from "http";
1292
+ import { URL as URL2 } from "url";
1293
+ function startCallbackServer() {
1294
+ return new Promise((resolve3, reject) => {
1295
+ let settled = false;
1296
+ let callbackResolve = null;
1297
+ let callbackReject = null;
1298
+ const callbackPromise = new Promise((res, rej) => {
1299
+ callbackResolve = res;
1300
+ callbackReject = rej;
1301
+ });
1302
+ const server = createServer((req, res) => {
1303
+ if (!req.url) {
1304
+ res.writeHead(400);
1305
+ res.end();
1306
+ return;
1485
1307
  }
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;
1308
+ let pathname;
1309
+ let params;
1310
+ try {
1311
+ const parsed = new URL2(req.url, "http://localhost");
1312
+ pathname = parsed.pathname;
1313
+ params = Object.fromEntries(parsed.searchParams.entries());
1314
+ } catch {
1315
+ res.writeHead(400);
1316
+ res.end("Bad request");
1317
+ return;
1542
1318
  }
1543
- }
1544
- });
1545
- prompt.on("finalize", () => {
1546
- if (prompt.state === "submit") {
1547
- prompt.value = Array.from(selected);
1548
- }
1319
+ if (pathname !== "/callback") {
1320
+ res.writeHead(404);
1321
+ res.end("Not found");
1322
+ return;
1323
+ }
1324
+ res.writeHead(200, { "Content-Type": "text/html" });
1325
+ res.end(
1326
+ '<!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>'
1327
+ );
1328
+ if (callbackResolve) {
1329
+ callbackResolve(params);
1330
+ callbackResolve = null;
1331
+ }
1332
+ setImmediate(() => server.close());
1333
+ });
1334
+ server.on("error", (err) => {
1335
+ if (!settled) {
1336
+ settled = true;
1337
+ if (callbackReject) callbackReject(err);
1338
+ reject(err);
1339
+ }
1340
+ });
1341
+ server.listen(0, "127.0.0.1", () => {
1342
+ if (settled) return;
1343
+ settled = true;
1344
+ const port = server.address().port;
1345
+ resolve3({
1346
+ port,
1347
+ waitForCallback: () => callbackPromise,
1348
+ close: () => server.close()
1349
+ });
1350
+ });
1549
1351
  });
1550
- const result = await prompt.prompt();
1551
- if (isCancel9(result)) return null;
1552
- return result;
1553
1352
  }
1554
1353
 
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");
1354
+ // src/utils/auth-flow.ts
1355
+ async function sleep(ms) {
1356
+ await new Promise((resolve3) => setTimeout(resolve3, ms));
1601
1357
  }
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;
1358
+ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1359
+ let server = null;
1360
+ if (!options.ci) {
1361
+ try {
1362
+ server = await startCallbackServer();
1363
+ } catch {
1364
+ }
1611
1365
  }
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");
1366
+ const session = await api.startCliAuthSession(server?.port, repoCanonical);
1367
+ const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
1368
+ const expiresAt = new Date(session.expiresAt).getTime();
1369
+ if (options.ci) {
1370
+ process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
1371
+ `);
1372
+ process.stdout.write(`VOCODER_SESSION_ID: ${session.sessionId}
1373
+ `);
1374
+ } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1375
+ if (reauth) {
1376
+ if (!options.yes) {
1377
+ const shouldOpen = await p7.confirm({
1378
+ message: "Open your browser to sign in again?"
1379
+ });
1380
+ if (p7.isCancel(shouldOpen)) {
1381
+ server?.close();
1382
+ p7.cancel("Setup cancelled.");
1383
+ return null;
1384
+ }
1385
+ if (!shouldOpen) {
1386
+ server?.close();
1387
+ p7.cancel("Setup cancelled.");
1388
+ return null;
1389
+ }
1390
+ const opened = await tryOpenBrowser(browserUrl);
1391
+ if (!opened) {
1392
+ p7.note(browserUrl, "Sign In");
1393
+ p7.log.info("Open the URL above manually to continue.");
1663
1394
  }
1395
+ } else {
1396
+ await tryOpenBrowser(browserUrl);
1664
1397
  }
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
- }
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
- }
1398
+ } else {
1399
+ let isLinkFlow = false;
1400
+ if (!options.yes) {
1401
+ const connectChoice = await p7.select({
1402
+ message: "Vocoder needs to be installed on your GitHub account to get started",
1403
+ options: [
1404
+ {
1405
+ value: "install",
1406
+ label: "Install GitHub App",
1407
+ hint: "new user"
1408
+ },
1409
+ {
1410
+ value: "link",
1411
+ label: "Already installed? Link your account",
1412
+ hint: "returning user"
1413
+ }
1414
+ ]
1415
+ });
1416
+ if (p7.isCancel(connectChoice)) {
1417
+ server?.close();
1418
+ p7.cancel("Setup cancelled.");
1419
+ return null;
1698
1420
  }
1699
- break;
1421
+ isLinkFlow = connectChoice === "link";
1422
+ }
1423
+ let urlToOpen = browserUrl;
1424
+ if (isLinkFlow) {
1425
+ try {
1426
+ const linkSession = await api.startCliGitHubLinkSession(
1427
+ session.sessionId,
1428
+ server?.port
1429
+ );
1430
+ urlToOpen = linkSession.oauthUrl;
1431
+ } catch {
1432
+ urlToOpen = browserUrl;
1433
+ }
1434
+ }
1435
+ const opened = await tryOpenBrowser(urlToOpen);
1436
+ if (!opened) {
1437
+ p7.log.warn("Could not open your browser automatically.");
1438
+ p7.note(urlToOpen, "GitHub");
1439
+ p7.log.info("Open the URL above to continue.");
1440
+ }
1700
1441
  }
1701
- if (!multi) {
1702
- const opt = getFiltered()[cursor];
1703
- prompt.value = opt?.bcp47 ?? null;
1442
+ }
1443
+ const authSpinner = p7.spinner();
1444
+ authSpinner.start("Waiting for GitHub authorization...");
1445
+ let rawToken = null;
1446
+ let callbackOrganizationId;
1447
+ let callbackDiscoveryReady = false;
1448
+ const deadline = Math.min(expiresAt, Date.now() + 10 * 60 * 1e3);
1449
+ let stopPolling = false;
1450
+ const serverCallback = server ? server.waitForCallback().catch(() => null) : Promise.resolve(null);
1451
+ const sessionPoll = (async () => {
1452
+ while (!stopPolling && Date.now() < expiresAt) {
1453
+ try {
1454
+ const result = await api.pollCliAuthSession(session.sessionId);
1455
+ if (result.status === "complete" || result.status === "failed") {
1456
+ return result;
1457
+ }
1458
+ } catch {
1459
+ }
1460
+ if (!stopPolling) await sleep(2e3);
1704
1461
  }
1462
+ return null;
1463
+ })();
1464
+ const winner = await new Promise((resolve3) => {
1465
+ let done = false;
1466
+ serverCallback.then((params) => {
1467
+ if (done || params === null || typeof params.token !== "string") return;
1468
+ done = true;
1469
+ resolve3({ kind: "server", params });
1470
+ }).catch(() => {
1471
+ });
1472
+ sessionPoll.then((result) => {
1473
+ if (done || result === null) return;
1474
+ if (result.status === "complete" || result.status === "failed") {
1475
+ done = true;
1476
+ resolve3({
1477
+ kind: "poll",
1478
+ result
1479
+ });
1480
+ }
1481
+ }).catch(() => {
1482
+ });
1483
+ setTimeout(
1484
+ () => {
1485
+ if (!done) {
1486
+ done = true;
1487
+ resolve3(null);
1488
+ }
1489
+ },
1490
+ Math.max(0, deadline - Date.now())
1491
+ );
1705
1492
  });
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;
1493
+ stopPolling = true;
1494
+ server?.close();
1495
+ if (winner !== null) {
1496
+ if (winner.kind === "server") {
1497
+ rawToken = winner.params.token;
1498
+ if (typeof winner.params.organizationId === "string" && winner.params.organizationId) {
1499
+ callbackOrganizationId = winner.params.organizationId;
1500
+ }
1501
+ if (winner.params.discovery_ready === "1") {
1502
+ callbackDiscoveryReady = true;
1503
+ }
1504
+ } else if (winner.result.status === "complete") {
1505
+ rawToken = winner.result.token;
1506
+ if (winner.result.organizationId) {
1507
+ callbackOrganizationId = winner.result.organizationId;
1713
1508
  }
1509
+ } else {
1510
+ authSpinner.stop();
1511
+ p7.log.error(winner.result.reason);
1512
+ return null;
1714
1513
  }
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
1514
  }
1744
- return picks;
1515
+ if (!rawToken) {
1516
+ authSpinner.stop();
1517
+ p7.log.error("The authentication link expired. Run `vocoder init` again.");
1518
+ return null;
1519
+ }
1520
+ const userInfo = await api.getCliUserInfo(rawToken);
1521
+ authSpinner.stop(`Authenticated as ${chalk6.bold(userInfo.email)}`);
1522
+ return {
1523
+ token: rawToken,
1524
+ ...userInfo,
1525
+ organizationId: callbackOrganizationId,
1526
+ discoveryReady: callbackDiscoveryReady
1527
+ };
1745
1528
  }
1746
1529
 
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
- }));
1530
+ // src/utils/mcp-setup.ts
1531
+ import * as p8 from "@clack/prompts";
1532
+ import chalk7 from "chalk";
1533
+ import { execSync as execSync5 } from "child_process";
1534
+ var MCP_DOCS_URL = "https://vocoder.app/docs/mcp";
1535
+ function mcpServerJson(apiKey) {
1536
+ return JSON.stringify(
1537
+ {
1538
+ mcpServers: {
1539
+ vocoder: {
1540
+ type: "stdio",
1541
+ command: "npx",
1542
+ args: ["-y", "@vocoder/mcp"],
1543
+ env: { VOCODER_API_KEY: apiKey }
1544
+ }
1545
+ }
1546
+ },
1547
+ null,
1548
+ 2
1549
+ );
1753
1550
  }
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);
1551
+ async function runMcpSetup(apiKey) {
1552
+ p8.log.message(
1553
+ chalk7.dim(
1554
+ " The Vocoder MCP server lets your AI editor add/remove locales,\n check translation status, and scaffold i18n directly in your project."
1555
+ )
1556
+ );
1557
+ const tool = await p8.select({
1558
+ message: "Which AI editor?",
1559
+ options: [
1560
+ { value: "claude", label: "Claude Code" },
1561
+ { value: "cursor", label: "Cursor" },
1562
+ { value: "windsurf", label: "Windsurf" },
1563
+ { value: "vscode", label: "VS Code (GitHub Copilot)" },
1564
+ { value: "other", label: "Other \u2014 show the config JSON" }
1565
+ ]
1566
+ });
1567
+ if (p8.isCancel(tool)) return;
1568
+ if (tool === "claude") {
1569
+ try {
1570
+ execSync5(
1571
+ `claude mcp add vocoder --scope user --transport stdio -e VOCODER_API_KEY=${apiKey} -- npx -y @vocoder/mcp`,
1572
+ { stdio: "pipe" }
1573
+ );
1574
+ p8.log.success("Vocoder MCP server registered in Claude Code.");
1575
+ } catch {
1576
+ p8.log.message(chalk7.dim("(automatic registration failed \u2014 run this command manually:)"));
1577
+ printCommand(
1578
+ `claude mcp add vocoder --scope user --transport stdio -e VOCODER_API_KEY=${apiKey} -- npx -y @vocoder/mcp`
1579
+ );
1580
+ p8.log.message(info(` Docs: ${MCP_DOCS_URL}`));
1762
1581
  }
1582
+ return;
1763
1583
  }
1764
- return Array.from(byFamily.values());
1584
+ const configPaths = {
1585
+ cursor: { path: "~/.cursor/mcp.json", merge: true },
1586
+ windsurf: { path: "~/.codeium/windsurf/mcp_config.json", merge: true },
1587
+ vscode: { path: ".vscode/mcp.json", merge: true },
1588
+ other: { path: ".mcp.json", merge: false }
1589
+ };
1590
+ const { path: configPath, merge } = configPaths[tool];
1591
+ const mergeNote = merge ? chalk7.dim(` Merge into ${configPath} (create if missing):`) : chalk7.dim(` Add to ${configPath}:`);
1592
+ p8.log.message(mergeNote);
1593
+ printCodeBlock(mcpServerJson(apiKey));
1594
+ p8.log.message(info(` Docs: ${MCP_DOCS_URL}`));
1765
1595
  }
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;
1596
+
1597
+ // src/utils/organization-select.ts
1598
+ import * as p11 from "@clack/prompts";
1599
+ import chalk10 from "chalk";
1600
+
1601
+ // src/utils/github-connect.ts
1602
+ import * as p9 from "@clack/prompts";
1603
+ import chalk8 from "chalk";
1604
+ async function runGitHubInstallFlow(params) {
1605
+ let server = null;
1771
1606
  try {
1772
- ({ sourceLocales } = await api.listLocales(userToken));
1607
+ server = await startCallbackServer();
1773
1608
  } catch {
1774
- p11.log.error(
1775
- "Failed to fetch supported locales. Check your connection and try again."
1776
- );
1777
- return null;
1778
1609
  }
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(", ")}`);
1610
+ const { installUrl } = await params.api.startCliGitHubInstall(
1611
+ params.userToken,
1612
+ {
1613
+ organizationId: params.organizationId,
1614
+ callbackPort: server?.port
1615
+ }
1616
+ );
1617
+ p9.log.info("Opening GitHub to install the Vocoder App...");
1618
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1619
+ const shouldOpen = params.yes ? true : await p9.confirm({ message: "Open in your browser?" });
1620
+ if (p9.isCancel(shouldOpen)) {
1621
+ server?.close();
1622
+ return null;
1623
+ }
1624
+ if (shouldOpen) {
1625
+ const opened = await tryOpenBrowser(installUrl);
1626
+ if (!opened) {
1627
+ p9.log.info(
1628
+ "Could not open a browser automatically. Use the URL above."
1629
+ );
1630
+ }
1631
+ }
1632
+ }
1633
+ const connectSpinner = p9.spinner();
1634
+ connectSpinner.start("Waiting for GitHub App installation...");
1635
+ if (server) {
1636
+ try {
1637
+ const params_timeout = 15 * 60 * 1e3;
1638
+ const callbackParams = await Promise.race([
1639
+ server.waitForCallback(),
1640
+ new Promise(
1641
+ (resolve3) => setTimeout(() => resolve3(null), params_timeout)
1642
+ )
1643
+ ]);
1644
+ server.close();
1645
+ if (!callbackParams) {
1646
+ connectSpinner.stop("GitHub App installation timed out");
1647
+ p9.log.error(
1648
+ "The installation flow timed out. Run `vocoder init` again."
1649
+ );
1650
+ return null;
1651
+ }
1652
+ if (callbackParams.error) {
1653
+ connectSpinner.stop("GitHub App installation failed");
1654
+ p9.log.error(callbackParams.error);
1655
+ return null;
1656
+ }
1657
+ const { organizationId, connectionLabel, workspace_created } = callbackParams;
1658
+ if (!organizationId || !connectionLabel) {
1659
+ connectSpinner.stop("GitHub App installation incomplete");
1660
+ p9.log.error("Missing organization or connection data from callback.");
1661
+ return null;
1662
+ }
1663
+ connectSpinner.stop(
1664
+ `Connected to GitHub as ${chalk8.bold(connectionLabel)}`
1665
+ );
1666
+ const orgName = workspace_created ? connectionLabel : organizationId;
1667
+ return {
1668
+ organizationId,
1669
+ organizationName: orgName,
1670
+ connectionLabel
1671
+ };
1672
+ } catch {
1673
+ server.close();
1674
+ connectSpinner.stop("GitHub App installation failed");
1675
+ return null;
1676
+ }
1784
1677
  }
1785
- const sourceLocale = await searchSelectLocale(
1786
- languageOptions,
1787
- "Source language (the language your code is written in)",
1788
- params.defaultSourceLocale ?? "en"
1678
+ connectSpinner.stop("Could not detect GitHub App installation automatically");
1679
+ p9.log.warn(
1680
+ "Complete the installation in your browser, then run `vocoder init` again."
1789
1681
  );
1790
- if (sourceLocale === null) return null;
1791
- let compatibleTargets;
1682
+ return null;
1683
+ }
1684
+ async function runGitHubDiscoveryFlow(params) {
1685
+ let server = null;
1792
1686
  try {
1793
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
1687
+ server = await startCallbackServer();
1794
1688
  } catch {
1795
- p11.log.error(
1796
- "Failed to fetch compatible target locales. Check your connection and try again."
1797
- );
1798
- return null;
1799
1689
  }
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
- );
1690
+ const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
1691
+ organizationId: params.organizationId,
1692
+ callbackPort: server?.port
1693
+ });
1694
+ p9.log.info("Opening GitHub to authorize your account...");
1695
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1696
+ const shouldOpen = params.yes ? true : await p9.confirm({ message: "Open in your browser?" });
1697
+ if (p9.isCancel(shouldOpen)) {
1698
+ server?.close();
1699
+ return null;
1700
+ }
1701
+ if (shouldOpen) {
1702
+ const opened = await tryOpenBrowser(oauthUrl);
1703
+ if (!opened) {
1704
+ p9.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
1705
+ }
1706
+ }
1813
1707
  }
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;
1708
+ const oauthSpinner = p9.spinner();
1709
+ oauthSpinner.start("Waiting for GitHub authorization...");
1710
+ if (server) {
1711
+ try {
1712
+ const timeoutMs = 10 * 60 * 1e3;
1713
+ const callbackParams = await Promise.race([
1714
+ server.waitForCallback(),
1715
+ new Promise(
1716
+ (resolve3) => setTimeout(() => resolve3(null), timeoutMs)
1717
+ )
1718
+ ]);
1719
+ server.close();
1720
+ if (!callbackParams) {
1721
+ oauthSpinner.stop("GitHub authorization timed out");
1722
+ return null;
1723
+ }
1724
+ if (callbackParams.error) {
1725
+ oauthSpinner.stop("GitHub authorization failed");
1726
+ p9.log.error(callbackParams.error);
1727
+ return null;
1834
1728
  }
1729
+ } catch {
1730
+ server.close();
1731
+ oauthSpinner.stop("GitHub authorization failed");
1732
+ return null;
1835
1733
  }
1836
1734
  }
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
1735
+ oauthSpinner.stop("GitHub account authorized");
1736
+ const discoveryResult = await params.api.getCliGitHubDiscovery(
1737
+ params.userToken
1738
+ );
1739
+ return discoveryResult.installations;
1740
+ }
1741
+ async function selectGitHubInstallation(installations, canInstallNew) {
1742
+ const options = installations.map((inst) => ({
1743
+ value: String(inst.installationId),
1744
+ label: inst.accountLogin,
1745
+ hint: [
1746
+ inst.accountType === "Organization" ? "GitHub org" : "personal account",
1747
+ inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
1748
+ inst.isSuspended ? "suspended" : ""
1749
+ ].filter(Boolean).join(" \xB7 ") || void 0
1750
+ }));
1751
+ if (canInstallNew) {
1752
+ options.push({
1753
+ value: "install_new",
1754
+ label: `Install on a new account ${chalk8.dim("(creates a new workspace)")}`
1755
+ });
1756
+ }
1757
+ const selected = await p9.select({
1758
+ message: "Which GitHub account should this workspace connect to?",
1759
+ options
1846
1760
  });
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
- };
1761
+ if (p9.isCancel(selected)) return null;
1762
+ if (selected === "install_new") return "install_new";
1763
+ return Number(selected);
1859
1764
  }
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)}`);
1765
+
1766
+ // src/utils/organization.ts
1767
+ import * as p10 from "@clack/prompts";
1768
+ import chalk9 from "chalk";
1769
+ async function selectOrganization(result) {
1770
+ const { organizations, canCreateOrganization } = result;
1771
+ if (organizations.length === 0) {
1772
+ return { action: "create" };
1867
1773
  }
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;
1774
+ const options = organizations.map((org) => {
1775
+ const atLimit = org.maxApps !== -1 && org.appCount >= org.maxApps;
1776
+ const hint = [
1777
+ org.projectCount > 0 ? `${org.projectCount} project${org.projectCount !== 1 ? "s" : ""}` : "",
1778
+ org.connectionLabel ? `GitHub: ${org.connectionLabel}` : "",
1779
+ atLimit ? chalk9.yellow(`${org.appCount}/${org.maxApps} apps \u2014 upgrade for more`) : ""
1780
+ ].filter(Boolean).join(" \xB7 ") || void 0;
1781
+ return { value: org.id, label: org.name, hint };
1782
+ });
1783
+ if (canCreateOrganization) {
1784
+ options.push({ value: "create", label: "Create new workspace" });
1876
1785
  }
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;
1786
+ const selected = await p10.select({
1787
+ message: "Select workspace",
1788
+ options
1789
+ });
1790
+ if (p10.isCancel(selected)) {
1791
+ return { action: "cancelled" };
1892
1792
  }
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
- );
1793
+ if (selected === "create") {
1794
+ return { action: "create" };
1905
1795
  }
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
- }
1796
+ const organization = organizations.find((org) => org.id === selected);
1797
+ if (!organization) {
1798
+ return { action: "cancelled" };
1925
1799
  }
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
- };
1800
+ return { action: "use", organization };
1947
1801
  }
1948
1802
 
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;
1803
+ // src/utils/organization-select.ts
1804
+ async function selectOrganizationForInit(params) {
1805
+ const { api, userToken, userEmail, identity, lookup, repoProjectId, options } = params;
1806
+ if (params.authOrganizationId) {
1807
+ const organizationData2 = await api.listOrganizations(userToken);
1808
+ const organization = organizationData2.organizations.find(
1809
+ (o) => o.id === params.authOrganizationId
1810
+ );
1811
+ const organizationName = organization?.name ?? userEmail;
1812
+ p11.log.success(
1813
+ `Connected as ${chalk10.bold(userEmail)} \u2014 workspace: ${chalk10.bold(organizationName)}`
1814
+ );
1815
+ return { organizationId: params.authOrganizationId, organizationName };
1959
1816
  }
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;
1817
+ const repoOrgContext = identity ? lookup?.organizationContext ?? null : null;
1818
+ if (repoOrgContext && !repoProjectId) {
1819
+ p11.log.success(`Workspace: ${chalk10.bold(repoOrgContext.organizationName)}`);
1820
+ return {
1821
+ organizationId: repoOrgContext.organizationId,
1822
+ organizationName: repoOrgContext.organizationName
1823
+ };
1973
1824
  }
1974
- }
1975
- function normalizePath(pathname) {
1976
- const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
1977
- if (!cleaned || !cleaned.includes("/")) {
1978
- return null;
1825
+ const organizationData = await api.listOrganizations(userToken, {
1826
+ repo: identity?.repoCanonical
1827
+ });
1828
+ const repoCanonical = identity?.repoCanonical ?? null;
1829
+ const covering = repoCanonical ? organizationData.organizations.filter((o) => o.coversRepo === true) : [];
1830
+ const connected = organizationData.organizations.filter(
1831
+ (o) => o.hasGitHubConnection
1832
+ );
1833
+ if (repoCanonical && covering.length === 1) {
1834
+ const organization = covering[0];
1835
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1836
+ return { organizationId: organization.id, organizationName: organization.name };
1979
1837
  }
1980
- return cleaned;
1981
- }
1982
- function parseRemoteUrl(remoteUrl) {
1983
- const trimmed = remoteUrl.trim();
1984
- if (!trimmed) {
1985
- return null;
1838
+ if (repoCanonical && covering.length > 1) {
1839
+ const choice = await p11.select({
1840
+ message: "Select workspace for this repo",
1841
+ options: covering.map((o) => ({
1842
+ value: o.id,
1843
+ label: `${o.name} ${chalk10.dim(`(${o.appCount} app${o.appCount !== 1 ? "s" : ""})`)}`
1844
+ }))
1845
+ });
1846
+ if (p11.isCancel(choice)) {
1847
+ p11.cancel("Setup cancelled.");
1848
+ return null;
1849
+ }
1850
+ const organization = covering.find((o) => o.id === choice);
1851
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1852
+ return { organizationId: organization.id, organizationName: organization.name };
1986
1853
  }
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;
1854
+ if (repoCanonical && covering.length === 0 && connected.length > 0) {
1855
+ const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
1856
+ p11.log.warn(
1857
+ `${chalk10.bold(shortRepo)} isn't accessible from your Vocoder installation.
1858
+ Grant access to this repository or install on the account that owns it.`
1859
+ );
1860
+ const fixOptions = [];
1861
+ for (const organization of connected) {
1862
+ if (organization.installationConfigureUrl) {
1863
+ fixOptions.push({
1864
+ value: `grant:${organization.id}`,
1865
+ label: `Configure ${chalk10.bold(organization.connectionLabel ?? organization.name)}'s GitHub App installation`
1866
+ });
1994
1867
  }
1995
- return { host, ownerRepoPath };
1996
1868
  }
1997
- return null;
1869
+ fixOptions.push({
1870
+ value: "install_new",
1871
+ label: `Install on a different GitHub account ${chalk10.dim("(creates a new personal workspace)")}`
1872
+ });
1873
+ fixOptions.push({ value: "cancel", label: "Cancel" });
1874
+ const fix = await p11.select({
1875
+ message: "How would you like to fix this?",
1876
+ options: fixOptions
1877
+ });
1878
+ if (p11.isCancel(fix) || fix === "cancel") {
1879
+ p11.cancel("Setup cancelled.");
1880
+ return null;
1881
+ }
1882
+ if (fix.startsWith("grant:")) {
1883
+ const organization = connected.find((o) => `grant:${o.id}` === fix);
1884
+ await tryOpenBrowser(organization.installationConfigureUrl);
1885
+ p11.cancel(
1886
+ `Grant access to ${chalk10.bold(shortRepo)} in your browser,
1887
+ then re-run ${chalk10.bold("vocoder init")}.`
1888
+ );
1889
+ return null;
1890
+ }
1891
+ const connectResult = await runGitHubInstallFlow({
1892
+ api,
1893
+ userToken,
1894
+ yes: options.yes
1895
+ });
1896
+ if (!connectResult) {
1897
+ p11.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1898
+ return null;
1899
+ }
1900
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
1901
+ return {
1902
+ organizationId: connectResult.organizationId,
1903
+ organizationName: connectResult.organizationName
1904
+ };
1998
1905
  }
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) {
1906
+ const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
1907
+ const cachedInstallations = discoveryResult?.installations ?? [];
1908
+ if (cachedInstallations.length > 0) {
1909
+ if (repoCanonical) {
1910
+ const repoOwner = repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
1911
+ if (repoOwner) {
1912
+ const hasMatchingAccount = cachedInstallations.some(
1913
+ (i) => i.accountLogin.toLowerCase() === repoOwner
1914
+ );
1915
+ if (!hasMatchingAccount) {
1916
+ p11.log.warn(
1917
+ `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
1918
+ The project will be created but translations won't trigger automatically.
1919
+ To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
1920
+ );
1921
+ }
1922
+ }
1923
+ }
1924
+ const validInstallations = cachedInstallations.filter(
1925
+ (i) => !i.isSuspended && !i.conflictLabel
1926
+ );
1927
+ let selectedInstallationId2 = null;
1928
+ if (validInstallations.length === 1 && cachedInstallations.length === 1) {
1929
+ selectedInstallationId2 = validInstallations[0].installationId;
1930
+ } else {
1931
+ selectedInstallationId2 = await selectGitHubInstallation(
1932
+ cachedInstallations.map((inst) => ({
1933
+ installationId: inst.installationId,
1934
+ accountLogin: inst.accountLogin,
1935
+ accountType: inst.accountType,
1936
+ isSuspended: inst.isSuspended,
1937
+ conflictLabel: inst.conflictLabel
1938
+ })),
1939
+ false
1940
+ );
1941
+ }
1942
+ if (selectedInstallationId2 === null || selectedInstallationId2 === "install_new") {
1943
+ p11.cancel("Setup cancelled. Re-run `vocoder init` and choose Install GitHub App.");
2004
1944
  return null;
2005
1945
  }
2006
- return { host, ownerRepoPath };
2007
- } catch {
2008
- return null;
1946
+ const claimResult2 = await api.claimCliGitHubInstallation(userToken, {
1947
+ installationId: String(selectedInstallationId2),
1948
+ organizationId: null
1949
+ });
1950
+ p11.log.success(`Workspace: ${chalk10.bold(claimResult2.organizationName)}`);
1951
+ return {
1952
+ organizationId: claimResult2.organizationId,
1953
+ organizationName: claimResult2.organizationName
1954
+ };
2009
1955
  }
2010
- }
2011
- function toCanonical(host, ownerRepoPath) {
2012
- if (host.includes("github.com")) {
2013
- return `github:${ownerRepoPath.toLowerCase()}`;
1956
+ if (organizationData.organizations.length === 1 && !organizationData.canCreateOrganization) {
1957
+ const organization = organizationData.organizations[0];
1958
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1959
+ return { organizationId: organization.id, organizationName: organization.name };
2014
1960
  }
2015
- if (host.includes("gitlab.com")) {
2016
- return `gitlab:${ownerRepoPath.toLowerCase()}`;
1961
+ const organizationResult = await selectOrganization(organizationData);
1962
+ if (organizationResult.action === "cancelled") {
1963
+ p11.cancel("Setup cancelled.");
1964
+ return null;
2017
1965
  }
2018
- if (host.includes("bitbucket.org")) {
2019
- return `bitbucket:${ownerRepoPath.toLowerCase()}`;
1966
+ if (organizationResult.action === "use") {
1967
+ const { organization } = organizationResult;
1968
+ p11.log.success(`Workspace: ${chalk10.bold(organization.name)}`);
1969
+ return { organizationId: organization.id, organizationName: organization.name };
2020
1970
  }
2021
- return `git:${host}/${ownerRepoPath.toLowerCase()}`;
2022
- }
2023
- function resolveGitRepositoryIdentity() {
2024
- const remoteUrl = safeExec("git config --get remote.origin.url");
2025
- if (!remoteUrl) {
1971
+ const connectChoice = await p11.select({
1972
+ message: "Connect your new workspace to GitHub",
1973
+ options: [
1974
+ { value: "install", label: "Install the Vocoder GitHub App" },
1975
+ { value: "link", label: "Link an existing installation" }
1976
+ ]
1977
+ });
1978
+ if (p11.isCancel(connectChoice)) {
1979
+ p11.cancel("Setup cancelled.");
2026
1980
  return null;
2027
1981
  }
2028
- const parsed = parseRemoteUrl(remoteUrl);
2029
- if (!parsed) {
2030
- return null;
1982
+ if (connectChoice === "install") {
1983
+ const connectResult = await runGitHubInstallFlow({
1984
+ api,
1985
+ userToken,
1986
+ yes: options.yes
1987
+ });
1988
+ if (!connectResult) {
1989
+ p11.log.error("GitHub App installation did not complete. Run `vocoder init` again.");
1990
+ return null;
1991
+ }
1992
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
1993
+ return {
1994
+ organizationId: connectResult.organizationId,
1995
+ organizationName: connectResult.organizationName
1996
+ };
2031
1997
  }
2032
- const repoRoot = safeExec("git rev-parse --show-toplevel");
2033
- if (!repoRoot) {
1998
+ const installations = await runGitHubDiscoveryFlow({
1999
+ api,
2000
+ userToken,
2001
+ yes: options.yes
2002
+ });
2003
+ if (!installations) return null;
2004
+ if (installations.length === 0) {
2005
+ p11.log.warn("No GitHub installations found. Install the Vocoder GitHub App first.");
2006
+ const installNow = await p11.confirm({
2007
+ message: "Open GitHub to install the App?"
2008
+ });
2009
+ if (p11.isCancel(installNow) || !installNow) return null;
2010
+ const connectResult = await runGitHubInstallFlow({
2011
+ api,
2012
+ userToken,
2013
+ yes: options.yes
2014
+ });
2015
+ if (!connectResult) return null;
2016
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
2017
+ return {
2018
+ organizationId: connectResult.organizationId,
2019
+ organizationName: connectResult.organizationName
2020
+ };
2021
+ }
2022
+ const selectedInstallationId = await selectGitHubInstallation(
2023
+ installations.map((inst) => ({
2024
+ installationId: inst.installationId,
2025
+ accountLogin: inst.accountLogin,
2026
+ accountType: inst.accountType,
2027
+ isSuspended: inst.isSuspended,
2028
+ conflictLabel: inst.conflictLabel
2029
+ })),
2030
+ true
2031
+ );
2032
+ if (selectedInstallationId === null) {
2033
+ p11.cancel("Setup cancelled.");
2034
2034
  return null;
2035
2035
  }
2036
+ if (selectedInstallationId === "install_new") {
2037
+ const connectResult = await runGitHubInstallFlow({
2038
+ api,
2039
+ userToken,
2040
+ yes: options.yes
2041
+ });
2042
+ if (!connectResult) return null;
2043
+ p11.log.success(`Workspace: ${chalk10.bold(connectResult.organizationName)}`);
2044
+ return {
2045
+ organizationId: connectResult.organizationId,
2046
+ organizationName: connectResult.organizationName
2047
+ };
2048
+ }
2049
+ const claimResult = await api.claimCliGitHubInstallation(userToken, {
2050
+ installationId: String(selectedInstallationId),
2051
+ organizationId: null
2052
+ });
2053
+ p11.log.success(`Workspace: ${chalk10.bold(claimResult.organizationName)}`);
2036
2054
  return {
2037
- repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
2038
- repoRoot
2055
+ organizationId: claimResult.organizationId,
2056
+ organizationName: claimResult.organizationName
2039
2057
  };
2040
2058
  }
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
2059
 
2052
2060
  // src/commands/init.ts
2053
2061
  loadEnv();
@@ -2246,7 +2254,7 @@ Translations won't run automatically until you grant access.
2246
2254
  );
2247
2255
  printApiKey(projectResult.apiKey, identity?.repoRoot);
2248
2256
  const wantsMcp = await p12.confirm({
2249
- message: "Set up the Vocoder MCP server for your AI editor?"
2257
+ message: "Set up the Vocoder MCP server?"
2250
2258
  });
2251
2259
  if (!p12.isCancel(wantsMcp) && wantsMcp) {
2252
2260
  await runMcpSetup(projectResult.apiKey);