@vocoder/cli 0.16.1 → 0.16.3

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