@vocoder/cli 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  readAuthData,
14
14
  verifyStoredAuth,
15
15
  writeAuthData
16
- } from "./chunk-T4BLNDJ3.mjs";
16
+ } from "./chunk-LLEMSC3X.mjs";
17
17
 
18
18
  // src/bin.ts
19
19
  import { Command } from "commander";
@@ -64,311 +64,29 @@ export default defineConfig({
64
64
  }
65
65
  }
66
66
 
67
- // src/utils/github-connect.ts
68
- import { spawn } from "child_process";
69
- import * as p from "@clack/prompts";
70
- import chalk from "chalk";
71
-
72
- // src/utils/local-server.ts
73
- import { createServer } from "http";
74
- import { URL as URL2 } from "url";
75
- function startCallbackServer() {
76
- return new Promise((resolve2, reject) => {
77
- let settled = false;
78
- let callbackResolve = null;
79
- let callbackReject = null;
80
- const callbackPromise = new Promise((res, rej) => {
81
- callbackResolve = res;
82
- callbackReject = rej;
83
- });
84
- const server = createServer((req, res) => {
85
- if (!req.url) {
86
- res.writeHead(400);
87
- res.end();
88
- return;
89
- }
90
- let pathname;
91
- let params;
92
- try {
93
- const parsed = new URL2(req.url, "http://localhost");
94
- pathname = parsed.pathname;
95
- params = Object.fromEntries(parsed.searchParams.entries());
96
- } catch {
97
- res.writeHead(400);
98
- res.end("Bad request");
99
- return;
100
- }
101
- if (pathname !== "/callback") {
102
- res.writeHead(404);
103
- res.end("Not found");
104
- return;
105
- }
106
- res.writeHead(200, { "Content-Type": "text/html" });
107
- res.end(
108
- '<!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>'
109
- );
110
- if (callbackResolve) {
111
- callbackResolve(params);
112
- callbackResolve = null;
113
- }
114
- setImmediate(() => server.close());
115
- });
116
- server.on("error", (err) => {
117
- if (!settled) {
118
- settled = true;
119
- if (callbackReject) callbackReject(err);
120
- reject(err);
121
- }
122
- });
123
- server.listen(0, "127.0.0.1", () => {
124
- if (settled) return;
125
- settled = true;
126
- const port = server.address().port;
127
- resolve2({
128
- port,
129
- waitForCallback: () => callbackPromise,
130
- close: () => server.close()
131
- });
132
- });
133
- });
134
- }
135
-
136
- // src/utils/github-connect.ts
137
- async function tryOpenBrowser(url) {
138
- if (!process.stdout.isTTY || process.env.CI === "true") {
139
- return false;
140
- }
141
- const platform = process.platform;
142
- let command;
143
- let args;
144
- if (platform === "darwin") {
145
- command = "open";
146
- args = [url];
147
- } else if (platform === "win32") {
148
- command = "rundll32";
149
- args = ["url.dll,FileProtocolHandler", url];
150
- } else {
151
- command = "xdg-open";
152
- args = [url];
153
- }
154
- return new Promise((resolve2) => {
155
- try {
156
- const child = spawn(command, args, {
157
- detached: true,
158
- stdio: "ignore",
159
- windowsHide: true
160
- });
161
- let settled = false;
162
- child.once("spawn", () => {
163
- if (settled) return;
164
- settled = true;
165
- child.unref();
166
- resolve2(true);
167
- });
168
- child.once("error", () => {
169
- if (settled) return;
170
- settled = true;
171
- resolve2(false);
172
- });
173
- setTimeout(() => {
174
- if (settled) return;
175
- settled = true;
176
- resolve2(false);
177
- }, 300);
178
- } catch {
179
- resolve2(false);
180
- }
181
- });
182
- }
183
- async function runGitHubInstallFlow(params) {
184
- let server = null;
185
- try {
186
- server = await startCallbackServer();
187
- } catch {
188
- }
189
- const { installUrl } = await params.api.startCliGitHubInstall(
190
- params.userToken,
191
- {
192
- organizationId: params.organizationId,
193
- callbackPort: server?.port
194
- }
195
- );
196
- p.log.info("Opening GitHub to install the Vocoder App...");
197
- p.note(installUrl, "Install URL");
198
- if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
199
- const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
200
- if (p.isCancel(shouldOpen)) {
201
- server?.close();
202
- return null;
203
- }
204
- if (shouldOpen) {
205
- const opened = await tryOpenBrowser(installUrl);
206
- if (!opened) {
207
- p.log.info(
208
- "Could not open a browser automatically. Use the URL above."
209
- );
210
- }
211
- }
212
- }
213
- const connectSpinner = p.spinner();
214
- connectSpinner.start("Waiting for GitHub App installation...");
215
- if (server) {
216
- try {
217
- const params_timeout = 15 * 60 * 1e3;
218
- const callbackParams = await Promise.race([
219
- server.waitForCallback(),
220
- new Promise(
221
- (resolve2) => setTimeout(() => resolve2(null), params_timeout)
222
- )
223
- ]);
224
- server.close();
225
- if (!callbackParams) {
226
- connectSpinner.stop("GitHub App installation timed out");
227
- p.log.error(
228
- "The installation flow timed out. Run `vocoder init` again."
229
- );
230
- return null;
231
- }
232
- if (callbackParams.error) {
233
- connectSpinner.stop("GitHub App installation failed");
234
- p.log.error(callbackParams.error);
235
- return null;
236
- }
237
- const { organizationId, connectionLabel, workspace_created } = callbackParams;
238
- if (!organizationId || !connectionLabel) {
239
- connectSpinner.stop("GitHub App installation incomplete");
240
- p.log.error("Missing organization or connection data from callback.");
241
- return null;
242
- }
243
- connectSpinner.stop(
244
- `Connected to GitHub as ${chalk.bold(connectionLabel)}`
245
- );
246
- const orgName = workspace_created ? connectionLabel : organizationId;
247
- return {
248
- organizationId,
249
- organizationName: orgName,
250
- connectionLabel
251
- };
252
- } catch {
253
- server.close();
254
- connectSpinner.stop("GitHub App installation failed");
255
- return null;
256
- }
257
- }
258
- connectSpinner.stop("Could not detect GitHub App installation automatically");
259
- p.log.warn(
260
- "Complete the installation in your browser, then run `vocoder init` again."
261
- );
262
- return null;
263
- }
264
- async function runGitHubDiscoveryFlow(params) {
265
- let server = null;
266
- try {
267
- server = await startCallbackServer();
268
- } catch {
269
- }
270
- const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
271
- organizationId: params.organizationId,
272
- callbackPort: server?.port
273
- });
274
- p.log.info("Opening GitHub to authorize your account...");
275
- p.note("Complete authorization in your browser.");
276
- if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
277
- const shouldOpen = params.yes ? true : await p.confirm({ message: "Open in your browser?" });
278
- if (p.isCancel(shouldOpen)) {
279
- server?.close();
280
- return null;
281
- }
282
- if (shouldOpen) {
283
- const opened = await tryOpenBrowser(oauthUrl);
284
- if (!opened) {
285
- p.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
286
- }
287
- }
288
- }
289
- const oauthSpinner = p.spinner();
290
- oauthSpinner.start("Waiting for GitHub authorization...");
291
- if (server) {
292
- try {
293
- const timeoutMs = 10 * 60 * 1e3;
294
- const callbackParams = await Promise.race([
295
- server.waitForCallback(),
296
- new Promise(
297
- (resolve2) => setTimeout(() => resolve2(null), timeoutMs)
298
- )
299
- ]);
300
- server.close();
301
- if (!callbackParams) {
302
- oauthSpinner.stop("GitHub authorization timed out");
303
- return null;
304
- }
305
- if (callbackParams.error) {
306
- oauthSpinner.stop("GitHub authorization failed");
307
- p.log.error(callbackParams.error);
308
- return null;
309
- }
310
- } catch {
311
- server.close();
312
- oauthSpinner.stop("GitHub authorization failed");
313
- return null;
314
- }
315
- }
316
- oauthSpinner.stop("GitHub account authorized");
317
- const discoveryResult = await params.api.getCliGitHubDiscovery(
318
- params.userToken
319
- );
320
- return discoveryResult.installations;
321
- }
322
- async function selectGitHubInstallation(installations, canInstallNew) {
323
- const options = installations.map((inst) => ({
324
- value: String(inst.installationId),
325
- label: inst.accountLogin,
326
- hint: [
327
- inst.accountType === "Organization" ? "organization" : "personal",
328
- inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
329
- inst.isSuspended ? "suspended" : ""
330
- ].filter(Boolean).join(" \xB7 ") || void 0
331
- }));
332
- if (canInstallNew) {
333
- options.push({
334
- value: "install_new",
335
- label: `Install on a new account ${chalk.dim("(creates a new personal workspace)")}`
336
- });
337
- }
338
- const selected = await p.select({
339
- message: "Select a GitHub installation",
340
- options
341
- });
342
- if (p.isCancel(selected)) return null;
343
- if (selected === "install_new") return "install_new";
344
- return Number(selected);
345
- }
346
-
347
- // src/utils/project-create.ts
348
- import * as p3 from "@clack/prompts";
349
- import chalk3 from "chalk";
350
-
351
- // src/utils/branch-select.ts
352
- import { execSync } from "child_process";
353
- import { isCancel as isCancel2, Prompt } from "@clack/core";
354
-
355
67
  // src/utils/theme.ts
356
- import chalk2 from "chalk";
68
+ import chalk from "chalk";
357
69
  var ORANGE = "#FC5206";
358
70
  var PINK = "#D51977";
359
71
  var BLUE = "#2450A9";
360
72
  var noColor = process.env.NO_COLOR === "1" || process.env.FORCE_COLOR === "0";
361
- var hex = (color) => (s) => noColor ? s : chalk2.hex(color)(s);
362
- var dim = (s) => noColor ? s : chalk2.dim(s);
363
- var bld = (s) => noColor ? s : chalk2.bold(s);
364
- var grn = (s) => noColor ? s : chalk2.green(s);
365
- var ylw = (s) => noColor ? s : chalk2.yellow(s);
366
- var red = (s) => noColor ? s : chalk2.red(s);
73
+ var hex = (color) => (s) => noColor ? s : chalk.hex(color)(s);
74
+ var dim = (s) => noColor ? s : chalk.dim(s);
75
+ var bld = (s) => noColor ? s : chalk.bold(s);
76
+ var grn = (s) => noColor ? s : chalk.green(s);
77
+ var ylw = (s) => noColor ? s : chalk.yellow(s);
78
+ var red = (s) => noColor ? s : chalk.red(s);
367
79
  var highlight = hex(PINK);
368
80
  var info = hex(BLUE);
369
81
  var active = hex(ORANGE);
370
82
 
83
+ // src/utils/project-create.ts
84
+ import * as p2 from "@clack/prompts";
85
+ import chalk2 from "chalk";
86
+
371
87
  // src/utils/branch-select.ts
88
+ import { execSync } from "child_process";
89
+ import { isCancel, Prompt } from "@clack/core";
372
90
  var S_BAR = "\u2502";
373
91
  var S_BAR_END = "\u2514";
374
92
  var S_ACTIVE = "\u25C6";
@@ -630,13 +348,13 @@ ${symbol(this.state)} ${message}
630
348
  }
631
349
  });
632
350
  const result = await prompt.prompt();
633
- if (isCancel2(result)) return null;
351
+ if (isCancel(result)) return null;
634
352
  return result;
635
353
  }
636
354
 
637
355
  // src/utils/locale-search.ts
638
- import { isCancel as isCancel3, Prompt as Prompt2 } from "@clack/core";
639
- import * as p2 from "@clack/prompts";
356
+ import { isCancel as isCancel2, Prompt as Prompt2 } from "@clack/core";
357
+ import * as p from "@clack/prompts";
640
358
  var S_BAR2 = "\u2502";
641
359
  var S_BAR_END2 = "\u2514";
642
360
  var S_ACTIVE2 = "\u25C6";
@@ -811,7 +529,7 @@ ${symbol2(this.state)} ${message}
811
529
  }
812
530
  });
813
531
  const result = await prompt.prompt();
814
- if (isCancel3(result)) return null;
532
+ if (isCancel2(result)) return null;
815
533
  return result;
816
534
  }
817
535
  async function searchSelectLocale(options, message, initialValue) {
@@ -833,218 +551,498 @@ async function searchMultiSelectLocales(options, message, initialValues) {
833
551
  if (result === null) return null;
834
552
  const picks = result;
835
553
  if (picks.length === 0) {
554
+ p.log.warn(
555
+ "At least one target language is required. Please select at least one."
556
+ );
557
+ return searchMultiSelectLocales(options, message, initialValues);
558
+ }
559
+ return picks;
560
+ }
561
+
562
+ // src/utils/project-create.ts
563
+ function buildLocaleOptions(locales) {
564
+ return locales.map((l) => ({
565
+ bcp47: l.code,
566
+ label: `${l.name} \u2014 ${l.code}`
567
+ }));
568
+ }
569
+ function buildLanguageOptions(locales) {
570
+ const byFamily = /* @__PURE__ */ new Map();
571
+ for (const l of locales) {
572
+ const family = l.code.split("-")[0].toLowerCase();
573
+ const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
574
+ const existing = byFamily.get(family);
575
+ if (!existing || l.code.length < existing.bcp47.length) {
576
+ byFamily.set(family, opt);
577
+ }
578
+ }
579
+ return Array.from(byFamily.values());
580
+ }
581
+ async function runProjectCreate(params) {
582
+ const { api, userToken, organizationId, repoCanonical } = params;
583
+ const projectName = (params.defaultName ?? "my-project").trim();
584
+ p2.log.success(`Project: ${chalk2.bold(projectName)}`);
585
+ let sourceLocales;
586
+ try {
587
+ ({ sourceLocales } = await api.listLocales(userToken));
588
+ } catch {
589
+ p2.log.error(
590
+ "Failed to fetch supported locales. Check your connection and try again."
591
+ );
592
+ return null;
593
+ }
594
+ const languageOptions = buildLanguageOptions(sourceLocales);
595
+ const appDir = params.defaultAppDir ?? "";
596
+ if (appDir) {
597
+ p2.log.success(`App directory: ${chalk2.bold(appDir)}`);
598
+ }
599
+ const sourceLocale = await searchSelectLocale(
600
+ languageOptions,
601
+ "Source language (the language your code is written in)",
602
+ params.defaultSourceLocale ?? "en"
603
+ );
604
+ if (sourceLocale === null) return null;
605
+ let compatibleTargets;
606
+ try {
607
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
608
+ } catch {
609
+ p2.log.error(
610
+ "Failed to fetch compatible target locales. Check your connection and try again."
611
+ );
612
+ return null;
613
+ }
614
+ const localeOptions = buildLocaleOptions(compatibleTargets);
615
+ const targetOptions = localeOptions.filter(
616
+ (opt) => opt.bcp47 !== sourceLocale
617
+ );
618
+ const targetLocales = await searchMultiSelectLocales(
619
+ targetOptions,
620
+ "Target languages (languages to translate into)"
621
+ );
622
+ if (targetLocales === null) return null;
623
+ if (targetLocales.length === 0) {
836
624
  p2.log.warn(
837
- "At least one target language is required. Please select at least one."
625
+ "No target languages selected \u2014 you can add them later from the dashboard."
838
626
  );
839
- return searchMultiSelectLocales(options, message, initialValues);
840
627
  }
841
- return picks;
842
- }
843
-
844
- // src/utils/project-create.ts
845
- function buildLocaleOptions(locales) {
846
- return locales.map((l) => ({
847
- bcp47: l.code,
848
- label: `${l.name} \u2014 ${l.code}`
849
- }));
850
- }
851
- function buildLanguageOptions(locales) {
852
- const byFamily = /* @__PURE__ */ new Map();
853
- for (const l of locales) {
854
- const family = l.code.split("-")[0].toLowerCase();
855
- const opt = { bcp47: l.code, label: `${l.name} \u2014 ${l.code}` };
856
- const existing = byFamily.get(family);
857
- if (!existing || l.code.length < existing.bcp47.length) {
858
- byFamily.set(family, opt);
628
+ const detected = detectGitBranches();
629
+ const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
630
+ let pushBranches = [];
631
+ {
632
+ let initial = initialBranches;
633
+ while (pushBranches.length === 0) {
634
+ const result = await filterableBranchSelect({
635
+ message: "Which branches should trigger translations?",
636
+ branches: detected.branches,
637
+ defaultBranch: detected.defaultBranch,
638
+ initialValues: initial
639
+ });
640
+ if (result === null) return null;
641
+ if (result.length === 0) {
642
+ p2.log.warn(
643
+ "At least one branch is required. Please select at least one."
644
+ );
645
+ initial = [detected.defaultBranch];
646
+ } else {
647
+ pushBranches = result;
648
+ }
859
649
  }
860
650
  }
861
- return Array.from(byFamily.values());
651
+ const targetBranches = pushBranches;
652
+ try {
653
+ const result = await api.createProject(userToken, {
654
+ organizationId,
655
+ name: projectName,
656
+ sourceLocale,
657
+ targetLocales,
658
+ targetBranches,
659
+ appDirs: appDir ? [appDir] : [],
660
+ repoCanonical
661
+ });
662
+ p2.log.success(`Project ${chalk2.bold(result.projectName)} created!`);
663
+ return result;
664
+ } catch (error) {
665
+ const message = error instanceof Error ? error.message : "Unknown error";
666
+ p2.log.error(`Failed to create project: ${message}`);
667
+ return null;
668
+ }
862
669
  }
863
- async function runProjectCreate(params) {
864
- const { api, userToken, organizationId, repoCanonical } = params;
865
- const projectName = (params.defaultName ?? "my-project").trim();
866
- p3.log.success(`Project: ${chalk3.bold(projectName)}`);
670
+ async function runAppCreate(params) {
671
+ const { api, userToken, projectId, projectName, repoCanonical } = params;
672
+ const existingScopes = new Set(params.existingApps.map((a) => a.appDir));
867
673
  let sourceLocales;
868
674
  try {
869
675
  ({ sourceLocales } = await api.listLocales(userToken));
870
676
  } catch {
871
- p3.log.error(
677
+ p2.log.error(
872
678
  "Failed to fetch supported locales. Check your connection and try again."
873
679
  );
874
680
  return null;
875
681
  }
876
682
  const languageOptions = buildLanguageOptions(sourceLocales);
877
683
  const appDir = params.defaultAppDir ?? "";
684
+ if (existingScopes.has(appDir)) {
685
+ p2.log.error(`App directory "${appDir}" is already configured for this project.`);
686
+ return null;
687
+ }
878
688
  if (appDir) {
879
- p3.log.success(`App directory: ${chalk3.bold(appDir)}`);
689
+ p2.log.success(`App directory: ${chalk2.bold(appDir)}`);
880
690
  }
881
691
  const sourceLocale = await searchSelectLocale(
882
692
  languageOptions,
883
- "Source language (the language your code is written in)",
884
- params.defaultSourceLocale ?? "en"
693
+ "Source language",
694
+ "en"
885
695
  );
886
696
  if (sourceLocale === null) return null;
887
697
  let compatibleTargets;
888
698
  try {
889
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
699
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
700
+ } catch {
701
+ p2.log.error(
702
+ "Failed to fetch compatible target locales. Check your connection and try again."
703
+ );
704
+ return null;
705
+ }
706
+ const targetOptions = buildLocaleOptions(compatibleTargets).filter(
707
+ (opt) => opt.bcp47 !== sourceLocale
708
+ );
709
+ const targetLocales = await searchMultiSelectLocales(
710
+ targetOptions,
711
+ "Target languages"
712
+ );
713
+ if (targetLocales === null) return null;
714
+ if (targetLocales.length === 0) {
715
+ p2.log.warn(
716
+ "No target languages selected \u2014 you can add them later from the dashboard."
717
+ );
718
+ }
719
+ const detectedApp = detectGitBranches();
720
+ let appPushBranches = [];
721
+ {
722
+ let initial = [detectedApp.defaultBranch];
723
+ while (appPushBranches.length === 0) {
724
+ const result = await filterableBranchSelect({
725
+ message: "Which branches should trigger translations?",
726
+ branches: detectedApp.branches,
727
+ defaultBranch: detectedApp.defaultBranch,
728
+ initialValues: initial
729
+ });
730
+ if (result === null) return null;
731
+ if (result.length === 0) {
732
+ p2.log.warn("At least one branch is required.");
733
+ initial = [detectedApp.defaultBranch];
734
+ } else {
735
+ appPushBranches = result;
736
+ }
737
+ }
738
+ }
739
+ const targetBranches = appPushBranches;
740
+ try {
741
+ const result = await api.createProject(userToken, {
742
+ projectId,
743
+ appDir,
744
+ sourceLocale,
745
+ targetLocales,
746
+ targetBranches,
747
+ repoCanonical: repoCanonical ?? ""
748
+ });
749
+ p2.log.success(
750
+ `App ${chalk2.bold(appDir)} added to ${chalk2.bold(projectName)}!`
751
+ );
752
+ return {
753
+ projectId: result.projectId,
754
+ projectName: result.projectName,
755
+ apiKey: result.apiKey,
756
+ appDir: result.appDir,
757
+ sourceLocale,
758
+ targetLocales,
759
+ targetBranches
760
+ };
761
+ } catch (error) {
762
+ const message = error instanceof Error ? error.message : "Unknown error";
763
+ p2.log.error(`Failed to add app: ${message}`);
764
+ return null;
765
+ }
766
+ }
767
+
768
+ // src/utils/github-connect.ts
769
+ import { spawn } from "child_process";
770
+ import * as p3 from "@clack/prompts";
771
+ import chalk3 from "chalk";
772
+
773
+ // src/utils/local-server.ts
774
+ import { createServer } from "http";
775
+ import { URL as URL2 } from "url";
776
+ function startCallbackServer() {
777
+ return new Promise((resolve2, reject) => {
778
+ let settled = false;
779
+ let callbackResolve = null;
780
+ let callbackReject = null;
781
+ const callbackPromise = new Promise((res, rej) => {
782
+ callbackResolve = res;
783
+ callbackReject = rej;
784
+ });
785
+ const server = createServer((req, res) => {
786
+ if (!req.url) {
787
+ res.writeHead(400);
788
+ res.end();
789
+ return;
790
+ }
791
+ let pathname;
792
+ let params;
793
+ try {
794
+ const parsed = new URL2(req.url, "http://localhost");
795
+ pathname = parsed.pathname;
796
+ params = Object.fromEntries(parsed.searchParams.entries());
797
+ } catch {
798
+ res.writeHead(400);
799
+ res.end("Bad request");
800
+ return;
801
+ }
802
+ if (pathname !== "/callback") {
803
+ res.writeHead(404);
804
+ res.end("Not found");
805
+ return;
806
+ }
807
+ res.writeHead(200, { "Content-Type": "text/html" });
808
+ res.end(
809
+ '<!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>'
810
+ );
811
+ if (callbackResolve) {
812
+ callbackResolve(params);
813
+ callbackResolve = null;
814
+ }
815
+ setImmediate(() => server.close());
816
+ });
817
+ server.on("error", (err) => {
818
+ if (!settled) {
819
+ settled = true;
820
+ if (callbackReject) callbackReject(err);
821
+ reject(err);
822
+ }
823
+ });
824
+ server.listen(0, "127.0.0.1", () => {
825
+ if (settled) return;
826
+ settled = true;
827
+ const port = server.address().port;
828
+ resolve2({
829
+ port,
830
+ waitForCallback: () => callbackPromise,
831
+ close: () => server.close()
832
+ });
833
+ });
834
+ });
835
+ }
836
+
837
+ // src/utils/github-connect.ts
838
+ async function tryOpenBrowser(url) {
839
+ if (!process.stdout.isTTY || process.env.CI === "true") {
840
+ return false;
841
+ }
842
+ const platform = process.platform;
843
+ let command;
844
+ let args;
845
+ if (platform === "darwin") {
846
+ command = "open";
847
+ args = [url];
848
+ } else if (platform === "win32") {
849
+ command = "rundll32";
850
+ args = ["url.dll,FileProtocolHandler", url];
851
+ } else {
852
+ command = "xdg-open";
853
+ args = [url];
854
+ }
855
+ return new Promise((resolve2) => {
856
+ try {
857
+ const child = spawn(command, args, {
858
+ detached: true,
859
+ stdio: "ignore",
860
+ windowsHide: true
861
+ });
862
+ let settled = false;
863
+ child.once("spawn", () => {
864
+ if (settled) return;
865
+ settled = true;
866
+ child.unref();
867
+ resolve2(true);
868
+ });
869
+ child.once("error", () => {
870
+ if (settled) return;
871
+ settled = true;
872
+ resolve2(false);
873
+ });
874
+ setTimeout(() => {
875
+ if (settled) return;
876
+ settled = true;
877
+ resolve2(false);
878
+ }, 300);
879
+ } catch {
880
+ resolve2(false);
881
+ }
882
+ });
883
+ }
884
+ async function runGitHubInstallFlow(params) {
885
+ let server = null;
886
+ try {
887
+ server = await startCallbackServer();
890
888
  } catch {
891
- p3.log.error(
892
- "Failed to fetch compatible target locales. Check your connection and try again."
893
- );
894
- return null;
895
889
  }
896
- const localeOptions = buildLocaleOptions(compatibleTargets);
897
- const targetOptions = localeOptions.filter(
898
- (opt) => opt.bcp47 !== sourceLocale
899
- );
900
- const targetLocales = await searchMultiSelectLocales(
901
- targetOptions,
902
- "Target languages (languages to translate into)"
890
+ const { installUrl } = await params.api.startCliGitHubInstall(
891
+ params.userToken,
892
+ {
893
+ organizationId: params.organizationId,
894
+ callbackPort: server?.port
895
+ }
903
896
  );
904
- if (targetLocales === null) return null;
905
- if (targetLocales.length === 0) {
906
- p3.log.warn(
907
- "No target languages selected \u2014 you can add them later from the dashboard."
908
- );
909
- }
910
- const detected = detectGitBranches();
911
- const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
912
- let pushBranches = [];
913
- {
914
- let initial = initialBranches;
915
- while (pushBranches.length === 0) {
916
- const result = await filterableBranchSelect({
917
- message: "Which branches should trigger translations?",
918
- branches: detected.branches,
919
- defaultBranch: detected.defaultBranch,
920
- initialValues: initial
921
- });
922
- if (result === null) return null;
923
- if (result.length === 0) {
924
- p3.log.warn(
925
- "At least one branch is required. Please select at least one."
897
+ p3.log.info("Opening GitHub to install the Vocoder App...");
898
+ p3.note(installUrl, "Install URL");
899
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
900
+ const shouldOpen = params.yes ? true : await p3.confirm({ message: "Open in your browser?" });
901
+ if (p3.isCancel(shouldOpen)) {
902
+ server?.close();
903
+ return null;
904
+ }
905
+ if (shouldOpen) {
906
+ const opened = await tryOpenBrowser(installUrl);
907
+ if (!opened) {
908
+ p3.log.info(
909
+ "Could not open a browser automatically. Use the URL above."
926
910
  );
927
- initial = [detected.defaultBranch];
928
- } else {
929
- pushBranches = result;
930
911
  }
931
912
  }
932
913
  }
933
- const targetBranches = pushBranches;
934
- try {
935
- const result = await api.createProject(userToken, {
936
- organizationId,
937
- name: projectName,
938
- sourceLocale,
939
- targetLocales,
940
- targetBranches,
941
- appDirs: appDir ? [appDir] : [],
942
- repoCanonical
943
- });
944
- p3.log.success(`Project ${chalk3.bold(result.projectName)} created!`);
945
- return result;
946
- } catch (error) {
947
- const message = error instanceof Error ? error.message : "Unknown error";
948
- p3.log.error(`Failed to create project: ${message}`);
949
- return null;
950
- }
951
- }
952
- async function runAppCreate(params) {
953
- const { api, userToken, projectId, projectName, repoCanonical } = params;
954
- const existingScopes = new Set(params.existingApps.map((a) => a.appDir));
955
- let sourceLocales;
956
- try {
957
- ({ sourceLocales } = await api.listLocales(userToken));
958
- } catch {
959
- p3.log.error(
960
- "Failed to fetch supported locales. Check your connection and try again."
961
- );
962
- return null;
963
- }
964
- const languageOptions = buildLanguageOptions(sourceLocales);
965
- const appDir = params.defaultAppDir ?? "";
966
- if (existingScopes.has(appDir)) {
967
- p3.log.error(`App directory "${appDir}" is already configured for this project.`);
968
- return null;
969
- }
970
- if (appDir) {
971
- p3.log.success(`App directory: ${chalk3.bold(appDir)}`);
914
+ const connectSpinner = p3.spinner();
915
+ connectSpinner.start("Waiting for GitHub App installation...");
916
+ if (server) {
917
+ try {
918
+ const params_timeout = 15 * 60 * 1e3;
919
+ const callbackParams = await Promise.race([
920
+ server.waitForCallback(),
921
+ new Promise(
922
+ (resolve2) => setTimeout(() => resolve2(null), params_timeout)
923
+ )
924
+ ]);
925
+ server.close();
926
+ if (!callbackParams) {
927
+ connectSpinner.stop("GitHub App installation timed out");
928
+ p3.log.error(
929
+ "The installation flow timed out. Run `vocoder init` again."
930
+ );
931
+ return null;
932
+ }
933
+ if (callbackParams.error) {
934
+ connectSpinner.stop("GitHub App installation failed");
935
+ p3.log.error(callbackParams.error);
936
+ return null;
937
+ }
938
+ const { organizationId, connectionLabel, workspace_created } = callbackParams;
939
+ if (!organizationId || !connectionLabel) {
940
+ connectSpinner.stop("GitHub App installation incomplete");
941
+ p3.log.error("Missing organization or connection data from callback.");
942
+ return null;
943
+ }
944
+ connectSpinner.stop(
945
+ `Connected to GitHub as ${chalk3.bold(connectionLabel)}`
946
+ );
947
+ const orgName = workspace_created ? connectionLabel : organizationId;
948
+ return {
949
+ organizationId,
950
+ organizationName: orgName,
951
+ connectionLabel
952
+ };
953
+ } catch {
954
+ server.close();
955
+ connectSpinner.stop("GitHub App installation failed");
956
+ return null;
957
+ }
972
958
  }
973
- const sourceLocale = await searchSelectLocale(
974
- languageOptions,
975
- "Source language",
976
- "en"
959
+ connectSpinner.stop("Could not detect GitHub App installation automatically");
960
+ p3.log.warn(
961
+ "Complete the installation in your browser, then run `vocoder init` again."
977
962
  );
978
- if (sourceLocale === null) return null;
979
- let compatibleTargets;
963
+ return null;
964
+ }
965
+ async function runGitHubDiscoveryFlow(params) {
966
+ let server = null;
980
967
  try {
981
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
968
+ server = await startCallbackServer();
982
969
  } catch {
983
- p3.log.error(
984
- "Failed to fetch compatible target locales. Check your connection and try again."
985
- );
986
- return null;
987
970
  }
988
- const targetOptions = buildLocaleOptions(compatibleTargets).filter(
989
- (opt) => opt.bcp47 !== sourceLocale
990
- );
991
- const targetLocales = await searchMultiSelectLocales(
992
- targetOptions,
993
- "Target languages"
994
- );
995
- if (targetLocales === null) return null;
996
- if (targetLocales.length === 0) {
997
- p3.log.warn(
998
- "No target languages selected \u2014 you can add them later from the dashboard."
999
- );
971
+ const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
972
+ organizationId: params.organizationId,
973
+ callbackPort: server?.port
974
+ });
975
+ p3.log.info("Opening GitHub to authorize your account...");
976
+ p3.note("Complete authorization in your browser.");
977
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
978
+ const shouldOpen = params.yes ? true : await p3.confirm({ message: "Open in your browser?" });
979
+ if (p3.isCancel(shouldOpen)) {
980
+ server?.close();
981
+ return null;
982
+ }
983
+ if (shouldOpen) {
984
+ const opened = await tryOpenBrowser(oauthUrl);
985
+ if (!opened) {
986
+ p3.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
987
+ }
988
+ }
1000
989
  }
1001
- const detectedApp = detectGitBranches();
1002
- let appPushBranches = [];
1003
- {
1004
- let initial = [detectedApp.defaultBranch];
1005
- while (appPushBranches.length === 0) {
1006
- const result = await filterableBranchSelect({
1007
- message: "Which branches should trigger translations?",
1008
- branches: detectedApp.branches,
1009
- defaultBranch: detectedApp.defaultBranch,
1010
- initialValues: initial
1011
- });
1012
- if (result === null) return null;
1013
- if (result.length === 0) {
1014
- p3.log.warn("At least one branch is required.");
1015
- initial = [detectedApp.defaultBranch];
1016
- } else {
1017
- appPushBranches = result;
990
+ const oauthSpinner = p3.spinner();
991
+ oauthSpinner.start("Waiting for GitHub authorization...");
992
+ if (server) {
993
+ try {
994
+ const timeoutMs = 10 * 60 * 1e3;
995
+ const callbackParams = await Promise.race([
996
+ server.waitForCallback(),
997
+ new Promise(
998
+ (resolve2) => setTimeout(() => resolve2(null), timeoutMs)
999
+ )
1000
+ ]);
1001
+ server.close();
1002
+ if (!callbackParams) {
1003
+ oauthSpinner.stop("GitHub authorization timed out");
1004
+ return null;
1005
+ }
1006
+ if (callbackParams.error) {
1007
+ oauthSpinner.stop("GitHub authorization failed");
1008
+ p3.log.error(callbackParams.error);
1009
+ return null;
1018
1010
  }
1011
+ } catch {
1012
+ server.close();
1013
+ oauthSpinner.stop("GitHub authorization failed");
1014
+ return null;
1019
1015
  }
1020
1016
  }
1021
- const targetBranches = appPushBranches;
1022
- try {
1023
- const result = await api.createProject(userToken, {
1024
- projectId,
1025
- appDir,
1026
- sourceLocale,
1027
- targetLocales,
1028
- targetBranches,
1029
- repoCanonical: repoCanonical ?? ""
1017
+ oauthSpinner.stop("GitHub account authorized");
1018
+ const discoveryResult = await params.api.getCliGitHubDiscovery(
1019
+ params.userToken
1020
+ );
1021
+ return discoveryResult.installations;
1022
+ }
1023
+ async function selectGitHubInstallation(installations, canInstallNew) {
1024
+ const options = installations.map((inst) => ({
1025
+ value: String(inst.installationId),
1026
+ label: inst.accountLogin,
1027
+ hint: [
1028
+ inst.accountType === "Organization" ? "organization" : "personal",
1029
+ inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
1030
+ inst.isSuspended ? "suspended" : ""
1031
+ ].filter(Boolean).join(" \xB7 ") || void 0
1032
+ }));
1033
+ if (canInstallNew) {
1034
+ options.push({
1035
+ value: "install_new",
1036
+ label: `Install on a new account ${chalk3.dim("(creates a new personal workspace)")}`
1030
1037
  });
1031
- p3.log.success(
1032
- `App ${chalk3.bold(appDir)} added to ${chalk3.bold(projectName)}!`
1033
- );
1034
- return {
1035
- projectId: result.projectId,
1036
- projectName: result.projectName,
1037
- apiKey: result.apiKey,
1038
- appDir: result.appDir,
1039
- sourceLocale,
1040
- targetLocales,
1041
- targetBranches
1042
- };
1043
- } catch (error) {
1044
- const message = error instanceof Error ? error.message : "Unknown error";
1045
- p3.log.error(`Failed to add app: ${message}`);
1046
- return null;
1047
1038
  }
1039
+ const selected = await p3.select({
1040
+ message: "Select a GitHub installation",
1041
+ options
1042
+ });
1043
+ if (p3.isCancel(selected)) return null;
1044
+ if (selected === "install_new") return "install_new";
1045
+ return Number(selected);
1048
1046
  }
1049
1047
 
1050
1048
  // src/commands/init.ts
@@ -1418,6 +1416,7 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1418
1416
  const session = await api.startCliAuthSession(server?.port, repoCanonical);
1419
1417
  const browserUrl = reauth ? session.verificationUrl : session.installUrl ?? session.verificationUrl;
1420
1418
  const expiresAt = new Date(session.expiresAt).getTime();
1419
+ p5.log.info(browserUrl);
1421
1420
  if (options.ci) {
1422
1421
  process.stdout.write(`VOCODER_AUTH_URL: ${browserUrl}
1423
1422
  `);
@@ -2171,6 +2170,9 @@ function matchBranchPattern(branch, pattern) {
2171
2170
  import * as p6 from "@clack/prompts";
2172
2171
  import { config as loadEnv2 } from "dotenv";
2173
2172
  loadEnv2();
2173
+ function extractShortCodeFromApiKey(apiKey) {
2174
+ return apiKey.slice(4, 14);
2175
+ }
2174
2176
  function validateLocalConfig(config) {
2175
2177
  if (!config.apiKey || config.apiKey.length === 0) {
2176
2178
  throw new Error("VOCODER_API_KEY is required. Set it in your .env file.");
@@ -2682,7 +2684,7 @@ async function sync(options = {}) {
2682
2684
  `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
2683
2685
  );
2684
2686
  }
2685
- const fingerprint = computeFingerprint(config.shortCode, sourceStrings);
2687
+ const fingerprint = computeFingerprint(extractShortCodeFromApiKey(localConfig.apiKey), sourceStrings);
2686
2688
  if (!options.force) {
2687
2689
  const cacheFile = getCacheFilePath(projectRoot, fingerprint);
2688
2690
  if (existsSync3(cacheFile)) {