glotfile 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,32 @@ var init_format = __esm({
31
31
  }
32
32
  });
33
33
 
34
+ // src/server/atomic-write.ts
35
+ import { writeFileSync, renameSync, mkdirSync, rmSync } from "fs";
36
+ import { dirname, join } from "path";
37
+ function writeFileAtomic(path, data) {
38
+ const dir = dirname(path);
39
+ mkdirSync(dir, { recursive: true });
40
+ const tmp = join(dir, `.${process.pid}.${counter++}.tmp`);
41
+ try {
42
+ writeFileSync(tmp, data);
43
+ renameSync(tmp, path);
44
+ } catch (e) {
45
+ try {
46
+ rmSync(tmp, { force: true });
47
+ } catch {
48
+ }
49
+ throw e;
50
+ }
51
+ }
52
+ var counter;
53
+ var init_atomic_write = __esm({
54
+ "src/server/atomic-write.ts"() {
55
+ "use strict";
56
+ counter = 0;
57
+ }
58
+ });
59
+
34
60
  // src/server/lint/registry.ts
35
61
  var RULE_IDS, DEFAULT_SEVERITY;
36
62
  var init_registry = __esm({
@@ -117,17 +143,6 @@ function validate(raw) {
117
143
  }
118
144
  }
119
145
  }
120
- if (!isObject(config.ai)) fail("config.ai must be an object");
121
- const ai = config.ai;
122
- if (typeof ai.provider !== "string" || !PROVIDERS.includes(ai.provider)) {
123
- fail(`config.ai.provider must be one of: ${PROVIDERS.join(", ")}`);
124
- }
125
- if (typeof ai.model !== "string") fail("config.ai.model must be a string");
126
- if (!(ai.endpoint === null || typeof ai.endpoint === "string")) fail("config.ai.endpoint must be a string or null");
127
- if (!(ai.region === void 0 || ai.region === null || typeof ai.region === "string")) {
128
- fail("config.ai.region must be a string or null");
129
- }
130
- if (typeof ai.batchSize !== "number") fail("config.ai.batchSize must be a number");
131
146
  if (!isObject(config.format)) fail("config.format must be an object");
132
147
  const fmt = config.format;
133
148
  if (typeof fmt.indent !== "number") fail("config.format.indent must be a number");
@@ -258,7 +273,6 @@ function defaultState() {
258
273
  { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
259
274
  { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" }
260
275
  ],
261
- ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", endpoint: null, region: null, batchSize: 25 },
262
276
  format: { indent: 2, sortKeys: true, finalNewline: true },
263
277
  spelling: { customWords: [] },
264
278
  autoExport: true
@@ -277,7 +291,7 @@ var init_schema = __esm({
277
291
  PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
278
292
  EXACT_SELECTOR_RE = /^=\d+$/;
279
293
  LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
280
- PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter"];
294
+ PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
281
295
  GlotfileError = class extends Error {
282
296
  };
283
297
  }
@@ -402,13 +416,13 @@ var init_plurals = __esm({
402
416
  });
403
417
 
404
418
  // src/server/storage.ts
405
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "fs";
406
- import { join, dirname } from "path";
419
+ import { existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, rmSync as rmSync2 } from "fs";
420
+ import { join as join2 } from "path";
407
421
  function splitDirFor(statePath) {
408
422
  return statePath.replace(/\.json$/i, "");
409
423
  }
410
424
  function detectFormat(statePath) {
411
- if (existsSync(join(splitDirFor(statePath), "config.json"))) return "split";
425
+ if (existsSync(join2(splitDirFor(statePath), "config.json"))) return "split";
412
426
  if (existsSync(statePath)) return "single";
413
427
  return "none";
414
428
  }
@@ -438,15 +452,15 @@ function assemble(parts) {
438
452
  return { ...parts.manifest, keys };
439
453
  }
440
454
  function loadSplit(splitDir) {
441
- const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
442
- const manifest = readJson(join(splitDir, "config.json"));
443
- const keysPath = join(splitDir, "keys.json");
444
- const keys = existsSync(keysPath) ? readJson(keysPath) : {};
445
- const localesDir = join(splitDir, "locales");
455
+ const readJson3 = (p) => JSON.parse(readFileSync(p, "utf8"));
456
+ const manifest = readJson3(join2(splitDir, "config.json"));
457
+ const keysPath = join2(splitDir, "keys.json");
458
+ const keys = existsSync(keysPath) ? readJson3(keysPath) : {};
459
+ const localesDir = join2(splitDir, "locales");
446
460
  const locales = {};
447
461
  if (existsSync(localesDir)) {
448
462
  for (const name of readdirSync(localesDir)) {
449
- if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson(join(localesDir, name));
463
+ if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson3(join2(localesDir, name));
450
464
  }
451
465
  }
452
466
  return assemble({ manifest, keys, locales });
@@ -458,15 +472,14 @@ function writeIfChanged(path, contents) {
458
472
  } catch {
459
473
  }
460
474
  if (current === contents) return false;
461
- mkdirSync(dirname(path), { recursive: true });
462
- writeFileSync(path, contents, "utf8");
475
+ writeFileAtomic(path, contents);
463
476
  return true;
464
477
  }
465
478
  function saveSplit(splitDir, state) {
466
479
  const fmt = state.config.format;
467
480
  const parts = disassemble(state);
468
- const localesDir = join(splitDir, "locales");
469
- mkdirSync(localesDir, { recursive: true });
481
+ const localesDir = join2(splitDir, "locales");
482
+ mkdirSync2(localesDir, { recursive: true });
470
483
  let written = 0;
471
484
  let skipped = 0;
472
485
  let deleted = 0;
@@ -474,15 +487,15 @@ function saveSplit(splitDir, state) {
474
487
  if (changed) written++;
475
488
  else skipped++;
476
489
  };
477
- track(writeIfChanged(join(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
478
- track(writeIfChanged(join(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
490
+ track(writeIfChanged(join2(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
491
+ track(writeIfChanged(join2(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
479
492
  for (const [locale, entries] of Object.entries(parts.locales)) {
480
- track(writeIfChanged(join(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
493
+ track(writeIfChanged(join2(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
481
494
  }
482
495
  const live = new Set(Object.keys(parts.locales).map((l) => `${l}.json`));
483
496
  for (const name of readdirSync(localesDir)) {
484
497
  if (name.endsWith(".json") && !live.has(name)) {
485
- rmSync(join(localesDir, name));
498
+ rmSync2(join2(localesDir, name));
486
499
  deleted++;
487
500
  }
488
501
  }
@@ -492,6 +505,7 @@ var init_storage = __esm({
492
505
  "src/server/storage.ts"() {
493
506
  "use strict";
494
507
  init_format();
508
+ init_atomic_write();
495
509
  }
496
510
  });
497
511
 
@@ -506,8 +520,7 @@ var init_normalize = __esm({
506
520
  });
507
521
 
508
522
  // src/server/state.ts
509
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
510
- import { dirname as dirname2 } from "path";
523
+ import { readFileSync as readFileSync2, existsSync as existsSync2, rmSync as rmSync3 } from "fs";
511
524
  import { randomUUID } from "crypto";
512
525
  function canonLocale(locale) {
513
526
  return locale.trim().toLowerCase().replace(/_/g, "-");
@@ -552,10 +565,9 @@ function saveState(path, state) {
552
565
  normalizeState(state);
553
566
  if (state.config.storage === "split") {
554
567
  saveSplit(splitDirFor(path), state);
555
- if (existsSync2(path)) rmSync2(path);
568
+ if (existsSync2(path)) rmSync3(path);
556
569
  } else {
557
- mkdirSync2(dirname2(path), { recursive: true });
558
- writeFileSync2(path, serializeJson(state, state.config.format), "utf8");
570
+ writeFileAtomic(path, serializeJson(state, state.config.format));
559
571
  }
560
572
  }
561
573
  function requireKey(state, key) {
@@ -703,6 +715,9 @@ function setMetadata(state, key, partial) {
703
715
  delete entry.contextAt;
704
716
  }
705
717
  Object.assign(entry, safe);
718
+ if ("context" in safe && !entry.context) delete entry.context;
719
+ if ("tags" in safe && !entry.tags?.length) delete entry.tags;
720
+ if ("maxLength" in safe && !entry.maxLength) delete entry.maxLength;
706
721
  }
707
722
  function addNote(state, key, text, clock = systemClock) {
708
723
  const entry = requireKey(state, key);
@@ -761,6 +776,7 @@ var init_state = __esm({
761
776
  "src/server/state.ts"() {
762
777
  "use strict";
763
778
  init_format();
779
+ init_atomic_write();
764
780
  init_schema();
765
781
  init_plurals();
766
782
  init_storage();
@@ -1494,8 +1510,8 @@ var init_adapters = __esm({
1494
1510
  });
1495
1511
 
1496
1512
  // src/server/export-run.ts
1497
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3 } from "fs";
1498
- import { dirname as dirname3, resolve } from "path";
1513
+ import { readFileSync as readFileSync3 } from "fs";
1514
+ import { resolve } from "path";
1499
1515
  function effectiveLocales(config) {
1500
1516
  const limit = config.exportLocales;
1501
1517
  if (!limit || limit.length === 0) return config.locales;
@@ -1532,8 +1548,7 @@ function exportToDisk(state, projectRoot, opts) {
1532
1548
  skipped++;
1533
1549
  continue;
1534
1550
  }
1535
- mkdirSync3(dirname3(abs), { recursive: true });
1536
- writeFileSync3(abs, f.contents, "utf8");
1551
+ writeFileAtomic(abs, f.contents);
1537
1552
  written++;
1538
1553
  }
1539
1554
  }
@@ -1543,6 +1558,7 @@ var init_export_run = __esm({
1543
1558
  "src/server/export-run.ts"() {
1544
1559
  "use strict";
1545
1560
  init_adapters();
1561
+ init_atomic_write();
1546
1562
  }
1547
1563
  });
1548
1564
 
@@ -1636,7 +1652,7 @@ function validateTranslation(req, translation) {
1636
1652
  return { id: req.id, error: "Placeholder mismatch between source and translation." };
1637
1653
  }
1638
1654
  if (req.maxLength !== void 0 && translation.length > req.maxLength) {
1639
- return { id: req.id, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1655
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1640
1656
  }
1641
1657
  return { id: req.id, translation };
1642
1658
  }
@@ -1888,7 +1904,7 @@ var init_bedrock = __esm({
1888
1904
  }
1889
1905
  const region = config.region ?? process.env.AWS_REGION;
1890
1906
  if (!region) {
1891
- throw new Error("AWS region is not set. Set config.ai.region or the AWS_REGION environment variable for the bedrock provider.");
1907
+ throw new Error("AWS region is not set. Set the Region in your local AI settings (.glotfile/settings.json) or the AWS_REGION environment variable for the bedrock provider.");
1892
1908
  }
1893
1909
  const require2 = createRequire2(import.meta.url);
1894
1910
  let sdk;
@@ -2016,9 +2032,131 @@ var init_openrouter = __esm({
2016
2032
  }
2017
2033
  });
2018
2034
 
2035
+ // src/server/ai/ollama.ts
2036
+ function ollamaClientOptions(config) {
2037
+ return {
2038
+ apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
2039
+ baseURL: config.endpoint ?? OLLAMA_BASE_URL
2040
+ };
2041
+ }
2042
+ var OLLAMA_BASE_URL, OllamaProvider;
2043
+ var init_ollama = __esm({
2044
+ "src/server/ai/ollama.ts"() {
2045
+ "use strict";
2046
+ init_openai();
2047
+ OLLAMA_BASE_URL = "http://localhost:11434/v1";
2048
+ OllamaProvider = class extends OpenAIProvider {
2049
+ constructor(config, client) {
2050
+ super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2051
+ }
2052
+ supportsVision() {
2053
+ return this.config.vision === true;
2054
+ }
2055
+ };
2056
+ }
2057
+ });
2058
+
2059
+ // src/server/ai/claudecode.ts
2060
+ import { spawn } from "child_process";
2061
+ function stripFences(s) {
2062
+ return s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
2063
+ }
2064
+ function defaultSpawn(prompt, systemPrompt, model) {
2065
+ return new Promise((resolve12, reject) => {
2066
+ const args = [
2067
+ "--print",
2068
+ "--output-format",
2069
+ "json",
2070
+ "--system-prompt",
2071
+ systemPrompt,
2072
+ // Only pass --model when explicitly configured; otherwise use the session default.
2073
+ ...model ? ["--model", model] : []
2074
+ ];
2075
+ const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
2076
+ let stdout = "";
2077
+ let stderr = "";
2078
+ child.stdout.on("data", (chunk2) => {
2079
+ stdout += chunk2.toString();
2080
+ });
2081
+ child.stderr.on("data", (chunk2) => {
2082
+ stderr += chunk2.toString();
2083
+ });
2084
+ child.stdin.write(prompt);
2085
+ child.stdin.end();
2086
+ child.on("close", (code) => {
2087
+ if (code !== 0) {
2088
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
2089
+ return;
2090
+ }
2091
+ try {
2092
+ const envelope = JSON.parse(stdout.trim());
2093
+ if (envelope.is_error) {
2094
+ reject(new Error(`claude error: ${envelope.result ?? "unknown error"}`));
2095
+ return;
2096
+ }
2097
+ resolve12(envelope.result ?? "");
2098
+ } catch {
2099
+ reject(new Error(`Failed to parse claude JSON output: ${stdout.slice(0, 200)}`));
2100
+ }
2101
+ });
2102
+ child.on("error", (err) => {
2103
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed?`));
2104
+ });
2105
+ });
2106
+ }
2107
+ var ClaudeCodeProvider;
2108
+ var init_claudecode = __esm({
2109
+ "src/server/ai/claudecode.ts"() {
2110
+ "use strict";
2111
+ init_provider();
2112
+ init_batch();
2113
+ ClaudeCodeProvider = class {
2114
+ constructor(config, spawnFn) {
2115
+ this.config = config;
2116
+ this.spawnFn = spawnFn ?? defaultSpawn;
2117
+ }
2118
+ config;
2119
+ spawnFn;
2120
+ supportsVision() {
2121
+ return false;
2122
+ }
2123
+ translate(reqs, onBatchComplete, signal) {
2124
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2125
+ }
2126
+ async complete(req) {
2127
+ const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
2128
+ const textBlocks = req.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
2129
+ const result = await this.spawnFn(textBlocks, systemParts.join("\n\n"), this.config.model);
2130
+ try {
2131
+ return JSON.parse(stripFences(result));
2132
+ } catch {
2133
+ return {};
2134
+ }
2135
+ }
2136
+ async callBatch(batch, signal) {
2137
+ if (signal?.aborted) return [];
2138
+ const prompt = buildBatchPrompt(batch);
2139
+ let result;
2140
+ try {
2141
+ result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2142
+ } catch (err) {
2143
+ if (signal?.aborted) return [];
2144
+ throw err;
2145
+ }
2146
+ if (signal?.aborted) return [];
2147
+ try {
2148
+ const parsed = JSON.parse(stripFences(result));
2149
+ return parsed.items ?? [];
2150
+ } catch {
2151
+ return [];
2152
+ }
2153
+ }
2154
+ };
2155
+ }
2156
+ });
2157
+
2019
2158
  // src/server/ai/index.ts
2020
- function makeProvider(config) {
2021
- const ai = config.ai;
2159
+ function makeProvider(ai) {
2022
2160
  switch (ai.provider) {
2023
2161
  case "anthropic":
2024
2162
  return new AnthropicProvider(ai);
@@ -2028,8 +2166,12 @@ function makeProvider(config) {
2028
2166
  return new BedrockProvider(ai);
2029
2167
  case "openrouter":
2030
2168
  return new OpenRouterProvider(ai);
2169
+ case "ollama":
2170
+ return new OllamaProvider(ai);
2171
+ case "claude-code":
2172
+ return new ClaudeCodeProvider(ai);
2031
2173
  default:
2032
- throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter.`);
2174
+ throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
2033
2175
  }
2034
2176
  }
2035
2177
  var init_ai = __esm({
@@ -2039,6 +2181,96 @@ var init_ai = __esm({
2039
2181
  init_openai();
2040
2182
  init_bedrock();
2041
2183
  init_openrouter();
2184
+ init_ollama();
2185
+ init_claudecode();
2186
+ }
2187
+ });
2188
+
2189
+ // src/server/glotfile-dir.ts
2190
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2191
+ import { resolve as resolve2 } from "path";
2192
+ function ensureGlotfileDir(projectRoot) {
2193
+ const dir = resolve2(projectRoot, ".glotfile");
2194
+ mkdirSync3(dir, { recursive: true });
2195
+ const ignore = resolve2(dir, ".gitignore");
2196
+ if (!existsSync3(ignore)) {
2197
+ try {
2198
+ writeFileSync2(ignore, "*\n");
2199
+ } catch {
2200
+ }
2201
+ }
2202
+ return dir;
2203
+ }
2204
+ var init_glotfile_dir = __esm({
2205
+ "src/server/glotfile-dir.ts"() {
2206
+ "use strict";
2207
+ }
2208
+ });
2209
+
2210
+ // src/server/local-settings.ts
2211
+ import { readFileSync as readFileSync4 } from "fs";
2212
+ import { resolve as resolve3 } from "path";
2213
+ function readJson(path) {
2214
+ try {
2215
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
2216
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2217
+ } catch {
2218
+ return {};
2219
+ }
2220
+ }
2221
+ function coerceAi(raw) {
2222
+ const a = raw && typeof raw === "object" ? raw : {};
2223
+ return {
2224
+ provider: PROVIDERS.includes(a.provider) ? a.provider : DEFAULT_AI.provider,
2225
+ model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
2226
+ endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
2227
+ region: typeof a.region === "string" ? a.region : null,
2228
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize,
2229
+ vision: typeof a.vision === "boolean" ? a.vision : void 0
2230
+ };
2231
+ }
2232
+ function loadLocalSettings(projectRoot) {
2233
+ const raw = readJson(settingsPath(projectRoot));
2234
+ return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
2235
+ }
2236
+ function saveLocalSettings(projectRoot, patch) {
2237
+ const path = settingsPath(projectRoot);
2238
+ const merged = { ...readJson(path) };
2239
+ if (patch.ai !== void 0) merged.ai = patch.ai;
2240
+ if (patch.editor !== void 0) merged.editor = patch.editor;
2241
+ ensureGlotfileDir(projectRoot);
2242
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
2243
+ }
2244
+ function aiConfigError(ai) {
2245
+ if (!ai || typeof ai !== "object") return "ai must be an object";
2246
+ const a = ai;
2247
+ if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
2248
+ return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
2249
+ }
2250
+ if (typeof a.model !== "string") return "ai.model must be a string";
2251
+ if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
2252
+ if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
2253
+ if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
2254
+ return null;
2255
+ }
2256
+ var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
2257
+ var init_local_settings = __esm({
2258
+ "src/server/local-settings.ts"() {
2259
+ "use strict";
2260
+ init_atomic_write();
2261
+ init_glotfile_dir();
2262
+ init_schema();
2263
+ EDITOR_IDS = ["vscode", "zed", "phpstorm"];
2264
+ isEditorId = (v) => EDITOR_IDS.includes(v);
2265
+ DEFAULT_AI = {
2266
+ provider: "anthropic",
2267
+ model: "claude-haiku-4-5-20251001",
2268
+ endpoint: null,
2269
+ region: null,
2270
+ batchSize: 25
2271
+ };
2272
+ DEFAULT_EDITOR = "vscode";
2273
+ settingsPath = (projectRoot) => resolve3(projectRoot, ".glotfile", "settings.json");
2042
2274
  }
2043
2275
  });
2044
2276
 
@@ -2054,8 +2286,8 @@ var init_glob = __esm({
2054
2286
  });
2055
2287
 
2056
2288
  // src/server/ai/run.ts
2057
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
2058
- import { resolve as resolve2, extname } from "path";
2289
+ import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
2290
+ import { resolve as resolve4, extname } from "path";
2059
2291
  function selectRequests(state, opts) {
2060
2292
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
2061
2293
  const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
@@ -2134,11 +2366,11 @@ function attachScreenshots(reqs, state, projectRoot) {
2134
2366
  const mediaType = MEDIA_TYPES[extname(screenshot).toLowerCase()];
2135
2367
  if (!mediaType) continue;
2136
2368
  if (!cache2.has(screenshot)) {
2137
- const abs = resolve2(projectRoot, screenshot);
2138
- if (!existsSync3(abs)) {
2369
+ const abs = resolve4(projectRoot, screenshot);
2370
+ if (!existsSync4(abs)) {
2139
2371
  cache2.set(screenshot, null);
2140
2372
  } else {
2141
- const buf = readFileSync4(abs);
2373
+ const buf = readFileSync5(abs);
2142
2374
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
2143
2375
  }
2144
2376
  }
@@ -2154,7 +2386,7 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
2154
2386
  const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
2155
2387
  return { skipped: keys.size };
2156
2388
  }
2157
- async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
2389
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
2158
2390
  if (!reqs.length) return [];
2159
2391
  const byLocale = /* @__PURE__ */ new Map();
2160
2392
  for (const req of reqs) {
@@ -2174,11 +2406,14 @@ async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency =
2174
2406
  while (next < groups.length) {
2175
2407
  if (signal?.aborted) break;
2176
2408
  const group = groups[next++];
2409
+ const locale = group[0].targetLocale;
2410
+ hooks.onLocaleStart?.(locale);
2177
2411
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
2178
2412
  done += batchResults.length;
2179
- onBatchComplete?.(done, total, batchResults);
2413
+ hooks.onBatchComplete?.(done, total, batchResults, locale);
2180
2414
  }, signal);
2181
2415
  allResults.push(...localeResults);
2416
+ if (!signal?.aborted) hooks.onLocaleDone?.(locale);
2182
2417
  }
2183
2418
  }
2184
2419
  const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
@@ -2200,10 +2435,11 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
2200
2435
  if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
2201
2436
  continue;
2202
2437
  }
2203
- if (res.error || res.translation === void 0) {
2438
+ if (res.translation === void 0) {
2204
2439
  errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
2205
2440
  continue;
2206
2441
  }
2442
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
2207
2443
  if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
2208
2444
  }
2209
2445
  return { written, errors };
@@ -2228,49 +2464,46 @@ var init_run = __esm({
2228
2464
  }
2229
2465
  });
2230
2466
 
2231
- // src/server/ai/log.ts
2232
- import { appendFileSync, readFileSync as readFileSync5, existsSync as existsSync4, mkdirSync as mkdirSync4 } from "fs";
2233
- import { resolve as resolve3 } from "path";
2467
+ // src/server/log.ts
2468
+ import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
2469
+ import { resolve as resolve5 } from "path";
2234
2470
  function logPath(projectRoot) {
2235
- return resolve3(projectRoot, ".glotfile", "ai-log.jsonl");
2471
+ return resolve5(projectRoot, ".glotfile", "log.jsonl");
2236
2472
  }
2237
- function appendAiLog(projectRoot, entry) {
2238
- mkdirSync4(resolve3(projectRoot, ".glotfile"), { recursive: true });
2473
+ function appendLog(projectRoot, entry) {
2474
+ ensureGlotfileDir(projectRoot);
2239
2475
  appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
2240
2476
  }
2241
- function readAiLog(projectRoot, limit = 100) {
2477
+ function readLog(projectRoot, limit = 100) {
2242
2478
  const path = logPath(projectRoot);
2243
- if (!existsSync4(path)) return [];
2244
- const lines = readFileSync5(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2245
- const entries = lines.map((l) => {
2246
- const e = JSON.parse(l);
2247
- e.kind ??= "translate";
2248
- return e;
2249
- });
2479
+ if (!existsSync5(path)) return [];
2480
+ const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2481
+ const entries = lines.map((l) => JSON.parse(l));
2250
2482
  return entries.reverse().slice(0, limit);
2251
2483
  }
2252
2484
  var init_log = __esm({
2253
- "src/server/ai/log.ts"() {
2485
+ "src/server/log.ts"() {
2254
2486
  "use strict";
2487
+ init_glotfile_dir();
2255
2488
  }
2256
2489
  });
2257
2490
 
2258
2491
  // src/server/scan.ts
2259
- import { existsSync as existsSync5, readFileSync as readFileSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2260
- import { resolve as resolve4, dirname as dirname4 } from "path";
2492
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2493
+ import { resolve as resolve6 } from "path";
2261
2494
  function loadUsageCache(projectRoot) {
2262
- const path = resolve4(projectRoot, ".glotfile", "usage.json");
2263
- if (!existsSync5(path)) return null;
2495
+ const path = resolve6(projectRoot, ".glotfile", "usage.json");
2496
+ if (!existsSync6(path)) return null;
2264
2497
  try {
2265
- return JSON.parse(readFileSync6(path, "utf8"));
2498
+ return JSON.parse(readFileSync7(path, "utf8"));
2266
2499
  } catch {
2267
2500
  return null;
2268
2501
  }
2269
2502
  }
2270
2503
  function saveUsageCache(projectRoot, cache2) {
2271
- const path = resolve4(projectRoot, ".glotfile", "usage.json");
2272
- mkdirSync5(dirname4(path), { recursive: true });
2273
- writeFileSync4(path, JSON.stringify(cache2, null, 2) + "\n", "utf8");
2504
+ ensureGlotfileDir(projectRoot);
2505
+ const path = resolve6(projectRoot, ".glotfile", "usage.json");
2506
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
2274
2507
  }
2275
2508
  function findMissing(state) {
2276
2509
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
@@ -2297,12 +2530,14 @@ function computeUsedKeys(state, cache2) {
2297
2530
  var init_scan = __esm({
2298
2531
  "src/server/scan.ts"() {
2299
2532
  "use strict";
2533
+ init_atomic_write();
2534
+ init_glotfile_dir();
2300
2535
  }
2301
2536
  });
2302
2537
 
2303
2538
  // src/server/scanner.ts
2304
- import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync7 } from "fs";
2305
- import { join as join2, extname as extname2, relative } from "path";
2539
+ import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync8 } from "fs";
2540
+ import { join as join3, extname as extname2, relative } from "path";
2306
2541
  function scannerForExt(ext) {
2307
2542
  return EXT_SCANNER[ext] ?? null;
2308
2543
  }
@@ -2428,7 +2663,7 @@ function* walkFiles(dir, root, exclude) {
2428
2663
  }
2429
2664
  for (const name of entries) {
2430
2665
  if (ALWAYS_EXCLUDE.has(name)) continue;
2431
- const abs = join2(dir, name);
2666
+ const abs = join3(dir, name);
2432
2667
  const rel = relative(root, abs);
2433
2668
  let st;
2434
2669
  try {
@@ -2458,7 +2693,7 @@ function runScan(projectRoot, opts, existing) {
2458
2693
  const ext = extname2(relPath);
2459
2694
  const scanner = scannerForExt(ext);
2460
2695
  if (!scanner) continue;
2461
- const abs = join2(projectRoot, relPath);
2696
+ const abs = join3(projectRoot, relPath);
2462
2697
  let st;
2463
2698
  try {
2464
2699
  st = statSync(abs);
@@ -2474,7 +2709,7 @@ function runScan(projectRoot, opts, existing) {
2474
2709
  }
2475
2710
  let content;
2476
2711
  try {
2477
- content = readFileSync7(abs, "utf8");
2712
+ content = readFileSync8(abs, "utf8");
2478
2713
  } catch {
2479
2714
  continue;
2480
2715
  }
@@ -2579,8 +2814,8 @@ var init_scanner = __esm({
2579
2814
  });
2580
2815
 
2581
2816
  // src/server/ai/context.ts
2582
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
2583
- import { resolve as resolve5 } from "path";
2817
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
2818
+ import { resolve as resolve7 } from "path";
2584
2819
  function globToRegExp2(glob) {
2585
2820
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
2586
2821
  return new RegExp(`^${escaped}$`);
@@ -2592,10 +2827,10 @@ function extractSnippets(refs, projectRoot, fileCache) {
2592
2827
  const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
2593
2828
  const snippets = [];
2594
2829
  for (const ref of selected) {
2595
- const absPath = resolve5(projectRoot, ref.file);
2830
+ const absPath = resolve7(projectRoot, ref.file);
2596
2831
  if (!fileCache.has(ref.file)) {
2597
- if (!existsSync6(absPath)) continue;
2598
- const content = readFileSync8(absPath, "utf8");
2832
+ if (!existsSync7(absPath)) continue;
2833
+ const content = readFileSync9(absPath, "utf8");
2599
2834
  fileCache.set(ref.file, content.split("\n"));
2600
2835
  }
2601
2836
  const lines = fileCache.get(ref.file);
@@ -2740,8 +2975,8 @@ var init_context = __esm({
2740
2975
  });
2741
2976
 
2742
2977
  // src/server/import/detect.ts
2743
- import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2744
- import { join as join3 } from "path";
2978
+ import { existsSync as existsSync9, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2979
+ import { join as join4 } from "path";
2745
2980
  function safeIsDir(p) {
2746
2981
  try {
2747
2982
  return statSync2(p).isDirectory();
@@ -2750,7 +2985,7 @@ function safeIsDir(p) {
2750
2985
  }
2751
2986
  }
2752
2987
  function listDirs(dir) {
2753
- return readdirSync3(dir).filter((e) => safeIsDir(join3(dir, e)));
2988
+ return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
2754
2989
  }
2755
2990
  function fileCount(dir) {
2756
2991
  try {
@@ -2764,22 +2999,22 @@ function pickSource(locales, sizeOf) {
2764
2999
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
2765
3000
  }
2766
3001
  function detectLaravel(root) {
2767
- const localeRoot = [join3(root, "resources", "lang"), join3(root, "lang")].find(safeIsDir);
3002
+ const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
2768
3003
  if (!localeRoot) return null;
2769
3004
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
2770
3005
  if (locales.length === 0) return null;
2771
- const sourceLocale = pickSource(locales, (loc) => fileCount(join3(localeRoot, loc)));
3006
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
2772
3007
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
2773
3008
  }
2774
3009
  function detectVue(root) {
2775
3010
  for (const rel of VUE_DIR_CANDIDATES) {
2776
- const localeRoot = join3(root, rel);
3011
+ const localeRoot = join4(root, rel);
2777
3012
  if (!safeIsDir(localeRoot)) continue;
2778
3013
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
2779
3014
  if (locales.length >= 2) {
2780
3015
  const sourceLocale = pickSource(locales, (loc) => {
2781
3016
  try {
2782
- return statSync2(join3(localeRoot, `${loc}.json`)).size;
3017
+ return statSync2(join4(localeRoot, `${loc}.json`)).size;
2783
3018
  } catch {
2784
3019
  return 0;
2785
3020
  }
@@ -2791,7 +3026,7 @@ function detectVue(root) {
2791
3026
  }
2792
3027
  function detectArb(root) {
2793
3028
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
2794
- const localeRoot = join3(root, rel);
3029
+ const localeRoot = join4(root, rel);
2795
3030
  if (!safeIsDir(localeRoot)) continue;
2796
3031
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
2797
3032
  if (locales.length >= 1) {
@@ -2801,7 +3036,7 @@ function detectArb(root) {
2801
3036
  return null;
2802
3037
  }
2803
3038
  function detect(root, formatOverride) {
2804
- if (!existsSync8(root)) return null;
3039
+ if (!existsSync9(root)) return null;
2805
3040
  if (formatOverride) {
2806
3041
  const fn = BY_FORMAT[formatOverride];
2807
3042
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -2856,8 +3091,8 @@ var init_flatten = __esm({
2856
3091
  });
2857
3092
 
2858
3093
  // src/server/import/parsers/vue-i18n-json.ts
2859
- import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "fs";
2860
- import { join as join4 } from "path";
3094
+ import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
3095
+ import { join as join5 } from "path";
2861
3096
  var LOCALE_RE2, vueI18nJson2;
2862
3097
  var init_vue_i18n_json2 = __esm({
2863
3098
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -2877,7 +3112,7 @@ var init_vue_i18n_json2 = __esm({
2877
3112
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2878
3113
  let data;
2879
3114
  try {
2880
- data = JSON.parse(readFileSync10(join4(localeRoot, file), "utf8"));
3115
+ data = JSON.parse(readFileSync11(join5(localeRoot, file), "utf8"));
2881
3116
  } catch (e) {
2882
3117
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
2883
3118
  continue;
@@ -2905,16 +3140,16 @@ var init_placeholders2 = __esm({
2905
3140
 
2906
3141
  // src/server/import/parsers/laravel-php.ts
2907
3142
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2908
- import { join as join5, relative as relative2 } from "path";
3143
+ import { join as join6, relative as relative2 } from "path";
2909
3144
  import { execFileSync } from "child_process";
2910
3145
  function listDirs2(dir) {
2911
- return readdirSync5(dir).filter((e) => statSync3(join5(dir, e)).isDirectory());
3146
+ return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
2912
3147
  }
2913
3148
  function listPhpFiles(dir) {
2914
3149
  const out = [];
2915
3150
  const walk = (d) => {
2916
3151
  for (const e of readdirSync5(d)) {
2917
- const full = join5(d, e);
3152
+ const full = join6(d, e);
2918
3153
  if (statSync3(full).isDirectory()) walk(full);
2919
3154
  else if (e.endsWith(".php")) out.push(full);
2920
3155
  }
@@ -2957,7 +3192,7 @@ var init_laravel_php2 = __esm({
2957
3192
  for (const locale of listDirs2(localeRoot).sort()) {
2958
3193
  if (locale === "vendor") continue;
2959
3194
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2960
- const localeDir = join5(localeRoot, locale);
3195
+ const localeDir = join6(localeRoot, locale);
2961
3196
  locales.push(locale);
2962
3197
  for (const file of listPhpFiles(localeDir)) {
2963
3198
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -2982,8 +3217,8 @@ var init_laravel_php2 = __esm({
2982
3217
  });
2983
3218
 
2984
3219
  // src/server/import/parsers/flutter-arb.ts
2985
- import { readdirSync as readdirSync6, readFileSync as readFileSync11 } from "fs";
2986
- import { join as join6 } from "path";
3220
+ import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
3221
+ import { join as join7 } from "path";
2987
3222
  function localeFromArbName(file) {
2988
3223
  const m = file.match(/^(.+)\.arb$/);
2989
3224
  if (!m) return null;
@@ -3023,7 +3258,7 @@ var init_flutter_arb2 = __esm({
3023
3258
  if (opts?.locales && !opts.locales.includes(locale)) continue;
3024
3259
  let data;
3025
3260
  try {
3026
- data = JSON.parse(readFileSync11(join6(localeRoot, file), "utf8"));
3261
+ data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
3027
3262
  } catch (e) {
3028
3263
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
3029
3264
  continue;
@@ -3127,7 +3362,6 @@ function assemble2(parsed, opts) {
3127
3362
  sourceLocale,
3128
3363
  locales,
3129
3364
  outputs: [output],
3130
- ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
3131
3365
  format: { indent: 2, sortKeys: true, finalNewline: true },
3132
3366
  spelling: { customWords: [] }
3133
3367
  },
@@ -3554,16 +3788,48 @@ var init_checks = __esm({
3554
3788
  }
3555
3789
  });
3556
3790
 
3791
+ // src/server/ui-prefs.ts
3792
+ import { readFileSync as readFileSync13 } from "fs";
3793
+ import { homedir } from "os";
3794
+ import { join as join8 } from "path";
3795
+ function readJson2(path) {
3796
+ try {
3797
+ const parsed = JSON.parse(readFileSync13(path, "utf8"));
3798
+ return parsed && typeof parsed === "object" ? parsed : {};
3799
+ } catch {
3800
+ return {};
3801
+ }
3802
+ }
3803
+ function loadUiPrefs(path) {
3804
+ const raw = readJson2(path);
3805
+ return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
3806
+ }
3807
+ function saveUiPrefs(path, prefs) {
3808
+ const merged = { ...readJson2(path), ...prefs };
3809
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3810
+ }
3811
+ var THEMES, isThemeMode, defaultUiPrefsPath, DEFAULTS;
3812
+ var init_ui_prefs = __esm({
3813
+ "src/server/ui-prefs.ts"() {
3814
+ "use strict";
3815
+ init_atomic_write();
3816
+ THEMES = ["system", "light", "dark"];
3817
+ isThemeMode = (v) => THEMES.includes(v);
3818
+ defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
3819
+ DEFAULTS = { theme: "system" };
3820
+ }
3821
+ });
3822
+
3557
3823
  // src/server/api.ts
3558
3824
  import { Hono } from "hono";
3559
3825
  import { streamSSE } from "hono/streaming";
3560
- import { writeFileSync as writeFileSync5, readFileSync as readFileSync12, mkdirSync as mkdirSync6, existsSync as existsSync9, readdirSync as readdirSync7, rmSync as rmSync3 } from "fs";
3561
- import { dirname as dirname5, resolve as resolve7, basename, relative as relative3, sep } from "path";
3826
+ import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, statSync as statSync4, rmSync as rmSync4 } from "fs";
3827
+ import { dirname as dirname2, resolve as resolve9, basename, relative as relative3, sep } from "path";
3562
3828
  function projectName(root) {
3563
- const nameFile = resolve7(root, ".idea", ".name");
3564
- if (existsSync9(nameFile)) {
3829
+ const nameFile = resolve9(root, ".idea", ".name");
3830
+ if (existsSync10(nameFile)) {
3565
3831
  try {
3566
- const name = readFileSync12(nameFile, "utf8").trim();
3832
+ const name = readFileSync14(nameFile, "utf8").trim();
3567
3833
  if (name) return name;
3568
3834
  } catch {
3569
3835
  }
@@ -3573,7 +3839,7 @@ function projectName(root) {
3573
3839
  function createApi(deps) {
3574
3840
  const app = new Hono();
3575
3841
  const load = () => loadState(deps.statePath);
3576
- const projectRoot = dirname5(resolve7(deps.statePath));
3842
+ const projectRoot = dirname2(resolve9(deps.statePath));
3577
3843
  let translateQueue = Promise.resolve();
3578
3844
  const withTranslateLock = (fn) => {
3579
3845
  const next = translateQueue.then(fn, fn);
@@ -3597,42 +3863,92 @@ function createApi(deps) {
3597
3863
  saveState(deps.statePath, s);
3598
3864
  scheduleAutoExport(s);
3599
3865
  };
3866
+ const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
3867
+ const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
3868
+ const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
3600
3869
  app.get("/state", (c) => c.json(load()));
3870
+ app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
3871
+ app.put("/ui-prefs", async (c) => {
3872
+ const { theme } = await c.req.json();
3873
+ if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
3874
+ saveUiPrefs(uiPrefsPath, { theme });
3875
+ return c.json({ ok: true });
3876
+ });
3877
+ app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
3878
+ app.put("/local-settings", async (c) => {
3879
+ const body = await c.req.json().catch(() => ({}));
3880
+ const patch = {};
3881
+ if (body.ai !== void 0) {
3882
+ const err = aiConfigError(body.ai);
3883
+ if (err) return c.json({ error: err }, 400);
3884
+ patch.ai = body.ai;
3885
+ }
3886
+ if (body.editor !== void 0) {
3887
+ if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
3888
+ patch.editor = body.editor;
3889
+ }
3890
+ if (patch.ai === void 0 && patch.editor === void 0) {
3891
+ return c.json({ error: "provide ai and/or editor" }, 400);
3892
+ }
3893
+ saveLocalSettings(projectRoot, patch);
3894
+ return c.json({ ok: true });
3895
+ });
3601
3896
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3602
3897
  app.get("/files", (c) => {
3603
3898
  const found = /* @__PURE__ */ new Map();
3604
- found.set(deps.statePath, { name: basename(deps.statePath), path: deps.statePath });
3605
- let entries = [];
3606
- try {
3607
- entries = readdirSync7(projectRoot);
3608
- } catch {
3609
- }
3610
- for (const name of entries) {
3611
- let abs;
3612
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve7(projectRoot, name, "config.json"))) {
3613
- abs = resolve7(projectRoot, `${name}.json`);
3614
- } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3615
- abs = resolve7(projectRoot, name);
3616
- } else {
3617
- continue;
3618
- }
3619
- if (found.has(abs)) continue;
3899
+ const activeRel = relative3(projectRoot, deps.statePath);
3900
+ found.set(deps.statePath, {
3901
+ name: basename(deps.statePath),
3902
+ path: deps.statePath,
3903
+ relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
3904
+ });
3905
+ function walk(dir, depth) {
3906
+ if (depth > 4) return;
3907
+ let entries = [];
3620
3908
  try {
3621
- loadState(abs);
3622
- found.set(abs, { name: basename(abs), path: abs });
3909
+ entries = readdirSync7(dir);
3623
3910
  } catch {
3911
+ return;
3912
+ }
3913
+ for (const name of entries) {
3914
+ if (name.startsWith(".") || name === "node_modules") continue;
3915
+ const abs = resolve9(dir, name);
3916
+ let filePath = null;
3917
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(abs, "config.json"))) {
3918
+ filePath = resolve9(dir, `${name}.json`);
3919
+ } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3920
+ filePath = abs;
3921
+ } else {
3922
+ try {
3923
+ if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
3924
+ } catch {
3925
+ }
3926
+ continue;
3927
+ }
3928
+ if (found.has(filePath)) continue;
3929
+ try {
3930
+ loadState(filePath);
3931
+ const rel = relative3(projectRoot, filePath);
3932
+ found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname2(rel) : void 0 });
3933
+ } catch {
3934
+ }
3624
3935
  }
3625
3936
  }
3626
- const files = [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
3937
+ walk(projectRoot, 0);
3938
+ const files = [...found.values()].sort((a, b) => {
3939
+ const ka = a.relDir ? `${a.relDir}/${a.name}` : a.name;
3940
+ const kb = b.relDir ? `${b.relDir}/${b.name}` : b.name;
3941
+ return ka.localeCompare(kb);
3942
+ });
3627
3943
  return c.json(files);
3628
3944
  });
3629
3945
  app.post("/file", async (c) => {
3630
3946
  const { path } = await c.req.json();
3631
3947
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
3632
- const resolved = resolve7(projectRoot, path);
3948
+ const resolved = resolve9(projectRoot, path);
3633
3949
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
3634
3950
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
3635
- if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
3951
+ if (!existsSync10(resolved)) return c.json({ error: "file not found" }, 400);
3636
3952
  loadState(resolved);
3637
3953
  deps.statePath = resolved;
3638
3954
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -3647,6 +3963,7 @@ function createApi(deps) {
3647
3963
  const s = load();
3648
3964
  createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
3649
3965
  persist(s);
3966
+ logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
3650
3967
  console.log(`[key] created ${key}`);
3651
3968
  return c.json({ ok: true });
3652
3969
  });
@@ -3656,37 +3973,45 @@ function createApi(deps) {
3656
3973
  const s = load();
3657
3974
  addCustomWord(s, word);
3658
3975
  persist(s);
3976
+ logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
3659
3977
  return c.json({ ok: true });
3660
3978
  });
3661
3979
  app.delete("/dictionary/:word", (c) => {
3662
3980
  const s = load();
3663
- removeCustomWord(s, c.req.param("word"));
3981
+ const word = c.req.param("word");
3982
+ removeCustomWord(s, word);
3664
3983
  persist(s);
3984
+ logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
3665
3985
  return c.json({ ok: true });
3666
3986
  });
3667
3987
  app.patch("/keys/:key", async (c) => {
3668
3988
  const key = c.req.param("key");
3669
3989
  const body = await c.req.json();
3670
3990
  const s = load();
3991
+ const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
3671
3992
  if (typeof body.rename === "string") renameKey(s, key, body.rename);
3672
3993
  const target = typeof body.rename === "string" ? body.rename : key;
3673
3994
  if (body.metadata) setMetadata(s, target, body.metadata);
3674
3995
  if (typeof body.source === "string") setSourceValue(s, target, body.source);
3675
3996
  if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
3676
3997
  persist(s);
3998
+ if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
3999
+ if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
4000
+ if (typeof body.source === "string") logChange({ kind: "translation", summary: `Set source value of ${target}`, key: target, locale: s.config.sourceLocale, before: beforeSource, after: body.source });
4001
+ if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
3677
4002
  if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
3678
4003
  return c.json({ ok: true });
3679
4004
  });
3680
4005
  function removeOrphanScreenshot(s, screenshot) {
3681
4006
  if (!screenshot) return;
3682
4007
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
3683
- const root = dirname5(resolve7(deps.statePath));
3684
- const abs = resolve7(root, screenshot);
4008
+ const root = dirname2(resolve9(deps.statePath));
4009
+ const abs = resolve9(root, screenshot);
3685
4010
  const rel = relative3(root, abs);
3686
4011
  const seg0 = rel.split(sep)[0] ?? "";
3687
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
4012
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync10(abs)) {
3688
4013
  try {
3689
- rmSync3(abs);
4014
+ rmSync4(abs);
3690
4015
  } catch {
3691
4016
  }
3692
4017
  }
@@ -3695,9 +4020,11 @@ function createApi(deps) {
3695
4020
  const s = load();
3696
4021
  const key = c.req.param("key");
3697
4022
  const shot = s.keys[key]?.screenshot;
4023
+ const before = valueText(s, key, s.config.sourceLocale);
3698
4024
  deleteKey(s, key);
3699
4025
  removeOrphanScreenshot(s, shot);
3700
4026
  persist(s);
4027
+ logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
3701
4028
  console.log(`[key] deleted ${key}`);
3702
4029
  return c.json({ ok: true });
3703
4030
  });
@@ -3721,6 +4048,7 @@ function createApi(deps) {
3721
4048
  }
3722
4049
  }
3723
4050
  persist(s);
4051
+ if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
3724
4052
  console.log(`[bulk] cleared ${cleared} value(s)`);
3725
4053
  return c.json({ cleared });
3726
4054
  });
@@ -3738,6 +4066,7 @@ function createApi(deps) {
3738
4066
  }
3739
4067
  for (const shot of shots) removeOrphanScreenshot(s, shot);
3740
4068
  persist(s);
4069
+ if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
3741
4070
  console.log(`[bulk] deleted ${removed.length} key(s)`);
3742
4071
  return c.json({ removed });
3743
4072
  });
@@ -3763,6 +4092,7 @@ function createApi(deps) {
3763
4092
  updated++;
3764
4093
  }
3765
4094
  persist(s);
4095
+ if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
3766
4096
  console.log(`[bulk] updated metadata on ${updated} key(s)`);
3767
4097
  return c.json({ updated });
3768
4098
  });
@@ -3786,6 +4116,7 @@ function createApi(deps) {
3786
4116
  }
3787
4117
  }
3788
4118
  persist(s);
4119
+ if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
3789
4120
  console.log(`[bulk] set state ${next} on ${updated} value(s)`);
3790
4121
  return c.json({ updated });
3791
4122
  });
@@ -3795,15 +4126,21 @@ function createApi(deps) {
3795
4126
  const s = load();
3796
4127
  const key = c.req.param("key");
3797
4128
  const locale = c.req.param("locale");
4129
+ const before = valueText(s, key, locale);
3798
4130
  if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
3799
4131
  else setTargetValue(s, key, locale, value);
3800
4132
  persist(s);
4133
+ logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
3801
4134
  return c.json({ ok: true });
3802
4135
  });
3803
4136
  app.delete("/keys/:key/values/:locale", (c) => {
3804
4137
  const s = load();
3805
- clearValue(s, c.req.param("key"), c.req.param("locale"));
4138
+ const key = c.req.param("key");
4139
+ const locale = c.req.param("locale");
4140
+ const before = valueText(s, key, locale);
4141
+ clearValue(s, key, locale);
3806
4142
  persist(s);
4143
+ logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
3807
4144
  return c.json({ ok: true });
3808
4145
  });
3809
4146
  app.put("/keys/:key/plural/:locale", async (c) => {
@@ -3812,52 +4149,68 @@ function createApi(deps) {
3812
4149
  const s = load();
3813
4150
  const key = c.req.param("key");
3814
4151
  const locale = c.req.param("locale");
4152
+ const before = s.keys[key]?.values[locale]?.forms;
3815
4153
  if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
3816
4154
  else setPluralForms(s, key, locale, forms);
3817
4155
  persist(s);
4156
+ logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
3818
4157
  return c.json({ ok: true });
3819
4158
  });
3820
4159
  app.post("/keys/:key/plural", async (c) => {
3821
4160
  const { arg } = await c.req.json();
3822
4161
  if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
3823
4162
  const s = load();
3824
- convertToPlural(s, c.req.param("key"), arg);
4163
+ const key = c.req.param("key");
4164
+ convertToPlural(s, key, arg);
3825
4165
  persist(s);
4166
+ logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
3826
4167
  return c.json({ ok: true });
3827
4168
  });
3828
4169
  app.delete("/keys/:key/plural", (c) => {
3829
4170
  const s = load();
3830
- convertToScalar(s, c.req.param("key"));
4171
+ const key = c.req.param("key");
4172
+ convertToScalar(s, key);
3831
4173
  persist(s);
4174
+ logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
3832
4175
  return c.json({ ok: true });
3833
4176
  });
3834
4177
  app.put("/keys/:key/values/:locale/state", async (c) => {
3835
4178
  const { state } = await c.req.json();
3836
4179
  const s = load();
3837
- setKeyState(s, c.req.param("key"), c.req.param("locale"), state);
4180
+ const key = c.req.param("key");
4181
+ const locale = c.req.param("locale");
4182
+ const before = s.keys[key]?.values[locale]?.state;
4183
+ setKeyState(s, key, locale, state);
3838
4184
  persist(s);
4185
+ logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
3839
4186
  return c.json({ ok: true });
3840
4187
  });
3841
4188
  app.post("/keys/:key/notes", async (c) => {
3842
4189
  const { text } = await c.req.json();
3843
4190
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3844
4191
  const s = load();
3845
- const note = addNote(s, c.req.param("key"), text);
4192
+ const key = c.req.param("key");
4193
+ const note = addNote(s, key, text);
3846
4194
  persist(s);
4195
+ logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
3847
4196
  return c.json(note);
3848
4197
  });
3849
4198
  app.put("/keys/:key/notes/:id", async (c) => {
3850
4199
  const { text } = await c.req.json();
3851
4200
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3852
4201
  const s = load();
3853
- editNote(s, c.req.param("key"), c.req.param("id"), text);
4202
+ const key = c.req.param("key");
4203
+ editNote(s, key, c.req.param("id"), text);
3854
4204
  persist(s);
4205
+ logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
3855
4206
  return c.json({ ok: true });
3856
4207
  });
3857
4208
  app.delete("/keys/:key/notes/:id", (c) => {
3858
4209
  const s = load();
3859
- deleteNote(s, c.req.param("key"), c.req.param("id"));
4210
+ const key = c.req.param("key");
4211
+ deleteNote(s, key, c.req.param("id"));
3860
4212
  persist(s);
4213
+ logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
3861
4214
  return c.json({ ok: true });
3862
4215
  });
3863
4216
  app.put("/config", async (c) => {
@@ -3866,6 +4219,7 @@ function createApi(deps) {
3866
4219
  return c.json({ error: "config.locales must be an array" }, 400);
3867
4220
  }
3868
4221
  const s = load();
4222
+ const beforeCfg = { locales: s.config.locales };
3869
4223
  const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
3870
4224
  for (const l of removed) {
3871
4225
  for (const e of Object.values(s.keys)) delete e.values[l];
@@ -3873,7 +4227,8 @@ function createApi(deps) {
3873
4227
  s.config = newConfig;
3874
4228
  validate(s);
3875
4229
  persist(s);
3876
- console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s), model ${newConfig.ai.model}`);
4230
+ logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
4231
+ console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
3877
4232
  return c.json({ ok: true });
3878
4233
  });
3879
4234
  app.get("/glossary", (c) => c.json(load().glossary));
@@ -3881,14 +4236,19 @@ function createApi(deps) {
3881
4236
  const entry = await c.req.json();
3882
4237
  if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
3883
4238
  const s = load();
4239
+ const before = s.glossary.find((g) => g.term === entry.term);
3884
4240
  upsertGlossaryEntry(s, entry);
3885
4241
  persist(s);
4242
+ logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
3886
4243
  return c.json({ ok: true });
3887
4244
  });
3888
4245
  app.delete("/glossary/:term", (c) => {
3889
4246
  const s = load();
3890
- deleteGlossaryEntry(s, decodeURIComponent(c.req.param("term")));
4247
+ const term = decodeURIComponent(c.req.param("term"));
4248
+ const before = s.glossary.find((g) => g.term === term);
4249
+ deleteGlossaryEntry(s, term);
3891
4250
  persist(s);
4251
+ logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
3892
4252
  return c.json({ ok: true });
3893
4253
  });
3894
4254
  app.post("/keys/:key/screenshot", async (c) => {
@@ -3896,18 +4256,18 @@ function createApi(deps) {
3896
4256
  const body = await c.req.parseBody();
3897
4257
  const file = body["file"];
3898
4258
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
3899
- const root = dirname5(resolve7(deps.statePath));
4259
+ const root = dirname2(resolve9(deps.statePath));
3900
4260
  const dirName = screenshotDirName(deps.statePath);
3901
- const dir = resolve7(root, dirName);
3902
- mkdirSync6(dir, { recursive: true });
4261
+ const dir = resolve9(root, dirName);
3903
4262
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
3904
- writeFileSync5(resolve7(dir, filename), Buffer.from(await file.arrayBuffer()));
4263
+ writeFileAtomic(resolve9(dir, filename), Buffer.from(await file.arrayBuffer()));
3905
4264
  const path = `${dirName}/${filename}`;
3906
4265
  const s = load();
3907
4266
  const prev = s.keys[key]?.screenshot;
3908
4267
  setMetadata(s, key, { screenshot: path });
3909
4268
  if (prev && prev !== path) removeOrphanScreenshot(s, prev);
3910
4269
  persist(s);
4270
+ logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
3911
4271
  return c.json({ path });
3912
4272
  });
3913
4273
  app.delete("/keys/:key/screenshot", (c) => {
@@ -3917,6 +4277,7 @@ function createApi(deps) {
3917
4277
  setMetadata(s, key, { screenshot: void 0 });
3918
4278
  removeOrphanScreenshot(s, shot);
3919
4279
  persist(s);
4280
+ logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
3920
4281
  return c.json({ ok: true });
3921
4282
  });
3922
4283
  app.get("/export/preview", (c) => {
@@ -3961,12 +4322,13 @@ function createApi(deps) {
3961
4322
  return c.json({ error: e.message }, 400);
3962
4323
  }
3963
4324
  persist(result.state);
4325
+ logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
3964
4326
  console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
3965
4327
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
3966
4328
  });
3967
4329
  app.post("/export", (c) => {
3968
4330
  const s = narrowForExport(load());
3969
- const root = dirname5(resolve7(deps.statePath));
4331
+ const root = dirname2(resolve9(deps.statePath));
3970
4332
  const warnings = [];
3971
4333
  let count = 0;
3972
4334
  for (const output of s.config.outputs) {
@@ -3974,9 +4336,8 @@ function createApi(deps) {
3974
4336
  const result = adapter.export(s, output);
3975
4337
  warnings.push(...result.warnings);
3976
4338
  for (const f of result.files) {
3977
- const abs = resolve7(root, f.path);
3978
- mkdirSync6(dirname5(abs), { recursive: true });
3979
- writeFileSync5(abs, f.contents, "utf8");
4339
+ const abs = resolve9(root, f.path);
4340
+ writeFileAtomic(abs, f.contents);
3980
4341
  count++;
3981
4342
  }
3982
4343
  }
@@ -3995,41 +4356,63 @@ function createApi(deps) {
3995
4356
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
3996
4357
  return;
3997
4358
  }
4359
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3998
4360
  let provider;
3999
4361
  try {
4000
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4362
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4001
4363
  } catch (e) {
4002
4364
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4003
4365
  return;
4004
4366
  }
4005
4367
  const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
4006
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4007
- console.log(`[translate] ${reqs.length} string(s) \u2192 ${s.config.ai.model}`);
4368
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4369
+ console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4008
4370
  let totalWritten = 0;
4009
4371
  const allErrors = [];
4010
4372
  const system = buildSystemPrompt();
4011
4373
  const reqById = new Map(reqs.map((r) => [r.id, r]));
4012
- await runLocaleParallel(reqs, provider, (done, total, batchResults) => {
4013
- const { written, errors } = applyResults(s, reqs, batchResults);
4014
- persist(s);
4015
- totalWritten += written;
4016
- allErrors.push(...errors);
4017
- appendAiLog(projectRoot, {
4018
- at: (/* @__PURE__ */ new Date()).toISOString(),
4019
- kind: "translate",
4020
- model: s.config.ai.model,
4021
- system,
4022
- items: batchResults.map((r) => {
4023
- const req = reqById.get(r.id);
4024
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
4025
- }),
4026
- results: batchResults
4027
- });
4028
- console.log(`[translate] ${done}/${total}`);
4029
- void stream.writeSSE({
4030
- event: "progress",
4031
- data: JSON.stringify({ done, total, written: totalWritten, errors })
4032
- });
4374
+ const localeTotals = /* @__PURE__ */ new Map();
4375
+ for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
4376
+ const localeDone = /* @__PURE__ */ new Map();
4377
+ await stream.writeSSE({
4378
+ event: "start",
4379
+ data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
4380
+ });
4381
+ await runLocaleParallel(reqs, provider, {
4382
+ // Announce a language the moment a worker picks it up — this is the
4383
+ // signal that "something is happening" during the long first LLM call.
4384
+ onLocaleStart: (locale) => {
4385
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
4386
+ },
4387
+ onBatchComplete: (done, total, batchResults, locale) => {
4388
+ const fresh = load();
4389
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
4390
+ persist(fresh);
4391
+ totalWritten += written;
4392
+ allErrors.push(...errors);
4393
+ appendLog(projectRoot, {
4394
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4395
+ kind: "translate",
4396
+ summary: `Translated ${batchResults.length} item(s)`,
4397
+ model: aiCfg.model,
4398
+ system,
4399
+ items: batchResults.map((r) => {
4400
+ const req = reqById.get(r.id);
4401
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
4402
+ }),
4403
+ results: batchResults
4404
+ });
4405
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
4406
+ localeDone.set(locale, ld);
4407
+ console.log(`[translate] ${done}/${total}`);
4408
+ void stream.writeSSE({
4409
+ event: "progress",
4410
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
4411
+ });
4412
+ },
4413
+ onLocaleDone: (locale) => {
4414
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4415
+ }
4033
4416
  }, void 0, signal);
4034
4417
  if (!signal?.aborted) {
4035
4418
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
@@ -4052,19 +4435,22 @@ function createApi(deps) {
4052
4435
  let written = 0;
4053
4436
  let errors = [];
4054
4437
  if (toTranslate.length) {
4438
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4055
4439
  let provider;
4056
4440
  try {
4057
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4441
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4058
4442
  } catch (e) {
4059
4443
  return c.json({ error: e.message }, 400);
4060
4444
  }
4061
4445
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4062
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4446
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4063
4447
  const results = await runLocaleParallel(toTranslate, provider);
4064
4448
  ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
4065
4449
  const entry = {
4066
4450
  at: (/* @__PURE__ */ new Date()).toISOString(),
4067
- model: s.config.ai.model,
4451
+ kind: "translate",
4452
+ summary: `Translated ${toTranslate.length} item(s)`,
4453
+ model: aiCfg.model,
4068
4454
  system: buildSystemPrompt(),
4069
4455
  // Log the screenshot PATH only — never the image bytes.
4070
4456
  items: toTranslate.map((r) => ({
@@ -4078,12 +4464,12 @@ function createApi(deps) {
4078
4464
  })),
4079
4465
  results
4080
4466
  };
4081
- appendAiLog(projectRoot, entry);
4467
+ appendLog(projectRoot, entry);
4082
4468
  }
4083
4469
  persist(s);
4084
4470
  return c.json({ requested: reqs.length, written, errors });
4085
4471
  }));
4086
- app.get("/ai-log", (c) => c.json(readAiLog(projectRoot, 100)));
4472
+ app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
4087
4473
  app.post("/scan", async (c) => {
4088
4474
  const s = load();
4089
4475
  const existing = loadUsageCache(projectRoot);
@@ -4107,7 +4493,7 @@ function createApi(deps) {
4107
4493
  const refs = [];
4108
4494
  const prefixRefs = [];
4109
4495
  for (const [file, entry] of Object.entries(cache2.files)) {
4110
- const abs = resolve7(projectRoot, file);
4496
+ const abs = resolve9(projectRoot, file);
4111
4497
  for (const r of entry.refs) {
4112
4498
  if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
4113
4499
  }
@@ -4148,9 +4534,10 @@ function createApi(deps) {
4148
4534
  keys: body.keys
4149
4535
  }, cache2, body.lastRunAt);
4150
4536
  if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4537
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4151
4538
  let provider;
4152
4539
  try {
4153
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4540
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4154
4541
  } catch (e) {
4155
4542
  return c.json({ error: e.message }, 400);
4156
4543
  }
@@ -4172,10 +4559,11 @@ function createApi(deps) {
4172
4559
  const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4173
4560
  const batch = raw;
4174
4561
  const { written, errors } = applyContext(s, targets, batch.items ?? []);
4175
- appendAiLog(projectRoot, {
4562
+ appendLog(projectRoot, {
4176
4563
  at: (/* @__PURE__ */ new Date()).toISOString(),
4177
4564
  kind: "context",
4178
- model: s.config.ai.model,
4565
+ summary: `Generated context for ${targets.length} key(s)`,
4566
+ model: aiCfg.model,
4179
4567
  system,
4180
4568
  items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4181
4569
  results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
@@ -4207,6 +4595,9 @@ var init_api = __esm({
4207
4595
  init_schema();
4208
4596
  init_run2();
4209
4597
  init_export_run();
4598
+ init_ui_prefs();
4599
+ init_local_settings();
4600
+ init_atomic_write();
4210
4601
  sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
4211
4602
  screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
4212
4603
  }
@@ -4221,7 +4612,7 @@ __export(server_exports, {
4221
4612
  import { Hono as Hono2 } from "hono";
4222
4613
  import { serve } from "@hono/node-server";
4223
4614
  import { fileURLToPath } from "url";
4224
- import { dirname as dirname6, join as join7, resolve as resolve8, extname as extname3, sep as sep2 } from "path";
4615
+ import { dirname as dirname3, join as join9, resolve as resolve10, extname as extname3, sep as sep2 } from "path";
4225
4616
  import { readFile, stat } from "fs/promises";
4226
4617
  import { createServer } from "net";
4227
4618
  import open from "open";
@@ -4239,14 +4630,14 @@ async function readFileResponse(absPath) {
4239
4630
  function buildApp(opts) {
4240
4631
  const app = new Hono2();
4241
4632
  app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
4242
- const projectRoot = dirname6(resolve8(opts.statePath));
4633
+ const projectRoot = dirname3(resolve10(opts.statePath));
4243
4634
  app.get("/:dir/*", async (c, next) => {
4244
4635
  const dirSeg = c.req.param("dir");
4245
4636
  if (!dirSeg.endsWith("-screenshots")) return next();
4246
- const shotsRoot = resolve8(projectRoot, dirSeg);
4637
+ const shotsRoot = resolve10(projectRoot, dirSeg);
4247
4638
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4248
4639
  const rest = pathname.slice(`/${dirSeg}`.length);
4249
- const target = resolve8(shotsRoot, "." + rest);
4640
+ const target = resolve10(shotsRoot, "." + rest);
4250
4641
  const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
4251
4642
  if (inside) {
4252
4643
  const file = await readFileResponse(target);
@@ -4255,19 +4646,21 @@ function buildApp(opts) {
4255
4646
  return c.notFound();
4256
4647
  });
4257
4648
  if (!opts.dev) {
4258
- const root = resolve8(opts.uiDir ?? DEFAULT_UI_DIR);
4649
+ const root = resolve10(opts.uiDir ?? DEFAULT_UI_DIR);
4259
4650
  app.get("/*", async (c) => {
4260
4651
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4261
- const target = resolve8(root, "." + pathname);
4652
+ const target = resolve10(root, "." + pathname);
4262
4653
  const inside = target === root || target.startsWith(root + sep2);
4263
4654
  if (inside && pathname !== "/") {
4264
4655
  const file = await readFileResponse(target);
4265
4656
  if (file) return file;
4266
4657
  }
4267
- const index = await readFileResponse(join7(root, "index.html"));
4658
+ const index = await readFileResponse(join9(root, "index.html"));
4268
4659
  if (index) return index;
4269
4660
  return c.notFound();
4270
4661
  });
4662
+ } else {
4663
+ app.get("/", (c) => c.html(DEV_LANDING_PAGE));
4271
4664
  }
4272
4665
  return app;
4273
4666
  }
@@ -4299,7 +4692,7 @@ async function startServer(opts) {
4299
4692
  });
4300
4693
  }
4301
4694
  function backgroundScan(statePath) {
4302
- const projectRoot = dirname6(resolve8(statePath));
4695
+ const projectRoot = dirname3(resolve10(statePath));
4303
4696
  Promise.resolve().then(() => {
4304
4697
  const state = loadState(statePath);
4305
4698
  const existing = loadUsageCache(projectRoot);
@@ -4311,7 +4704,7 @@ function backgroundScan(statePath) {
4311
4704
  console.warn("[scan] failed:", err instanceof Error ? err.message : String(err));
4312
4705
  });
4313
4706
  }
4314
- var here, DEFAULT_UI_DIR, MIME, DEFAULT_PORT, DEV_PORT;
4707
+ var here, DEFAULT_UI_DIR, MIME, DEFAULT_PORT, DEV_PORT, DEV_UI_URL, DEV_LANDING_PAGE;
4315
4708
  var init_server = __esm({
4316
4709
  "src/server/server.ts"() {
4317
4710
  "use strict";
@@ -4319,8 +4712,8 @@ var init_server = __esm({
4319
4712
  init_state();
4320
4713
  init_scan();
4321
4714
  init_scanner();
4322
- here = dirname6(fileURLToPath(import.meta.url));
4323
- DEFAULT_UI_DIR = join7(here, "..", "ui");
4715
+ here = dirname3(fileURLToPath(import.meta.url));
4716
+ DEFAULT_UI_DIR = join9(here, "..", "ui");
4324
4717
  MIME = {
4325
4718
  ".html": "text/html; charset=utf-8",
4326
4719
  ".js": "text/javascript; charset=utf-8",
@@ -4337,6 +4730,15 @@ var init_server = __esm({
4337
4730
  };
4338
4731
  DEFAULT_PORT = 3e3;
4339
4732
  DEV_PORT = 8787;
4733
+ DEV_UI_URL = "http://localhost:5173";
4734
+ DEV_LANDING_PAGE = `<!doctype html>
4735
+ <html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
4736
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:16vh auto;padding:0 1.5rem;color:#1f2937}h1{font-size:1.4rem}a{color:#2563eb}code{background:#f3f4f6;padding:.1em .35em;border-radius:.3em}</style>
4737
+ </head><body>
4738
+ <h1>Glotfile \u2014 dev API server</h1>
4739
+ <p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
4740
+ <p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
4741
+ </body></html>`;
4340
4742
  }
4341
4743
  });
4342
4744
 
@@ -4345,14 +4747,15 @@ init_state();
4345
4747
  init_export_run();
4346
4748
  init_storage();
4347
4749
  init_ai();
4750
+ init_local_settings();
4348
4751
  init_run();
4349
4752
  init_provider();
4350
4753
  init_log();
4351
4754
  init_scan();
4352
4755
  init_scanner();
4353
4756
  init_context();
4354
- import { resolve as resolve9, dirname as dirname7 } from "path";
4355
- import { readFileSync as readFileSync13, existsSync as existsSync10 } from "fs";
4757
+ import { resolve as resolve11, dirname as dirname4 } from "path";
4758
+ import { readFileSync as readFileSync15, existsSync as existsSync11 } from "fs";
4356
4759
 
4357
4760
  // src/server/lint/run.ts
4358
4761
  init_glob();
@@ -4654,15 +5057,15 @@ async function runLint(state, options = {}) {
4654
5057
 
4655
5058
  // src/server/lint/outputs.ts
4656
5059
  init_adapters();
4657
- import { readFileSync as readFileSync9, existsSync as existsSync7 } from "fs";
4658
- import { resolve as resolve6 } from "path";
5060
+ import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
5061
+ import { resolve as resolve8 } from "path";
4659
5062
  function checkOutputs(state, root) {
4660
5063
  const out = [];
4661
5064
  for (const output of state.config.outputs) {
4662
5065
  const result = getAdapter(output.adapter).export(state, output);
4663
5066
  for (const file of result.files) {
4664
- const abs = resolve6(root, file.path);
4665
- const current = existsSync7(abs) ? readFileSync9(abs, "utf8") : null;
5067
+ const abs = resolve8(root, file.path);
5068
+ const current = existsSync8(abs) ? readFileSync10(abs, "utf8") : null;
4666
5069
  if (current === null) {
4667
5070
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4668
5071
  } else if (current !== file.contents) {
@@ -4740,7 +5143,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
4740
5143
  var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "build-context", "scan", "prune", "split"];
4741
5144
  var isCommand = (s) => s != null && COMMANDS.includes(s);
4742
5145
  function parseArgs(argv) {
4743
- const statePath = resolve9(process.cwd(), "glotfile.json");
5146
+ const statePath = resolve11(process.cwd(), "glotfile.json");
4744
5147
  const first = argv[0];
4745
5148
  if (first === "help" || first === "--help" || first === "-h") {
4746
5149
  return isCommand(argv[1]) ? { command: argv[1], statePath, help: true } : { command: "help", statePath };
@@ -4758,7 +5161,7 @@ function parseArgs(argv) {
4758
5161
  if (flag === "--help" || flag === "-h") args.help = true;
4759
5162
  else if (flag === "--dev") args.dev = true;
4760
5163
  else if ((flag === "--file" || flag === "-f") && next) {
4761
- args.statePath = resolve9(process.cwd(), next);
5164
+ args.statePath = resolve11(process.cwd(), next);
4762
5165
  i++;
4763
5166
  } else if (flag === "--adapter" && next) {
4764
5167
  args.adapter = next;
@@ -4814,7 +5217,7 @@ function watchTargetFor(statePath) {
4814
5217
  return detectFormat(statePath) === "split" ? { path: splitDirFor(statePath), recursive: true } : { path: statePath, recursive: false };
4815
5218
  }
4816
5219
  async function runExport(args) {
4817
- const root = dirname7(resolve9(args.statePath));
5220
+ const root = dirname4(resolve11(args.statePath));
4818
5221
  const runOnce = () => {
4819
5222
  const state = loadState(args.statePath);
4820
5223
  const result = exportToDisk(state, root, args.adapter ? { adapter: args.adapter } : void 0);
@@ -4850,7 +5253,7 @@ async function runExport(args) {
4850
5253
  }
4851
5254
  async function runTranslate(args) {
4852
5255
  const state = loadState(args.statePath);
4853
- const projectRoot = dirname7(resolve9(args.statePath));
5256
+ const projectRoot = dirname4(resolve11(args.statePath));
4854
5257
  const reqs = selectRequests(state, {
4855
5258
  // Default to translating only empty values; --all forces a full re-translate
4856
5259
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -4862,33 +5265,38 @@ async function runTranslate(args) {
4862
5265
  let written = 0;
4863
5266
  let errors = [];
4864
5267
  if (toTranslate.length) {
5268
+ const ai = loadLocalSettings(projectRoot).ai;
4865
5269
  let provider;
4866
5270
  try {
4867
- provider = makeProvider(state.config);
5271
+ provider = makeProvider(ai);
4868
5272
  } catch (e) {
4869
5273
  console.error(e.message);
4870
5274
  process.exitCode = 1;
4871
5275
  return;
4872
5276
  }
4873
5277
  const { skipped } = attachScreenshotsForProvider(toTranslate, state, projectRoot, provider.supportsVision());
4874
- if (skipped) console.warn(`Model "${state.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
5278
+ if (skipped) console.warn(`Model "${ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4875
5279
  console.log(`Translating ${toTranslate.length} string(s)\u2026`);
4876
5280
  let batchCallbackFired = false;
4877
- const results = await runLocaleParallel(toTranslate, provider, (done, total, batchResults) => {
4878
- batchCallbackFired = true;
4879
- const batchApplied = applyResults(state, toTranslate, batchResults);
4880
- written += batchApplied.written;
4881
- errors.push(...batchApplied.errors);
4882
- saveState(args.statePath, state);
4883
- process.stdout.write(`\r ${done}/${total} translated`);
5281
+ const results = await runLocaleParallel(toTranslate, provider, {
5282
+ onBatchComplete: (done, total, batchResults) => {
5283
+ batchCallbackFired = true;
5284
+ const batchApplied = applyResults(state, toTranslate, batchResults);
5285
+ written += batchApplied.written;
5286
+ errors.push(...batchApplied.errors);
5287
+ saveState(args.statePath, state);
5288
+ process.stdout.write(`\r ${done}/${total} translated`);
5289
+ }
4884
5290
  });
4885
5291
  process.stdout.write("\n");
4886
5292
  if (!batchCallbackFired) {
4887
5293
  ({ written, errors } = applyResults(state, toTranslate, results));
4888
5294
  }
4889
- appendAiLog(projectRoot, {
5295
+ appendLog(projectRoot, {
4890
5296
  at: (/* @__PURE__ */ new Date()).toISOString(),
4891
- model: state.config.ai.model,
5297
+ kind: "translate",
5298
+ summary: `Translated ${toTranslate.length} item(s)`,
5299
+ model: ai.model,
4892
5300
  system: buildSystemPrompt(),
4893
5301
  items: toTranslate.map((r) => ({
4894
5302
  id: r.id,
@@ -4915,7 +5323,7 @@ function printReport(report, format, rawText) {
4915
5323
  }
4916
5324
  async function runLintCmd(args) {
4917
5325
  const state = loadState(args.statePath);
4918
- const rawText = existsSync10(args.statePath) ? readFileSync13(args.statePath, "utf8") : "";
5326
+ const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
4919
5327
  const report = await runLint(state, { locales: args.locales, ruleIds: args.ruleIds });
4920
5328
  printReport(report, args.format, rawText);
4921
5329
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
@@ -4935,8 +5343,8 @@ async function runCheck(args) {
4935
5343
  process.exitCode = 1;
4936
5344
  return;
4937
5345
  }
4938
- const rawText = existsSync10(args.statePath) ? readFileSync13(args.statePath, "utf8") : "";
4939
- const root = dirname7(resolve9(args.statePath));
5346
+ const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5347
+ const root = dirname4(resolve11(args.statePath));
4940
5348
  const lint = await runLint(state, {});
4941
5349
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
4942
5350
  const counts = countSeverities(findings);
@@ -4946,9 +5354,9 @@ async function runCheck(args) {
4946
5354
  }
4947
5355
  async function runImportCmd(args) {
4948
5356
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
4949
- const projectRoot = args.importSource ? resolve9(args.importSource) : dirname7(resolve9(args.statePath));
4950
- const out = resolve9(projectRoot, "glotfile.json");
4951
- if (existsSync10(out) && !args.importForce) {
5357
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname4(resolve11(args.statePath));
5358
+ const out = resolve11(projectRoot, "glotfile.json");
5359
+ if (existsSync11(out) && !args.importForce) {
4952
5360
  console.error(`${out} already exists; pass --force to overwrite`);
4953
5361
  process.exitCode = 1;
4954
5362
  return;
@@ -4973,7 +5381,7 @@ async function runImportCmd(args) {
4973
5381
  }
4974
5382
  async function runBuildContext(args) {
4975
5383
  const state = loadState(args.statePath);
4976
- const projectRoot = dirname7(resolve9(args.statePath));
5384
+ const projectRoot = dirname4(resolve11(args.statePath));
4977
5385
  const cache2 = loadUsageCache(projectRoot);
4978
5386
  if (!cache2) {
4979
5387
  console.error("No usage index found. Run 'glotfile scan' first.");
@@ -4992,7 +5400,7 @@ async function runBuildContext(args) {
4992
5400
  }
4993
5401
  let provider;
4994
5402
  try {
4995
- provider = makeProvider(state.config);
5403
+ provider = makeProvider(loadLocalSettings(projectRoot).ai);
4996
5404
  } catch (e) {
4997
5405
  console.error(e.message);
4998
5406
  process.exitCode = 1;
@@ -5022,7 +5430,7 @@ async function runBuildContext(args) {
5022
5430
  }
5023
5431
  async function runScanCmd(args) {
5024
5432
  const state = loadState(args.statePath);
5025
- const projectRoot = dirname7(resolve9(args.statePath));
5433
+ const projectRoot = dirname4(resolve11(args.statePath));
5026
5434
  const existing = loadUsageCache(projectRoot);
5027
5435
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
5028
5436
  const fileCount2 = Object.keys(result.files).length;
@@ -5041,7 +5449,7 @@ async function runPrune(args) {
5041
5449
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
5042
5450
  }
5043
5451
  if (args.unused) {
5044
- const projectRoot = dirname7(resolve9(args.statePath));
5452
+ const projectRoot = dirname4(resolve11(args.statePath));
5045
5453
  const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
5046
5454
  const used = new Set(computeUsedKeys(state, cache2));
5047
5455
  for (const k of Object.keys(state.keys)) {
@@ -5208,9 +5616,10 @@ async function main(argv) {
5208
5616
  if (args.command === "split") return runSplit(args);
5209
5617
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
5210
5618
  const { url } = await startServer2({ statePath: args.statePath, dev: args.dev });
5211
- console.log(`Glotfile running at ${url}`);
5619
+ if (args.dev) console.log(`Glotfile dev API on ${url} \u2014 open the UI at the Vite "Local:" URL above`);
5620
+ else console.log(`Glotfile running at ${url}`);
5212
5621
  }
5213
- if (resolve9(process.argv[1] ?? "") === resolve9(fileURLToPath2(import.meta.url))) {
5622
+ if (resolve11(process.argv[1] ?? "") === resolve11(fileURLToPath2(import.meta.url))) {
5214
5623
  main(process.argv.slice(2)).catch((err) => {
5215
5624
  console.error(err instanceof Error ? err.message : String(err));
5216
5625
  process.exit(1);