@ai-translate/cli 0.3.0 → 0.4.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.
@@ -1,12 +1,15 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { auditCatalogs, defineConfig, defineConfig as defineConfig$1, resolveStateScope, syncCatalogs, usesGeneratorSelfCheck, validateCatalogs, withTranslationIssueCache } from "@ai-translate/core";
2
3
  import { adoptExistingTranslations } from "@ai-translate/fs-json";
3
4
  import { existsSync, promises } from "node:fs";
4
5
  import * as path from "node:path";
5
6
  import { config, parse } from "dotenv";
6
7
  import { createJiti } from "jiti";
7
- import { appleIntegration } from "@ai-translate/apple";
8
- import { detectProject, renderConfig, requiredConfigPackages } from "@ai-translate/integrations";
8
+ import { appleIntegration, expoIntegration } from "@ai-translate/apple";
9
+ import { detectProject, isLocaleTag, renderConfig, requiredConfigPackages } from "@ai-translate/integrations";
9
10
  import { builtinIntegrations } from "@ai-translate/next";
11
+ import { execFile, spawn } from "node:child_process";
12
+ import { promisify } from "node:util";
10
13
  import { randomUUID } from "node:crypto";
11
14
  import * as os from "node:os";
12
15
  import { supportsScopedSave } from "@ai-translate/core/types";
@@ -67,28 +70,350 @@ async function loadConfig(cwd, explicitPath) {
67
70
  };
68
71
  }
69
72
  //#endregion
70
- //#region src/init.ts
71
- const CONFIG_FILENAME = "ai-translate.config.ts";
72
- const DEFAULT_AI_SDK_PACKAGE = "@ai-sdk/openai";
73
- /** Platforms compose independent detectors; the runner has no platform dependencies. */
74
- const builtinInitIntegrations = [...builtinIntegrations, appleIntegration];
75
- function describe(setup) {
76
- return [
77
- `Detected ${setup.displayName}:`,
78
- ...setup.evidence.map((item) => ` - ${item.detail} (${item.source})`),
79
- ` - Source locale ${setup.plan.sourceLocale}, ${String(setup.plan.targetLocales.length)} target locale(s): ${setup.plan.targetLocales.join(", ")}`
80
- ];
73
+ //#region src/init-project.ts
74
+ const MANAGERS = [
75
+ "npm",
76
+ "pnpm",
77
+ "yarn",
78
+ "bun"
79
+ ];
80
+ const LOCKFILES = {
81
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
82
+ pnpm: ["pnpm-lock.yaml"],
83
+ yarn: ["yarn.lock"],
84
+ bun: ["bun.lock", "bun.lockb"]
85
+ };
86
+ const SCRIPTS = {
87
+ translate: "ai-translate sync",
88
+ "translate:check": "ai-translate check",
89
+ "translate:validate": "ai-translate validate"
90
+ };
91
+ const DEPENDENCY_FIELDS = [
92
+ "dependencies",
93
+ "devDependencies",
94
+ "optionalDependencies"
95
+ ];
96
+ function isFirstPartyPackage(name) {
97
+ return name === "ai-translate" || name.startsWith("@ai-translate/");
98
+ }
99
+ /** Registry versions, ranges, and tags are refreshable. Paths, URLs, workspace
100
+ * protocols, and aliases are intentional user choices and remain untouched. */
101
+ function isRegistryVersion(version) {
102
+ return /^[a-z\d.*+~^<>=| _-]+$/iu.test(version.trim());
103
+ }
104
+ function installCommand(manager, packages, cwd, isWorkspaceRoot, field) {
105
+ const adding = packages.length > 0;
106
+ const saveFlag = field === void 0 ? void 0 : manager === "npm" || manager === "pnpm" ? {
107
+ dependencies: "--save-prod",
108
+ devDependencies: "--save-dev",
109
+ optionalDependencies: "--save-optional"
110
+ }[field] : {
111
+ dependencies: void 0,
112
+ devDependencies: "--dev",
113
+ optionalDependencies: "--optional"
114
+ }[field];
115
+ return {
116
+ command: manager,
117
+ args: [
118
+ manager === "npm" || !adding ? "install" : "add",
119
+ ...saveFlag === void 0 ? [] : [saveFlag],
120
+ ...manager === "pnpm" && adding && isWorkspaceRoot ? ["--workspace-root"] : [],
121
+ ...manager === "yarn" ? [] : ["--ignore-scripts"],
122
+ ...packages
123
+ ],
124
+ cwd,
125
+ ...manager === "yarn" ? { env: { YARN_ENABLE_SCRIPTS: "false" } } : {}
126
+ };
81
127
  }
82
- async function missingPackages(cwd, plan, options) {
83
- const expected = requiredConfigPackages(plan, options);
128
+ function isRecord(value) {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
131
+ function parseManifest(contents, manifestPath) {
132
+ let value;
84
133
  try {
85
- const raw = await promises.readFile(path.join(cwd, "package.json"), "utf8");
86
- const manifest = JSON.parse(raw);
87
- const declared = /* @__PURE__ */ new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {})]);
88
- return expected.filter((name) => !declared.has(name));
134
+ value = JSON.parse(contents);
135
+ } catch {
136
+ throw new Error(`Invalid JSON in ${manifestPath}. Fix it before running init.`);
137
+ }
138
+ if (!isRecord(value)) throw new Error(`${manifestPath} must contain a JSON object.`);
139
+ for (const field of [
140
+ "dependencies",
141
+ "devDependencies",
142
+ "optionalDependencies",
143
+ "peerDependencies",
144
+ "scripts"
145
+ ]) {
146
+ const entries = value[field];
147
+ if (entries !== void 0 && (!isRecord(entries) || Object.values(entries).some((entry) => typeof entry !== "string" || field !== "scripts" && entry.trim() === ""))) throw new Error(`${manifestPath}: ${field} must be an object of strings.`);
148
+ }
149
+ if (value.packageManager !== void 0 && typeof value.packageManager !== "string") throw new Error(`${manifestPath}: packageManager must be a string.`);
150
+ if (value.workspaces !== void 0) {
151
+ const patterns = isRecord(value.workspaces) ? value.workspaces.packages : value.workspaces;
152
+ if (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== "string" || pattern.trim() === "")) throw new Error(`${manifestPath}: workspaces must contain an array of package patterns.`);
153
+ }
154
+ return value;
155
+ }
156
+ async function readOptional(filePath) {
157
+ try {
158
+ return await promises.readFile(filePath, "utf8");
159
+ } catch (error) {
160
+ if (error.code === "ENOENT") return;
161
+ throw error;
162
+ }
163
+ }
164
+ async function exists(filePath) {
165
+ try {
166
+ await promises.lstat(filePath);
167
+ return true;
168
+ } catch (error) {
169
+ if (error.code === "ENOENT") return false;
170
+ throw error;
171
+ }
172
+ }
173
+ /** Only literal lists establish membership; YAML aliases and expressions do not. */
174
+ function pnpmWorkspacePatterns(contents) {
175
+ const lines = contents.split(/\r?\n/u);
176
+ const start = lines.findIndex((line) => /^packages\s*:/u.test(line));
177
+ if (start === -1) return;
178
+ const inline = lines[start]?.replace(/^packages\s*:\s*/u, "").trim() ?? "";
179
+ if (inline !== "" && !inline.startsWith("#")) try {
180
+ const parsed = JSON.parse(inline);
181
+ return Array.isArray(parsed) && parsed.every((value) => typeof value === "string") ? parsed : void 0;
89
182
  } catch {
90
- return [...expected];
183
+ return;
91
184
  }
185
+ const patterns = [];
186
+ for (const line of lines.slice(start + 1)) {
187
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
188
+ if (!/^(?:\s|-)/u.test(line)) break;
189
+ const item = line.match(/^\s*-\s+(.+?)\s*$/u)?.[1];
190
+ if (item === void 0) return;
191
+ const doubleQuoted = item.match(/^("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/u)?.[1];
192
+ const singleQuoted = item.match(/^'((?:[^']|'')*)'\s*(?:#.*)?$/u)?.[1];
193
+ if (doubleQuoted !== void 0) try {
194
+ patterns.push(JSON.parse(doubleQuoted));
195
+ } catch {
196
+ return;
197
+ }
198
+ else if (singleQuoted !== void 0) patterns.push(singleQuoted.replaceAll("''", "'"));
199
+ else {
200
+ const plain = item.replace(/\s+#.*$/u, "");
201
+ if (/^[!*&@[{?]|[\s"'#:>|]/u.test(plain)) return;
202
+ patterns.push(plain);
203
+ }
204
+ }
205
+ return patterns;
206
+ }
207
+ function matchesWorkspace(relative, patterns) {
208
+ const normalized = relative.split(path.sep).join("/");
209
+ const matches = (pattern) => path.matchesGlob(normalized, pattern.replace(/^\.\//u, "").replace(/\/$/u, ""));
210
+ return patterns.some((pattern) => !pattern.startsWith("!") && matches(pattern)) && !patterns.some((pattern) => pattern.startsWith("!") && matches(pattern.slice(1)));
211
+ }
212
+ async function workspaceRoot(cwd, manifest) {
213
+ for (let directory = cwd;; directory = path.dirname(directory)) {
214
+ const text = directory === cwd ? void 0 : await readOptional(path.join(directory, "package.json"));
215
+ const candidate = directory === cwd ? manifest : text === void 0 ? {} : parseManifest(text, path.join(directory, "package.json"));
216
+ const pnpmWorkspace = await readOptional(path.join(directory, "pnpm-workspace.yaml"));
217
+ if (candidate.workspaces !== void 0 || pnpmWorkspace !== void 0) {
218
+ const declared = isRecord(candidate.workspaces) ? candidate.workspaces.packages : candidate.workspaces;
219
+ const patterns = pnpmWorkspace === void 0 ? declared : pnpmWorkspacePatterns(pnpmWorkspace);
220
+ return directory === cwd || patterns !== void 0 && matchesWorkspace(path.relative(directory, cwd), patterns) ? {
221
+ root: directory,
222
+ manifest: candidate
223
+ } : void 0;
224
+ }
225
+ if (path.dirname(directory) === directory || await exists(path.join(directory, ".git"))) return;
226
+ }
227
+ }
228
+ function managerFromDeclaration(value) {
229
+ if (value === void 0) return;
230
+ if (typeof value !== "string") throw new Error("The package manager must be npm, pnpm, yarn, or bun.");
231
+ const name = value.split("@")[0];
232
+ if (!MANAGERS.includes(name)) throw new Error(`Unsupported package manager ${value}. Use --package-manager npm, pnpm, yarn, or bun.`);
233
+ return name;
234
+ }
235
+ /** Reads project metadata only. Preview never executes a package manager or writes files. */
236
+ async function planProjectSetup(cwd, packages, options = {}) {
237
+ for (const name of packages) if (name.length > 214 || !/^(?:@[a-z\d][a-z\d._-]*\/)?[a-z\d][a-z\d._-]*$/iu.test(name)) throw new Error(`Invalid dependency package name ${JSON.stringify(name)}.`);
238
+ const root = await promises.realpath(cwd);
239
+ const manifestPath = path.join(root, "package.json");
240
+ if (await exists(manifestPath)) {
241
+ if (!(await promises.lstat(manifestPath)).isFile()) throw new Error(`${manifestPath} must be a regular file, not a symlink or directory.`);
242
+ }
243
+ const originalManifest = await readOptional(manifestPath);
244
+ const manifest = originalManifest === void 0 ? { private: true } : parseManifest(originalManifest, manifestPath);
245
+ const workspace = await workspaceRoot(root, manifest);
246
+ let manager = managerFromDeclaration(options.packageManager ?? manifest.packageManager ?? workspace?.manifest.packageManager);
247
+ if (manager === void 0) {
248
+ const detected = /* @__PURE__ */ new Set();
249
+ for (let directory = root;; directory = path.dirname(directory)) {
250
+ for (const candidate of MANAGERS) for (const fileName of LOCKFILES[candidate]) if (await exists(path.join(directory, fileName))) detected.add(candidate);
251
+ if (directory === (workspace?.root ?? root)) break;
252
+ }
253
+ if (detected.size > 1) throw new Error(`Ambiguous package managers (${[...detected].join(", ")}). Pass --package-manager to choose.`);
254
+ manager = [...detected][0] ?? "npm";
255
+ }
256
+ const declared = new Set(DEPENDENCY_FIELDS.flatMap((field) => Object.keys(manifest[field] ?? {})));
257
+ const missing = [...new Set(packages)].filter((name) => !declared.has(name));
258
+ const grouped = {
259
+ dependencies: [],
260
+ devDependencies: [],
261
+ optionalDependencies: []
262
+ };
263
+ const notices = [];
264
+ for (const name of new Set(packages)) {
265
+ const fields = DEPENDENCY_FIELDS.filter((field) => Object.hasOwn(manifest[field] ?? {}, name));
266
+ if (!isFirstPartyPackage(name)) {
267
+ if (fields.length === 0) grouped.devDependencies.push(name);
268
+ continue;
269
+ }
270
+ if (fields.length > 1) throw new Error(`${name} is declared in multiple dependency sections (${fields.join(", ")}). Keep it in one section before running init so its dependency category can be preserved.`);
271
+ const field = fields[0] ?? "devDependencies";
272
+ const version = manifest[field]?.[name];
273
+ if (version !== void 0 && !isRegistryVersion(version)) {
274
+ notices.push(`Kept ${name}'s custom source in ${field}. Init cannot refresh it automatically; ensure it supports the generated configuration.`);
275
+ continue;
276
+ }
277
+ grouped[field].push(`${name}@latest`);
278
+ if (version !== void 0) notices.push(`Refresh ${name} to the latest release, keeping it in ${field}.`);
279
+ }
280
+ const scripts = { ...manifest.scripts };
281
+ let changed = originalManifest === void 0;
282
+ for (const [name, command] of Object.entries(SCRIPTS)) if (!Object.hasOwn(scripts, name)) {
283
+ scripts[name] = command;
284
+ changed = true;
285
+ }
286
+ const indent = originalManifest?.match(/\r?\n([\t ]+)"/u)?.[1] ?? " ";
287
+ const newline = originalManifest?.includes("\r\n") === true ? "\r\n" : "\n";
288
+ const manifestContents = changed ? `${JSON.stringify({
289
+ ...manifest,
290
+ scripts
291
+ }, null, indent).replaceAll("\n", newline)}${newline}` : void 0;
292
+ const installCommands = DEPENDENCY_FIELDS.filter((field) => grouped[field].length > 0).map((field) => installCommand(manager, grouped[field], root, workspace?.root === root, field));
293
+ if (installCommands.length === 0) installCommands.push(installCommand(manager, [], root, workspace?.root === root));
294
+ return {
295
+ packageManager: manager,
296
+ manifestPath,
297
+ manifestContents,
298
+ originalManifest,
299
+ packages: missing,
300
+ installCommands,
301
+ notices
302
+ };
303
+ }
304
+ const executeFile = promisify(execFile);
305
+ /** Windows package-manager shims need cmd.exe. Only fixed manager names and
306
+ * argument tokens generated by this module may enter its command string. */
307
+ function packageManagerProcess(command, args, platform = process.platform) {
308
+ if (platform !== "win32" || command === "bun") return {
309
+ command,
310
+ args: [...args]
311
+ };
312
+ if (!MANAGERS.includes(command) || args.some((arg) => !/^[-@a-z\d._/]+$/iu.test(arg))) throw new Error("Unsafe Windows package-manager command or argument.");
313
+ return {
314
+ command: process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe",
315
+ args: [
316
+ "/d",
317
+ "/s",
318
+ "/c",
319
+ `"${command} ${args.join(" ")}"`
320
+ ],
321
+ windowsVerbatimArguments: true
322
+ };
323
+ }
324
+ const installProject = async (command, args, cwd, env) => {
325
+ let actualArgs = [...args];
326
+ let actualEnv = {
327
+ ...process.env,
328
+ ...env
329
+ };
330
+ if (command === "yarn") {
331
+ const probe = packageManagerProcess(command, ["--version"]);
332
+ const { stdout } = await executeFile(probe.command, probe.args, {
333
+ cwd,
334
+ timeout: 15e3,
335
+ ...probe.windowsVerbatimArguments === void 0 ? {} : { windowsVerbatimArguments: probe.windowsVerbatimArguments }
336
+ });
337
+ const major = Number.parseInt(stdout.trim().split(".")[0] ?? "", 10);
338
+ if (!Number.isFinite(major) || major < 1) throw new Error("Unable to determine the installed Yarn version.");
339
+ if (major === 1) {
340
+ actualArgs = [
341
+ ...actualArgs,
342
+ "--ignore-scripts",
343
+ ...actualArgs[0] === "add" ? ["--ignore-workspace-root-check"] : []
344
+ ];
345
+ actualEnv = { ...process.env };
346
+ }
347
+ }
348
+ await new Promise((resolve, reject) => {
349
+ const invocation = packageManagerProcess(command, actualArgs);
350
+ const child = spawn(invocation.command, invocation.args, {
351
+ cwd,
352
+ env: actualEnv,
353
+ shell: false,
354
+ stdio: "inherit",
355
+ ...invocation.windowsVerbatimArguments === void 0 ? {} : { windowsVerbatimArguments: invocation.windowsVerbatimArguments }
356
+ });
357
+ child.once("error", reject);
358
+ child.once("exit", (code, signal) => {
359
+ if (code === 0) resolve();
360
+ else reject(/* @__PURE__ */ new Error(`${command} exited ${signal === null ? `with code ${String(code)}` : `after signal ${signal}`}.`));
361
+ });
362
+ });
363
+ };
364
+ /** Leaves successful setup edits in place if installing fails, so init can be retried. */
365
+ async function applyProjectSetup(plan, options = {}) {
366
+ if (await readOptional(plan.manifestPath) !== plan.originalManifest) throw new Error(`${plan.manifestPath} changed during init. Run init again.`);
367
+ if (await exists(plan.manifestPath) && !(await promises.lstat(plan.manifestPath)).isFile()) throw new Error(`${plan.manifestPath} must be a regular file, not a symlink or directory.`);
368
+ const lines = [...plan.notices];
369
+ if (plan.manifestContents !== void 0) {
370
+ await promises.writeFile(plan.manifestPath, plan.manifestContents, {
371
+ encoding: "utf8",
372
+ flag: plan.originalManifest === void 0 ? "wx" : "w"
373
+ });
374
+ lines.push(`${plan.originalManifest === void 0 ? "Created" : "Updated"} package.json with translation scripts.`);
375
+ }
376
+ for (const { command, args, cwd, env } of plan.installCommands) {
377
+ if (options.install === false) {
378
+ if (command === "yarn") lines.push(`Install dependencies (Yarn 2+): YARN_ENABLE_SCRIPTS=false yarn ${args.join(" ")}`, `Install dependencies (Yarn 1): yarn ${args.join(" ")} --ignore-scripts${args[0] === "add" ? " --ignore-workspace-root-check" : ""}`);
379
+ else lines.push(`Install dependencies: ${command} ${args.join(" ")}`);
380
+ continue;
381
+ }
382
+ try {
383
+ await (options.installer ?? installProject)(command, args, cwd, env);
384
+ } catch (error) {
385
+ const detail = error instanceof Error ? error.message : String(error);
386
+ throw new Error(`Dependency installation failed: ${detail} Setup files were kept. Retry init to finish installing all required dependencies.`, { cause: error });
387
+ }
388
+ }
389
+ if (options.install !== false) lines.push(`Dependencies installed with ${plan.packageManager}; lifecycle scripts were disabled.`);
390
+ return lines;
391
+ }
392
+ //#endregion
393
+ //#region src/init.ts
394
+ const CONFIG_FILENAME = "ai-translate.config.ts";
395
+ /** Detection and config plans stay independent of the installation workflow. */
396
+ const builtinInitIntegrations = [
397
+ ...builtinIntegrations,
398
+ appleIntegration,
399
+ expoIntegration
400
+ ];
401
+ function catalogs(setup) {
402
+ return [setup.plan.catalog, ...setup.plan.additionalCatalogs ?? []];
403
+ }
404
+ function hasResources(setup) {
405
+ return catalogs(setup).some((catalog) => catalog.kind !== "adapter" || !Array.isArray(catalog.options.include) || catalog.options.include.length > 0);
406
+ }
407
+ function overlaps(left, right) {
408
+ if (left.kind === "adapter" || right.kind === "adapter") return left.kind === "adapter" && right.kind === "adapter" && left.factory.from === right.factory.from && left.factory.name === right.factory.name && left.options.rootDir === right.options.rootDir;
409
+ const a = path.resolve(left.rootDir);
410
+ const b = path.resolve(right.rootDir);
411
+ const leftFiles = left.kind === "document-json" && left.localeFiles !== void 0 ? Object.values(left.localeFiles).map((file) => path.resolve(a, file)) : void 0;
412
+ const rightFiles = right.kind === "document-json" && right.localeFiles !== void 0 ? Object.values(right.localeFiles).map((file) => path.resolve(b, file)) : void 0;
413
+ if (leftFiles !== void 0 && rightFiles !== void 0) return leftFiles.some((file) => rightFiles.includes(file));
414
+ if (leftFiles !== void 0) return leftFiles.some((file) => right.kind === "document-json" ? path.dirname(file) === b : file.startsWith(`${b}${path.sep}`));
415
+ if (rightFiles !== void 0) return rightFiles.some((file) => left.kind === "document-json" ? path.dirname(file) === a : file.startsWith(`${a}${path.sep}`));
416
+ return a === b || a.startsWith(`${b}${path.sep}`) || b.startsWith(`${a}${path.sep}`);
92
417
  }
93
418
  function chooseSetup(setups, requested) {
94
419
  if (requested !== void 0) {
@@ -96,49 +421,186 @@ function chooseSetup(setups, requested) {
96
421
  if (match === void 0) throw new Error(`No ${requested} setup was detected. Detected: ${setups.map((setup) => setup.integrationId).join(", ") || "none"}.`);
97
422
  return match;
98
423
  }
99
- const [best, ...rest] = setups;
100
- if (best === void 0) throw new Error("No supported localization setup was found. ai-translate init recognises next-intl and i18next, Apple String Catalogs, localized .strings tables, and Xcode or Apple Swift package projects. For Expo, React Native, or Tauri apps with hardcoded text, first externalize strings into localization resources; init does not extract text from application code. Run init from the project root, or write ai-translate.config.ts by hand.");
101
- if (rest.length > 0 && rest[0]?.confidence === best.confidence) throw new Error(`Found more than one localization setup (${setups.map((setup) => setup.integrationId).join(", ")}). Re-run with --integration <id> to choose.`);
102
- return best;
103
- }
104
- /**
105
- * Detects the project's localization setup and writes a config for it.
106
- *
107
- * Nothing else is touched. Installing packages, wiring scripts, and editing the
108
- * application configuration stay in the user's hands, so `init` on an unfamiliar repository
109
- * produces exactly one new file and a list of instructions.
110
- */
424
+ const [best] = setups;
425
+ if (best === void 0) throw new Error("No supported localization setup was found. ai-translate init recognises next-intl and i18next, Expo locale mappings, Apple String Catalogs, localized .strings tables, and Xcode or Apple Swift package projects. For apps with hardcoded text, first externalize strings into localization resources. Run init from the project root; extraction and runtime wiring are described in docs/native-apps.md.");
426
+ const selected = [];
427
+ for (const candidate of setups.filter(hasResources)) {
428
+ const conflict = selected.find((other) => catalogs(other).some((left) => catalogs(candidate).some((right) => overlaps(left, right))));
429
+ if (conflict !== void 0) {
430
+ if (conflict.confidence === candidate.confidence) throw new Error(`Found overlapping localization setups (${conflict.integrationId}, ${candidate.integrationId}). Re-run with --integration <id> to choose.`);
431
+ continue;
432
+ }
433
+ selected.push(candidate);
434
+ }
435
+ if (selected.length < 2) return selected[0] ?? best;
436
+ if (selected.some((setup) => setup.plan.sourceLocale !== best.plan.sourceLocale)) throw new Error("Detected setups use different source locales. Run init with --integration <id> and use separate configs for each source locale.");
437
+ const targetLocales = resolveTargets(selected.flatMap((setup) => setup.plan.targetLocales), best.plan.sourceLocale);
438
+ const combined = selected.flatMap((setup) => catalogs(setup).map((catalog, index) => {
439
+ const id = `${setup.integrationId}-${String(index + 1)}`;
440
+ return catalog.kind === "adapter" ? {
441
+ ...catalog,
442
+ options: {
443
+ ...catalog.options,
444
+ id
445
+ }
446
+ } : {
447
+ ...catalog,
448
+ id,
449
+ messageFormat: catalog.messageFormat ?? setup.plan.messageFormat
450
+ };
451
+ }));
452
+ return {
453
+ confidence: best.confidence,
454
+ displayName: selected.map((setup) => setup.displayName).join(" + "),
455
+ evidence: selected.flatMap((setup) => setup.evidence),
456
+ integrationId: selected.map((setup) => setup.integrationId).join("+"),
457
+ plan: {
458
+ ...best.plan,
459
+ catalog: combined[0] ?? best.plan.catalog,
460
+ additionalCatalogs: combined.slice(1),
461
+ targetLocales,
462
+ warnings: selected.flatMap((setup) => setup.plan.warnings)
463
+ }
464
+ };
465
+ }
466
+ function resolveTargets(locales, source) {
467
+ const names = /* @__PURE__ */ new Map();
468
+ const sourceTag = Intl.getCanonicalLocales(source)[0];
469
+ for (const locale of locales) {
470
+ if (!isLocaleTag(locale)) throw new Error(`Invalid target locale ${JSON.stringify(locale)}.`);
471
+ const canonical = Intl.getCanonicalLocales(locale)[0] ?? locale;
472
+ if (canonical === sourceTag) throw new Error(`Target locale ${locale} is also the source locale.`);
473
+ const previous = names.get(canonical);
474
+ if (previous !== void 0 && previous !== locale) throw new Error(`Locale aliases ${previous} and ${locale} refer to the same language. Normalize the resource names before combining setups.`);
475
+ names.set(canonical, locale);
476
+ }
477
+ return [...names.values()];
478
+ }
479
+ async function readSetupFile(cwd, name) {
480
+ try {
481
+ const file = path.join(cwd, name);
482
+ const stat = await promises.lstat(file);
483
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${name} must be a regular file; init will not follow symbolic links.`);
484
+ return await promises.readFile(file, "utf8");
485
+ } catch (error) {
486
+ if (error.code === "ENOENT") return null;
487
+ throw error;
488
+ }
489
+ }
490
+ function appendLines(original, additions) {
491
+ const existing = original ?? "";
492
+ const missing = additions.filter((line) => !existing.split(/\r?\n/u).includes(line));
493
+ if (missing.length === 0) return existing;
494
+ const newline = existing.includes("\r\n") ? "\r\n" : "\n";
495
+ return `${existing}${existing.length > 0 && !existing.endsWith("\n") ? newline : ""}${missing.join(newline)}${newline}`;
496
+ }
497
+ function apiKeyVariable(options) {
498
+ if (options.provider !== "ai-sdk") return "OPENAI_API_KEY";
499
+ return {
500
+ "@ai-sdk/anthropic": "ANTHROPIC_API_KEY",
501
+ "@ai-sdk/google": "GOOGLE_GENERATIVE_AI_API_KEY",
502
+ "@ai-sdk/groq": "GROQ_API_KEY",
503
+ "@ai-sdk/mistral": "MISTRAL_API_KEY",
504
+ "@ai-sdk/openai": "OPENAI_API_KEY",
505
+ "@ai-sdk/xai": "XAI_API_KEY"
506
+ }[options.providerPackage ?? "@ai-sdk/openai"];
507
+ }
508
+ /** Detect, plan all changes, then install the generated config's direct dependencies. */
111
509
  async function runInit(cwd, options = {}) {
510
+ await readSetupFile(cwd, "package.json");
112
511
  const setups = await detectProject(cwd, { integrations: options.integrations ?? builtinInitIntegrations });
113
- const setup = chooseSetup(setups, options.integration);
114
- const contents = renderConfig(setup.plan, {
115
- ...options.model === void 0 ? {} : { model: options.model },
116
- ...options.provider === void 0 ? {} : { provider: options.provider },
117
- ...options.providerPackage === void 0 ? {} : { providerPackage: options.providerPackage }
118
- });
119
- const configPath = path.join(cwd, CONFIG_FILENAME);
120
- const lines = describe(setup);
121
- for (const warning of setup.plan.warnings) lines.push(` ! ${warning}`);
122
- const others = setups.filter((candidate) => candidate !== setup);
512
+ const detected = chooseSetup(setups, options.integration);
513
+ const requestedLocales = options.locales === void 0 ? void 0 : resolveTargets(options.locales, detected.plan.sourceLocale).map((locale) => detected.plan.targetLocales.find((existing) => Intl.getCanonicalLocales(existing)[0] === Intl.getCanonicalLocales(locale)[0]) ?? locale);
514
+ const setup = requestedLocales === void 0 ? detected : {
515
+ ...detected,
516
+ plan: {
517
+ ...detected.plan,
518
+ targetLocales: requestedLocales
519
+ }
520
+ };
521
+ for (const catalog of catalogs(setup)) {
522
+ if (catalog.kind !== "document-json" || catalog.localeFiles === void 0) continue;
523
+ const missing = setup.plan.targetLocales.filter((locale) => !Object.hasOwn(catalog.localeFiles ?? {}, locale));
524
+ if (missing.length > 0) throw new Error(`Missing locale file mappings for ${missing.join(", ")}. Add them to the project's locale configuration (expo.locales for Expo), or use --locale to select only mapped languages.`);
525
+ }
526
+ const contents = renderConfig(setup.plan, options);
527
+ const lines = [
528
+ `Detected ${setup.displayName}:`,
529
+ ...setup.evidence.map((item) => ` - ${item.detail} (${item.source})`),
530
+ ` - Source locale ${setup.plan.sourceLocale}, ${String(setup.plan.targetLocales.length)} target locale(s): ${setup.plan.targetLocales.join(", ")}`,
531
+ ...setup.plan.warnings.map((warning) => ` ! ${warning}`)
532
+ ];
533
+ const selectedIds = setup.integrationId.split("+");
534
+ const others = setups.filter((candidate) => !selectedIds.includes(candidate.integrationId));
123
535
  if (others.length > 0) lines.push(`Also detected, not used: ${others.map((candidate) => candidate.displayName).join(", ")}.`);
536
+ const existing = [];
537
+ for (const name of CONFIG_CANDIDATES) {
538
+ const original = await readSetupFile(cwd, name);
539
+ if (original !== null) existing.push({
540
+ contents,
541
+ name,
542
+ original
543
+ });
544
+ }
545
+ if (existing.length > 1) throw new Error(`Multiple ai-translate configs exist: ${existing.map((file) => file.name).join(", ")}. Keep one config before running init.`);
546
+ const config = existing[0] ?? {
547
+ contents,
548
+ name: CONFIG_FILENAME,
549
+ original: null
550
+ };
551
+ if (config.original !== null && config.original !== contents && options.force !== true && options.preview !== true) throw new Error(`${config.name} already exists with different contents. Use --preview to review changes or --force to overwrite it.`);
552
+ const project = await planProjectSetup(cwd, requiredConfigPackages(setup.plan, options), options.packageManager === void 0 ? {} : { packageManager: options.packageManager });
553
+ const files = [config];
554
+ const ignore = await readSetupFile(cwd, ".gitignore");
555
+ files.push({
556
+ name: ".gitignore",
557
+ original: ignore,
558
+ contents: appendLines(ignore, [
559
+ "node_modules/",
560
+ ".env.local",
561
+ ".env.*.local"
562
+ ])
563
+ });
564
+ const key = apiKeyVariable(options);
565
+ if (key !== void 0) {
566
+ const example = await readSetupFile(cwd, ".env.example");
567
+ const hasKey = example?.split(/\r?\n/u).some((line) => line.trimStart().startsWith(`${key}=`)) === true;
568
+ files.push({
569
+ name: ".env.example",
570
+ original: example,
571
+ contents: hasKey ? example : appendLines(example, [`${key}=`])
572
+ });
573
+ }
574
+ lines.push("", `Package manager: ${project.packageManager}.`);
124
575
  if (options.preview === true) {
125
- lines.push("", `Would write ${CONFIG_FILENAME}:`, "", contents);
576
+ lines.push(...project.notices);
577
+ for (const file of files) if (file.contents !== file.original || file === config) lines.push("", `Would write ${file.name}:`, "", file.name === ".env.example" ? `${key ?? "PROVIDER_API_KEY"}= (existing entries preserved)` : file.contents);
578
+ if (project.manifestContents !== void 0) lines.push("", "Would write package.json:", "", project.manifestContents);
579
+ for (const command of project.installCommands) lines.push("", `Would ${options.install === false ? "skip installation; run" : "install with"}: ${command.command} ${command.args.join(" ")}`);
126
580
  return {
127
581
  configPath: null,
128
582
  lines,
129
583
  setup
130
584
  };
131
585
  }
132
- if (await promises.access(configPath).then(() => true).catch(() => false) && options.force !== true) throw new Error(`${CONFIG_FILENAME} already exists. Pass --force to overwrite it.`);
133
- await promises.writeFile(configPath, contents, "utf8");
134
- lines.push("", `Wrote ${CONFIG_FILENAME}.`, "", "Next steps:");
135
- const install = await missingPackages(cwd, setup.plan, options);
136
- let step = 1;
137
- if (install.length > 0) lines.push(` ${String(step++)}. Install: ${install.join(" ")}`);
138
- const apiKeyVariable = options.provider === "ai-sdk" ? `the API key your ${options.providerPackage ?? DEFAULT_AI_SDK_PACKAGE} provider reads` : "OPENAI_API_KEY";
139
- lines.push(` ${String(step++)}. Set ${apiKeyVariable}, in your shell or in .env.local.`, ` ${String(step++)}. Review the model and locale list in ${CONFIG_FILENAME}.`, ` ${String(step++)}. Run "ai-translate validate" to confirm the config loads.`, ` ${String(step++)}. Run "ai-translate check" to see what a sync would do.`, ` ${String(step)}. Run "ai-translate sync" to translate.`);
586
+ for (const file of files) {
587
+ if (file.contents === file.original) continue;
588
+ if (await readSetupFile(cwd, file.name) !== file.original) throw new Error(`${file.name} changed during init; rerun to review the current project.`);
589
+ await promises.writeFile(path.join(cwd, file.name), file.contents, {
590
+ encoding: "utf8",
591
+ flag: file.original === null ? "wx" : "w"
592
+ });
593
+ lines.push(`Wrote ${file.name}.`);
594
+ }
595
+ lines.push(...await applyProjectSetup(project, { install: options.install !== false }));
596
+ lines.push("", "Setup complete. Before translating:");
597
+ if (key !== void 0) lines.push(` Set ${key} in your shell or .env.local; .env.example documents the variable.`);
598
+ else lines.push(` Configure the credentials your ${options.providerPackage ?? "AI SDK"} provider reads.`);
599
+ if (setup.plan.targetLocales.length === 0) lines.push(` Choose target languages in ${config.name}, or rerun init --locale fr --locale de --force.`);
600
+ if (!hasResources(setup)) lines.push(" Extract localization resources using the instructions above, then rerun init --force.");
601
+ lines.push(" Run npx ai-translate sync --dry-run to review the work, then npx ai-translate sync to translate.");
140
602
  return {
141
- configPath,
603
+ configPath: path.join(cwd, config.name),
142
604
  lines,
143
605
  setup
144
606
  };
@@ -491,6 +953,10 @@ function requireProviderChoice(value) {
491
953
  if (value !== "ai-sdk" && value !== "openai") throw new Error(`Option "--provider" accepts "openai" or "ai-sdk", not "${value}".`);
492
954
  return value;
493
955
  }
956
+ function requirePackageManager(value) {
957
+ if (value !== "npm" && value !== "pnpm" && value !== "yarn" && value !== "bun") throw new Error("Option \"--package-manager\" accepts npm, pnpm, yarn, or bun.");
958
+ return value;
959
+ }
494
960
  function requireIdenticalToSourcePolicy(value) {
495
961
  if (value !== "adopt" && value !== "skip") throw new Error(`Option "--identical-to-source" accepts "adopt" or "skip", not "${value}".`);
496
962
  return value;
@@ -644,7 +1110,7 @@ function printHelp() {
644
1110
  console.log(`ai-translate
645
1111
 
646
1112
  Usage:
647
- ai-translate init [--integration <id>] [--provider <openai|ai-sdk>] [--provider-package <@ai-sdk/...>] [--model <id>] [--preview] [--force]
1113
+ ai-translate init [--locale <locale>] [--integration <id>] [--provider <openai|ai-sdk>] [--provider-package <@ai-sdk/...>] [--model <id>] [--package-manager <npm|pnpm|yarn|bun>] [--no-install] [--preview] [--force]
648
1114
  ai-translate validate [--config <path>]
649
1115
  ai-translate check [--config <path>] [--locale <locale>] [--catalog <id>] [--unit <id>] [--include-path <json-pointer>] [--max-pending-translations <count>]
650
1116
  ai-translate audit [--check] [--refresh] [--config <path>] [--locale <locale>] [--catalog <id>] [--unit <id>] [--include-path <json-pointer>]
@@ -677,6 +1143,13 @@ function parseCommand(argv) {
677
1143
  if (flag.length === 0) throw new Error("Encountered an empty option flag.");
678
1144
  const nextValue = inlineValue ?? argv[index + 1];
679
1145
  switch (flag) {
1146
+ case "no-install":
1147
+ options.install = false;
1148
+ break;
1149
+ case "package-manager":
1150
+ options.packageManager = requireOptionValue(flag, nextValue);
1151
+ if (inlineValue === void 0) index += 1;
1152
+ break;
680
1153
  case "check":
681
1154
  options.auditCheck = true;
682
1155
  break;
@@ -776,20 +1249,34 @@ async function runCli(argv = process.argv.slice(2), cwd = process.cwd()) {
776
1249
  return 0;
777
1250
  }
778
1251
  if (parsed.command === "version") {
779
- console.log("0.0.0");
1252
+ const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
1253
+ console.log(manifest.version);
780
1254
  return 0;
781
1255
  }
782
1256
  switch (parsed.command) {
783
1257
  case "init": {
784
1258
  const result = await runInit(cwd, {
785
1259
  force: parsed.options.force === true,
1260
+ install: parsed.options.install !== false,
786
1261
  ...parsed.options.integration === void 0 ? {} : { integration: parsed.options.integration },
787
1262
  ...parsed.options.model === void 0 ? {} : { model: parsed.options.model },
1263
+ ...parsed.options.locales === void 0 ? {} : { locales: parsed.options.locales },
1264
+ ...parsed.options.packageManager === void 0 ? {} : { packageManager: requirePackageManager(parsed.options.packageManager) },
788
1265
  preview: parsed.options.preview === true || parsed.options.dryRun === true,
789
1266
  ...parsed.options.provider === void 0 ? {} : { provider: requireProviderChoice(parsed.options.provider) },
790
1267
  ...parsed.options.providerPackage === void 0 ? {} : { providerPackage: parsed.options.providerPackage }
791
1268
  });
792
1269
  console.log(result.lines.join("\n"));
1270
+ if (result.configPath !== null && parsed.options.install !== false) {
1271
+ await loadEnvFiles(cwd);
1272
+ const { config, configPath } = await loadConfig(cwd, result.configPath);
1273
+ const validation = await validateConfig(config, configPath, { locales: [] });
1274
+ if (validation.issues.filter((issue) => issue.severity === "error").length > 0) {
1275
+ console.error(JSON.stringify(validation, null, 2));
1276
+ throw new Error("Setup files were created, but localization validation failed. Fix the reported resources and run npx ai-translate validate.");
1277
+ }
1278
+ console.log("Generated configuration and source resources validated.");
1279
+ }
793
1280
  return 0;
794
1281
  }
795
1282
  case "validate": {
@@ -962,4 +1449,4 @@ async function runCli(argv = process.argv.slice(2), cwd = process.cwd()) {
962
1449
  //#endregion
963
1450
  export { loadEnvFiles as a, loadConfig as i, runCli as n, findConfigPath as r, defineConfig$1 as t };
964
1451
 
965
- //# sourceMappingURL=src-Bb6vfpFm.mjs.map
1452
+ //# sourceMappingURL=src-SwBEpdIU.mjs.map