@vocoder/cli 0.11.0 → 0.12.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
@@ -11,8 +11,9 @@ import {
11
11
  getSetupSnippets,
12
12
  loadVocoderConfig,
13
13
  readAuthData,
14
+ verifyStoredAuth,
14
15
  writeAuthData
15
- } from "./chunk-XF3KGGYQ.mjs";
16
+ } from "./chunk-SR6SDGUI.mjs";
16
17
 
17
18
  // src/bin.ts
18
19
  import { Command } from "commander";
@@ -40,17 +41,21 @@ function writeVocoderConfig(options) {
40
41
  const {
41
42
  targetBranches = ["main"],
42
43
  useTypeScript = true,
43
- cwd = process.cwd()
44
+ cwd = process.cwd(),
45
+ appDir
44
46
  } = options;
45
47
  if (findExistingConfig(cwd)) return null;
46
48
  const ext = useTypeScript ? "ts" : "js";
47
49
  const configPath = join(cwd, `vocoder.config.${ext}`);
48
50
  const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
51
+ const defaultIncludes = ["**/*.{tsx,jsx,ts,js}"];
52
+ const includes = appDir ? defaultIncludes.map((p14) => `${appDir}/${p14}`) : defaultIncludes;
53
+ const includesStr = includes.map((p14) => `'${p14}'`).join(", ");
49
54
  const content = `import { defineConfig } from '@vocoder/config'
50
55
 
51
56
  export default defineConfig({
52
57
  targetBranches: [${branchesStr}],
53
- include: ['**/*.{tsx,jsx,ts,js}'],
58
+ include: [${includesStr}],
54
59
  exclude: [
55
60
  '**/node_modules/**',
56
61
  '**/.next/**',
@@ -877,25 +882,9 @@ async function runProjectCreate(params) {
877
882
  return null;
878
883
  }
879
884
  const languageOptions = buildLanguageOptions(sourceLocales);
880
- let appDir;
881
- if (params.defaultAppDir) {
882
- appDir = params.defaultAppDir;
885
+ const appDir = params.defaultAppDir ?? "";
886
+ if (appDir) {
883
887
  p3.log.success(`App directory: ${chalk4.bold(appDir)}`);
884
- } else {
885
- const rawScope = await p3.text({
886
- message: "App directory (leave blank for the entire repo)",
887
- placeholder: "e.g. apps/web, packages/frontend",
888
- initialValue: "",
889
- validate(value) {
890
- const v = value.trim();
891
- if (!v) return;
892
- if (v.startsWith("/"))
893
- return "Use a relative path, not an absolute path";
894
- if (v.includes("..")) return 'Path must not contain ".."';
895
- }
896
- });
897
- if (p3.isCancel(rawScope)) return null;
898
- appDir = (rawScope ?? "").trim();
899
888
  }
900
889
  const sourceLocale = await searchSelectLocale(
901
890
  languageOptions,
@@ -968,7 +957,7 @@ async function runProjectCreate(params) {
968
957
  return null;
969
958
  }
970
959
  }
971
- async function runProjectAppCreate(params) {
960
+ async function runAppCreate(params) {
972
961
  const { api, userToken, projectId, projectName, repoCanonical } = params;
973
962
  const existingScopes = new Set(params.existingApps.map((a) => a.appDir));
974
963
  let sourceLocales;
@@ -981,35 +970,13 @@ async function runProjectAppCreate(params) {
981
970
  return null;
982
971
  }
983
972
  const languageOptions = buildLanguageOptions(sourceLocales);
984
- let appDir;
985
- if (params.defaultAppDir && !existingScopes.has(params.defaultAppDir)) {
986
- appDir = params.defaultAppDir;
973
+ const appDir = params.defaultAppDir ?? "";
974
+ if (existingScopes.has(appDir)) {
975
+ p3.log.error(`App directory "${appDir}" is already configured for this project.`);
976
+ return null;
977
+ }
978
+ if (appDir) {
987
979
  p3.log.success(`App directory: ${chalk4.bold(appDir)}`);
988
- } else {
989
- if (params.existingApps.length > 0) {
990
- const configuredList = params.existingApps.map((a) => chalk4.dim(a.appDir || "(entire repo)")).join(", ");
991
- p3.log.info(`Already configured: ${configuredList}`);
992
- }
993
- const hasWholeRepoApp = existingScopes.has("");
994
- const rawScope = await p3.text({
995
- message: "App directory for this new app",
996
- placeholder: "e.g. apps/backend",
997
- initialValue: params.defaultAppDir ?? "",
998
- validate(value) {
999
- const v = value.trim();
1000
- if (!v && hasWholeRepoApp)
1001
- return "This project already covers the entire repo.";
1002
- if (!v)
1003
- return "App directory is required when other apps already exist.";
1004
- if (v.startsWith("/"))
1005
- return "Use a relative path, not an absolute path.";
1006
- if (v.includes("..")) return 'Path must not contain "..".';
1007
- if (existingScopes.has(v))
1008
- return `"${v}" is already configured. Choose a different directory.`;
1009
- }
1010
- });
1011
- if (p3.isCancel(rawScope)) return null;
1012
- appDir = (rawScope ?? "").trim();
1013
980
  }
1014
981
  const sourceLocale = await searchSelectLocale(
1015
982
  languageOptions,
@@ -1061,7 +1028,7 @@ async function runProjectAppCreate(params) {
1061
1028
  }
1062
1029
  const targetBranches = appPushBranches;
1063
1030
  try {
1064
- const result = await api.createProjectApp(userToken, {
1031
+ const result = await api.createApp(userToken, {
1065
1032
  projectId,
1066
1033
  appDir,
1067
1034
  sourceLocale,
@@ -1301,7 +1268,7 @@ function printPlanLimitMessage(apiUrl, message) {
1301
1268
  p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
1302
1269
  }
1303
1270
  function runScaffold(params) {
1304
- const { sourceLocale, targetBranches } = params;
1271
+ const { sourceLocale, targetBranches, appDir } = params;
1305
1272
  const detection = detectLocalEcosystem();
1306
1273
  const useTypeScript = detection.isTypeScript;
1307
1274
  if (detection.ecosystem) {
@@ -1344,7 +1311,8 @@ function runScaffold(params) {
1344
1311
  framework: detection.framework,
1345
1312
  ecosystem: detection.ecosystem,
1346
1313
  sourceLocale,
1347
- targetBranches
1314
+ targetBranches,
1315
+ appDir
1348
1316
  });
1349
1317
  const steps = [];
1350
1318
  if (snippets.pluginStep) {
@@ -1377,7 +1345,7 @@ function runScaffold(params) {
1377
1345
  printCodeBlock(step.code);
1378
1346
  if (i < steps.length - 1) p5.log.message("");
1379
1347
  }
1380
- const written = writeVocoderConfig({ targetBranches, useTypeScript });
1348
+ const written = writeVocoderConfig({ targetBranches, useTypeScript, appDir });
1381
1349
  if (written) {
1382
1350
  p5.log.success(`Created ${chalk6.cyan(written)}`);
1383
1351
  } else if (!findExistingConfig(process.cwd())) {
@@ -1447,17 +1415,6 @@ function printCodeBlock(code) {
1447
1415
  `
1448
1416
  );
1449
1417
  }
1450
- async function verifyStoredToken(api, token) {
1451
- try {
1452
- return await api.getCliUserInfo(token);
1453
- } catch (err) {
1454
- clearAuthData();
1455
- if (err instanceof VocoderAPIError && err.status === 404) {
1456
- return { userGone: true };
1457
- }
1458
- return null;
1459
- }
1460
- }
1461
1418
  async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1462
1419
  let server = null;
1463
1420
  if (!options.ci) {
@@ -1486,8 +1443,9 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1486
1443
  return null;
1487
1444
  }
1488
1445
  if (!shouldOpen) {
1489
- p5.note(browserUrl, "Sign In");
1490
- p5.log.info("Open the URL above manually in your browser to continue.");
1446
+ server?.close();
1447
+ p5.cancel("Setup cancelled.");
1448
+ return null;
1491
1449
  } else {
1492
1450
  const opened = await tryOpenBrowser2(browserUrl);
1493
1451
  if (!opened) {
@@ -1638,17 +1596,17 @@ async function init(options = {}) {
1638
1596
  true
1639
1597
  );
1640
1598
  if (!authResult) return 1;
1641
- const spinner4 = p5.spinner();
1642
- spinner4.start("Generating new API key...");
1599
+ const spinner7 = p5.spinner();
1600
+ spinner7.start("Generating new API key...");
1643
1601
  try {
1644
1602
  const { apiKey } = await anonApi2.regenerateProjectApiKey(
1645
1603
  authResult.token,
1646
1604
  exactMatch.projectId
1647
1605
  );
1648
- spinner4.stop("New API key generated");
1606
+ spinner7.stop("New API key generated");
1649
1607
  printApiKey(apiKey);
1650
1608
  } catch (err) {
1651
- spinner4.stop("Failed to generate key");
1609
+ spinner7.stop("Failed to generate key");
1652
1610
  const msg = err instanceof Error ? err.message : String(err);
1653
1611
  p5.log.error(`Could not generate API key: ${msg}`);
1654
1612
  p5.log.info("Try again or generate one from the dashboard.");
@@ -1683,46 +1641,23 @@ async function init(options = {}) {
1683
1641
  let userEmail;
1684
1642
  let userName;
1685
1643
  let authOrganizationId;
1686
- const stored = readAuthData();
1687
- if (stored) {
1688
- const verified = await verifyStoredToken(api, stored.token);
1689
- if (verified && !("userGone" in verified)) {
1690
- p5.log.success(`Authenticated as ${chalk6.bold(verified.email)}`);
1691
- userToken = stored.token;
1692
- userEmail = verified.email;
1693
- userName = verified.name;
1694
- } else {
1695
- const isFirstTime = verified !== null && "userGone" in verified;
1696
- if (isFirstTime) {
1697
- p5.log.warn("Account not found \u2014 starting fresh setup");
1698
- } else {
1699
- p5.log.warn("Stored credentials expired \u2014 signing in again");
1700
- }
1701
- const authResult = await runAuthFlow(
1702
- api,
1703
- options,
1704
- /* reauth */
1705
- !isFirstTime,
1706
- identity?.repoCanonical
1707
- );
1708
- if (!authResult) return 1;
1709
- userToken = authResult.token;
1710
- userEmail = authResult.email;
1711
- userName = authResult.name;
1712
- authOrganizationId = authResult.organizationId;
1713
- writeAuthData({
1714
- token: userToken,
1715
- userId: authResult.userId,
1716
- email: userEmail,
1717
- name: userName,
1718
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
1719
- });
1720
- }
1644
+ const storedAuth = await verifyStoredAuth(api);
1645
+ if (storedAuth.status === "valid") {
1646
+ p5.log.success(`Authenticated as ${chalk6.bold(storedAuth.email)}`);
1647
+ userToken = storedAuth.token;
1648
+ userEmail = storedAuth.email;
1649
+ userName = storedAuth.name;
1721
1650
  } else {
1651
+ const reauth = storedAuth.status === "expired";
1652
+ if (reauth) {
1653
+ p5.log.warn("Stored credentials expired \u2014 signing in again");
1654
+ } else if (storedAuth.status === "gone") {
1655
+ p5.log.warn("Account not found \u2014 starting fresh setup");
1656
+ }
1722
1657
  const authResult = await runAuthFlow(
1723
1658
  api,
1724
1659
  options,
1725
- false,
1660
+ reauth,
1726
1661
  identity?.repoCanonical
1727
1662
  );
1728
1663
  if (!authResult) return 1;
@@ -1999,7 +1934,7 @@ async function init(options = {}) {
1999
1934
  `${chalk6.bold(repoProjectName)} is already set up for this repo.
2000
1935
  Configured apps: ${existingAppsForRepo.map((a) => chalk6.cyan(a.appDir || "(entire repo)")).join(", ")}`
2001
1936
  );
2002
- const appResult = await runProjectAppCreate({
1937
+ const appResult = await runAppCreate({
2003
1938
  api,
2004
1939
  userToken,
2005
1940
  projectId: repoProjectId,
@@ -2015,7 +1950,8 @@ async function init(options = {}) {
2015
1950
  }
2016
1951
  runScaffold({
2017
1952
  sourceLocale: appResult.sourceLocale,
2018
- targetBranches: appResult.targetBranches
1953
+ targetBranches: appResult.targetBranches,
1954
+ appDir: identity?.repoAppDir
2019
1955
  });
2020
1956
  p5.outro("You're all set.");
2021
1957
  return 0;
@@ -2072,7 +2008,7 @@ async function init(options = {}) {
2072
2008
  return 1;
2073
2009
  }
2074
2010
  const chosen = existingProjects.find((proj) => proj.id === chosenId);
2075
- const appResult = await runProjectAppCreate({
2011
+ const appResult = await runAppCreate({
2076
2012
  api,
2077
2013
  userToken,
2078
2014
  projectId: chosen.id,
@@ -2088,7 +2024,8 @@ async function init(options = {}) {
2088
2024
  }
2089
2025
  runScaffold({
2090
2026
  sourceLocale: appResult.sourceLocale,
2091
- targetBranches: appResult.targetBranches
2027
+ targetBranches: appResult.targetBranches,
2028
+ appDir: identity?.repoAppDir
2092
2029
  });
2093
2030
  p5.outro("You're all set.");
2094
2031
  return 0;
@@ -2123,7 +2060,8 @@ Translations won't run automatically until you grant access.
2123
2060
  }
2124
2061
  runScaffold({
2125
2062
  sourceLocale: projectResult.sourceLocale,
2126
- targetBranches: projectResult.targetBranches
2063
+ targetBranches: projectResult.targetBranches,
2064
+ appDir: identity?.repoAppDir
2127
2065
  });
2128
2066
  printApiKey(projectResult.apiKey);
2129
2067
  p5.outro("You're all set.");
@@ -2142,30 +2080,16 @@ Translations won't run automatically until you grant access.
2142
2080
  }
2143
2081
  }
2144
2082
 
2145
- // src/commands/logout.ts
2146
- import * as p6 from "@clack/prompts";
2147
- async function logout(options = {}) {
2148
- const stored = readAuthData();
2149
- if (!stored) {
2150
- p6.log.info("Not currently authenticated.");
2151
- return 0;
2152
- }
2153
- const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
2154
- const api = new VocoderAPI({ apiUrl, apiKey: "" });
2155
- try {
2156
- await api.revokeCliToken(stored.token);
2157
- } catch {
2158
- }
2159
- clearAuthData();
2160
- p6.log.success(`Logged out (was ${stored.email})`);
2161
- return 0;
2162
- }
2083
+ // src/commands/locales.ts
2084
+ import * as p8 from "@clack/prompts";
2085
+ import chalk9 from "chalk";
2086
+ import { config as loadEnv3 } from "dotenv";
2163
2087
 
2164
2088
  // src/commands/sync.ts
2165
2089
  import { createHash, randomUUID } from "crypto";
2166
2090
  import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2167
2091
  import { join as join3 } from "path";
2168
- import * as p8 from "@clack/prompts";
2092
+ import * as p7 from "@clack/prompts";
2169
2093
  import chalk8 from "chalk";
2170
2094
 
2171
2095
  // src/utils/branch.ts
@@ -2235,7 +2159,7 @@ function matchBranchPattern(branch, pattern) {
2235
2159
  }
2236
2160
 
2237
2161
  // src/utils/config.ts
2238
- import * as p7 from "@clack/prompts";
2162
+ import * as p6 from "@clack/prompts";
2239
2163
  import chalk7 from "chalk";
2240
2164
  import { config as loadEnv2 } from "dotenv";
2241
2165
  loadEnv2();
@@ -2294,7 +2218,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2294
2218
  };
2295
2219
  const fileConfig = loadVocoderConfig(process.cwd());
2296
2220
  if (!fileConfig) {
2297
- p7.log.warn(
2221
+ p6.log.warn(
2298
2222
  `No ${chalk7.cyan("vocoder.config.ts")} found \u2014 run ${chalk7.cyan("npx @vocoder/cli init")} to generate one.`
2299
2223
  );
2300
2224
  }
@@ -2325,7 +2249,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2325
2249
  excludePattern = fileConfig.exclude;
2326
2250
  configSources.excludePattern = "vocoder.config";
2327
2251
  } else if (envExcludePattern) {
2328
- excludePattern = envExcludePattern.split(",").map((p10) => p10.trim()).filter(Boolean);
2252
+ excludePattern = envExcludePattern.split(",").map((p14) => p14.trim()).filter(Boolean);
2329
2253
  configSources.excludePattern = "environment";
2330
2254
  } else {
2331
2255
  excludePattern = defaults.excludePattern;
@@ -2382,7 +2306,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2382
2306
  ...maxWaitMs ? [`Max wait: ${chalk7.cyan(String(configSources.maxWaitMs))}`] : [],
2383
2307
  `No fallback: ${chalk7.cyan(String(configSources.noFallback))}`
2384
2308
  ];
2385
- p7.note(lines.join("\n"), "Configuration sources");
2309
+ p6.note(lines.join("\n"), "Configuration sources");
2386
2310
  }
2387
2311
  return {
2388
2312
  includePattern,
@@ -2452,8 +2376,8 @@ function buildTranslationData(params) {
2452
2376
  const hashKeyed = {};
2453
2377
  for (const [locale, localeMap] of Object.entries(params.translations)) {
2454
2378
  hashKeyed[locale] = {};
2455
- for (const [text2, translation] of Object.entries(localeMap)) {
2456
- const hash = textToHash.get(text2);
2379
+ for (const [text, translation] of Object.entries(localeMap)) {
2380
+ const hash = textToHash.get(text);
2457
2381
  if (hash) hashKeyed[locale][hash] = translation;
2458
2382
  }
2459
2383
  }
@@ -2560,6 +2484,13 @@ function getLimitErrorGuidance(limitError) {
2560
2484
  `Upgrade plan: ${limitError.upgradeUrl}`
2561
2485
  ];
2562
2486
  }
2487
+ if (limitError.limitType === "target_locales") {
2488
+ return [
2489
+ `Current target locales: ${limitError.current}`,
2490
+ `Plan limit: ${limitError.current} (${limitError.planId})`,
2491
+ `Upgrade plan: ${limitError.upgradeUrl}`
2492
+ ];
2493
+ }
2563
2494
  return [
2564
2495
  `Plan: ${limitError.planId}`,
2565
2496
  `Current: ${limitError.current}`,
@@ -2644,22 +2575,22 @@ async function fetchApiSnapshot(api, params) {
2644
2575
  async function sync(options = {}) {
2645
2576
  const startTime = Date.now();
2646
2577
  const projectRoot = process.cwd();
2647
- p8.intro("Vocoder Sync");
2578
+ p7.intro("Vocoder Sync");
2648
2579
  const mergedConfig = await getMergedConfig(options, options.verbose);
2649
2580
  if (!mergedConfig.apiKey) {
2650
- p8.log.warn("No API key found. Run init to get started:");
2651
- p8.log.info(" npx @vocoder/cli init");
2652
- p8.log.info("");
2653
- p8.log.info(
2581
+ p7.log.warn("No API key found. Run init to get started:");
2582
+ p7.log.info(" npx @vocoder/cli init");
2583
+ p7.log.info("");
2584
+ p7.log.info(
2654
2585
  " Or add your key to .env: VOCODER_API_KEY=vcp_..."
2655
2586
  );
2656
- p8.outro("Run `npx @vocoder/cli init` to set up your project.");
2587
+ p7.outro("Run `npx @vocoder/cli init` to set up your project.");
2657
2588
  return 1;
2658
2589
  }
2659
- const spinner4 = p8.spinner();
2590
+ const spinner7 = p7.spinner();
2660
2591
  try {
2661
2592
  const branch = detectBranch(options.branch);
2662
- spinner4.start("Loading project configuration");
2593
+ spinner7.start("Loading project configuration");
2663
2594
  const localConfig = {
2664
2595
  apiKey: mergedConfig.apiKey,
2665
2596
  apiUrl: mergedConfig.apiUrl || "https://vocoder.app"
@@ -2683,18 +2614,18 @@ async function sync(options = {}) {
2683
2614
  ...fileConfig?.appIndustry ? { appIndustry: fileConfig.appIndustry } : {},
2684
2615
  ...fileConfig?.formality ? { formality: fileConfig.formality } : {}
2685
2616
  };
2686
- spinner4.stop(`Branch: ${chalk8.cyan(branch)}`);
2617
+ spinner7.stop(`Branch: ${chalk8.cyan(branch)}`);
2687
2618
  if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
2688
- p8.log.warn(
2619
+ p7.log.warn(
2689
2620
  `Skipping translations (${chalk8.cyan(branch)} is not a target branch)`
2690
2621
  );
2691
- p8.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
2692
- p8.log.info("Use --force to translate anyway");
2693
- p8.outro("");
2622
+ p7.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
2623
+ p7.log.info("Use --force to translate anyway");
2624
+ p7.outro("");
2694
2625
  return 0;
2695
2626
  }
2696
2627
  const patternsDisplay = Array.isArray(config.includePattern) ? config.includePattern.join(", ") : config.includePattern;
2697
- spinner4.start(`Extracting strings from ${patternsDisplay}`);
2628
+ spinner7.start(`Extracting strings from ${patternsDisplay}`);
2698
2629
  const extractor = new StringExtractor();
2699
2630
  const extractedStrings = await extractor.extractFromProject(
2700
2631
  config.includePattern,
@@ -2702,14 +2633,14 @@ async function sync(options = {}) {
2702
2633
  config.excludePattern
2703
2634
  );
2704
2635
  if (extractedStrings.length === 0) {
2705
- spinner4.stop("No translatable strings found");
2706
- p8.log.warn(
2636
+ spinner7.stop("No translatable strings found");
2637
+ p7.log.warn(
2707
2638
  "Make sure you are wrapping translatable strings with Vocoder"
2708
2639
  );
2709
- p8.outro("");
2640
+ p7.outro("");
2710
2641
  return 0;
2711
2642
  }
2712
- spinner4.stop(
2643
+ spinner7.stop(
2713
2644
  `Extracted ${chalk8.cyan(extractedStrings.length)} strings from ${chalk8.cyan(patternsDisplay)}`
2714
2645
  );
2715
2646
  if (options.verbose) {
@@ -2717,10 +2648,10 @@ async function sync(options = {}) {
2717
2648
  if (extractedStrings.length > 5) {
2718
2649
  sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
2719
2650
  }
2720
- p8.note(sampleLines.join("\n"), "Sample strings");
2651
+ p7.note(sampleLines.join("\n"), "Sample strings");
2721
2652
  }
2722
2653
  if (options.dryRun) {
2723
- p8.note(
2654
+ p7.note(
2724
2655
  [
2725
2656
  `Strings: ${extractedStrings.length}`,
2726
2657
  `Branch: ${branch}`,
@@ -2731,12 +2662,12 @@ async function sync(options = {}) {
2731
2662
  ].join("\n"),
2732
2663
  "Dry run - would translate"
2733
2664
  );
2734
- p8.outro("No API calls made.");
2665
+ p7.outro("No API calls made.");
2735
2666
  return 0;
2736
2667
  }
2737
2668
  const repoIdentity = resolveGitRepositoryIdentity();
2738
2669
  if (!repoIdentity && options.verbose) {
2739
- p8.log.warn(
2670
+ p7.log.warn(
2740
2671
  "Could not detect git remote origin. Sync will continue without repo metadata."
2741
2672
  );
2742
2673
  }
@@ -2744,7 +2675,7 @@ async function sync(options = {}) {
2744
2675
  const stringEntries = buildStringEntries(extractedStrings);
2745
2676
  const sourceStrings = stringEntries.map((entry) => entry.text);
2746
2677
  if (options.verbose && stringEntries.length !== extractedStrings.length) {
2747
- p8.log.info(
2678
+ p7.log.info(
2748
2679
  `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
2749
2680
  );
2750
2681
  }
@@ -2753,17 +2684,17 @@ async function sync(options = {}) {
2753
2684
  const cacheFile = getCacheFilePath(projectRoot, fingerprint);
2754
2685
  if (existsSync3(cacheFile)) {
2755
2686
  if (options.verbose) {
2756
- p8.log.info(`Cache hit: ${chalk8.dim(cacheFile)} (fingerprint ${chalk8.cyan(fingerprint)})`);
2687
+ p7.log.info(`Cache hit: ${chalk8.dim(cacheFile)} (fingerprint ${chalk8.cyan(fingerprint)})`);
2757
2688
  }
2758
2689
  const duration2 = ((Date.now() - startTime) / 1e3).toFixed(1);
2759
- p8.outro(`Up to date (${duration2}s)`);
2690
+ p7.outro(`Up to date (${duration2}s)`);
2760
2691
  return 0;
2761
2692
  }
2762
2693
  if (options.verbose) {
2763
- p8.log.info(`No cache for fingerprint ${chalk8.cyan(fingerprint)} \u2014 will submit to API`);
2694
+ p7.log.info(`No cache for fingerprint ${chalk8.cyan(fingerprint)} \u2014 will submit to API`);
2764
2695
  }
2765
2696
  }
2766
- spinner4.start("Submitting strings to Vocoder API");
2697
+ spinner7.start("Submitting strings to Vocoder API");
2767
2698
  const batchResponse = await api.submitTranslation(
2768
2699
  branch,
2769
2700
  stringEntries,
@@ -2773,38 +2704,38 @@ async function sync(options = {}) {
2773
2704
  requestedMaxWaitMs: waitTimeoutMs,
2774
2705
  clientRunId: randomUUID(),
2775
2706
  force: options.force,
2776
- // Sync appIndustry from vocoder.config.ts to ProjectApp on every push
2707
+ // Sync appIndustry from vocoder.config.ts to App on every push
2777
2708
  ...config.appIndustry ? { appIndustry: config.appIndustry } : {}
2778
2709
  },
2779
2710
  repoIdentity ? { ...repoIdentity, commitSha } : { commitSha }
2780
2711
  );
2781
- spinner4.stop("Strings submitted");
2712
+ spinner7.stop("Strings submitted");
2782
2713
  const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
2783
2714
  branch,
2784
2715
  requestedMode,
2785
2716
  policy: config.syncPolicy
2786
2717
  });
2787
2718
  if (options.verbose) {
2788
- p8.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
2789
- p8.log.info(`Requested mode: ${requestedMode}`);
2790
- p8.log.info(`Effective mode: ${effectiveMode}`);
2791
- p8.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2719
+ p7.log.info(`Batch: ${chalk8.dim(batchResponse.batchId)}`);
2720
+ p7.log.info(`Requested mode: ${requestedMode}`);
2721
+ p7.log.info(`Effective mode: ${effectiveMode}`);
2722
+ p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2792
2723
  if (batchResponse.queueStatus) {
2793
- p8.log.info(`Queue status: ${batchResponse.queueStatus}`);
2724
+ p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
2794
2725
  }
2795
2726
  }
2796
2727
  if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
2797
- p8.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
2728
+ p7.log.success(`Up to date \u2014 ${chalk8.cyan(batchResponse.totalStrings)} strings, no changes`);
2798
2729
  } else if (batchResponse.newStrings === 0) {
2799
2730
  const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk8.yellow(batchResponse.deletedStrings)} archived` : "";
2800
- p8.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2731
+ p7.log.success(`No new strings \u2014 ${chalk8.cyan(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2801
2732
  } else {
2802
2733
  const statParts = [`${chalk8.cyan(batchResponse.newStrings)} new, ${chalk8.cyan(batchResponse.totalStrings)} total`];
2803
2734
  if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
2804
2735
  statParts.push(`${chalk8.yellow(batchResponse.deletedStrings)} archived`);
2805
2736
  }
2806
2737
  const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
2807
- p8.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
2738
+ p7.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.join(", ")}${estTime}`);
2808
2739
  }
2809
2740
  let artifacts = null;
2810
2741
  if (batchResponse.translations) {
@@ -2816,7 +2747,7 @@ async function sync(options = {}) {
2816
2747
  let waitError = null;
2817
2748
  if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
2818
2749
  const waitTimeoutSecs = Math.round(waitTimeoutMs / 1e3);
2819
- spinner4.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
2750
+ spinner7.start(`Waiting for translations (max ${waitTimeoutSecs}s)`);
2820
2751
  let lastProgress = 0;
2821
2752
  try {
2822
2753
  const completion = await api.waitForCompletion(
@@ -2825,7 +2756,7 @@ async function sync(options = {}) {
2825
2756
  (progress) => {
2826
2757
  const percent = Math.round(progress * 100);
2827
2758
  if (percent > lastProgress) {
2828
- spinner4.message(`Translating... ${percent}%`);
2759
+ spinner7.message(`Translating... ${percent}%`);
2829
2760
  lastProgress = percent;
2830
2761
  }
2831
2762
  }
@@ -2835,14 +2766,14 @@ async function sync(options = {}) {
2835
2766
  translations: completion.translations,
2836
2767
  localeMetadata: completion.localeMetadata
2837
2768
  };
2838
- spinner4.stop("Translations complete");
2769
+ spinner7.stop("Translations complete");
2839
2770
  } catch (error) {
2840
- spinner4.stop("Translation wait incomplete");
2771
+ spinner7.stop("Translation wait incomplete");
2841
2772
  waitError = error instanceof Error ? error : new Error(String(error));
2842
2773
  if (effectiveMode === "required") {
2843
2774
  throw waitError;
2844
2775
  }
2845
- p8.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2776
+ p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2846
2777
  }
2847
2778
  }
2848
2779
  if (!artifacts) {
@@ -2851,14 +2782,14 @@ async function sync(options = {}) {
2851
2782
  "Fresh translations are not available and fallback is disabled (--no-fallback)."
2852
2783
  );
2853
2784
  }
2854
- spinner4.start("Loading fallback translations");
2785
+ spinner7.start("Loading fallback translations");
2855
2786
  const localFallback = readLocalCache({
2856
2787
  projectRoot,
2857
2788
  fingerprint
2858
2789
  });
2859
2790
  if (localFallback) {
2860
2791
  artifacts = localFallback;
2861
- spinner4.stop(`Using local cached snapshot (${fingerprint})`);
2792
+ spinner7.stop(`Using local cached snapshot (${fingerprint})`);
2862
2793
  } else {
2863
2794
  try {
2864
2795
  const apiSnapshot = await fetchApiSnapshot(api, {
@@ -2867,15 +2798,15 @@ async function sync(options = {}) {
2867
2798
  });
2868
2799
  if (apiSnapshot) {
2869
2800
  artifacts = apiSnapshot;
2870
- spinner4.stop("Using latest completed API snapshot");
2801
+ spinner7.stop("Using latest completed API snapshot");
2871
2802
  } else {
2872
- spinner4.stop("No completed API snapshot available");
2803
+ spinner7.stop("No completed API snapshot available");
2873
2804
  }
2874
2805
  } catch (error) {
2875
- spinner4.stop("Failed to fetch API snapshot");
2806
+ spinner7.stop("Failed to fetch API snapshot");
2876
2807
  if (options.verbose) {
2877
2808
  const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
2878
- p8.log.warn(`Snapshot fetch error: ${message}`);
2809
+ p7.log.warn(`Snapshot fetch error: ${message}`);
2879
2810
  }
2880
2811
  }
2881
2812
  }
@@ -2907,85 +2838,433 @@ async function sync(options = {}) {
2907
2838
  });
2908
2839
  const cachePath = writeCache({ projectRoot, fingerprint, data });
2909
2840
  if (options.verbose) {
2910
- p8.log.info(`Cache written: ${cachePath}`);
2841
+ p7.log.info(`Cache written: ${cachePath}`);
2911
2842
  }
2912
2843
  } catch (error) {
2913
2844
  if (options.verbose) {
2914
2845
  const message = error instanceof Error ? error.message : "Unknown cache write error";
2915
- p8.log.warn(`Failed to write cache: ${message}`);
2846
+ p7.log.warn(`Failed to write cache: ${message}`);
2916
2847
  }
2917
2848
  }
2918
2849
  if (artifacts.source !== "fresh") {
2919
2850
  const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
2920
- p8.log.warn(
2851
+ p7.log.warn(
2921
2852
  `Using ${sourceLabel}. New strings may appear after the background sync completes.`
2922
2853
  );
2923
2854
  }
2924
2855
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
2925
- p8.outro(`Sync complete! (${duration}s)`);
2856
+ p7.outro(`Sync complete! (${duration}s)`);
2926
2857
  return 0;
2927
2858
  } catch (error) {
2928
- spinner4.stop();
2859
+ spinner7.stop();
2929
2860
  if (error instanceof VocoderAPIError && error.syncPolicyError) {
2930
- p8.log.error(error.syncPolicyError.message);
2861
+ p7.log.error(error.syncPolicyError.message);
2931
2862
  const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
2932
2863
  for (const line of guidance) {
2933
- p8.log.info(line);
2864
+ p7.log.info(line);
2934
2865
  }
2935
2866
  return 1;
2936
2867
  }
2937
2868
  if (error instanceof VocoderAPIError && error.limitError) {
2938
2869
  const { limitError } = error;
2939
- p8.log.error(limitError.message);
2870
+ p7.log.error(limitError.message);
2940
2871
  const guidance = getLimitErrorGuidance(limitError);
2941
2872
  for (const line of guidance) {
2942
- p8.log.info(line);
2873
+ p7.log.info(line);
2943
2874
  }
2944
2875
  return 1;
2945
2876
  }
2946
2877
  if (error instanceof Error) {
2947
- p8.log.error(error.message);
2878
+ p7.log.error(error.message);
2948
2879
  const isInvalidKey = error.message.toLowerCase().includes("invalid api key") || error instanceof VocoderAPIError && error.status === 401;
2949
2880
  if (isInvalidKey) {
2950
- p8.log.warn(
2881
+ p7.log.warn(
2951
2882
  "API key rejected \u2014 the project may have been deleted or the key revoked."
2952
2883
  );
2953
- p8.log.info(
2884
+ p7.log.info(
2954
2885
  " Run `npx @vocoder/cli init` to create a new project and key."
2955
2886
  );
2956
2887
  } else if (error.message.includes("git branch")) {
2957
- p8.log.warn("Run from a git repository, or use:");
2958
- p8.log.info(" vocoder sync --branch main");
2888
+ p7.log.warn("Run from a git repository, or use:");
2889
+ p7.log.info(" vocoder sync --branch main");
2959
2890
  }
2960
2891
  if (options.verbose) {
2961
- p8.log.info(`Full error: ${error.stack ?? error}`);
2892
+ p7.log.info(`Full error: ${error.stack ?? error}`);
2962
2893
  }
2963
2894
  }
2964
2895
  return 1;
2965
2896
  }
2966
2897
  }
2967
2898
 
2968
- // src/commands/whoami.ts
2899
+ // src/commands/locales.ts
2900
+ loadEnv3();
2901
+ function getApiConfig(options) {
2902
+ const apiKey = process.env.VOCODER_API_KEY;
2903
+ if (!apiKey) {
2904
+ p8.log.error(
2905
+ "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
2906
+ );
2907
+ return null;
2908
+ }
2909
+ return {
2910
+ apiKey,
2911
+ apiUrl: options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app"
2912
+ };
2913
+ }
2914
+ async function listProjectLocales(options = {}) {
2915
+ const config = getApiConfig(options);
2916
+ if (!config) return 1;
2917
+ const api = new VocoderAPI(config);
2918
+ try {
2919
+ const projectConfig2 = await api.getProjectConfig();
2920
+ p8.log.info(
2921
+ `Source locale: ${chalk9.cyan(projectConfig2.sourceLocale)}`
2922
+ );
2923
+ if (projectConfig2.targetLocales.length === 0) {
2924
+ p8.log.info("Target locales: (none configured)");
2925
+ } else {
2926
+ p8.log.info(
2927
+ `Target locales: ${projectConfig2.targetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
2928
+ );
2929
+ }
2930
+ return 0;
2931
+ } catch (error) {
2932
+ p8.log.error(
2933
+ error instanceof Error ? error.message : "Failed to fetch project locales."
2934
+ );
2935
+ return 1;
2936
+ }
2937
+ }
2938
+ async function addLocales(locales, options = {}) {
2939
+ if (locales.length === 0) {
2940
+ p8.log.error("No locale codes provided.");
2941
+ return 1;
2942
+ }
2943
+ const config = getApiConfig(options);
2944
+ if (!config) return 1;
2945
+ const api = new VocoderAPI(config);
2946
+ let lastTargetLocales = [];
2947
+ let hadError = false;
2948
+ for (const locale of locales) {
2949
+ const spinner7 = p8.spinner();
2950
+ spinner7.start(`Adding ${locale}\u2026`);
2951
+ try {
2952
+ const result = await api.addLocale(locale);
2953
+ lastTargetLocales = result.targetLocales;
2954
+ spinner7.stop(`Added ${chalk9.cyan(locale)}`);
2955
+ } catch (error) {
2956
+ spinner7.stop(`Failed to add ${chalk9.red(locale)}`);
2957
+ hadError = true;
2958
+ if (error instanceof VocoderAPIError && error.limitError) {
2959
+ const { limitError } = error;
2960
+ p8.log.error(limitError.message);
2961
+ for (const line of getLimitErrorGuidance(limitError)) {
2962
+ p8.log.info(line);
2963
+ }
2964
+ break;
2965
+ }
2966
+ p8.log.error(
2967
+ error instanceof Error ? error.message : "Unknown error"
2968
+ );
2969
+ }
2970
+ }
2971
+ if (lastTargetLocales.length > 0) {
2972
+ p8.log.info(
2973
+ `Target locales now: ${lastTargetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
2974
+ );
2975
+ }
2976
+ return hadError ? 1 : 0;
2977
+ }
2978
+ async function removeLocales(locales, options = {}) {
2979
+ if (locales.length === 0) {
2980
+ p8.log.error("No locale codes provided.");
2981
+ return 1;
2982
+ }
2983
+ const config = getApiConfig(options);
2984
+ if (!config) return 1;
2985
+ const api = new VocoderAPI(config);
2986
+ let lastTargetLocales = [];
2987
+ let hadError = false;
2988
+ for (const locale of locales) {
2989
+ const spinner7 = p8.spinner();
2990
+ spinner7.start(`Removing ${locale}\u2026`);
2991
+ try {
2992
+ const result = await api.removeLocale(locale);
2993
+ lastTargetLocales = result.targetLocales;
2994
+ spinner7.stop(`Removed ${chalk9.cyan(locale)}`);
2995
+ } catch (error) {
2996
+ spinner7.stop(`Failed to remove ${chalk9.red(locale)}`);
2997
+ hadError = true;
2998
+ p8.log.error(
2999
+ error instanceof Error ? error.message : "Unknown error"
3000
+ );
3001
+ }
3002
+ }
3003
+ if (lastTargetLocales.length > 0) {
3004
+ p8.log.info(
3005
+ `Target locales now: ${lastTargetLocales.map((l) => chalk9.cyan(l)).join(", ")}`
3006
+ );
3007
+ } else if (!hadError) {
3008
+ p8.log.info("Target locales now: (none configured)");
3009
+ }
3010
+ return hadError ? 1 : 0;
3011
+ }
3012
+ async function listSupportedLocales(options = {}) {
3013
+ const config = getApiConfig(options);
3014
+ if (!config) return 1;
3015
+ const api = new VocoderAPI(config);
3016
+ try {
3017
+ const result = await api.listLocales(config.apiKey);
3018
+ p8.log.info(chalk9.bold("Source locales:"));
3019
+ printLocaleTable(result.sourceLocales);
3020
+ p8.log.info("");
3021
+ p8.log.info(chalk9.bold("Target locales:"));
3022
+ printLocaleTable(result.targetLocales);
3023
+ return 0;
3024
+ } catch (error) {
3025
+ p8.log.error(
3026
+ error instanceof Error ? error.message : "Failed to fetch supported locales."
3027
+ );
3028
+ return 1;
3029
+ }
3030
+ }
3031
+ function printLocaleTable(locales) {
3032
+ for (const locale of locales) {
3033
+ const native = locale.nativeName && locale.nativeName !== locale.name ? ` (${locale.nativeName})` : "";
3034
+ p8.log.info(` ${chalk9.cyan(locale.code.padEnd(10))} ${locale.name}${native}`);
3035
+ }
3036
+ }
3037
+
3038
+ // src/commands/logout.ts
2969
3039
  import * as p9 from "@clack/prompts";
2970
- import chalk9 from "chalk";
3040
+ async function logout(options = {}) {
3041
+ const stored = readAuthData();
3042
+ if (!stored) {
3043
+ p9.log.info("Not currently authenticated.");
3044
+ return 0;
3045
+ }
3046
+ const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
3047
+ const api = new VocoderAPI({ apiUrl, apiKey: "" });
3048
+ try {
3049
+ await api.revokeCliToken(stored.token);
3050
+ } catch {
3051
+ }
3052
+ clearAuthData();
3053
+ p9.log.success(`Logged out (was ${stored.email})`);
3054
+ return 0;
3055
+ }
3056
+
3057
+ // src/commands/project-config.ts
3058
+ import * as p10 from "@clack/prompts";
3059
+ import chalk10 from "chalk";
3060
+ import { config as loadEnv4 } from "dotenv";
3061
+ loadEnv4();
3062
+ async function projectConfig(options = {}) {
3063
+ const apiKey = process.env.VOCODER_API_KEY;
3064
+ if (!apiKey) {
3065
+ p10.log.error(
3066
+ "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3067
+ );
3068
+ return 1;
3069
+ }
3070
+ const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
3071
+ const api = new VocoderAPI({ apiKey, apiUrl });
3072
+ try {
3073
+ const config = await api.getProjectConfig();
3074
+ const lines = [
3075
+ `Project: ${chalk10.bold(config.projectName)}`,
3076
+ `Organization: ${config.organizationName}`,
3077
+ `Source locale: ${chalk10.cyan(config.sourceLocale)}`,
3078
+ `Target locales: ${config.targetLocales.length > 0 ? config.targetLocales.map((l) => chalk10.cyan(l)).join(", ") : chalk10.dim("(none)")}`,
3079
+ `Target branches: ${config.targetBranches.map((b) => chalk10.cyan(b)).join(", ")}`,
3080
+ ...config.primaryBranch ? [`Primary branch: ${chalk10.cyan(config.primaryBranch)}`] : [],
3081
+ `Sync policy:`,
3082
+ ` Blocking branches: ${config.syncPolicy.blockingBranches.map((b) => chalk10.cyan(b)).join(", ")}`,
3083
+ ` Blocking mode: ${chalk10.cyan(config.syncPolicy.blockingMode)}`,
3084
+ ` Non-blocking mode: ${chalk10.cyan(config.syncPolicy.nonBlockingMode)}`,
3085
+ ` Max wait: ${chalk10.cyan(String(config.syncPolicy.defaultMaxWaitMs))} ms`
3086
+ ];
3087
+ p10.note(lines.join("\n"), `${config.projectName} \u2014 project config`);
3088
+ return 0;
3089
+ } catch (error) {
3090
+ p10.log.error(
3091
+ error instanceof Error ? error.message : "Failed to fetch project config."
3092
+ );
3093
+ return 1;
3094
+ }
3095
+ }
3096
+
3097
+ // src/commands/translations.ts
3098
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
3099
+ import { join as join4 } from "path";
3100
+ import * as p11 from "@clack/prompts";
3101
+ import chalk11 from "chalk";
3102
+ import { config as loadEnv5 } from "dotenv";
3103
+ loadEnv5();
3104
+ async function getTranslations(options = {}) {
3105
+ const apiKey = process.env.VOCODER_API_KEY;
3106
+ if (!apiKey) {
3107
+ p11.log.error(
3108
+ "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3109
+ );
3110
+ return 1;
3111
+ }
3112
+ const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
3113
+ const api = new VocoderAPI({ apiKey, apiUrl });
3114
+ let branch;
3115
+ try {
3116
+ branch = detectBranch(options.branch);
3117
+ } catch (error) {
3118
+ p11.log.error(
3119
+ error instanceof Error ? error.message : "Failed to detect branch."
3120
+ );
3121
+ return 1;
3122
+ }
3123
+ const spinner7 = p11.spinner();
3124
+ spinner7.start(`Fetching translations for ${chalk11.cyan(branch)}\u2026`);
3125
+ try {
3126
+ const projectConfig2 = await api.getProjectConfig();
3127
+ const targetLocales = options.locale ? [options.locale] : projectConfig2.targetLocales;
3128
+ if (targetLocales.length === 0) {
3129
+ spinner7.stop("No target locales configured.");
3130
+ p11.log.info("Add target locales with `vocoder locales add <code>`.");
3131
+ return 1;
3132
+ }
3133
+ const snapshot = await api.getTranslationSnapshot({ branch, targetLocales });
3134
+ spinner7.stop(`Fetched translations for ${chalk11.cyan(branch)}`);
3135
+ if (snapshot.status === "NOT_FOUND") {
3136
+ p11.log.warn(
3137
+ `No translation snapshot found for branch "${branch}". Run \`vocoder sync\` to generate one.`
3138
+ );
3139
+ return 1;
3140
+ }
3141
+ const translations = snapshot.translations ?? {};
3142
+ if (options.output) {
3143
+ writeLocaleFiles(translations, options.output);
3144
+ } else {
3145
+ process.stdout.write(JSON.stringify(translations, null, 2));
3146
+ process.stdout.write("\n");
3147
+ }
3148
+ return 0;
3149
+ } catch (error) {
3150
+ spinner7.stop("Failed to fetch translations.");
3151
+ p11.log.error(
3152
+ error instanceof Error ? error.message : "Unknown error."
3153
+ );
3154
+ return 1;
3155
+ }
3156
+ }
3157
+ function writeLocaleFiles(translations, outputDir) {
3158
+ mkdirSync2(outputDir, { recursive: true });
3159
+ for (const [locale, strings] of Object.entries(translations)) {
3160
+ const filePath = join4(outputDir, `${locale}.json`);
3161
+ writeFileSync4(filePath, JSON.stringify(strings, null, 2) + "\n", "utf-8");
3162
+ p11.log.success(`Wrote ${chalk11.cyan(filePath)}`);
3163
+ }
3164
+ }
3165
+
3166
+ // src/commands/create-project.ts
3167
+ import * as p12 from "@clack/prompts";
3168
+ import chalk12 from "chalk";
3169
+ import { config as loadEnv6 } from "dotenv";
3170
+ loadEnv6();
3171
+ async function createProject(options) {
3172
+ const authData = readAuthData();
3173
+ if (!authData) {
3174
+ p12.log.error(
3175
+ "Not logged in. Run `npx @vocoder/cli init` to authenticate first."
3176
+ );
3177
+ return 1;
3178
+ }
3179
+ const apiUrl = options.apiUrl ?? process.env.VOCODER_API_URL ?? "https://vocoder.app";
3180
+ const api = new VocoderAPI({ apiKey: "", apiUrl });
3181
+ let repoCanonical;
3182
+ let appDir = options.appDir ?? ".";
3183
+ if (options.repo) {
3184
+ repoCanonical = options.repo;
3185
+ } else {
3186
+ const identity = resolveGitRepositoryIdentity();
3187
+ if (identity) {
3188
+ repoCanonical = identity.repoCanonical;
3189
+ if (!options.appDir && identity.repoAppDir) {
3190
+ appDir = identity.repoAppDir;
3191
+ }
3192
+ } else {
3193
+ p12.log.warn(
3194
+ "Could not detect a git remote. The project will be created without repo binding \u2014 sync-on-push will not function until a repository is connected via the Vocoder dashboard."
3195
+ );
3196
+ }
3197
+ }
3198
+ const targetLocales = options.targetLocales ? options.targetLocales.split(",").map((l) => l.trim()).filter(Boolean) : [];
3199
+ const targetBranches = options.targetBranches ? options.targetBranches.split(",").map((b) => b.trim()).filter(Boolean) : ["main"];
3200
+ const spinner7 = p12.spinner();
3201
+ spinner7.start(`Creating project "${options.name}"\u2026`);
3202
+ try {
3203
+ const result = await api.createProject(authData.token, {
3204
+ organizationId: options.workspace,
3205
+ name: options.name,
3206
+ sourceLocale: options.sourceLocale,
3207
+ targetLocales,
3208
+ targetBranches,
3209
+ appDirs: [appDir],
3210
+ ...repoCanonical ? { repoCanonical } : {}
3211
+ });
3212
+ spinner7.stop(`Created project ${chalk12.bold(result.projectName)}`);
3213
+ const lines = [
3214
+ `Project ID: ${result.projectId}`,
3215
+ `Source locale: ${chalk12.cyan(result.sourceLocale)}`,
3216
+ `Target locales: ${result.targetLocales.length > 0 ? result.targetLocales.map((l) => chalk12.cyan(l)).join(", ") : chalk12.dim("(none)")}`,
3217
+ `Branches: ${result.targetBranches.map((b) => chalk12.cyan(b)).join(", ")}`,
3218
+ ...repoCanonical ? [`Repository: ${chalk12.cyan(repoCanonical)}${appDir !== "." ? ` (${appDir})` : ""}`] : [],
3219
+ "",
3220
+ `Add this to your .env file:`,
3221
+ ` ${chalk12.bold("VOCODER_API_KEY")}=${chalk12.cyan(result.apiKey)}`
3222
+ ];
3223
+ p12.note(lines.join("\n"), "Project created");
3224
+ if (!result.repositoryBound && repoCanonical) {
3225
+ p12.log.warn(
3226
+ `Repository "${repoCanonical}" was not automatically connected. Ensure your GitHub App installation covers this repository.`
3227
+ );
3228
+ }
3229
+ return 0;
3230
+ } catch (error) {
3231
+ spinner7.stop("Failed to create project.");
3232
+ if (error instanceof VocoderAPIError && error.limitError) {
3233
+ const { limitError } = error;
3234
+ p12.log.error(limitError.message);
3235
+ for (const line of getLimitErrorGuidance(limitError)) {
3236
+ p12.log.info(line);
3237
+ }
3238
+ return 1;
3239
+ }
3240
+ p12.log.error(
3241
+ error instanceof Error ? error.message : "Unknown error."
3242
+ );
3243
+ return 1;
3244
+ }
3245
+ }
3246
+
3247
+ // src/commands/whoami.ts
3248
+ import * as p13 from "@clack/prompts";
3249
+ import chalk13 from "chalk";
2971
3250
  async function whoami(options = {}) {
2972
3251
  const stored = readAuthData();
2973
3252
  if (!stored) {
2974
- p9.log.info("Not logged in. Run `vocoder init` to authenticate.");
3253
+ p13.log.info("Not logged in. Run `vocoder init` to authenticate.");
2975
3254
  return 1;
2976
3255
  }
2977
3256
  const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
2978
3257
  const api = new VocoderAPI({ apiUrl, apiKey: "" });
2979
3258
  try {
2980
3259
  const info = await api.getCliUserInfo(stored.token);
2981
- p9.log.info(`Logged in as ${chalk9.bold(info.email)}`);
3260
+ p13.log.info(`Logged in as ${chalk13.bold(info.email)}`);
2982
3261
  if (info.name) {
2983
- p9.log.info(`Name: ${info.name}`);
3262
+ p13.log.info(`Name: ${info.name}`);
2984
3263
  }
2985
- p9.log.info(`API: ${apiUrl}`);
3264
+ p13.log.info(`API: ${apiUrl}`);
2986
3265
  return 0;
2987
3266
  } catch {
2988
- p9.log.error(
3267
+ p13.log.error(
2989
3268
  "Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate."
2990
3269
  );
2991
3270
  return 1;
@@ -3017,5 +3296,38 @@ program.command("sync").description("Extract strings and sync translations").opt
3017
3296
  });
3018
3297
  program.command("logout").description("Log out and remove stored credentials").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(logout, options));
3019
3298
  program.command("whoami").description("Show the currently authenticated user").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(whoami, options));
3299
+ var localesCmd = program.command("locales").description("Manage project target locales").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listProjectLocales, options));
3300
+ localesCmd.command("add <codes...>").description("Add one or more target locales by BCP 47 code (e.g. fr de pt-BR)").option("--api-url <url>", "Override Vocoder API URL").action(
3301
+ (codes, options) => runCommand((opts) => addLocales(codes, opts), options)
3302
+ );
3303
+ localesCmd.command("remove <codes...>").description("Remove one or more target locales by BCP 47 code").option("--api-url <url>", "Override Vocoder API URL").action(
3304
+ (codes, options) => runCommand((opts) => removeLocales(codes, opts), options)
3305
+ );
3306
+ localesCmd.command("supported").description("List all locales supported by Vocoder").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listSupportedLocales, options));
3307
+ program.command("project").description("Show current project configuration").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(projectConfig, options));
3308
+ program.command("translations").description("Download the current translation snapshot").option("--branch <branch>", "Git branch (auto-detected if omitted)").option("--locale <locale>", "Fetch a specific locale only").option("--output <dir>", "Write locale JSON files to this directory").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(getTranslations, options));
3309
+ program.command("create-project").description("Create a new Vocoder project (requires prior `vocoder init`)").requiredOption("--name <name>", "Project display name").requiredOption("--source-locale <code>", "Source language BCP 47 code (e.g. en)").requiredOption("--workspace <org-id>", "Workspace organization ID").option(
3310
+ "--target-locales <codes>",
3311
+ "Comma-separated target locale codes (e.g. fr,de,pt-BR)"
3312
+ ).option(
3313
+ "--target-branches <branches>",
3314
+ "Comma-separated branch names to sync (default: main)"
3315
+ ).option(
3316
+ "--repo <canonical>",
3317
+ "Git repo canonical (e.g. github:owner/repo). Auto-detected from git remote if omitted."
3318
+ ).option(
3319
+ "--app-dir <path>",
3320
+ "App directory within the repo for monorepos (default: .)"
3321
+ ).option("--api-url <url>", "Override Vocoder API URL").action((options) => {
3322
+ const translated = {
3323
+ ...options,
3324
+ // Commander camelCases dashed options
3325
+ sourceLocale: options.sourceLocale,
3326
+ targetLocales: options.targetLocales,
3327
+ targetBranches: options.targetBranches,
3328
+ workspace: options.workspace
3329
+ };
3330
+ return runCommand(createProject, translated);
3331
+ });
3020
3332
  program.parse(process.argv);
3021
3333
  //# sourceMappingURL=bin.mjs.map