@vocoder/cli 0.14.0 → 0.15.0

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
@@ -6,6 +6,7 @@ import {
6
6
  VocoderAPIError,
7
7
  buildInstallCommand,
8
8
  clearAuthData,
9
+ computeFingerprint,
9
10
  detectLocalEcosystem,
10
11
  getPackagesToInstall,
11
12
  getSetupSnippets,
@@ -13,15 +14,15 @@ import {
13
14
  readAuthData,
14
15
  verifyStoredAuth,
15
16
  writeAuthData
16
- } from "./chunk-T4BLNDJ3.mjs";
17
+ } from "./chunk-62KCB6C6.mjs";
17
18
 
18
19
  // src/bin.ts
19
20
  import { Command } from "commander";
20
21
 
21
22
  // src/commands/init.ts
22
- import * as p5 from "@clack/prompts";
23
+ import * as p6 from "@clack/prompts";
23
24
  import { execSync as execSync3, spawn as spawn2 } from "child_process";
24
- import { existsSync as existsSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
25
+ import { existsSync as existsSync3, readFileSync, writeFileSync as writeFileSync2 } from "fs";
25
26
 
26
27
  // src/utils/write-config.ts
27
28
  import { existsSync, writeFileSync } from "fs";
@@ -41,18 +42,21 @@ function writeVocoderConfig(options) {
41
42
  const {
42
43
  targetBranches = ["main"],
43
44
  useTypeScript = true,
44
- cwd = process.cwd()
45
+ cwd = process.cwd(),
46
+ appId
45
47
  } = options;
46
48
  if (findExistingConfig(cwd)) return null;
47
49
  const ext = useTypeScript ? "ts" : "js";
48
50
  const configPath = join(cwd, `vocoder.config.${ext}`);
49
51
  const branchesStr = targetBranches.map((b) => `'${b}'`).join(", ");
50
52
  const includes = ["**/*.{tsx,jsx,ts,js}"];
51
- const includesStr = includes.map((p14) => `'${p14}'`).join(", ");
53
+ const includesStr = includes.map((p15) => `'${p15}'`).join(", ");
54
+ const appIdLine = appId ? ` appId: '${appId}',
55
+ ` : "";
52
56
  const content = `import { defineConfig } from '@vocoder/config'
53
57
 
54
58
  export default defineConfig({
55
- targetBranches: [${branchesStr}],
59
+ ${appIdLine} targetBranches: [${branchesStr}],
56
60
  include: [${includesStr}],
57
61
  })
58
62
  `;
@@ -64,311 +68,34 @@ export default defineConfig({
64
68
  }
65
69
  }
66
70
 
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
71
  // src/utils/theme.ts
356
- import chalk2 from "chalk";
72
+ import chalk from "chalk";
357
73
  var ORANGE = "#FC5206";
358
74
  var PINK = "#D51977";
359
75
  var BLUE = "#2450A9";
360
76
  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);
77
+ var hex = (color) => (s) => noColor ? s : chalk.hex(color)(s);
78
+ var dim = (s) => noColor ? s : chalk.dim(s);
79
+ var bld = (s) => noColor ? s : chalk.bold(s);
80
+ var grn = (s) => noColor ? s : chalk.green(s);
81
+ var ylw = (s) => noColor ? s : chalk.yellow(s);
82
+ var red = (s) => noColor ? s : chalk.red(s);
367
83
  var highlight = hex(PINK);
368
84
  var info = hex(BLUE);
369
85
  var active = hex(ORANGE);
370
86
 
371
- // src/utils/branch-select.ts
87
+ // src/commands/init.ts
88
+ import { join as join2, resolve as resolve2 } from "path";
89
+
90
+ // src/utils/project-create.ts
91
+ import * as p3 from "@clack/prompts";
92
+ import chalk2 from "chalk";
93
+
94
+ // src/utils/app-dir-select.ts
95
+ import { existsSync as existsSync2, statSync } from "fs";
96
+ import { resolve } from "path";
97
+ import { isCancel, Prompt } from "@clack/core";
98
+ import * as p from "@clack/prompts";
372
99
  var S_BAR = "\u2502";
373
100
  var S_BAR_END = "\u2514";
374
101
  var S_ACTIVE = "\u25C6";
@@ -387,6 +114,195 @@ function symbol(state) {
387
114
  return active(S_ACTIVE);
388
115
  }
389
116
  }
117
+ function validateAppDirPath(val, existing, opts = {}) {
118
+ if (val.startsWith("/")) return "Must be a relative path (e.g. apps/web)";
119
+ if (val.includes("..")) return "Path traversal not allowed";
120
+ const hasWholeRepo = existing.includes("");
121
+ const hasScoped = existing.some((d) => d !== "");
122
+ if (val === "" && hasScoped) return "Cannot add whole-repo scope to a monorepo project";
123
+ if (val !== "" && hasWholeRepo) return "Cannot add a scoped directory to a whole-repo project";
124
+ if (existing.includes(val)) return `Already added: ${val}`;
125
+ const nested = existing.find(
126
+ (d) => d !== "" && (val.startsWith(d + "/") || d.startsWith(val + "/"))
127
+ );
128
+ if (nested) return `"${val}" overlaps with already-added "${nested}"`;
129
+ if (val !== "") {
130
+ const abs = resolve(opts.cwd ?? process.cwd(), val);
131
+ if (!existsSync2(abs)) return `Directory not found: ${val}`;
132
+ if (!statSync(abs).isDirectory()) return `Not a directory: ${val}`;
133
+ }
134
+ return null;
135
+ }
136
+ async function collectAppDirs(opts = {}) {
137
+ const added = [];
138
+ let filter = "";
139
+ let cursor = 0;
140
+ let addCursor = false;
141
+ const isNewDir = () => {
142
+ const t = filter.trim();
143
+ return t.length > 0 && !added.includes(t);
144
+ };
145
+ const clampCursor = () => {
146
+ const max = added.length - 1;
147
+ if (cursor > max) cursor = Math.max(0, max);
148
+ };
149
+ const prompt = new Prompt(
150
+ {
151
+ validate() {
152
+ return void 0;
153
+ },
154
+ render() {
155
+ const trimmed = filter.trim();
156
+ const hdr = `${dim(S_BAR)}
157
+ ${symbol(this.state)} App directories
158
+ `;
159
+ switch (this.state) {
160
+ case "submit": {
161
+ const summary = added.length > 0 ? bld(added.join(", ")) : dim("none (single-app project)");
162
+ return `${hdr}${dim(S_BAR)} ${summary}`;
163
+ }
164
+ case "cancel":
165
+ return `${hdr}${dim(S_BAR)}`;
166
+ default: {
167
+ const inputHint = filter.length > 0 ? filter : added.length === 0 ? dim("e.g. apps/web") : dim("e.g. apps/api");
168
+ const lines = [
169
+ hdr.trimEnd(),
170
+ `${info(S_BAR)} ${dim("/")} ${inputHint}`,
171
+ info(S_BAR)
172
+ ];
173
+ for (let i = 0; i < added.length; i++) {
174
+ const isCursor = i === cursor && !addCursor;
175
+ const icon = isCursor ? active("\u25FC") : info("\u25FC");
176
+ const label = isCursor ? bld(added[i]) : added[i];
177
+ lines.push(`${info(S_BAR)} ${icon} ${label}`);
178
+ }
179
+ const atLimit = opts.maxDirs !== void 0 && added.length >= opts.maxDirs;
180
+ if (atLimit) {
181
+ lines.push(`${info(S_BAR)} ${dim(`App limit reached (${added.length}/${opts.maxDirs} on your plan)`)}`);
182
+ } else if (isNewDir()) {
183
+ const err = validateAppDirPath(trimmed, added, opts);
184
+ const icon = addCursor ? active("\u25FB") : dim("\u25FB");
185
+ const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}"`;
186
+ lines.push(`${info(S_BAR)} ${icon} ${label}`);
187
+ }
188
+ lines.push(info(S_BAR));
189
+ if (atLimit) {
190
+ lines.push(dim(`${S_BAR} \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
191
+ } else if (added.length === 0 && !isNewDir()) {
192
+ lines.push(dim(`${S_BAR} Monorepo? Type each app's subdirectory path and press Space.`));
193
+ lines.push(dim(`${S_BAR} Single app? Press Enter to skip this step.`));
194
+ } else if (added.length > 0) {
195
+ lines.push(dim(`${S_BAR} ${added.length} added \xB7 \u2191\u2193 to select, Space to remove \xB7 Enter to confirm`));
196
+ }
197
+ const barEnd = this.state === "error" ? ylw(S_BAR_END) : info(S_BAR_END);
198
+ if (this.state === "error") {
199
+ lines.push(`${ylw(S_BAR_END)} ${ylw(this.error)}`);
200
+ } else {
201
+ lines.push(barEnd);
202
+ }
203
+ lines.push("");
204
+ return lines.join("\n");
205
+ }
206
+ }
207
+ }
208
+ },
209
+ false
210
+ );
211
+ prompt.on("key", (key) => {
212
+ if (!key || key === " ") return;
213
+ const cp = key.codePointAt(0) ?? 0;
214
+ if (cp === 127 || cp === 8) {
215
+ filter = filter.slice(0, -1);
216
+ addCursor = false;
217
+ } else if (cp >= 32 && cp !== 127) {
218
+ filter += key;
219
+ cursor = 0;
220
+ addCursor = false;
221
+ }
222
+ });
223
+ prompt.on("cursor", (action) => {
224
+ switch (action) {
225
+ case "up":
226
+ if (addCursor) {
227
+ addCursor = false;
228
+ cursor = Math.max(0, added.length - 1);
229
+ } else {
230
+ cursor = Math.max(0, cursor - 1);
231
+ }
232
+ break;
233
+ case "down":
234
+ if (!addCursor && cursor >= added.length - 1 && isNewDir()) {
235
+ addCursor = true;
236
+ } else if (!addCursor) {
237
+ cursor = Math.min(added.length - 1, cursor + 1);
238
+ }
239
+ break;
240
+ case "space": {
241
+ if (addCursor || filter.trim().length > 0 && isNewDir()) {
242
+ if (opts.maxDirs !== void 0 && added.length >= opts.maxDirs) break;
243
+ const trimmed = filter.trim();
244
+ const err = validateAppDirPath(trimmed, added, opts);
245
+ if (!err) {
246
+ added.push(trimmed);
247
+ filter = "";
248
+ addCursor = false;
249
+ cursor = 0;
250
+ }
251
+ } else if (added.length > 0 && !isNewDir()) {
252
+ clampCursor();
253
+ added.splice(cursor, 1);
254
+ if (cursor >= added.length) cursor = Math.max(0, added.length - 1);
255
+ }
256
+ break;
257
+ }
258
+ }
259
+ });
260
+ prompt.on("finalize", () => {
261
+ if (prompt.state === "submit") {
262
+ prompt.value = [...added];
263
+ }
264
+ });
265
+ const result = await prompt.prompt();
266
+ if (isCancel(result)) return null;
267
+ return result;
268
+ }
269
+ async function promptSingleAppDir(params) {
270
+ const { existingDirs, cwd } = params;
271
+ const input = await p.text({
272
+ message: "App directory to add",
273
+ placeholder: "apps/web",
274
+ validate(val) {
275
+ const err = validateAppDirPath(val ?? "", existingDirs, { cwd });
276
+ if (err) return err;
277
+ if (!val) return "Directory is required";
278
+ return void 0;
279
+ }
280
+ });
281
+ if (p.isCancel(input)) return null;
282
+ return input;
283
+ }
284
+
285
+ // src/utils/branch-select.ts
286
+ import { execSync } from "child_process";
287
+ import { isCancel as isCancel3, Prompt as Prompt2 } from "@clack/core";
288
+ var S_BAR2 = "\u2502";
289
+ var S_BAR_END2 = "\u2514";
290
+ var S_ACTIVE2 = "\u25C6";
291
+ var S_SUBMIT2 = "\u25C6";
292
+ var S_CANCEL2 = "\u25A0";
293
+ var S_ERROR2 = "\u25B2";
294
+ function symbol2(state) {
295
+ switch (state) {
296
+ case "submit":
297
+ return grn(S_SUBMIT2);
298
+ case "cancel":
299
+ return red(S_CANCEL2);
300
+ case "error":
301
+ return ylw(S_ERROR2);
302
+ default:
303
+ return active(S_ACTIVE2);
304
+ }
305
+ }
390
306
  function detectGitBranches(cwd) {
391
307
  const workDir = cwd ?? process.cwd();
392
308
  try {
@@ -450,8 +366,8 @@ function filterItems(items, query) {
450
366
  const lower = query.toLowerCase();
451
367
  return items.filter((i) => i.value.toLowerCase().includes(lower));
452
368
  }
453
- function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, optional = false, excludedPatterns = /* @__PURE__ */ new Set()) {
454
- const lines = [];
369
+ function buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedPatterns = /* @__PURE__ */ new Set()) {
370
+ const lines = [info(S_BAR2)];
455
371
  const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE);
456
372
  for (let i = scrollOffset; i < end; i++) {
457
373
  const item = filtered[i];
@@ -460,26 +376,20 @@ function buildList(filtered, cursor, scrollOffset, selected, filter, customPatte
460
376
  const icon = isChecked ? isCursor ? info("\u25FC") : info("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB");
461
377
  let label = item.isCustom ? `${item.label} ${dim("(custom)")}` : item.label;
462
378
  if (isCursor) label = bld(label);
463
- lines.push(`${info(S_BAR)} ${icon} ${label}`);
379
+ lines.push(`${info(S_BAR2)} ${icon} ${label}`);
464
380
  }
465
381
  const trimmed = filter.trim();
466
- const allItems = [...filtered];
467
- const isNewPattern = trimmed.length > 0 && !allItems.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
382
+ const isNewPattern = trimmed.length > 0 && !filtered.some((i) => i.value === trimmed) && !customPatterns.includes(trimmed);
468
383
  if (isNewPattern) {
469
384
  const err = validateBranchPattern(trimmed) ?? (excludedPatterns.has(trimmed) ? "Already used for automatic translation" : null);
470
385
  const icon = addCursor ? active("\u25FB") : dim("\u25FB");
471
386
  const label = err ? `${ylw("+")} ${dim(`"${trimmed}" \u2014 ${err}`)}` : `${grn("+")} Add "${trimmed}" as branch pattern`;
472
- lines.push(`${info(S_BAR)} ${icon} ${label}`);
387
+ lines.push(`${info(S_BAR2)} ${icon} ${label}`);
473
388
  } else if (filtered.length === 0 && trimmed.length === 0) {
474
- lines.push(dim(`${S_BAR} No branches detected`));
389
+ lines.push(dim(`${S_BAR2} No branches detected`));
475
390
  }
476
391
  const hidden = filtered.length - (end - scrollOffset);
477
- if (hidden > 0) lines.push(dim(`${S_BAR} ${hidden} more`));
478
- if (selected.size > 0) {
479
- lines.push(dim(`${S_BAR} ${selected.size} selected \u2014 Enter to confirm`));
480
- } else if (optional) {
481
- lines.push(dim(`${S_BAR} Enter to skip`));
482
- }
392
+ if (hidden > 0) lines.push(dim(`${S_BAR2} ${hidden} more`));
483
393
  return lines.join("\n");
484
394
  }
485
395
  async function filterableBranchSelect(params) {
@@ -510,7 +420,7 @@ async function filterableBranchSelect(params) {
510
420
  if (scrollOffset < 0) scrollOffset = 0;
511
421
  }
512
422
  };
513
- const prompt = new Prompt(
423
+ const prompt = new Prompt2(
514
424
  {
515
425
  validate() {
516
426
  if (!optional && selected.size === 0)
@@ -520,51 +430,34 @@ async function filterableBranchSelect(params) {
520
430
  render() {
521
431
  const filtered = getFiltered();
522
432
  clampCursor(filtered);
523
- const hdr = `${dim(S_BAR)}
524
- ${symbol(this.state)} ${message}
433
+ const hdr = `${dim(S_BAR2)}
434
+ ${symbol2(this.state)} ${message}
525
435
  `;
526
- const hint = filter.length > 0 ? filter : dim("type to filter or add pattern, \u2191\u2193 navigate, space select");
436
+ const inputHint = filter.length > 0 ? filter : dim("type to filter \xB7 type a custom pattern to add it");
437
+ const footer = selected.size > 0 ? dim(`${S_BAR2} ${selected.size} selected \xB7 \u2191\u2193 navigate \xB7 Space to select \xB7 Enter to confirm`) : 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`);
527
438
  switch (this.state) {
528
439
  case "submit": {
529
440
  const summary = selected.size > 0 ? bld(Array.from(selected).join(", ")) : dim("none");
530
- return `${hdr}${dim(S_BAR)} ${summary}`;
441
+ return `${hdr}${dim(S_BAR2)} ${summary}`;
531
442
  }
532
443
  case "cancel":
533
- return `${hdr}${dim(S_BAR)}`;
444
+ return `${hdr}${dim(S_BAR2)}`;
534
445
  case "error":
535
446
  return [
536
447
  hdr.trimEnd(),
537
- `${ylw(S_BAR)} ${dim("/")} ${hint}`,
538
- buildList(
539
- filtered,
540
- cursor,
541
- scrollOffset,
542
- selected,
543
- filter,
544
- customPatterns,
545
- addCursor,
546
- optional,
547
- excludedSet
548
- ),
549
- `${ylw(S_BAR_END)} ${ylw(this.error)}`,
448
+ `${ylw(S_BAR2)} ${dim("/")} ${inputHint}`,
449
+ buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
450
+ footer,
451
+ `${ylw(S_BAR_END2)} ${ylw(this.error)}`,
550
452
  ""
551
453
  ].join("\n");
552
454
  default:
553
455
  return [
554
456
  hdr.trimEnd(),
555
- `${info(S_BAR)} ${dim("/")} ${hint}`,
556
- buildList(
557
- filtered,
558
- cursor,
559
- scrollOffset,
560
- selected,
561
- filter,
562
- customPatterns,
563
- addCursor,
564
- optional,
565
- excludedSet
566
- ),
567
- `${info(S_BAR_END)}`,
457
+ `${info(S_BAR2)} ${dim("/")} ${inputHint}`,
458
+ buildList(filtered, cursor, scrollOffset, selected, filter, customPatterns, addCursor, excludedSet),
459
+ footer,
460
+ `${info(S_BAR_END2)}`,
568
461
  ""
569
462
  ].join("\n");
570
463
  }
@@ -630,29 +523,29 @@ ${symbol(this.state)} ${message}
630
523
  }
631
524
  });
632
525
  const result = await prompt.prompt();
633
- if (isCancel2(result)) return null;
526
+ if (isCancel3(result)) return null;
634
527
  return result;
635
528
  }
636
529
 
637
530
  // src/utils/locale-search.ts
638
- import { isCancel as isCancel3, Prompt as Prompt2 } from "@clack/core";
531
+ import { isCancel as isCancel4, Prompt as Prompt3 } from "@clack/core";
639
532
  import * as p2 from "@clack/prompts";
640
- var S_BAR2 = "\u2502";
641
- var S_BAR_END2 = "\u2514";
642
- var S_ACTIVE2 = "\u25C6";
643
- var S_SUBMIT2 = "\u25C6";
644
- var S_CANCEL2 = "\u25A0";
645
- var S_ERROR2 = "\u25B2";
646
- function symbol2(state) {
533
+ var S_BAR3 = "\u2502";
534
+ var S_BAR_END3 = "\u2514";
535
+ var S_ACTIVE3 = "\u25C6";
536
+ var S_SUBMIT3 = "\u25C6";
537
+ var S_CANCEL3 = "\u25A0";
538
+ var S_ERROR3 = "\u25B2";
539
+ function symbol3(state) {
647
540
  switch (state) {
648
541
  case "submit":
649
- return grn(S_SUBMIT2);
542
+ return grn(S_SUBMIT3);
650
543
  case "cancel":
651
- return red(S_CANCEL2);
544
+ return red(S_CANCEL3);
652
545
  case "error":
653
- return ylw(S_ERROR2);
546
+ return ylw(S_ERROR3);
654
547
  default:
655
- return active(S_ACTIVE2);
548
+ return active(S_ACTIVE3);
656
549
  }
657
550
  }
658
551
  var MAX_VISIBLE2 = 12;
@@ -666,25 +559,20 @@ function filterLocales(options, query) {
666
559
  function buildList2(filtered, cursor, scrollOffset, selected) {
667
560
  const isMulti = selected !== null;
668
561
  const end = Math.min(filtered.length, scrollOffset + MAX_VISIBLE2);
669
- const visibleLines = [];
562
+ const visibleLines = [info(S_BAR3)];
670
563
  for (let i = scrollOffset; i < end; i++) {
671
564
  const opt = filtered[i];
672
565
  const isCursor = i === cursor;
673
566
  const isChecked = isMulti && selected.has(opt.bcp47);
674
567
  const icon = isMulti ? isChecked ? isCursor ? info("\u25FC") : info("\u25FC") : isCursor ? active("\u25FB") : dim("\u25FB") : isCursor ? active("\u25CF") : dim("\u25CB");
675
568
  visibleLines.push(
676
- `${info(S_BAR2)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`
569
+ `${info(S_BAR3)} ${icon} ${isCursor ? bld(opt.label) : opt.label}`
677
570
  );
678
571
  }
679
572
  const hidden = filtered.length - (end - scrollOffset);
680
573
  if (hidden > 0)
681
- visibleLines.push(dim(`${S_BAR2} ${hidden} more \u2014 keep typing to narrow`));
682
- if (filtered.length === 0) visibleLines.push(dim(`${S_BAR2} No matches`));
683
- if (isMulti && selected.size > 0) {
684
- visibleLines.push(
685
- dim(`${S_BAR2} ${selected.size} selected \u2014 Enter to confirm`)
686
- );
687
- }
574
+ visibleLines.push(dim(`${S_BAR3} ${hidden} more \u2014 keep typing to narrow`));
575
+ if (filtered.length === 0) visibleLines.push(dim(`${S_BAR3} No matches`));
688
576
  return visibleLines.join("\n");
689
577
  }
690
578
  async function runFilterablePrompt(opts) {
@@ -705,7 +593,7 @@ async function runFilterablePrompt(opts) {
705
593
  scrollOffset = cursor - MAX_VISIBLE2 + 1;
706
594
  if (scrollOffset < 0) scrollOffset = 0;
707
595
  };
708
- const prompt = new Prompt2(
596
+ const prompt = new Prompt3(
709
597
  {
710
598
  initialValue: !multi ? options[cursor]?.bcp47 ?? null : null,
711
599
  validate() {
@@ -718,43 +606,34 @@ async function runFilterablePrompt(opts) {
718
606
  render() {
719
607
  const filtered = getFiltered();
720
608
  clampCursor(filtered);
721
- const hdr = `${dim(S_BAR2)}
722
- ${symbol2(this.state)} ${message}
609
+ const hdr = `${dim(S_BAR3)}
610
+ ${symbol3(this.state)} ${message}
723
611
  `;
724
- const hint = filter.length > 0 ? filter : dim(
725
- `type to filter, \u2191\u2193 navigate${multi ? ", space select" : ""}`
726
- );
612
+ const inputHint = filter.length > 0 ? filter : dim("type to filter");
613
+ 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`);
727
614
  switch (this.state) {
728
615
  case "submit": {
729
616
  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 ?? "";
730
- return `${hdr}${dim(S_BAR2)} ${bld(val || dim("none"))}`;
617
+ return `${hdr}${dim(S_BAR3)} ${bld(val || dim("none"))}`;
731
618
  }
732
619
  case "cancel":
733
- return `${hdr}${dim(S_BAR2)}`;
620
+ return `${hdr}${dim(S_BAR3)}`;
734
621
  case "error":
735
622
  return [
736
623
  hdr.trimEnd(),
737
- `${ylw(S_BAR2)} ${dim("/")} ${hint}`,
738
- buildList2(
739
- filtered,
740
- cursor,
741
- scrollOffset,
742
- multi ? selected : null
743
- ),
744
- `${ylw(S_BAR_END2)} ${ylw(this.error)}`,
624
+ `${ylw(S_BAR3)} ${dim("/")} ${inputHint}`,
625
+ buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
626
+ footer,
627
+ `${ylw(S_BAR_END3)} ${ylw(this.error)}`,
745
628
  ""
746
629
  ].join("\n");
747
630
  default:
748
631
  return [
749
632
  hdr.trimEnd(),
750
- `${info(S_BAR2)} ${dim("/")} ${hint}`,
751
- buildList2(
752
- filtered,
753
- cursor,
754
- scrollOffset,
755
- multi ? selected : null
756
- ),
757
- `${info(S_BAR_END2)}`,
633
+ `${info(S_BAR3)} ${dim("/")} ${inputHint}`,
634
+ buildList2(filtered, cursor, scrollOffset, multi ? selected : null),
635
+ footer,
636
+ `${info(S_BAR_END3)}`,
758
637
  ""
759
638
  ].join("\n");
760
639
  }
@@ -811,7 +690,7 @@ ${symbol2(this.state)} ${message}
811
690
  }
812
691
  });
813
692
  const result = await prompt.prompt();
814
- if (isCancel3(result)) return null;
693
+ if (isCancel4(result)) return null;
815
694
  return result;
816
695
  }
817
696
  async function searchSelectLocale(options, message, initialValue) {
@@ -858,12 +737,116 @@ function buildLanguageOptions(locales) {
858
737
  byFamily.set(family, opt);
859
738
  }
860
739
  }
861
- return Array.from(byFamily.values());
740
+ return Array.from(byFamily.values());
741
+ }
742
+ async function runProjectCreate(params) {
743
+ const { api, userToken, organizationId, repoCanonical, repoRoot } = params;
744
+ const projectName = (params.defaultName ?? "my-project").trim();
745
+ p3.log.success(`Project: ${chalk2.bold(projectName)}`);
746
+ let sourceLocales;
747
+ try {
748
+ ({ sourceLocales } = await api.listLocales(userToken));
749
+ } catch {
750
+ p3.log.error(
751
+ "Failed to fetch supported locales. Check your connection and try again."
752
+ );
753
+ return null;
754
+ }
755
+ const languageOptions = buildLanguageOptions(sourceLocales);
756
+ const appDirs = await collectAppDirs({ cwd: repoRoot, maxDirs: params.maxAppDirs });
757
+ if (appDirs === null) return null;
758
+ if (appDirs.length > 0) {
759
+ p3.log.success(`App directories: ${appDirs.map((d) => chalk2.bold(d)).join(", ")}`);
760
+ }
761
+ const sourceLocale = await searchSelectLocale(
762
+ languageOptions,
763
+ "Source language (the language your code is written in)",
764
+ params.defaultSourceLocale ?? "en"
765
+ );
766
+ if (sourceLocale === null) return null;
767
+ let compatibleTargets;
768
+ try {
769
+ compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
770
+ } catch {
771
+ p3.log.error(
772
+ "Failed to fetch compatible target locales. Check your connection and try again."
773
+ );
774
+ return null;
775
+ }
776
+ const localeOptions = buildLocaleOptions(compatibleTargets);
777
+ const targetOptions = localeOptions.filter(
778
+ (opt) => opt.bcp47 !== sourceLocale
779
+ );
780
+ const targetLocales = await searchMultiSelectLocales(
781
+ targetOptions,
782
+ "Target languages (languages to translate into)"
783
+ );
784
+ if (targetLocales === null) return null;
785
+ if (targetLocales.length === 0) {
786
+ p3.log.warn(
787
+ "No target languages selected \u2014 you can add them later from the dashboard."
788
+ );
789
+ }
790
+ const detected = detectGitBranches();
791
+ const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
792
+ let pushBranches = [];
793
+ {
794
+ let initial = initialBranches;
795
+ while (pushBranches.length === 0) {
796
+ const result = await filterableBranchSelect({
797
+ message: "Which branches should trigger translations?",
798
+ branches: detected.branches,
799
+ defaultBranch: detected.defaultBranch,
800
+ initialValues: initial
801
+ });
802
+ if (result === null) return null;
803
+ if (result.length === 0) {
804
+ p3.log.warn(
805
+ "At least one branch is required. Please select at least one."
806
+ );
807
+ initial = [detected.defaultBranch];
808
+ } else {
809
+ pushBranches = result;
810
+ }
811
+ }
812
+ }
813
+ const targetBranches = pushBranches;
814
+ try {
815
+ const result = await api.createProject(userToken, {
816
+ organizationId,
817
+ name: projectName,
818
+ sourceLocale,
819
+ targetLocales,
820
+ targetBranches,
821
+ appDirs,
822
+ repoCanonical
823
+ });
824
+ p3.log.success(`Project ${chalk2.bold(result.projectName)} created!`);
825
+ return {
826
+ projectId: result.projectId,
827
+ projectName: result.projectName,
828
+ apiKey: result.apiKey,
829
+ sourceLocale,
830
+ targetLocales,
831
+ targetBranches,
832
+ repositoryBound: result.repositoryBound,
833
+ configureUrl: result.configureUrl,
834
+ apps: result.apps
835
+ };
836
+ } catch (error) {
837
+ const message = error instanceof Error ? error.message : "Unknown error";
838
+ p3.log.error(`Failed to create project: ${message}`);
839
+ return null;
840
+ }
862
841
  }
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)}`);
842
+ async function runAppCreate(params) {
843
+ const { api, userToken, projectId, projectName, repoCanonical } = params;
844
+ const existingDirs = params.existingApps.map((a) => a.appDir);
845
+ const appDir = await promptSingleAppDir({ existingDirs });
846
+ if (appDir === null) return null;
847
+ if (appDir) {
848
+ p3.log.success(`App directory: ${chalk2.bold(appDir)}`);
849
+ }
867
850
  let sourceLocales;
868
851
  try {
869
852
  ({ sourceLocales } = await api.listLocales(userToken));
@@ -874,14 +857,10 @@ async function runProjectCreate(params) {
874
857
  return null;
875
858
  }
876
859
  const languageOptions = buildLanguageOptions(sourceLocales);
877
- const appDir = params.defaultAppDir ?? "";
878
- if (appDir) {
879
- p3.log.success(`App directory: ${chalk3.bold(appDir)}`);
880
- }
881
860
  const sourceLocale = await searchSelectLocale(
882
861
  languageOptions,
883
- "Source language (the language your code is written in)",
884
- params.defaultSourceLocale ?? "en"
862
+ "Source language",
863
+ "en"
885
864
  );
886
865
  if (sourceLocale === null) return null;
887
866
  let compatibleTargets;
@@ -893,13 +872,12 @@ async function runProjectCreate(params) {
893
872
  );
894
873
  return null;
895
874
  }
896
- const localeOptions = buildLocaleOptions(compatibleTargets);
897
- const targetOptions = localeOptions.filter(
875
+ const targetOptions = buildLocaleOptions(compatibleTargets).filter(
898
876
  (opt) => opt.bcp47 !== sourceLocale
899
877
  );
900
878
  const targetLocales = await searchMultiSelectLocales(
901
879
  targetOptions,
902
- "Target languages (languages to translate into)"
880
+ "Target languages"
903
881
  );
904
882
  if (targetLocales === null) return null;
905
883
  if (targetLocales.length === 0) {
@@ -907,154 +885,340 @@ async function runProjectCreate(params) {
907
885
  "No target languages selected \u2014 you can add them later from the dashboard."
908
886
  );
909
887
  }
910
- const detected = detectGitBranches();
911
- const initialBranches = params.defaultBranches?.length ? params.defaultBranches : [detected.defaultBranch];
912
- let pushBranches = [];
888
+ const detectedApp = detectGitBranches();
889
+ let appPushBranches = [];
913
890
  {
914
- let initial = initialBranches;
915
- while (pushBranches.length === 0) {
891
+ let initial = [detectedApp.defaultBranch];
892
+ while (appPushBranches.length === 0) {
916
893
  const result = await filterableBranchSelect({
917
894
  message: "Which branches should trigger translations?",
918
- branches: detected.branches,
919
- defaultBranch: detected.defaultBranch,
895
+ branches: detectedApp.branches,
896
+ defaultBranch: detectedApp.defaultBranch,
920
897
  initialValues: initial
921
898
  });
922
899
  if (result === null) return null;
923
900
  if (result.length === 0) {
924
- p3.log.warn(
925
- "At least one branch is required. Please select at least one."
926
- );
927
- initial = [detected.defaultBranch];
901
+ p3.log.warn("At least one branch is required.");
902
+ initial = [detectedApp.defaultBranch];
928
903
  } else {
929
- pushBranches = result;
904
+ appPushBranches = result;
930
905
  }
931
906
  }
932
907
  }
933
- const targetBranches = pushBranches;
908
+ const targetBranches = appPushBranches;
934
909
  try {
935
- const result = await api.createProject(userToken, {
936
- organizationId,
937
- name: projectName,
910
+ const result = await api.createApp(userToken, {
911
+ projectId,
912
+ appDir,
938
913
  sourceLocale,
939
914
  targetLocales,
940
915
  targetBranches,
941
- appDirs: appDir ? [appDir] : [],
942
- repoCanonical
916
+ repoCanonical: repoCanonical ?? ""
943
917
  });
944
- p3.log.success(`Project ${chalk3.bold(result.projectName)} created!`);
945
- return result;
918
+ p3.log.success(
919
+ `App ${chalk2.bold(appDir || "(root)")} added to ${chalk2.bold(projectName)}!`
920
+ );
921
+ return {
922
+ projectId: result.projectId,
923
+ projectName: result.projectName,
924
+ appDir: result.appDir,
925
+ appId: result.appId,
926
+ sourceLocale,
927
+ targetLocales,
928
+ targetBranches
929
+ };
946
930
  } catch (error) {
947
931
  const message = error instanceof Error ? error.message : "Unknown error";
948
- p3.log.error(`Failed to create project: ${message}`);
932
+ p3.log.error(`Failed to add app: ${message}`);
949
933
  return null;
950
934
  }
951
935
  }
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;
936
+
937
+ // src/utils/github-connect.ts
938
+ import * as p4 from "@clack/prompts";
939
+ import chalk3 from "chalk";
940
+ import { spawn } from "child_process";
941
+
942
+ // src/utils/local-server.ts
943
+ import { createServer } from "http";
944
+ import { URL as URL2 } from "url";
945
+ function startCallbackServer() {
946
+ return new Promise((resolve3, reject) => {
947
+ let settled = false;
948
+ let callbackResolve = null;
949
+ let callbackReject = null;
950
+ const callbackPromise = new Promise((res, rej) => {
951
+ callbackResolve = res;
952
+ callbackReject = rej;
953
+ });
954
+ const server = createServer((req, res) => {
955
+ if (!req.url) {
956
+ res.writeHead(400);
957
+ res.end();
958
+ return;
959
+ }
960
+ let pathname;
961
+ let params;
962
+ try {
963
+ const parsed = new URL2(req.url, "http://localhost");
964
+ pathname = parsed.pathname;
965
+ params = Object.fromEntries(parsed.searchParams.entries());
966
+ } catch {
967
+ res.writeHead(400);
968
+ res.end("Bad request");
969
+ return;
970
+ }
971
+ if (pathname !== "/callback") {
972
+ res.writeHead(404);
973
+ res.end("Not found");
974
+ return;
975
+ }
976
+ res.writeHead(200, { "Content-Type": "text/html" });
977
+ res.end(
978
+ '<!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>'
979
+ );
980
+ if (callbackResolve) {
981
+ callbackResolve(params);
982
+ callbackResolve = null;
983
+ }
984
+ setImmediate(() => server.close());
985
+ });
986
+ server.on("error", (err) => {
987
+ if (!settled) {
988
+ settled = true;
989
+ if (callbackReject) callbackReject(err);
990
+ reject(err);
991
+ }
992
+ });
993
+ server.listen(0, "127.0.0.1", () => {
994
+ if (settled) return;
995
+ settled = true;
996
+ const port = server.address().port;
997
+ resolve3({
998
+ port,
999
+ waitForCallback: () => callbackPromise,
1000
+ close: () => server.close()
1001
+ });
1002
+ });
1003
+ });
1004
+ }
1005
+
1006
+ // src/utils/github-connect.ts
1007
+ async function tryOpenBrowser(url) {
1008
+ if (!process.stdout.isTTY || process.env.CI === "true") {
1009
+ return false;
1010
+ }
1011
+ const platform = process.platform;
1012
+ let command;
1013
+ let args;
1014
+ if (platform === "darwin") {
1015
+ command = "open";
1016
+ args = [url];
1017
+ } else if (platform === "win32") {
1018
+ command = "rundll32";
1019
+ args = ["url.dll,FileProtocolHandler", url];
1020
+ } else {
1021
+ command = "xdg-open";
1022
+ args = [url];
1023
+ }
1024
+ return new Promise((resolve3) => {
1025
+ try {
1026
+ const child = spawn(command, args, {
1027
+ detached: true,
1028
+ stdio: "ignore",
1029
+ windowsHide: true
1030
+ });
1031
+ let settled = false;
1032
+ child.once("spawn", () => {
1033
+ if (settled) return;
1034
+ settled = true;
1035
+ child.unref();
1036
+ resolve3(true);
1037
+ });
1038
+ child.once("error", () => {
1039
+ if (settled) return;
1040
+ settled = true;
1041
+ resolve3(false);
1042
+ });
1043
+ setTimeout(() => {
1044
+ if (settled) return;
1045
+ settled = true;
1046
+ resolve3(false);
1047
+ }, 300);
1048
+ } catch {
1049
+ resolve3(false);
1050
+ }
1051
+ });
1052
+ }
1053
+ async function runGitHubInstallFlow(params) {
1054
+ let server = null;
956
1055
  try {
957
- ({ sourceLocales } = await api.listLocales(userToken));
1056
+ server = await startCallbackServer();
958
1057
  } catch {
959
- p3.log.error(
960
- "Failed to fetch supported locales. Check your connection and try again."
961
- );
962
- return null;
963
1058
  }
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;
1059
+ const { installUrl } = await params.api.startCliGitHubInstall(
1060
+ params.userToken,
1061
+ {
1062
+ organizationId: params.organizationId,
1063
+ callbackPort: server?.port
1064
+ }
1065
+ );
1066
+ p4.log.info("Opening GitHub to install the Vocoder App...");
1067
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1068
+ const shouldOpen = params.yes ? true : await p4.confirm({ message: "Open in your browser?" });
1069
+ if (p4.isCancel(shouldOpen)) {
1070
+ server?.close();
1071
+ return null;
1072
+ }
1073
+ if (shouldOpen) {
1074
+ const opened = await tryOpenBrowser(installUrl);
1075
+ if (!opened) {
1076
+ p4.log.info(
1077
+ "Could not open a browser automatically. Use the URL above."
1078
+ );
1079
+ }
1080
+ }
969
1081
  }
970
- if (appDir) {
971
- p3.log.success(`App directory: ${chalk3.bold(appDir)}`);
1082
+ const connectSpinner = p4.spinner();
1083
+ connectSpinner.start("Waiting for GitHub App installation...");
1084
+ if (server) {
1085
+ try {
1086
+ const params_timeout = 15 * 60 * 1e3;
1087
+ const callbackParams = await Promise.race([
1088
+ server.waitForCallback(),
1089
+ new Promise(
1090
+ (resolve3) => setTimeout(() => resolve3(null), params_timeout)
1091
+ )
1092
+ ]);
1093
+ server.close();
1094
+ if (!callbackParams) {
1095
+ connectSpinner.stop("GitHub App installation timed out");
1096
+ p4.log.error(
1097
+ "The installation flow timed out. Run `vocoder init` again."
1098
+ );
1099
+ return null;
1100
+ }
1101
+ if (callbackParams.error) {
1102
+ connectSpinner.stop("GitHub App installation failed");
1103
+ p4.log.error(callbackParams.error);
1104
+ return null;
1105
+ }
1106
+ const { organizationId, connectionLabel, workspace_created } = callbackParams;
1107
+ if (!organizationId || !connectionLabel) {
1108
+ connectSpinner.stop("GitHub App installation incomplete");
1109
+ p4.log.error("Missing organization or connection data from callback.");
1110
+ return null;
1111
+ }
1112
+ connectSpinner.stop(
1113
+ `Connected to GitHub as ${chalk3.bold(connectionLabel)}`
1114
+ );
1115
+ const orgName = workspace_created ? connectionLabel : organizationId;
1116
+ return {
1117
+ organizationId,
1118
+ organizationName: orgName,
1119
+ connectionLabel
1120
+ };
1121
+ } catch {
1122
+ server.close();
1123
+ connectSpinner.stop("GitHub App installation failed");
1124
+ return null;
1125
+ }
972
1126
  }
973
- const sourceLocale = await searchSelectLocale(
974
- languageOptions,
975
- "Source language",
976
- "en"
1127
+ connectSpinner.stop("Could not detect GitHub App installation automatically");
1128
+ p4.log.warn(
1129
+ "Complete the installation in your browser, then run `vocoder init` again."
977
1130
  );
978
- if (sourceLocale === null) return null;
979
- let compatibleTargets;
1131
+ return null;
1132
+ }
1133
+ async function runGitHubDiscoveryFlow(params) {
1134
+ let server = null;
980
1135
  try {
981
- compatibleTargets = await api.listCompatibleLocales(userToken, sourceLocale);
1136
+ server = await startCallbackServer();
982
1137
  } catch {
983
- p3.log.error(
984
- "Failed to fetch compatible target locales. Check your connection and try again."
985
- );
986
- return null;
987
1138
  }
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
- );
1139
+ const { oauthUrl } = await params.api.startCliGitHubOAuth(params.userToken, {
1140
+ organizationId: params.organizationId,
1141
+ callbackPort: server?.port
1142
+ });
1143
+ p4.log.info("Opening GitHub to authorize your account...");
1144
+ p4.note("Complete authorization in your browser.");
1145
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1146
+ const shouldOpen = params.yes ? true : await p4.confirm({ message: "Open in your browser?" });
1147
+ if (p4.isCancel(shouldOpen)) {
1148
+ server?.close();
1149
+ return null;
1150
+ }
1151
+ if (shouldOpen) {
1152
+ const opened = await tryOpenBrowser(oauthUrl);
1153
+ if (!opened) {
1154
+ p4.log.info(`Could not open browser automatically. Visit: ${oauthUrl}`);
1155
+ }
1156
+ }
1000
1157
  }
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;
1158
+ const oauthSpinner = p4.spinner();
1159
+ oauthSpinner.start("Waiting for GitHub authorization...");
1160
+ if (server) {
1161
+ try {
1162
+ const timeoutMs = 10 * 60 * 1e3;
1163
+ const callbackParams = await Promise.race([
1164
+ server.waitForCallback(),
1165
+ new Promise(
1166
+ (resolve3) => setTimeout(() => resolve3(null), timeoutMs)
1167
+ )
1168
+ ]);
1169
+ server.close();
1170
+ if (!callbackParams) {
1171
+ oauthSpinner.stop("GitHub authorization timed out");
1172
+ return null;
1173
+ }
1174
+ if (callbackParams.error) {
1175
+ oauthSpinner.stop("GitHub authorization failed");
1176
+ p4.log.error(callbackParams.error);
1177
+ return null;
1018
1178
  }
1179
+ } catch {
1180
+ server.close();
1181
+ oauthSpinner.stop("GitHub authorization failed");
1182
+ return null;
1019
1183
  }
1020
1184
  }
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 ?? ""
1185
+ oauthSpinner.stop("GitHub account authorized");
1186
+ const discoveryResult = await params.api.getCliGitHubDiscovery(
1187
+ params.userToken
1188
+ );
1189
+ return discoveryResult.installations;
1190
+ }
1191
+ async function selectGitHubInstallation(installations, canInstallNew) {
1192
+ const options = installations.map((inst) => ({
1193
+ value: String(inst.installationId),
1194
+ label: inst.accountLogin,
1195
+ hint: [
1196
+ inst.accountType === "Organization" ? "organization" : "personal",
1197
+ inst.conflictLabel ? `connected to ${inst.conflictLabel}` : "",
1198
+ inst.isSuspended ? "suspended" : ""
1199
+ ].filter(Boolean).join(" \xB7 ") || void 0
1200
+ }));
1201
+ if (canInstallNew) {
1202
+ options.push({
1203
+ value: "install_new",
1204
+ label: `Install on a new account ${chalk3.dim("(creates a new personal workspace)")}`
1030
1205
  });
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
1206
  }
1207
+ const selected = await p4.select({
1208
+ message: "Select a GitHub installation",
1209
+ options
1210
+ });
1211
+ if (p4.isCancel(selected)) return null;
1212
+ if (selected === "install_new") return "install_new";
1213
+ return Number(selected);
1048
1214
  }
1049
1215
 
1050
1216
  // src/commands/init.ts
1051
1217
  import chalk5 from "chalk";
1052
- import { join as join2 } from "path";
1053
1218
  import { config as loadEnv } from "dotenv";
1054
1219
 
1055
1220
  // src/utils/git-identity.ts
1056
1221
  import { execSync as execSync2 } from "child_process";
1057
- import { relative, resolve } from "path";
1058
1222
  var SHA_REGEX = /^[0-9a-f]{40}$/i;
1059
1223
  function detectCommitSha() {
1060
1224
  if (process.env.VOCODER_COMMIT_SHA && SHA_REGEX.test(process.env.VOCODER_COMMIT_SHA)) {
@@ -1132,21 +1296,13 @@ function resolveGitRepositoryIdentity() {
1132
1296
  if (!parsed) {
1133
1297
  return null;
1134
1298
  }
1135
- const repositoryRoot = safeExec("git rev-parse --show-toplevel");
1136
- const currentDirectory = process.cwd();
1137
- let repoAppDir = "";
1138
- if (repositoryRoot) {
1139
- const relativePath = relative(
1140
- resolve(repositoryRoot),
1141
- resolve(currentDirectory)
1142
- ).replace(/\\/g, "/").trim();
1143
- if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
1144
- repoAppDir = relativePath;
1145
- }
1299
+ const repoRoot = safeExec("git rev-parse --show-toplevel");
1300
+ if (!repoRoot) {
1301
+ return null;
1146
1302
  }
1147
1303
  return {
1148
1304
  repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
1149
- repoAppDir
1305
+ repoRoot
1150
1306
  };
1151
1307
  }
1152
1308
  function resolveGitContext() {
@@ -1160,47 +1316,47 @@ function resolveGitContext() {
1160
1316
  return { identity, warnings };
1161
1317
  }
1162
1318
 
1163
- // src/utils/workspace.ts
1164
- import * as p4 from "@clack/prompts";
1319
+ // src/utils/organization.ts
1320
+ import * as p5 from "@clack/prompts";
1165
1321
  import chalk4 from "chalk";
1166
- async function selectWorkspace(result) {
1167
- const { workspaces, canCreateWorkspace } = result;
1168
- if (workspaces.length === 0) {
1322
+ async function selectOrganization(result) {
1323
+ const { organizations, canCreateOrganization } = result;
1324
+ if (organizations.length === 0) {
1169
1325
  return { action: "create" };
1170
1326
  }
1171
- const options = workspaces.map((ws) => ({
1172
- value: ws.id,
1173
- label: ws.name,
1327
+ const options = organizations.map((org) => ({
1328
+ value: org.id,
1329
+ label: org.name,
1174
1330
  hint: [
1175
- ws.projectCount > 0 ? `${ws.projectCount} project${ws.projectCount !== 1 ? "s" : ""}` : "",
1176
- ws.connectionLabel ? `GitHub: ${ws.connectionLabel}` : ""
1331
+ org.projectCount > 0 ? `${org.projectCount} project${org.projectCount !== 1 ? "s" : ""}` : "",
1332
+ org.connectionLabel ? `GitHub: ${org.connectionLabel}` : ""
1177
1333
  ].filter(Boolean).join(" \xB7 ") || void 0
1178
1334
  }));
1179
- if (canCreateWorkspace) {
1335
+ if (canCreateOrganization) {
1180
1336
  options.push({ value: "create", label: "Create new workspace" });
1181
1337
  }
1182
- const selected = await p4.select({
1338
+ const selected = await p5.select({
1183
1339
  message: "Select workspace",
1184
1340
  options
1185
1341
  });
1186
- if (p4.isCancel(selected)) {
1342
+ if (p5.isCancel(selected)) {
1187
1343
  return { action: "cancelled" };
1188
1344
  }
1189
1345
  if (selected === "create") {
1190
1346
  return { action: "create" };
1191
1347
  }
1192
- const workspace = workspaces.find((ws) => ws.id === selected);
1193
- if (!workspace) {
1348
+ const organization = organizations.find((org) => org.id === selected);
1349
+ if (!organization) {
1194
1350
  return { action: "cancelled" };
1195
1351
  }
1196
- return { action: "use", workspace };
1352
+ return { action: "use", organization };
1197
1353
  }
1198
1354
 
1199
1355
  // src/commands/init.ts
1200
1356
  loadEnv();
1201
1357
  var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
1202
1358
  async function sleep(ms) {
1203
- await new Promise((resolve2) => setTimeout(resolve2, ms));
1359
+ await new Promise((resolve3) => setTimeout(resolve3, ms));
1204
1360
  }
1205
1361
  async function tryOpenBrowser2(url) {
1206
1362
  if (!process.stdout.isTTY || process.env.CI === "true") {
@@ -1218,7 +1374,7 @@ async function tryOpenBrowser2(url) {
1218
1374
  command = "xdg-open";
1219
1375
  args = [url];
1220
1376
  }
1221
- return await new Promise((resolve2) => {
1377
+ return await new Promise((resolve3) => {
1222
1378
  try {
1223
1379
  const child = spawn2(command, args, {
1224
1380
  detached: true,
@@ -1230,20 +1386,20 @@ async function tryOpenBrowser2(url) {
1230
1386
  if (settled) return;
1231
1387
  settled = true;
1232
1388
  child.unref();
1233
- resolve2(true);
1389
+ resolve3(true);
1234
1390
  });
1235
1391
  child.once("error", () => {
1236
1392
  if (settled) return;
1237
1393
  settled = true;
1238
- resolve2(false);
1394
+ resolve3(false);
1239
1395
  });
1240
1396
  setTimeout(() => {
1241
1397
  if (settled) return;
1242
1398
  settled = true;
1243
- resolve2(false);
1399
+ resolve3(false);
1244
1400
  }, 300);
1245
1401
  } catch {
1246
- resolve2(false);
1402
+ resolve3(false);
1247
1403
  }
1248
1404
  });
1249
1405
  }
@@ -1255,24 +1411,24 @@ function getSubscriptionSettingsUrl(apiUrl) {
1255
1411
  return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
1256
1412
  }
1257
1413
  function printPlanLimitMessage(apiUrl, message) {
1258
- p5.log.error(`You are over your plan limits.
1414
+ p6.log.error(`You are over your plan limits.
1259
1415
  ${message}`);
1260
- p5.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
1416
+ p6.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
1261
1417
  }
1262
1418
  function runScaffold(params) {
1263
- const { sourceLocale, targetBranches, appDir } = params;
1419
+ const { sourceLocale, targetBranches } = params;
1264
1420
  const detection = detectLocalEcosystem();
1265
1421
  const useTypeScript = detection.isTypeScript;
1266
1422
  if (detection.ecosystem) {
1267
1423
  const frameworkLabel = detection.framework ?? detection.ecosystem;
1268
1424
  const pmLabel = detection.packageManager;
1269
- p5.log.info(`Detected: ${chalk5.bold(frameworkLabel)} (${pmLabel})`);
1425
+ p6.log.info(`Detected: ${chalk5.bold(frameworkLabel)} (${pmLabel})`);
1270
1426
  }
1271
1427
  const { devPackages, runtimePackages } = getPackagesToInstall(detection);
1272
1428
  const allPackages = [...devPackages, ...runtimePackages];
1273
1429
  if (allPackages.length > 0) {
1274
- p5.log.info("");
1275
- const installSpinner = p5.spinner();
1430
+ p6.log.info("");
1431
+ const installSpinner = p6.spinner();
1276
1432
  installSpinner.start(`Installing ${allPackages.join(", ")}...`);
1277
1433
  try {
1278
1434
  if (devPackages.length > 0) {
@@ -1292,19 +1448,22 @@ function runScaffold(params) {
1292
1448
  installSpinner.stop("Package installation failed");
1293
1449
  const cmds = [
1294
1450
  devPackages.length > 0 ? buildInstallCommand(detection.packageManager, devPackages, true) : null,
1295
- runtimePackages.length > 0 ? buildInstallCommand(detection.packageManager, runtimePackages, false) : null
1451
+ runtimePackages.length > 0 ? buildInstallCommand(
1452
+ detection.packageManager,
1453
+ runtimePackages,
1454
+ false
1455
+ ) : null
1296
1456
  ].filter(Boolean).join(" && ");
1297
- p5.log.warn(`Run manually: ${highlight(cmds)}`);
1457
+ p6.log.warn(`Run manually: ${highlight(cmds)}`);
1298
1458
  }
1299
1459
  } else if (detection.ecosystem) {
1300
- p5.log.info(`Packages: ${chalk5.green("already installed")}`);
1460
+ p6.log.info(`Packages: ${chalk5.green("already installed")}`);
1301
1461
  }
1302
1462
  const snippets = getSetupSnippets({
1303
1463
  framework: detection.framework,
1304
1464
  ecosystem: detection.ecosystem,
1305
1465
  sourceLocale,
1306
- targetBranches,
1307
- appDir
1466
+ targetBranches
1308
1467
  });
1309
1468
  const steps = [];
1310
1469
  if (snippets.pluginStep) {
@@ -1326,36 +1485,23 @@ function runScaffold(params) {
1326
1485
  hint: "mark strings for extraction \u2014 Vocoder picks these up on push",
1327
1486
  code: snippets.wrapStep.code
1328
1487
  });
1329
- p5.log.message("");
1330
- p5.log.message(chalk5.bold("Finish setup in your code"));
1331
- p5.log.message("");
1488
+ p6.log.message("");
1489
+ p6.log.message(chalk5.bold("Finish setup in your code"));
1490
+ p6.log.message("");
1332
1491
  for (let i = 0; i < steps.length; i++) {
1333
1492
  const step = steps[i];
1334
- p5.log.step(
1335
- `${chalk5.bold(step.label)} ${chalk5.dim(`\u2014 ${step.hint}`)}`
1336
- );
1493
+ p6.log.step(`${chalk5.bold(step.label)} ${chalk5.dim(`\u2014 ${step.hint}`)}`);
1337
1494
  printCodeBlock(step.code);
1338
- if (i < steps.length - 1) p5.log.message("");
1339
- }
1340
- const written = writeVocoderConfig({ targetBranches, useTypeScript });
1341
- if (written) {
1342
- p5.log.success(`Created ${highlight(written)}`);
1343
- } else if (!findExistingConfig(process.cwd())) {
1344
- const ext = useTypeScript ? "ts" : "js";
1345
- p5.log.warn(
1346
- `Could not write vocoder.config.${ext} \u2014 create it manually with your extraction patterns.`
1347
- );
1495
+ if (i < steps.length - 1) p6.log.message("");
1348
1496
  }
1349
- p5.log.message("");
1497
+ p6.log.message("");
1350
1498
  const branchList = targetBranches.length > 0 ? targetBranches.map((b) => highlight(b)).join(" or ") : highlight("your target branch");
1351
- p5.log.success(
1352
- `Push to ${branchList} to trigger your first translation run.`
1353
- );
1354
- p5.log.message(info(" Docs: https://vocoder.app/docs/getting-started"));
1499
+ p6.log.success(`Push to ${branchList} to trigger your first translation run.`);
1500
+ p6.log.message(info(" Docs: https://vocoder.app/docs/getting-started"));
1355
1501
  }
1356
- function writeApiKeyToEnv(apiKey) {
1357
- const envPath = join2(process.cwd(), ".env");
1358
- if (!existsSync2(envPath)) return false;
1502
+ function writeApiKeyToEnv(apiKey, repoRoot) {
1503
+ const envPath = join2(repoRoot ?? process.cwd(), ".env");
1504
+ if (!existsSync3(envPath)) return false;
1359
1505
  try {
1360
1506
  const content = readFileSync(envPath, "utf-8");
1361
1507
  const keyLine = `VOCODER_API_KEY=${apiKey}`;
@@ -1373,15 +1519,36 @@ function writeApiKeyToEnv(apiKey) {
1373
1519
  return false;
1374
1520
  }
1375
1521
  }
1376
- function printApiKey(apiKey) {
1377
- const saved = writeApiKeyToEnv(apiKey);
1378
- p5.log.message("");
1379
- p5.log.message(chalk5.bold("Your API Key"));
1522
+ function printApiKey(apiKey, repoRoot) {
1523
+ const saved = writeApiKeyToEnv(apiKey, repoRoot);
1524
+ p6.log.message("");
1525
+ p6.log.message(chalk5.bold("Your API Key"));
1380
1526
  printCodeBlock(`VOCODER_API_KEY=${apiKey}`);
1381
1527
  if (saved) {
1382
- p5.log.success(chalk5.dim("Saved to .env"));
1528
+ p6.log.success(chalk5.dim("Saved to .env"));
1383
1529
  } else {
1384
- p5.log.message(chalk5.dim(" Add the above to your .env file"));
1530
+ p6.log.message(chalk5.dim(" Add the above to your .env file"));
1531
+ }
1532
+ }
1533
+ function writeAppConfigs(apps, targetBranches, useTypeScript, repoRoot) {
1534
+ const base = repoRoot ?? process.cwd();
1535
+ for (const app of apps) {
1536
+ const dir = app.appDir ? resolve2(base, app.appDir) : base;
1537
+ const written = writeVocoderConfig({
1538
+ targetBranches,
1539
+ appId: app.appId,
1540
+ cwd: dir,
1541
+ useTypeScript
1542
+ });
1543
+ if (written) {
1544
+ const displayPath = app.appDir ? `${app.appDir}/${written}` : written;
1545
+ p6.log.success(`Created ${highlight(displayPath)}`);
1546
+ } else if (!findExistingConfig(dir)) {
1547
+ const ext = useTypeScript ? "ts" : "js";
1548
+ p6.log.warn(
1549
+ `Could not write ${app.appDir ? `${app.appDir}/` : ""}vocoder.config.${ext} \u2014 create it manually.`
1550
+ );
1551
+ }
1385
1552
  }
1386
1553
  }
1387
1554
  function printCodeBlock(code) {
@@ -1426,23 +1593,23 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1426
1593
  } else if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
1427
1594
  if (reauth) {
1428
1595
  if (!options.yes) {
1429
- const shouldOpen = await p5.confirm({
1596
+ const shouldOpen = await p6.confirm({
1430
1597
  message: "Open your browser to sign in again?"
1431
1598
  });
1432
- if (p5.isCancel(shouldOpen)) {
1599
+ if (p6.isCancel(shouldOpen)) {
1433
1600
  server?.close();
1434
- p5.cancel("Setup cancelled.");
1601
+ p6.cancel("Setup cancelled.");
1435
1602
  return null;
1436
1603
  }
1437
1604
  if (!shouldOpen) {
1438
1605
  server?.close();
1439
- p5.cancel("Setup cancelled.");
1606
+ p6.cancel("Setup cancelled.");
1440
1607
  return null;
1441
1608
  } else {
1442
1609
  const opened = await tryOpenBrowser2(browserUrl);
1443
1610
  if (!opened) {
1444
- p5.note(browserUrl, "Sign In");
1445
- p5.log.info("Open the URL above manually to continue.");
1611
+ p6.note(browserUrl, "Sign In");
1612
+ p6.log.info("Open the URL above manually to continue.");
1446
1613
  }
1447
1614
  }
1448
1615
  } else {
@@ -1451,20 +1618,24 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1451
1618
  } else {
1452
1619
  let isLinkFlow = false;
1453
1620
  if (!options.yes) {
1454
- const connectChoice = await p5.select({
1621
+ const connectChoice = await p6.select({
1455
1622
  message: "Vocoder needs to be installed on your GitHub account to get started",
1456
1623
  options: [
1457
1624
  {
1458
1625
  value: "install",
1459
1626
  label: "Install GitHub App",
1460
- hint: "recommended"
1627
+ hint: "new user"
1461
1628
  },
1462
- { value: "link", label: "Already installed? Link your account" }
1629
+ {
1630
+ value: "link",
1631
+ label: "Already installed? Link your account",
1632
+ hint: "returning user"
1633
+ }
1463
1634
  ]
1464
1635
  });
1465
- if (p5.isCancel(connectChoice)) {
1636
+ if (p6.isCancel(connectChoice)) {
1466
1637
  server?.close();
1467
- p5.cancel("Setup cancelled.");
1638
+ p6.cancel("Setup cancelled.");
1468
1639
  return null;
1469
1640
  }
1470
1641
  isLinkFlow = connectChoice === "link";
@@ -1483,13 +1654,13 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1483
1654
  }
1484
1655
  const opened = await tryOpenBrowser2(urlToOpen);
1485
1656
  if (!opened) {
1486
- p5.log.warn("Could not open your browser automatically.");
1487
- p5.note(urlToOpen, "GitHub");
1488
- p5.log.info("Open the URL above to continue.");
1657
+ p6.log.warn("Could not open your browser automatically.");
1658
+ p6.note(urlToOpen, "GitHub");
1659
+ p6.log.info("Open the URL above to continue.");
1489
1660
  }
1490
1661
  }
1491
1662
  }
1492
- const authSpinner = p5.spinner();
1663
+ const authSpinner = p6.spinner();
1493
1664
  authSpinner.start("Waiting for GitHub authorization...");
1494
1665
  let rawToken = null;
1495
1666
  let callbackOrganizationId;
@@ -1510,19 +1681,19 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1510
1681
  }
1511
1682
  return null;
1512
1683
  })();
1513
- const winner = await new Promise((resolve2) => {
1684
+ const winner = await new Promise((resolve3) => {
1514
1685
  let done = false;
1515
1686
  serverCallback.then((params) => {
1516
1687
  if (done || params === null || typeof params.token !== "string") return;
1517
1688
  done = true;
1518
- resolve2({ kind: "server", params });
1689
+ resolve3({ kind: "server", params });
1519
1690
  }).catch(() => {
1520
1691
  });
1521
1692
  sessionPoll.then((result) => {
1522
1693
  if (done || result === null) return;
1523
1694
  if (result.status === "complete" || result.status === "failed") {
1524
1695
  done = true;
1525
- resolve2({
1696
+ resolve3({
1526
1697
  kind: "poll",
1527
1698
  result
1528
1699
  });
@@ -1533,7 +1704,7 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1533
1704
  () => {
1534
1705
  if (!done) {
1535
1706
  done = true;
1536
- resolve2(null);
1707
+ resolve3(null);
1537
1708
  }
1538
1709
  },
1539
1710
  Math.max(0, deadline - Date.now())
@@ -1557,13 +1728,13 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1557
1728
  }
1558
1729
  } else {
1559
1730
  authSpinner.stop();
1560
- p5.log.error(winner.result.reason);
1731
+ p6.log.error(winner.result.reason);
1561
1732
  return null;
1562
1733
  }
1563
1734
  }
1564
1735
  if (!rawToken) {
1565
1736
  authSpinner.stop();
1566
- p5.log.error("The authentication link expired. Run `vocoder init` again.");
1737
+ p6.log.error("The authentication link expired. Run `vocoder init` again.");
1567
1738
  return null;
1568
1739
  }
1569
1740
  const userInfo = await api.getCliUserInfo(rawToken);
@@ -1577,34 +1748,44 @@ async function runAuthFlow(api, options, reauth = false, repoCanonical) {
1577
1748
  }
1578
1749
  async function init(options = {}) {
1579
1750
  const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
1580
- p5.intro(chalk5.bold("Vocoder Setup"));
1751
+ p6.intro(chalk5.bold("Vocoder Setup"));
1581
1752
  try {
1582
1753
  const gitContext = resolveGitContext();
1583
1754
  const identity = gitContext.identity;
1584
1755
  if (gitContext.warnings.length > 0) {
1585
1756
  for (const warning of gitContext.warnings) {
1586
- p5.log.warn(warning);
1757
+ p6.log.warn(warning);
1587
1758
  }
1588
1759
  }
1589
1760
  let existingAppsForRepo = [];
1590
1761
  let repoProjectId = null;
1591
1762
  let repoProjectName = null;
1763
+ let lookup = null;
1592
1764
  if (identity) {
1593
1765
  const anonApi = new VocoderAPI({ apiUrl, apiKey: "" });
1594
- const lookup = await anonApi.lookupAppByRepo({
1766
+ lookup = await anonApi.lookupAppByRepo({
1595
1767
  repoCanonical: identity.repoCanonical,
1596
- appDir: identity.repoAppDir
1768
+ appDir: ""
1597
1769
  });
1598
- if (lookup.exactMatch) {
1599
- const { exactMatch } = lookup;
1600
- p5.log.success(`Project: ${chalk5.bold(exactMatch.projectName)}`);
1601
- p5.log.info(
1602
- `Branches: ${highlight((exactMatch.targetBranches ?? ["main"]).join(", "))}`
1770
+ if (lookup.existingApps.length > 0) {
1771
+ const allApps = lookup.existingApps;
1772
+ const firstApp = allApps[0];
1773
+ p6.log.success(`Project: ${chalk5.bold(firstApp.projectName)}`);
1774
+ p6.log.info(
1775
+ `Configured apps: ${allApps.map((a) => highlight(a.appDir || "(entire repo)")).join(", ")}`
1603
1776
  );
1604
- const needsKey = await p5.confirm({
1605
- message: "Need to regenerate your API key?"
1777
+ const routeAction = await p6.select({
1778
+ message: "This repo is already set up. What would you like to do?",
1779
+ options: [
1780
+ { value: "key", label: "Get an API key for this project" },
1781
+ { value: "add", label: "Add a new app directory" }
1782
+ ]
1606
1783
  });
1607
- if (!p5.isCancel(needsKey) && needsKey) {
1784
+ if (p6.isCancel(routeAction)) {
1785
+ p6.cancel("Setup cancelled.");
1786
+ return 1;
1787
+ }
1788
+ if (routeAction === "key") {
1608
1789
  const anonApi2 = new VocoderAPI({ apiUrl, apiKey: "" });
1609
1790
  const authResult = await runAuthFlow(
1610
1791
  anonApi2,
@@ -1613,44 +1794,37 @@ async function init(options = {}) {
1613
1794
  true
1614
1795
  );
1615
1796
  if (!authResult) return 1;
1616
- const spinner7 = p5.spinner();
1617
- spinner7.start("Generating new API key...");
1797
+ const spinner7 = p6.spinner();
1798
+ spinner7.start("Generating API key...");
1799
+ let apiKey;
1618
1800
  try {
1619
- const { apiKey } = await anonApi2.regenerateProjectApiKey(
1801
+ ({ apiKey } = await anonApi2.regenerateProjectApiKey(
1620
1802
  authResult.token,
1621
- exactMatch.projectId
1622
- );
1623
- spinner7.stop("New API key generated");
1624
- printApiKey(apiKey);
1803
+ firstApp.projectId
1804
+ ));
1805
+ spinner7.stop("API key ready");
1625
1806
  } catch (err) {
1626
1807
  spinner7.stop("Failed to generate key");
1627
1808
  const msg = err instanceof Error ? err.message : String(err);
1628
- p5.log.error(`Could not generate API key: ${msg}`);
1629
- p5.log.info("Try again or generate one from the dashboard.");
1809
+ p6.log.error(`Could not generate API key: ${msg}`);
1810
+ p6.log.info("Try again or generate one from the dashboard.");
1630
1811
  return 1;
1631
1812
  }
1632
- }
1633
- const isTs = detectLocalEcosystem().isTypeScript;
1634
- const written = writeVocoderConfig({ targetBranches: exactMatch.targetBranches ?? ["main"], useTypeScript: isTs });
1635
- if (written) p5.log.success(`Created ${highlight(written)}`);
1636
- p5.outro("Vocoder is already set up for this repository.");
1637
- return 0;
1638
- }
1639
- if (lookup.hasWholeRepoApp) {
1640
- const wholeRepo = lookup.existingApps.find((a) => a.appDir === "");
1641
- if (wholeRepo) {
1642
- p5.log.success(`Project: ${chalk5.bold(wholeRepo.projectName)}`);
1643
- const isTs = detectLocalEcosystem().isTypeScript;
1644
- const written = writeVocoderConfig({ targetBranches: ["main"], useTypeScript: isTs });
1645
- if (written) p5.log.success(`Created ${highlight(written)}`);
1646
- p5.outro("Vocoder is already set up for this repository.");
1813
+ printApiKey(apiKey, identity.repoRoot);
1814
+ const detection2 = detectLocalEcosystem();
1815
+ const targetBranches = lookup.exactMatch?.targetBranches ?? ["main"];
1816
+ writeAppConfigs(
1817
+ allApps.map((a) => ({ appDir: a.appDir, appId: a.appId })),
1818
+ targetBranches,
1819
+ detection2.isTypeScript,
1820
+ identity.repoRoot
1821
+ );
1822
+ p6.outro("Vocoder is set up for this repository.");
1647
1823
  return 0;
1648
1824
  }
1649
- }
1650
- if (lookup.existingApps.length > 0) {
1651
- existingAppsForRepo = lookup.existingApps;
1652
- repoProjectId = lookup.existingApps[0]?.projectId ?? null;
1653
- repoProjectName = lookup.existingApps[0]?.projectName ?? null;
1825
+ existingAppsForRepo = allApps;
1826
+ repoProjectId = firstApp.projectId;
1827
+ repoProjectName = firstApp.projectName;
1654
1828
  }
1655
1829
  }
1656
1830
  const api = new VocoderAPI({ apiUrl, apiKey: "" });
@@ -1660,16 +1834,16 @@ async function init(options = {}) {
1660
1834
  let authOrganizationId;
1661
1835
  const storedAuth = await verifyStoredAuth(api);
1662
1836
  if (storedAuth.status === "valid") {
1663
- p5.log.success(`Authenticated as ${chalk5.bold(storedAuth.email)}`);
1837
+ p6.log.success(`Authenticated as ${chalk5.bold(storedAuth.email)}`);
1664
1838
  userToken = storedAuth.token;
1665
1839
  userEmail = storedAuth.email;
1666
1840
  userName = storedAuth.name;
1667
1841
  } else {
1668
1842
  const reauth = storedAuth.status === "expired";
1669
1843
  if (reauth) {
1670
- p5.log.warn("Stored credentials expired \u2014 signing in again");
1844
+ p6.log.warn("Stored credentials expired \u2014 signing in again");
1671
1845
  } else if (storedAuth.status === "gone") {
1672
- p5.log.warn("Account not found \u2014 starting fresh setup");
1846
+ p6.log.warn("Account not found \u2014 starting fresh setup");
1673
1847
  }
1674
1848
  const authResult = await runAuthFlow(
1675
1849
  api,
@@ -1690,101 +1864,57 @@ async function init(options = {}) {
1690
1864
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1691
1865
  });
1692
1866
  }
1693
- let selectedWorkspaceId;
1694
- let selectedWorkspaceName;
1867
+ let selectedOrganizationId;
1868
+ let selectedOrganizationName;
1869
+ const repoOrgContext = identity ? lookup?.organizationContext ?? null : null;
1695
1870
  if (authOrganizationId) {
1696
- const workspaceData = await api.listWorkspaces(userToken);
1697
- const ws = workspaceData.workspaces.find(
1871
+ const organizationData = await api.listOrganizations(userToken);
1872
+ const ws = organizationData.organizations.find(
1698
1873
  (w) => w.id === authOrganizationId
1699
1874
  );
1700
- selectedWorkspaceId = authOrganizationId;
1701
- selectedWorkspaceName = ws?.name ?? userEmail;
1702
- p5.log.success(
1703
- `Connected as ${chalk5.bold(userEmail)} \u2014 workspace: ${chalk5.bold(selectedWorkspaceName)}`
1875
+ selectedOrganizationId = authOrganizationId;
1876
+ selectedOrganizationName = ws?.name ?? userEmail;
1877
+ p6.log.success(
1878
+ `Connected as ${chalk5.bold(userEmail)} \u2014 workspace: ${chalk5.bold(selectedOrganizationName)}`
1704
1879
  );
1880
+ } else if (repoOrgContext && !repoProjectId) {
1881
+ selectedOrganizationId = repoOrgContext.organizationId;
1882
+ selectedOrganizationName = repoOrgContext.organizationName;
1883
+ p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1705
1884
  } else {
1706
- const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
1707
- const cachedInstallations = discoveryResult?.installations ?? [];
1708
- if (cachedInstallations.length > 0) {
1709
- if (identity?.repoCanonical) {
1710
- const repoOwner = identity.repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
1711
- if (repoOwner) {
1712
- const hasMatchingAccount = cachedInstallations.some(
1713
- (i) => i.accountLogin.toLowerCase() === repoOwner
1714
- );
1715
- if (!hasMatchingAccount) {
1716
- p5.log.warn(
1717
- `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
1718
- The project will be created but translations won't trigger automatically.
1719
- To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
1720
- );
1721
- }
1722
- }
1723
- }
1724
- const validInstallations = cachedInstallations.filter(
1725
- (i) => !i.isSuspended && !i.conflictLabel
1726
- );
1727
- let selectedInstallationId = null;
1728
- if (validInstallations.length === 1 && cachedInstallations.length === 1) {
1729
- selectedInstallationId = validInstallations[0].installationId;
1730
- } else {
1731
- selectedInstallationId = await selectGitHubInstallation(
1732
- cachedInstallations.map((inst) => ({
1733
- installationId: inst.installationId,
1734
- accountLogin: inst.accountLogin,
1735
- accountType: inst.accountType,
1736
- isSuspended: inst.isSuspended,
1737
- conflictLabel: inst.conflictLabel
1738
- })),
1739
- false
1740
- );
1741
- }
1742
- if (selectedInstallationId === null || selectedInstallationId === "install_new") {
1743
- p5.cancel(
1744
- "Setup cancelled. Re-run `vocoder init` and choose Install GitHub App."
1745
- );
1746
- return 1;
1747
- }
1748
- const claimResult = await api.claimCliGitHubInstallation(userToken, {
1749
- installationId: String(selectedInstallationId),
1750
- organizationId: null
1751
- });
1752
- selectedWorkspaceId = claimResult.organizationId;
1753
- selectedWorkspaceName = claimResult.organizationName;
1754
- p5.log.success(`Workspace: ${chalk5.bold(selectedWorkspaceName)}`);
1755
- } else {
1756
- const workspaceData = await api.listWorkspaces(userToken, {
1757
- repo: identity?.repoCanonical
1758
- });
1885
+ const organizationData = await api.listOrganizations(userToken, {
1886
+ repo: identity?.repoCanonical
1887
+ });
1888
+ {
1759
1889
  const repoCanonical = identity?.repoCanonical ?? null;
1760
- const covering = repoCanonical ? workspaceData.workspaces.filter((w) => w.coversRepo === true) : [];
1761
- const connected = workspaceData.workspaces.filter(
1890
+ const covering = repoCanonical ? organizationData.organizations.filter((w) => w.coversRepo === true) : [];
1891
+ const connected = organizationData.organizations.filter(
1762
1892
  (w) => w.hasGitHubConnection
1763
1893
  );
1764
1894
  if (repoCanonical && covering.length === 1) {
1765
1895
  const ws = covering[0];
1766
- selectedWorkspaceId = ws.id;
1767
- selectedWorkspaceName = ws.name;
1768
- p5.log.success(`Workspace: ${chalk5.bold(selectedWorkspaceName)}`);
1896
+ selectedOrganizationId = ws.id;
1897
+ selectedOrganizationName = ws.name;
1898
+ p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1769
1899
  } else if (repoCanonical && covering.length > 1) {
1770
- const choice = await p5.select({
1900
+ const choice = await p6.select({
1771
1901
  message: "Select workspace for this repo",
1772
1902
  options: covering.map((w) => ({
1773
1903
  value: w.id,
1774
- label: `${w.name} ${chalk5.dim(`(${w.projectCount} project${w.projectCount !== 1 ? "s" : ""})`)}`
1904
+ label: `${w.name} ${chalk5.dim(`(${w.appCount} app${w.appCount !== 1 ? "s" : ""})`)}`
1775
1905
  }))
1776
1906
  });
1777
- if (p5.isCancel(choice)) {
1778
- p5.cancel("Setup cancelled.");
1907
+ if (p6.isCancel(choice)) {
1908
+ p6.cancel("Setup cancelled.");
1779
1909
  return 1;
1780
1910
  }
1781
1911
  const ws = covering.find((w) => w.id === choice);
1782
- selectedWorkspaceId = ws.id;
1783
- selectedWorkspaceName = ws.name;
1784
- p5.log.success(`Workspace: ${chalk5.bold(selectedWorkspaceName)}`);
1912
+ selectedOrganizationId = ws.id;
1913
+ selectedOrganizationName = ws.name;
1914
+ p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1785
1915
  } else if (repoCanonical && covering.length === 0 && connected.length > 0) {
1786
1916
  const shortRepo = repoCanonical.split(":")[1] ?? repoCanonical;
1787
- p5.log.warn(
1917
+ p6.log.warn(
1788
1918
  `${chalk5.bold(shortRepo)} isn't accessible from your Vocoder installation.
1789
1919
  Grant access to this repository or install on the account that owns it.`
1790
1920
  );
@@ -1802,18 +1932,18 @@ async function init(options = {}) {
1802
1932
  label: `Install on a different GitHub account ${chalk5.dim("(creates a new personal workspace)")}`
1803
1933
  });
1804
1934
  fixOptions.push({ value: "cancel", label: "Cancel" });
1805
- const fix = await p5.select({
1935
+ const fix = await p6.select({
1806
1936
  message: "How would you like to fix this?",
1807
1937
  options: fixOptions
1808
1938
  });
1809
- if (p5.isCancel(fix) || fix === "cancel") {
1810
- p5.cancel("Setup cancelled.");
1939
+ if (p6.isCancel(fix) || fix === "cancel") {
1940
+ p6.cancel("Setup cancelled.");
1811
1941
  return 1;
1812
1942
  }
1813
1943
  if (fix.startsWith("grant:")) {
1814
1944
  const ws = connected.find((w) => `grant:${w.id}` === fix);
1815
1945
  await tryOpenBrowser2(ws.installationConfigureUrl);
1816
- p5.cancel(
1946
+ p6.cancel(
1817
1947
  `Grant access to ${chalk5.bold(shortRepo)} in your browser,
1818
1948
  then re-run ${chalk5.bold("vocoder init")}.`
1819
1949
  );
@@ -1825,40 +1955,94 @@ async function init(options = {}) {
1825
1955
  yes: options.yes
1826
1956
  });
1827
1957
  if (!connectResult) {
1828
- p5.log.error(
1958
+ p6.log.error(
1829
1959
  "GitHub App installation did not complete. Run `vocoder init` again."
1830
1960
  );
1831
1961
  return 1;
1832
1962
  }
1833
- selectedWorkspaceId = connectResult.organizationId;
1834
- selectedWorkspaceName = connectResult.organizationName;
1835
- p5.log.success(`Workspace: ${chalk5.bold(selectedWorkspaceName)}`);
1963
+ selectedOrganizationId = connectResult.organizationId;
1964
+ selectedOrganizationName = connectResult.organizationName;
1965
+ p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1836
1966
  } else {
1837
- if (workspaceData.workspaces.length === 1 && !workspaceData.canCreateWorkspace) {
1838
- const ws = workspaceData.workspaces[0];
1839
- selectedWorkspaceId = ws.id;
1840
- selectedWorkspaceName = ws.name;
1841
- p5.log.success(`Workspace: ${chalk5.bold(selectedWorkspaceName)}`);
1967
+ const discoveryResult = await api.getCliGitHubDiscovery(userToken).catch(() => null);
1968
+ const cachedInstallations = discoveryResult?.installations ?? [];
1969
+ if (cachedInstallations.length > 0) {
1970
+ if (identity?.repoCanonical) {
1971
+ const repoOwner = identity.repoCanonical.split(":")[1]?.split("/")[0]?.toLowerCase();
1972
+ if (repoOwner) {
1973
+ const hasMatchingAccount = cachedInstallations.some(
1974
+ (i) => i.accountLogin.toLowerCase() === repoOwner
1975
+ );
1976
+ if (!hasMatchingAccount) {
1977
+ p6.log.warn(
1978
+ `None of your GitHub App installations belong to "${repoOwner}", the account that owns this repository.
1979
+ The project will be created but translations won't trigger automatically.
1980
+ To fix: install the Vocoder GitHub App on "${repoOwner}" instead.`
1981
+ );
1982
+ }
1983
+ }
1984
+ }
1985
+ const validInstallations = cachedInstallations.filter(
1986
+ (i) => !i.isSuspended && !i.conflictLabel
1987
+ );
1988
+ let selectedInstallationId = null;
1989
+ if (validInstallations.length === 1 && cachedInstallations.length === 1) {
1990
+ selectedInstallationId = validInstallations[0].installationId;
1991
+ } else {
1992
+ selectedInstallationId = await selectGitHubInstallation(
1993
+ cachedInstallations.map((inst) => ({
1994
+ installationId: inst.installationId,
1995
+ accountLogin: inst.accountLogin,
1996
+ accountType: inst.accountType,
1997
+ isSuspended: inst.isSuspended,
1998
+ conflictLabel: inst.conflictLabel
1999
+ })),
2000
+ false
2001
+ );
2002
+ }
2003
+ if (selectedInstallationId === null || selectedInstallationId === "install_new") {
2004
+ p6.cancel(
2005
+ "Setup cancelled. Re-run `vocoder init` and choose Install GitHub App."
2006
+ );
2007
+ return 1;
2008
+ }
2009
+ const claimResult = await api.claimCliGitHubInstallation(
2010
+ userToken,
2011
+ {
2012
+ installationId: String(selectedInstallationId),
2013
+ organizationId: null
2014
+ }
2015
+ );
2016
+ selectedOrganizationId = claimResult.organizationId;
2017
+ selectedOrganizationName = claimResult.organizationName;
2018
+ p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
2019
+ } else if (organizationData.organizations.length === 1 && !organizationData.canCreateOrganization) {
2020
+ const ws = organizationData.organizations[0];
2021
+ selectedOrganizationId = ws.id;
2022
+ selectedOrganizationName = ws.name;
2023
+ p6.log.success(`Workspace: ${chalk5.bold(selectedOrganizationName)}`);
1842
2024
  } else {
1843
- const workspaceResult = await selectWorkspace(workspaceData);
1844
- if (workspaceResult.action === "cancelled") {
1845
- p5.cancel("Setup cancelled.");
2025
+ const organizationResult = await selectOrganization(organizationData);
2026
+ if (organizationResult.action === "cancelled") {
2027
+ p6.cancel("Setup cancelled.");
1846
2028
  return 1;
1847
2029
  }
1848
- if (workspaceResult.action === "use") {
1849
- selectedWorkspaceId = workspaceResult.workspace.id;
1850
- selectedWorkspaceName = workspaceResult.workspace.name;
1851
- p5.log.success(`Workspace: ${chalk5.bold(selectedWorkspaceName)}`);
2030
+ if (organizationResult.action === "use") {
2031
+ selectedOrganizationId = organizationResult.organization.id;
2032
+ selectedOrganizationName = organizationResult.organization.name;
2033
+ p6.log.success(
2034
+ `Workspace: ${chalk5.bold(selectedOrganizationName)}`
2035
+ );
1852
2036
  } else {
1853
- const connectChoice = await p5.select({
2037
+ const connectChoice = await p6.select({
1854
2038
  message: "Connect your new workspace to GitHub",
1855
2039
  options: [
1856
2040
  { value: "install", label: "Install the Vocoder GitHub App" },
1857
2041
  { value: "link", label: "Link an existing installation" }
1858
2042
  ]
1859
2043
  });
1860
- if (p5.isCancel(connectChoice)) {
1861
- p5.cancel("Setup cancelled.");
2044
+ if (p6.isCancel(connectChoice)) {
2045
+ p6.cancel("Setup cancelled.");
1862
2046
  return 1;
1863
2047
  }
1864
2048
  if (connectChoice === "install") {
@@ -1868,15 +2052,15 @@ async function init(options = {}) {
1868
2052
  yes: options.yes
1869
2053
  });
1870
2054
  if (!connectResult) {
1871
- p5.log.error(
2055
+ p6.log.error(
1872
2056
  "GitHub App installation did not complete. Run `vocoder init` again."
1873
2057
  );
1874
2058
  return 1;
1875
2059
  }
1876
- selectedWorkspaceId = connectResult.organizationId;
1877
- selectedWorkspaceName = connectResult.organizationName;
1878
- p5.log.success(
1879
- `Workspace: ${chalk5.bold(selectedWorkspaceName)}`
2060
+ selectedOrganizationId = connectResult.organizationId;
2061
+ selectedOrganizationName = connectResult.organizationName;
2062
+ p6.log.success(
2063
+ `Workspace: ${chalk5.bold(selectedOrganizationName)}`
1880
2064
  );
1881
2065
  } else {
1882
2066
  const installations = await runGitHubDiscoveryFlow({
@@ -1886,21 +2070,21 @@ async function init(options = {}) {
1886
2070
  });
1887
2071
  if (!installations) return 1;
1888
2072
  if (installations.length === 0) {
1889
- p5.log.warn(
2073
+ p6.log.warn(
1890
2074
  "No GitHub installations found. Install the Vocoder GitHub App first."
1891
2075
  );
1892
- const installNow = await p5.confirm({
2076
+ const installNow = await p6.confirm({
1893
2077
  message: "Open GitHub to install the App?"
1894
2078
  });
1895
- if (p5.isCancel(installNow) || !installNow) return 1;
2079
+ if (p6.isCancel(installNow) || !installNow) return 1;
1896
2080
  const connectResult = await runGitHubInstallFlow({
1897
2081
  api,
1898
2082
  userToken,
1899
2083
  yes: options.yes
1900
2084
  });
1901
2085
  if (!connectResult) return 1;
1902
- selectedWorkspaceId = connectResult.organizationId;
1903
- selectedWorkspaceName = connectResult.organizationName;
2086
+ selectedOrganizationId = connectResult.organizationId;
2087
+ selectedOrganizationName = connectResult.organizationName;
1904
2088
  } else {
1905
2089
  const selectedInstallationId = await selectGitHubInstallation(
1906
2090
  installations.map((inst) => ({
@@ -1913,7 +2097,7 @@ async function init(options = {}) {
1913
2097
  true
1914
2098
  );
1915
2099
  if (selectedInstallationId === null) {
1916
- p5.cancel("Setup cancelled.");
2100
+ p6.cancel("Setup cancelled.");
1917
2101
  return 1;
1918
2102
  }
1919
2103
  if (selectedInstallationId === "install_new") {
@@ -1923,8 +2107,8 @@ async function init(options = {}) {
1923
2107
  yes: options.yes
1924
2108
  });
1925
2109
  if (!connectResult) return 1;
1926
- selectedWorkspaceId = connectResult.organizationId;
1927
- selectedWorkspaceName = connectResult.organizationName;
2110
+ selectedOrganizationId = connectResult.organizationId;
2111
+ selectedOrganizationName = connectResult.organizationName;
1928
2112
  } else {
1929
2113
  const claimResult = await api.claimCliGitHubInstallation(
1930
2114
  userToken,
@@ -1933,12 +2117,12 @@ async function init(options = {}) {
1933
2117
  organizationId: null
1934
2118
  }
1935
2119
  );
1936
- selectedWorkspaceId = claimResult.organizationId;
1937
- selectedWorkspaceName = claimResult.organizationName;
2120
+ selectedOrganizationId = claimResult.organizationId;
2121
+ selectedOrganizationName = claimResult.organizationName;
1938
2122
  }
1939
2123
  }
1940
- p5.log.success(
1941
- `Workspace: ${chalk5.bold(selectedWorkspaceName)}`
2124
+ p6.log.success(
2125
+ `Workspace: ${chalk5.bold(selectedOrganizationName)}`
1942
2126
  );
1943
2127
  }
1944
2128
  }
@@ -1947,116 +2131,85 @@ async function init(options = {}) {
1947
2131
  }
1948
2132
  }
1949
2133
  if (repoProjectId && repoProjectName && existingAppsForRepo.length > 0) {
1950
- p5.log.info(
1951
- `${chalk5.bold(repoProjectName)} is already set up for this repo.
1952
- Configured apps: ${existingAppsForRepo.map((a) => highlight(a.appDir || "(entire repo)")).join(", ")}`
1953
- );
1954
2134
  const appResult = await runAppCreate({
1955
2135
  api,
1956
2136
  userToken,
1957
2137
  projectId: repoProjectId,
1958
2138
  projectName: repoProjectName,
1959
- organizationName: selectedWorkspaceName,
2139
+ organizationName: selectedOrganizationName,
1960
2140
  repoCanonical: identity?.repoCanonical,
1961
- defaultAppDir: identity?.repoAppDir,
1962
2141
  existingApps: existingAppsForRepo
1963
2142
  });
1964
2143
  if (!appResult) {
1965
- p5.log.error("App setup failed. Run `vocoder init` again.");
2144
+ p6.log.error("App setup failed. Run `vocoder init` again.");
1966
2145
  return 1;
1967
2146
  }
2147
+ const detection2 = detectLocalEcosystem();
1968
2148
  runScaffold({
1969
2149
  sourceLocale: appResult.sourceLocale,
1970
- targetBranches: appResult.targetBranches,
1971
- appDir: identity?.repoAppDir
2150
+ targetBranches: appResult.targetBranches
1972
2151
  });
1973
- p5.outro("You're all set.");
2152
+ writeAppConfigs(
2153
+ [{ appDir: appResult.appDir, appId: appResult.appId }],
2154
+ appResult.targetBranches,
2155
+ detection2.isTypeScript,
2156
+ identity?.repoRoot
2157
+ );
2158
+ p6.log.info(
2159
+ chalk5.dim("Use the VOCODER_API_KEY already in your root .env")
2160
+ );
2161
+ p6.outro("You're all set.");
1974
2162
  return 0;
1975
2163
  }
2164
+ let remainingApps;
1976
2165
  try {
1977
- const wsCheck = await api.listWorkspaces(userToken);
1978
- const ws = wsCheck.workspaces.find((w) => w.id === selectedWorkspaceId);
1979
- if (ws && ws.maxProjects !== -1 && ws.projectCount >= ws.maxProjects) {
1980
- p5.log.warn(
1981
- `Project limit reached \u2014 ${ws.projectCount}/${ws.maxProjects} on your ${chalk5.bold(ws.planId)} plan.`
1982
- );
1983
- const options2 = [];
1984
- if (repoProjectId) {
1985
- options2.push({
1986
- value: "connect",
1987
- label: `Reconnect this repo to ${chalk5.bold(repoProjectName ?? "existing project")}`
2166
+ const wsCheck = await api.listOrganizations(userToken);
2167
+ const ws = wsCheck.organizations.find(
2168
+ (w) => w.id === selectedOrganizationId
2169
+ );
2170
+ if (ws) {
2171
+ if (ws.maxApps !== -1 && ws.appCount >= ws.maxApps) {
2172
+ p6.log.warn(
2173
+ `App limit reached \u2014 ${ws.appCount}/${ws.maxApps} on your ${chalk5.bold(ws.planId)} plan.`
2174
+ );
2175
+ const limitAction = await p6.select({
2176
+ message: "What would you like to do?",
2177
+ options: [
2178
+ { value: "upgrade", label: "Upgrade plan" },
2179
+ { value: "cancel", label: "Cancel" }
2180
+ ]
1988
2181
  });
1989
- }
1990
- options2.push({ value: "upgrade", label: "Upgrade plan" });
1991
- options2.push({ value: "cancel", label: "Cancel" });
1992
- const limitAction = await p5.select({
1993
- message: "What would you like to do?",
1994
- options: options2
1995
- });
1996
- if (p5.isCancel(limitAction) || limitAction === "cancel") {
1997
- p5.cancel("Setup cancelled.");
1998
- return 1;
1999
- }
2000
- if (limitAction === "upgrade") {
2182
+ if (p6.isCancel(limitAction) || limitAction === "cancel") {
2183
+ p6.cancel("Setup cancelled.");
2184
+ return 1;
2185
+ }
2001
2186
  await tryOpenBrowser2(`${apiUrl}${SUBSCRIPTION_SETTINGS_PATH}`);
2002
- p5.cancel(
2187
+ p6.cancel(
2003
2188
  "Upgrade your plan in the browser, then re-run `vocoder init`."
2004
2189
  );
2005
2190
  return 1;
2006
2191
  }
2007
- const existingProjects = await api.listApps(
2008
- userToken,
2009
- selectedWorkspaceId
2010
- );
2011
- const chosenProject = existingProjects.find(
2012
- (proj) => proj.id === repoProjectId
2013
- );
2014
- if (!chosenProject) {
2015
- p5.log.error("Could not find the project. Try again.");
2016
- return 1;
2017
- }
2018
- try {
2019
- const appResult = await api.createApp(userToken, {
2020
- projectId: chosenProject.id,
2021
- appDir: identity?.repoAppDir ?? "",
2022
- sourceLocale: chosenProject.sourceLocale,
2023
- targetLocales: chosenProject.targetLocales,
2024
- targetBranches: chosenProject.targetBranches,
2025
- repoCanonical: identity?.repoCanonical ?? ""
2026
- });
2027
- p5.log.success(`Connected to project: ${chalk5.bold(chosenProject.name)}`);
2028
- printApiKey(appResult.apiKey);
2029
- runScaffold({
2030
- sourceLocale: chosenProject.sourceLocale,
2031
- targetBranches: chosenProject.targetBranches,
2032
- appDir: identity?.repoAppDir
2033
- });
2034
- } catch (err) {
2035
- const msg = err instanceof Error ? err.message : String(err);
2036
- p5.log.error(`Failed to create app binding: ${msg}`);
2037
- return 1;
2038
- }
2039
- p5.outro("You're all set.");
2040
- return 0;
2192
+ remainingApps = ws.maxApps === -1 ? void 0 : Math.max(0, ws.maxApps - ws.appCount);
2041
2193
  }
2042
2194
  } catch {
2043
2195
  }
2044
2196
  const projectResult = await runProjectCreate({
2045
2197
  api,
2046
2198
  userToken,
2047
- organizationId: selectedWorkspaceId,
2199
+ organizationId: selectedOrganizationId,
2048
2200
  defaultName: identity?.repoCanonical ? identity.repoCanonical.split("/").pop() : void 0,
2049
2201
  defaultSourceLocale: "en",
2050
2202
  repoCanonical: identity?.repoCanonical,
2203
+ repoRoot: identity?.repoRoot,
2051
2204
  defaultBranches: ["main"],
2052
- defaultAppDir: identity?.repoAppDir
2205
+ maxAppDirs: remainingApps
2053
2206
  });
2054
2207
  if (!projectResult) {
2055
- p5.log.error("Project creation failed. Run `vocoder init` again.");
2208
+ p6.log.error("Project creation failed. Run `vocoder init` again.");
2056
2209
  return 1;
2057
2210
  }
2058
2211
  if (!projectResult.repositoryBound && identity?.repoCanonical) {
2059
- p5.log.warn(
2212
+ p6.log.warn(
2060
2213
  `This repository isn't accessible to your GitHub App installation.
2061
2214
  Translations won't run automatically until you grant access.
2062
2215
 
@@ -2067,13 +2220,19 @@ Translations won't run automatically until you grant access.
2067
2220
  ` : "")
2068
2221
  );
2069
2222
  }
2223
+ const detection = detectLocalEcosystem();
2070
2224
  runScaffold({
2071
2225
  sourceLocale: projectResult.sourceLocale,
2072
- targetBranches: projectResult.targetBranches,
2073
- appDir: identity?.repoAppDir
2226
+ targetBranches: projectResult.targetBranches
2074
2227
  });
2075
- printApiKey(projectResult.apiKey);
2076
- p5.outro("You're all set.");
2228
+ writeAppConfigs(
2229
+ projectResult.apps,
2230
+ projectResult.targetBranches,
2231
+ detection.isTypeScript,
2232
+ identity?.repoRoot
2233
+ );
2234
+ printApiKey(projectResult.apiKey, identity?.repoRoot);
2235
+ p6.outro("You're all set.");
2077
2236
  return 0;
2078
2237
  } catch (error) {
2079
2238
  if (error instanceof Error) {
@@ -2081,24 +2240,25 @@ Translations won't run automatically until you grant access.
2081
2240
  printPlanLimitMessage(apiUrl, error.message);
2082
2241
  return 1;
2083
2242
  }
2084
- p5.log.error(`Error: ${error.message}`);
2243
+ p6.log.error(`Error: ${error.message}`);
2085
2244
  } else {
2086
- p5.log.error("Unknown setup error");
2245
+ p6.log.error("Unknown setup error");
2087
2246
  }
2088
2247
  return 1;
2089
2248
  }
2090
2249
  }
2091
2250
 
2092
2251
  // src/commands/locales.ts
2093
- import * as p8 from "@clack/prompts";
2252
+ import * as p9 from "@clack/prompts";
2094
2253
  import chalk7 from "chalk";
2095
2254
  import { config as loadEnv3 } from "dotenv";
2255
+ import { readFileSync as readFileSync3 } from "fs";
2096
2256
 
2097
2257
  // src/commands/sync.ts
2098
- import { createHash, randomUUID } from "crypto";
2099
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2258
+ import { randomUUID } from "crypto";
2259
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2100
2260
  import { join as join3 } from "path";
2101
- import * as p7 from "@clack/prompts";
2261
+ import * as p8 from "@clack/prompts";
2102
2262
  import chalk6 from "chalk";
2103
2263
 
2104
2264
  // src/utils/branch.ts
@@ -2168,9 +2328,12 @@ function matchBranchPattern(branch, pattern) {
2168
2328
  }
2169
2329
 
2170
2330
  // src/utils/config.ts
2171
- import * as p6 from "@clack/prompts";
2331
+ import * as p7 from "@clack/prompts";
2172
2332
  import { config as loadEnv2 } from "dotenv";
2173
2333
  loadEnv2();
2334
+ function extractShortCodeFromApiKey(apiKey) {
2335
+ return apiKey.slice(4, 14);
2336
+ }
2174
2337
  function validateLocalConfig(config) {
2175
2338
  if (!config.apiKey || config.apiKey.length === 0) {
2176
2339
  throw new Error("VOCODER_API_KEY is required. Set it in your .env file.");
@@ -2221,7 +2384,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2221
2384
  };
2222
2385
  const fileConfig = loadVocoderConfig(process.cwd());
2223
2386
  if (!fileConfig) {
2224
- p6.log.warn(
2387
+ p7.log.warn(
2225
2388
  `No ${highlight("vocoder.config.ts")} found \u2014 run ${highlight("npx @vocoder/cli init")} to generate one.`
2226
2389
  );
2227
2390
  }
@@ -2252,7 +2415,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2252
2415
  excludePattern = fileConfig.exclude;
2253
2416
  configSources.excludePattern = "vocoder.config";
2254
2417
  } else if (envExcludePattern) {
2255
- excludePattern = envExcludePattern.split(",").map((p14) => p14.trim()).filter(Boolean);
2418
+ excludePattern = envExcludePattern.split(",").map((p15) => p15.trim()).filter(Boolean);
2256
2419
  configSources.excludePattern = "environment";
2257
2420
  } else {
2258
2421
  excludePattern = defaults.excludePattern;
@@ -2309,7 +2472,7 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2309
2472
  ...maxWaitMs ? [`Max wait: ${highlight(String(configSources.maxWaitMs))}`] : [],
2310
2473
  `No fallback: ${highlight(String(configSources.noFallback))}`
2311
2474
  ];
2312
- p6.note(lines.join("\n"), "Configuration sources");
2475
+ p7.note(lines.join("\n"), "Configuration sources");
2313
2476
  }
2314
2477
  return {
2315
2478
  includePattern,
@@ -2324,10 +2487,6 @@ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
2324
2487
  }
2325
2488
 
2326
2489
  // src/commands/sync.ts
2327
- function computeFingerprint(shortCode, texts) {
2328
- const sorted = [...texts].sort();
2329
- return createHash("sha256").update(`${shortCode}:${sorted.join("\0")}`).digest("hex").slice(0, 12);
2330
- }
2331
2490
  function isRecord(value) {
2332
2491
  return typeof value === "object" && value !== null && !Array.isArray(value);
2333
2492
  }
@@ -2375,15 +2534,6 @@ function getCacheFilePath(projectRoot, fingerprint) {
2375
2534
  return join3(projectRoot, "node_modules", ".vocoder", "cache", `${fingerprint}.json`);
2376
2535
  }
2377
2536
  function buildTranslationData(params) {
2378
- const textToHash = new Map(params.stringEntries.map((e) => [e.text, e.key]));
2379
- const hashKeyed = {};
2380
- for (const [locale, localeMap] of Object.entries(params.translations)) {
2381
- hashKeyed[locale] = {};
2382
- for (const [text, translation] of Object.entries(localeMap)) {
2383
- const hash = textToHash.get(text);
2384
- if (hash) hashKeyed[locale][hash] = translation;
2385
- }
2386
- }
2387
2537
  const locales = {};
2388
2538
  for (const code of [params.sourceLocale, ...params.targetLocales]) {
2389
2539
  const meta = params.localeMetadata?.[code];
@@ -2391,13 +2541,13 @@ function buildTranslationData(params) {
2391
2541
  }
2392
2542
  return {
2393
2543
  config: { sourceLocale: params.sourceLocale, targetLocales: params.targetLocales, locales },
2394
- translations: hashKeyed,
2544
+ translations: params.translations,
2395
2545
  updatedAt: params.updatedAt
2396
2546
  };
2397
2547
  }
2398
2548
  function readLocalCache(params) {
2399
2549
  const cacheFilePath = getCacheFilePath(params.projectRoot, params.fingerprint);
2400
- if (!existsSync3(cacheFilePath)) return null;
2550
+ if (!existsSync4(cacheFilePath)) return null;
2401
2551
  try {
2402
2552
  const raw = readFileSync2(cacheFilePath, "utf-8");
2403
2553
  const parsed = JSON.parse(raw);
@@ -2456,9 +2606,10 @@ function normalizeTranslations(params) {
2456
2606
  if (!merged[params.sourceLocale]) {
2457
2607
  merged[params.sourceLocale] = {};
2458
2608
  }
2459
- for (const sourceText of params.sourceStrings) {
2460
- if (!(sourceText in merged[params.sourceLocale])) {
2461
- merged[params.sourceLocale][sourceText] = sourceText;
2609
+ for (const entry of params.stringEntries) {
2610
+ if (!entry.text) continue;
2611
+ if (!(entry.key in merged[params.sourceLocale])) {
2612
+ merged[params.sourceLocale][entry.key] = entry.text;
2462
2613
  }
2463
2614
  }
2464
2615
  return merged;
@@ -2534,11 +2685,11 @@ function mergeContext(current, incoming) {
2534
2685
  return Array.from(merged).join(" | ");
2535
2686
  }
2536
2687
  function buildStringEntries(extractedStrings) {
2537
- const byText = /* @__PURE__ */ new Map();
2688
+ const byKey = /* @__PURE__ */ new Map();
2538
2689
  for (const str of extractedStrings) {
2539
- const existing = byText.get(str.text);
2690
+ const existing = byKey.get(str.key);
2540
2691
  if (!existing) {
2541
- byText.set(str.text, {
2692
+ byKey.set(str.key, {
2542
2693
  key: str.key,
2543
2694
  text: str.text,
2544
2695
  ...str.context ? { context: str.context } : {},
@@ -2553,11 +2704,8 @@ function buildStringEntries(extractedStrings) {
2553
2704
  } else if (existing.formality && str.formality && existing.formality !== str.formality) {
2554
2705
  existing.formality = "auto";
2555
2706
  }
2556
- if (str.key < existing.key) {
2557
- existing.key = str.key;
2558
- }
2559
2707
  }
2560
- return Array.from(byText.values());
2708
+ return Array.from(byKey.values());
2561
2709
  }
2562
2710
  async function fetchApiSnapshot(api, params) {
2563
2711
  const snapshot = await api.getTranslationSnapshot({
@@ -2578,19 +2726,19 @@ async function fetchApiSnapshot(api, params) {
2578
2726
  async function sync(options = {}) {
2579
2727
  const startTime = Date.now();
2580
2728
  const projectRoot = process.cwd();
2581
- p7.intro(chalk6.bold("Vocoder Sync"));
2729
+ p8.intro(chalk6.bold("Vocoder Sync"));
2582
2730
  const mergedConfig = await getMergedConfig(options, options.verbose);
2583
2731
  if (!mergedConfig.apiKey) {
2584
- p7.log.warn("No API key found. Run init to get started:");
2585
- p7.log.info(" npx @vocoder/cli init");
2586
- p7.log.info("");
2587
- p7.log.info(
2732
+ p8.log.warn("No API key found. Run init to get started:");
2733
+ p8.log.info(" npx @vocoder/cli init");
2734
+ p8.log.info("");
2735
+ p8.log.info(
2588
2736
  " Or add your key to .env: VOCODER_API_KEY=vca_..."
2589
2737
  );
2590
- p7.outro("Run `npx @vocoder/cli init` to set up your project.");
2738
+ p8.outro("Run `npx @vocoder/cli init` to set up your project.");
2591
2739
  return 1;
2592
2740
  }
2593
- const spinner7 = p7.spinner();
2741
+ const spinner7 = p8.spinner();
2594
2742
  try {
2595
2743
  const branch = detectBranch(options.branch);
2596
2744
  spinner7.start("Loading project configuration");
@@ -2619,12 +2767,12 @@ async function sync(options = {}) {
2619
2767
  };
2620
2768
  spinner7.stop(`Branch: ${highlight(branch)}`);
2621
2769
  if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
2622
- p7.log.warn(
2770
+ p8.log.warn(
2623
2771
  `Skipping translations (${highlight(branch)} is not a target branch)`
2624
2772
  );
2625
- p7.log.info(`Target branches: ${config.targetBranches.map((b) => highlight(b)).join(", ")}`);
2626
- p7.log.info("Use --force to translate anyway");
2627
- p7.outro("");
2773
+ p8.log.info(`Target branches: ${config.targetBranches.map((b) => highlight(b)).join(", ")}`);
2774
+ p8.log.info("Use --force to translate anyway");
2775
+ p8.outro("");
2628
2776
  return 0;
2629
2777
  }
2630
2778
  const patternsDisplay = Array.isArray(config.includePattern) ? config.includePattern.join(", ") : config.includePattern;
@@ -2637,10 +2785,10 @@ async function sync(options = {}) {
2637
2785
  );
2638
2786
  if (extractedStrings.length === 0) {
2639
2787
  spinner7.stop("No translatable strings found");
2640
- p7.log.warn(
2788
+ p8.log.warn(
2641
2789
  "Make sure you are wrapping translatable strings with Vocoder"
2642
2790
  );
2643
- p7.outro("");
2791
+ p8.outro("");
2644
2792
  return 0;
2645
2793
  }
2646
2794
  spinner7.stop(
@@ -2651,10 +2799,10 @@ async function sync(options = {}) {
2651
2799
  if (extractedStrings.length > 5) {
2652
2800
  sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
2653
2801
  }
2654
- p7.note(sampleLines.join("\n"), "Sample strings");
2802
+ p8.note(sampleLines.join("\n"), "Sample strings");
2655
2803
  }
2656
2804
  if (options.dryRun) {
2657
- p7.note(
2805
+ p8.note(
2658
2806
  [
2659
2807
  `Strings: ${extractedStrings.length}`,
2660
2808
  `Branch: ${branch}`,
@@ -2665,36 +2813,36 @@ async function sync(options = {}) {
2665
2813
  ].join("\n"),
2666
2814
  "Dry run - would translate"
2667
2815
  );
2668
- p7.outro("No API calls made.");
2816
+ p8.outro("No API calls made.");
2669
2817
  return 0;
2670
2818
  }
2671
2819
  const repoIdentity = resolveGitRepositoryIdentity();
2672
2820
  if (!repoIdentity && options.verbose) {
2673
- p7.log.warn(
2821
+ p8.log.warn(
2674
2822
  "Could not detect git remote origin. Sync will continue without repo metadata."
2675
2823
  );
2676
2824
  }
2677
2825
  const commitSha = detectCommitSha() ?? void 0;
2678
2826
  const stringEntries = buildStringEntries(extractedStrings);
2679
- const sourceStrings = stringEntries.map((entry) => entry.text);
2680
2827
  if (options.verbose && stringEntries.length !== extractedStrings.length) {
2681
- p7.log.info(
2682
- `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
2828
+ p8.log.info(
2829
+ `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique strings`
2683
2830
  );
2684
2831
  }
2685
- const fingerprint = computeFingerprint(config.shortCode, sourceStrings);
2832
+ const sourceKeys = stringEntries.map((entry) => entry.key);
2833
+ const fingerprint = computeFingerprint(extractShortCodeFromApiKey(localConfig.apiKey), sourceKeys);
2686
2834
  if (!options.force) {
2687
2835
  const cacheFile = getCacheFilePath(projectRoot, fingerprint);
2688
- if (existsSync3(cacheFile)) {
2836
+ if (existsSync4(cacheFile)) {
2689
2837
  if (options.verbose) {
2690
- p7.log.info(`Cache hit: ${chalk6.dim(cacheFile)} (fingerprint ${highlight(fingerprint)})`);
2838
+ p8.log.info(`Cache hit: ${chalk6.dim(cacheFile)} (fingerprint ${highlight(fingerprint)})`);
2691
2839
  }
2692
2840
  const duration2 = ((Date.now() - startTime) / 1e3).toFixed(1);
2693
- p7.outro(`Up to date (${duration2}s)`);
2841
+ p8.outro(`Up to date (${duration2}s)`);
2694
2842
  return 0;
2695
2843
  }
2696
2844
  if (options.verbose) {
2697
- p7.log.info(`No cache for fingerprint ${highlight(fingerprint)} \u2014 will submit to API`);
2845
+ p8.log.info(`No cache for fingerprint ${highlight(fingerprint)} \u2014 will submit to API`);
2698
2846
  }
2699
2847
  }
2700
2848
  spinner7.start("Submitting strings to Vocoder API");
@@ -2719,26 +2867,26 @@ async function sync(options = {}) {
2719
2867
  policy: config.syncPolicy
2720
2868
  });
2721
2869
  if (options.verbose) {
2722
- p7.log.info(`Batch: ${chalk6.dim(batchResponse.batchId)}`);
2723
- p7.log.info(`Requested mode: ${requestedMode}`);
2724
- p7.log.info(`Effective mode: ${effectiveMode}`);
2725
- p7.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2870
+ p8.log.info(`Batch: ${chalk6.dim(batchResponse.batchId)}`);
2871
+ p8.log.info(`Requested mode: ${requestedMode}`);
2872
+ p8.log.info(`Effective mode: ${effectiveMode}`);
2873
+ p8.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
2726
2874
  if (batchResponse.queueStatus) {
2727
- p7.log.info(`Queue status: ${batchResponse.queueStatus}`);
2875
+ p8.log.info(`Queue status: ${batchResponse.queueStatus}`);
2728
2876
  }
2729
2877
  }
2730
2878
  if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
2731
- p7.log.success(`Up to date \u2014 ${highlight(batchResponse.totalStrings)} strings, no changes`);
2879
+ p8.log.success(`Up to date \u2014 ${highlight(batchResponse.totalStrings)} strings, no changes`);
2732
2880
  } else if (batchResponse.newStrings === 0) {
2733
2881
  const archivedNote = batchResponse.deletedStrings && batchResponse.deletedStrings > 0 ? `, ${chalk6.yellow(batchResponse.deletedStrings)} archived` : "";
2734
- p7.log.success(`No new strings \u2014 ${highlight(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2882
+ p8.log.success(`No new strings \u2014 ${highlight(batchResponse.totalStrings)} total${archivedNote}, using existing translations`);
2735
2883
  } else {
2736
2884
  const statParts = [`${highlight(batchResponse.newStrings)} new, ${highlight(batchResponse.totalStrings)} total`];
2737
2885
  if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
2738
2886
  statParts.push(`${chalk6.yellow(batchResponse.deletedStrings)} archived`);
2739
2887
  }
2740
2888
  const estTime = batchResponse.estimatedTime ? ` (~${batchResponse.estimatedTime}s)` : "";
2741
- p7.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.map((l) => highlight(l)).join(", ")}${estTime}`);
2889
+ p8.log.info(`${statParts.join(", ")} \u2192 syncing to ${config.targetLocales.map((l) => highlight(l)).join(", ")}${estTime}`);
2742
2890
  }
2743
2891
  let artifacts = null;
2744
2892
  if (batchResponse.translations) {
@@ -2776,7 +2924,7 @@ async function sync(options = {}) {
2776
2924
  if (effectiveMode === "required") {
2777
2925
  throw waitError;
2778
2926
  }
2779
- p7.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2927
+ p8.log.warn(`Best-effort wait ended early: ${waitError.message}`);
2780
2928
  }
2781
2929
  }
2782
2930
  if (!artifacts) {
@@ -2809,7 +2957,7 @@ async function sync(options = {}) {
2809
2957
  spinner7.stop("Failed to fetch API snapshot");
2810
2958
  if (options.verbose) {
2811
2959
  const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
2812
- p7.log.warn(`Snapshot fetch error: ${message}`);
2960
+ p8.log.warn(`Snapshot fetch error: ${message}`);
2813
2961
  }
2814
2962
  }
2815
2963
  }
@@ -2827,72 +2975,71 @@ async function sync(options = {}) {
2827
2975
  const finalTranslations = normalizeTranslations({
2828
2976
  sourceLocale: config.sourceLocale,
2829
2977
  targetLocales: config.targetLocales,
2830
- sourceStrings,
2978
+ stringEntries,
2831
2979
  translations: artifacts.translations
2832
2980
  });
2833
2981
  try {
2834
2982
  const data = buildTranslationData({
2835
2983
  sourceLocale: config.sourceLocale,
2836
2984
  targetLocales: config.targetLocales,
2837
- stringEntries,
2838
2985
  translations: finalTranslations,
2839
2986
  localeMetadata: artifacts.localeMetadata,
2840
2987
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2841
2988
  });
2842
2989
  const cachePath = writeCache({ projectRoot, fingerprint, data });
2843
2990
  if (options.verbose) {
2844
- p7.log.info(`Cache written: ${highlight(cachePath)}`);
2991
+ p8.log.info(`Cache written: ${highlight(cachePath)}`);
2845
2992
  }
2846
2993
  } catch (error) {
2847
2994
  if (options.verbose) {
2848
2995
  const message = error instanceof Error ? error.message : "Unknown cache write error";
2849
- p7.log.warn(`Failed to write cache: ${message}`);
2996
+ p8.log.warn(`Failed to write cache: ${message}`);
2850
2997
  }
2851
2998
  }
2852
2999
  if (artifacts.source !== "fresh") {
2853
3000
  const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
2854
- p7.log.warn(
3001
+ p8.log.warn(
2855
3002
  `Using ${sourceLabel}. New strings may appear after the background sync completes.`
2856
3003
  );
2857
3004
  }
2858
3005
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
2859
- p7.outro(`Sync complete! (${duration}s)`);
3006
+ p8.outro(`Sync complete! (${duration}s)`);
2860
3007
  return 0;
2861
3008
  } catch (error) {
2862
3009
  spinner7.stop();
2863
3010
  if (error instanceof VocoderAPIError && error.syncPolicyError) {
2864
- p7.log.error(error.syncPolicyError.message);
3011
+ p8.log.error(error.syncPolicyError.message);
2865
3012
  const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
2866
3013
  for (const line of guidance) {
2867
- p7.log.info(line);
3014
+ p8.log.info(line);
2868
3015
  }
2869
3016
  return 1;
2870
3017
  }
2871
3018
  if (error instanceof VocoderAPIError && error.limitError) {
2872
3019
  const { limitError } = error;
2873
- p7.log.error(limitError.message);
3020
+ p8.log.error(limitError.message);
2874
3021
  const guidance = getLimitErrorGuidance(limitError);
2875
3022
  for (const line of guidance) {
2876
- p7.log.info(line);
3023
+ p8.log.info(line);
2877
3024
  }
2878
3025
  return 1;
2879
3026
  }
2880
3027
  if (error instanceof Error) {
2881
- p7.log.error(error.message);
3028
+ p8.log.error(error.message);
2882
3029
  const isInvalidKey = error.message.toLowerCase().includes("invalid api key") || error instanceof VocoderAPIError && error.status === 401;
2883
3030
  if (isInvalidKey) {
2884
- p7.log.warn(
3031
+ p8.log.warn(
2885
3032
  "API key rejected \u2014 the project may have been deleted or the key revoked."
2886
3033
  );
2887
- p7.log.info(
3034
+ p8.log.info(
2888
3035
  " Run `npx @vocoder/cli init` to create a new project and key."
2889
3036
  );
2890
3037
  } else if (error.message.includes("git branch")) {
2891
- p7.log.warn("Run from a git repository, or use:");
2892
- p7.log.info(" vocoder sync --branch main");
3038
+ p8.log.warn("Run from a git repository, or use:");
3039
+ p8.log.info(" vocoder sync --branch main");
2893
3040
  }
2894
3041
  if (options.verbose) {
2895
- p7.log.info(`Full error: ${error.stack ?? error}`);
3042
+ p8.log.info(`Full error: ${error.stack ?? error}`);
2896
3043
  }
2897
3044
  }
2898
3045
  return 1;
@@ -2901,10 +3048,21 @@ async function sync(options = {}) {
2901
3048
 
2902
3049
  // src/commands/locales.ts
2903
3050
  loadEnv3();
3051
+ function readLocalAppId() {
3052
+ const configPath = findExistingConfig(process.cwd());
3053
+ if (!configPath) return void 0;
3054
+ try {
3055
+ const content = readFileSync3(configPath, "utf-8");
3056
+ const match = content.match(/appId:\s*['"]([^'"]+)['"]/);
3057
+ return match?.[1];
3058
+ } catch {
3059
+ return void 0;
3060
+ }
3061
+ }
2904
3062
  function getApiConfig(options) {
2905
3063
  const apiKey = process.env.VOCODER_API_KEY;
2906
3064
  if (!apiKey) {
2907
- p8.log.error(
3065
+ p9.log.error(
2908
3066
  "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
2909
3067
  );
2910
3068
  return null;
@@ -2920,19 +3078,19 @@ async function listProjectLocales(options = {}) {
2920
3078
  const api = new VocoderAPI(config);
2921
3079
  try {
2922
3080
  const projectConfig = await api.getAppConfig();
2923
- p8.log.info(
3081
+ p9.log.info(
2924
3082
  `Source locale: ${highlight(projectConfig.sourceLocale)}`
2925
3083
  );
2926
3084
  if (projectConfig.targetLocales.length === 0) {
2927
- p8.log.info("Target locales: (none configured)");
3085
+ p9.log.info("Target locales: (none configured)");
2928
3086
  } else {
2929
- p8.log.info(
3087
+ p9.log.info(
2930
3088
  `Target locales: ${projectConfig.targetLocales.map((l) => highlight(l)).join(", ")}`
2931
3089
  );
2932
3090
  }
2933
3091
  return 0;
2934
3092
  } catch (error) {
2935
- p8.log.error(
3093
+ p9.log.error(
2936
3094
  error instanceof Error ? error.message : "Failed to fetch project locales."
2937
3095
  );
2938
3096
  return 1;
@@ -2940,19 +3098,20 @@ async function listProjectLocales(options = {}) {
2940
3098
  }
2941
3099
  async function addLocales(locales, options = {}) {
2942
3100
  if (locales.length === 0) {
2943
- p8.log.error("No locale codes provided.");
3101
+ p9.log.error("No locale codes provided.");
2944
3102
  return 1;
2945
3103
  }
2946
3104
  const config = getApiConfig(options);
2947
3105
  if (!config) return 1;
2948
3106
  const api = new VocoderAPI(config);
3107
+ const appId = readLocalAppId();
2949
3108
  let lastTargetLocales = [];
2950
3109
  let hadError = false;
2951
3110
  for (const locale of locales) {
2952
- const spinner7 = p8.spinner();
3111
+ const spinner7 = p9.spinner();
2953
3112
  spinner7.start(`Adding ${locale}\u2026`);
2954
3113
  try {
2955
- const result = await api.addLocale(locale);
3114
+ const result = await api.addLocale(locale, void 0, appId);
2956
3115
  lastTargetLocales = result.targetLocales;
2957
3116
  spinner7.stop(`Added ${highlight(locale)}`);
2958
3117
  } catch (error) {
@@ -2960,19 +3119,19 @@ async function addLocales(locales, options = {}) {
2960
3119
  hadError = true;
2961
3120
  if (error instanceof VocoderAPIError && error.limitError) {
2962
3121
  const { limitError } = error;
2963
- p8.log.error(limitError.message);
3122
+ p9.log.error(limitError.message);
2964
3123
  for (const line of getLimitErrorGuidance(limitError)) {
2965
- p8.log.info(line);
3124
+ p9.log.info(line);
2966
3125
  }
2967
3126
  break;
2968
3127
  }
2969
- p8.log.error(
3128
+ p9.log.error(
2970
3129
  error instanceof Error ? error.message : "Unknown error"
2971
3130
  );
2972
3131
  }
2973
3132
  }
2974
3133
  if (lastTargetLocales.length > 0) {
2975
- p8.log.info(
3134
+ p9.log.info(
2976
3135
  `Target locales now: ${lastTargetLocales.map((l) => highlight(l)).join(", ")}`
2977
3136
  );
2978
3137
  }
@@ -2980,35 +3139,36 @@ async function addLocales(locales, options = {}) {
2980
3139
  }
2981
3140
  async function removeLocales(locales, options = {}) {
2982
3141
  if (locales.length === 0) {
2983
- p8.log.error("No locale codes provided.");
3142
+ p9.log.error("No locale codes provided.");
2984
3143
  return 1;
2985
3144
  }
2986
3145
  const config = getApiConfig(options);
2987
3146
  if (!config) return 1;
2988
3147
  const api = new VocoderAPI(config);
3148
+ const appId = readLocalAppId();
2989
3149
  let lastTargetLocales = [];
2990
3150
  let hadError = false;
2991
3151
  for (const locale of locales) {
2992
- const spinner7 = p8.spinner();
3152
+ const spinner7 = p9.spinner();
2993
3153
  spinner7.start(`Removing ${locale}\u2026`);
2994
3154
  try {
2995
- const result = await api.removeLocale(locale);
3155
+ const result = await api.removeLocale(locale, void 0, appId);
2996
3156
  lastTargetLocales = result.targetLocales;
2997
3157
  spinner7.stop(`Removed ${highlight(locale)}`);
2998
3158
  } catch (error) {
2999
3159
  spinner7.stop(`Failed to remove ${chalk7.red(locale)}`);
3000
3160
  hadError = true;
3001
- p8.log.error(
3161
+ p9.log.error(
3002
3162
  error instanceof Error ? error.message : "Unknown error"
3003
3163
  );
3004
3164
  }
3005
3165
  }
3006
3166
  if (lastTargetLocales.length > 0) {
3007
- p8.log.info(
3167
+ p9.log.info(
3008
3168
  `Target locales now: ${lastTargetLocales.map((l) => highlight(l)).join(", ")}`
3009
3169
  );
3010
3170
  } else if (!hadError) {
3011
- p8.log.info("Target locales now: (none configured)");
3171
+ p9.log.info("Target locales now: (none configured)");
3012
3172
  }
3013
3173
  return hadError ? 1 : 0;
3014
3174
  }
@@ -3018,14 +3178,14 @@ async function listSupportedLocales(options = {}) {
3018
3178
  const api = new VocoderAPI(config);
3019
3179
  try {
3020
3180
  const result = await api.listLocales(config.apiKey);
3021
- p8.log.info(chalk7.bold("Source locales:"));
3181
+ p9.log.info(chalk7.bold("Source locales:"));
3022
3182
  printLocaleTable(result.sourceLocales);
3023
- p8.log.info("");
3024
- p8.log.info(chalk7.bold("Target locales:"));
3183
+ p9.log.info("");
3184
+ p9.log.info(chalk7.bold("Target locales:"));
3025
3185
  printLocaleTable(result.targetLocales);
3026
3186
  return 0;
3027
3187
  } catch (error) {
3028
- p8.log.error(
3188
+ p9.log.error(
3029
3189
  error instanceof Error ? error.message : "Failed to fetch supported locales."
3030
3190
  );
3031
3191
  return 1;
@@ -3034,16 +3194,16 @@ async function listSupportedLocales(options = {}) {
3034
3194
  function printLocaleTable(locales) {
3035
3195
  for (const locale of locales) {
3036
3196
  const native = locale.nativeName && locale.nativeName !== locale.name ? ` (${locale.nativeName})` : "";
3037
- p8.log.info(` ${highlight(locale.code.padEnd(10))} ${locale.name}${native}`);
3197
+ p9.log.info(` ${highlight(locale.code.padEnd(10))} ${locale.name}${native}`);
3038
3198
  }
3039
3199
  }
3040
3200
 
3041
3201
  // src/commands/logout.ts
3042
- import * as p9 from "@clack/prompts";
3202
+ import * as p10 from "@clack/prompts";
3043
3203
  async function logout(options = {}) {
3044
3204
  const stored = readAuthData();
3045
3205
  if (!stored) {
3046
- p9.log.info("Not currently authenticated.");
3206
+ p10.log.info("Not currently authenticated.");
3047
3207
  return 0;
3048
3208
  }
3049
3209
  const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
@@ -3053,19 +3213,19 @@ async function logout(options = {}) {
3053
3213
  } catch {
3054
3214
  }
3055
3215
  clearAuthData();
3056
- p9.log.success(`Logged out (was ${stored.email})`);
3216
+ p10.log.success(`Logged out (was ${stored.email})`);
3057
3217
  return 0;
3058
3218
  }
3059
3219
 
3060
3220
  // src/commands/app-config.ts
3061
- import * as p10 from "@clack/prompts";
3221
+ import * as p11 from "@clack/prompts";
3062
3222
  import chalk8 from "chalk";
3063
3223
  import { config as loadEnv4 } from "dotenv";
3064
3224
  loadEnv4();
3065
3225
  async function appConfig(options = {}) {
3066
3226
  const apiKey = process.env.VOCODER_API_KEY;
3067
3227
  if (!apiKey) {
3068
- p10.log.error(
3228
+ p11.log.error(
3069
3229
  "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3070
3230
  );
3071
3231
  return 1;
@@ -3087,10 +3247,10 @@ async function appConfig(options = {}) {
3087
3247
  ` Non-blocking mode: ${highlight(config.syncPolicy.nonBlockingMode)}`,
3088
3248
  ` Max wait: ${highlight(String(config.syncPolicy.defaultMaxWaitMs))} ms`
3089
3249
  ];
3090
- p10.note(lines.join("\n"), `${config.projectName} \u2014 app config`);
3250
+ p11.note(lines.join("\n"), `${config.projectName} \u2014 app config`);
3091
3251
  return 0;
3092
3252
  } catch (error) {
3093
- p10.log.error(
3253
+ p11.log.error(
3094
3254
  error instanceof Error ? error.message : "Failed to fetch project config."
3095
3255
  );
3096
3256
  return 1;
@@ -3100,13 +3260,13 @@ async function appConfig(options = {}) {
3100
3260
  // src/commands/translations.ts
3101
3261
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
3102
3262
  import { join as join4 } from "path";
3103
- import * as p11 from "@clack/prompts";
3263
+ import * as p12 from "@clack/prompts";
3104
3264
  import { config as loadEnv5 } from "dotenv";
3105
3265
  loadEnv5();
3106
3266
  async function getTranslations(options = {}) {
3107
3267
  const apiKey = process.env.VOCODER_API_KEY;
3108
3268
  if (!apiKey) {
3109
- p11.log.error(
3269
+ p12.log.error(
3110
3270
  "VOCODER_API_KEY is not set. Run `npx @vocoder/cli init` to set up your project."
3111
3271
  );
3112
3272
  return 1;
@@ -3117,25 +3277,25 @@ async function getTranslations(options = {}) {
3117
3277
  try {
3118
3278
  branch = detectBranch(options.branch);
3119
3279
  } catch (error) {
3120
- p11.log.error(
3280
+ p12.log.error(
3121
3281
  error instanceof Error ? error.message : "Failed to detect branch."
3122
3282
  );
3123
3283
  return 1;
3124
3284
  }
3125
- const spinner7 = p11.spinner();
3285
+ const spinner7 = p12.spinner();
3126
3286
  spinner7.start(`Fetching translations for ${highlight(branch)}\u2026`);
3127
3287
  try {
3128
3288
  const projectConfig = await api.getAppConfig();
3129
3289
  const targetLocales = options.locale ? [options.locale] : projectConfig.targetLocales;
3130
3290
  if (targetLocales.length === 0) {
3131
3291
  spinner7.stop("No target locales configured.");
3132
- p11.log.info("Add target locales with `vocoder locales add <code>`.");
3292
+ p12.log.info("Add target locales with `vocoder locales add <code>`.");
3133
3293
  return 1;
3134
3294
  }
3135
3295
  const snapshot = await api.getTranslationSnapshot({ branch, targetLocales });
3136
3296
  spinner7.stop(`Fetched translations for ${highlight(branch)}`);
3137
3297
  if (snapshot.status === "NOT_FOUND") {
3138
- p11.log.warn(
3298
+ p12.log.warn(
3139
3299
  `No translation snapshot found for branch "${branch}". Run \`vocoder sync\` to generate one.`
3140
3300
  );
3141
3301
  return 1;
@@ -3150,7 +3310,7 @@ async function getTranslations(options = {}) {
3150
3310
  return 0;
3151
3311
  } catch (error) {
3152
3312
  spinner7.stop("Failed to fetch translations.");
3153
- p11.log.error(
3313
+ p12.log.error(
3154
3314
  error instanceof Error ? error.message : "Unknown error."
3155
3315
  );
3156
3316
  return 1;
@@ -3161,19 +3321,19 @@ function writeLocaleFiles(translations, outputDir) {
3161
3321
  for (const [locale, strings] of Object.entries(translations)) {
3162
3322
  const filePath = join4(outputDir, `${locale}.json`);
3163
3323
  writeFileSync4(filePath, JSON.stringify(strings, null, 2) + "\n", "utf-8");
3164
- p11.log.success(`Wrote ${highlight(filePath)}`);
3324
+ p12.log.success(`Wrote ${highlight(filePath)}`);
3165
3325
  }
3166
3326
  }
3167
3327
 
3168
3328
  // src/commands/create-app.ts
3169
- import * as p12 from "@clack/prompts";
3329
+ import * as p13 from "@clack/prompts";
3170
3330
  import chalk9 from "chalk";
3171
3331
  import { config as loadEnv6 } from "dotenv";
3172
3332
  loadEnv6();
3173
3333
  async function createApp(options) {
3174
3334
  const authData = readAuthData();
3175
3335
  if (!authData) {
3176
- p12.log.error(
3336
+ p13.log.error(
3177
3337
  "Not logged in. Run `npx @vocoder/cli init` to authenticate first."
3178
3338
  );
3179
3339
  return 1;
@@ -3192,18 +3352,18 @@ async function createApp(options) {
3192
3352
  appDir = identity.repoAppDir;
3193
3353
  }
3194
3354
  } else {
3195
- p12.log.warn(
3355
+ p13.log.warn(
3196
3356
  "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."
3197
3357
  );
3198
3358
  }
3199
3359
  }
3200
3360
  const targetLocales = options.targetLocales ? options.targetLocales.split(",").map((l) => l.trim()).filter(Boolean) : [];
3201
3361
  const targetBranches = options.targetBranches ? options.targetBranches.split(",").map((b) => b.trim()).filter(Boolean) : ["main"];
3202
- const spinner7 = p12.spinner();
3362
+ const spinner7 = p13.spinner();
3203
3363
  spinner7.start(`Creating app "${options.name}"\u2026`);
3204
3364
  try {
3205
3365
  const result = await api.createProject(authData.token, {
3206
- organizationId: options.workspace,
3366
+ organizationId: options.organization,
3207
3367
  name: options.name,
3208
3368
  sourceLocale: options.sourceLocale,
3209
3369
  targetLocales,
@@ -3222,9 +3382,9 @@ async function createApp(options) {
3222
3382
  `Add this to your .env file:`,
3223
3383
  ` ${chalk9.bold("VOCODER_API_KEY")}=${highlight(result.apiKey)}`
3224
3384
  ];
3225
- p12.note(lines.join("\n"), "Project created");
3385
+ p13.note(lines.join("\n"), "Project created");
3226
3386
  if (!result.repositoryBound && repoCanonical) {
3227
- p12.log.warn(
3387
+ p13.log.warn(
3228
3388
  `Repository "${repoCanonical}" was not automatically connected. Ensure your GitHub App installation covers this repository.`
3229
3389
  );
3230
3390
  }
@@ -3233,13 +3393,13 @@ async function createApp(options) {
3233
3393
  spinner7.stop("Failed to create project.");
3234
3394
  if (error instanceof VocoderAPIError && error.limitError) {
3235
3395
  const { limitError } = error;
3236
- p12.log.error(limitError.message);
3396
+ p13.log.error(limitError.message);
3237
3397
  for (const line of getLimitErrorGuidance(limitError)) {
3238
- p12.log.info(line);
3398
+ p13.log.info(line);
3239
3399
  }
3240
3400
  return 1;
3241
3401
  }
3242
- p12.log.error(
3402
+ p13.log.error(
3243
3403
  error instanceof Error ? error.message : "Unknown error."
3244
3404
  );
3245
3405
  return 1;
@@ -3247,26 +3407,26 @@ async function createApp(options) {
3247
3407
  }
3248
3408
 
3249
3409
  // src/commands/whoami.ts
3250
- import * as p13 from "@clack/prompts";
3410
+ import * as p14 from "@clack/prompts";
3251
3411
  import chalk10 from "chalk";
3252
3412
  async function whoami(options = {}) {
3253
3413
  const stored = readAuthData();
3254
3414
  if (!stored) {
3255
- p13.log.info("Not logged in. Run `vocoder init` to authenticate.");
3415
+ p14.log.info("Not logged in. Run `vocoder init` to authenticate.");
3256
3416
  return 1;
3257
3417
  }
3258
3418
  const apiUrl = options.apiUrl ?? stored.apiUrl ?? "https://vocoder.app";
3259
3419
  const api = new VocoderAPI({ apiUrl, apiKey: "" });
3260
3420
  try {
3261
3421
  const info2 = await api.getCliUserInfo(stored.token);
3262
- p13.log.info(`Logged in as ${chalk10.bold(info2.email)}`);
3422
+ p14.log.info(`Logged in as ${chalk10.bold(info2.email)}`);
3263
3423
  if (info2.name) {
3264
- p13.log.info(`Name: ${info2.name}`);
3424
+ p14.log.info(`Name: ${info2.name}`);
3265
3425
  }
3266
- p13.log.info(`API: ${apiUrl}`);
3426
+ p14.log.info(`API: ${apiUrl}`);
3267
3427
  return 0;
3268
3428
  } catch {
3269
- p13.log.error(
3429
+ p14.log.error(
3270
3430
  "Stored credentials are invalid or expired. Run `vocoder init` to re-authenticate."
3271
3431
  );
3272
3432
  return 1;
@@ -3308,7 +3468,7 @@ localesCmd.command("remove <codes...>").description("Remove one or more target l
3308
3468
  localesCmd.command("supported").description("List all locales supported by Vocoder").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(listSupportedLocales, options));
3309
3469
  program.command("project").description("Show current app configuration").option("--api-url <url>", "Override Vocoder API URL").action((options) => runCommand(appConfig, options));
3310
3470
  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));
3311
- program.command("create-app").description("Create a new Vocoder app (requires prior `vocoder init`)").requiredOption("--name <name>", "App display name").requiredOption("--source-locale <code>", "Source language BCP 47 code (e.g. en)").requiredOption("--workspace <org-id>", "Workspace organization ID").option(
3471
+ program.command("create-app").description("Create a new Vocoder app (requires prior `vocoder init`)").requiredOption("--name <name>", "App display name").requiredOption("--source-locale <code>", "Source language BCP 47 code (e.g. en)").requiredOption("--organization <org-id>", "Organization ID").option(
3312
3472
  "--target-locales <codes>",
3313
3473
  "Comma-separated target locale codes (e.g. fr,de,pt-BR)"
3314
3474
  ).option(
@@ -3327,7 +3487,7 @@ program.command("create-app").description("Create a new Vocoder app (requires pr
3327
3487
  sourceLocale: options.sourceLocale,
3328
3488
  targetLocales: options.targetLocales,
3329
3489
  targetBranches: options.targetBranches,
3330
- workspace: options.workspace
3490
+ organization: options.organization
3331
3491
  };
3332
3492
  return runCommand(createApp, translated);
3333
3493
  });