@i18n-micro/cli 0.0.0-stage → 2.0.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/bin/i18n-micro.mjs +3 -0
  4. package/dist/chunks/check-BaP_H-mS.mjs +175 -0
  5. package/dist/chunks/clean-Do9z6D5j.mjs +68 -0
  6. package/dist/chunks/clean-bQVjG_2M.mjs +1 -0
  7. package/dist/chunks/export-C2J-pagB.mjs +219 -0
  8. package/dist/chunks/export-usBaNIpp.mjs +1 -0
  9. package/dist/chunks/extract-CJ8UfrVJ.mjs +30 -0
  10. package/dist/chunks/format-6CtMgO-M.mjs +26 -0
  11. package/dist/chunks/http-whB7a5ye.mjs +100 -0
  12. package/dist/chunks/import-CnL8U6Za.mjs +99 -0
  13. package/dist/chunks/info-rYas4Yw3.mjs +32 -0
  14. package/dist/chunks/json-BV_apCMu.mjs +439 -0
  15. package/dist/chunks/lint-BaEIQGMl.mjs +125 -0
  16. package/dist/chunks/nuxt-dKxG087V.mjs +366 -0
  17. package/dist/chunks/package-f9OTcDxl.mjs +3 -0
  18. package/dist/chunks/project-DgWgr7pF.mjs +206 -0
  19. package/dist/chunks/protect-D1HIuxmJ.mjs +36 -0
  20. package/dist/chunks/pseudo-oXBaONgF.mjs +32 -0
  21. package/dist/chunks/rename-uJnBX86A.mjs +117 -0
  22. package/dist/chunks/runner-wHXdhRdA.mjs +200 -0
  23. package/dist/chunks/search-Dj_Ynq_x.mjs +67 -0
  24. package/dist/chunks/source-Bx2lHSW7.mjs +744 -0
  25. package/dist/chunks/spec-BwZlT28L.mjs +507 -0
  26. package/dist/chunks/stats-BU-SmrVR.mjs +156 -0
  27. package/dist/chunks/sync-BY2EFEda.mjs +1 -0
  28. package/dist/chunks/sync-C0NVDabK.mjs +56 -0
  29. package/dist/chunks/sync-remote-y3ZATbF2.mjs +148 -0
  30. package/dist/chunks/text-to-i18n-DweavFLm.mjs +404 -0
  31. package/dist/chunks/translate-CmnmgAHX.mjs +543 -0
  32. package/dist/cli.mjs +4 -0
  33. package/dist/index.mjs +3 -0
  34. package/dist/src/cli.d.ts +1 -0
  35. package/dist/src/commands/check.d.ts +28 -0
  36. package/dist/src/commands/clean.d.ts +26 -0
  37. package/dist/src/commands/export.d.ts +10 -0
  38. package/dist/src/commands/extract.d.ts +6 -0
  39. package/dist/src/commands/format.d.ts +8 -0
  40. package/dist/src/commands/import.d.ts +8 -0
  41. package/dist/src/commands/info.d.ts +5 -0
  42. package/dist/src/commands/lint.d.ts +16 -0
  43. package/dist/src/commands/pseudo.d.ts +9 -0
  44. package/dist/src/commands/rename.d.ts +9 -0
  45. package/dist/src/commands/search.d.ts +20 -0
  46. package/dist/src/commands/stats.d.ts +20 -0
  47. package/dist/src/commands/sync-remote.d.ts +30 -0
  48. package/dist/src/commands/sync.d.ts +20 -0
  49. package/dist/src/commands/text-to-i18n.d.ts +10 -0
  50. package/dist/src/commands/translate.d.ts +14 -0
  51. package/dist/src/csv.d.ts +3 -0
  52. package/dist/src/errors.d.ts +22 -0
  53. package/dist/src/fs.d.ts +45 -0
  54. package/dist/src/glossary.d.ts +12 -0
  55. package/dist/src/help.d.ts +3 -0
  56. package/dist/src/http.d.ts +10 -0
  57. package/dist/src/index.d.ts +3 -0
  58. package/dist/src/json.d.ts +34 -0
  59. package/dist/src/lint-rules.d.ts +9 -0
  60. package/dist/src/log.d.ts +88 -0
  61. package/dist/src/nuxt.d.ts +66 -0
  62. package/dist/src/po.d.ts +14 -0
  63. package/dist/src/project.d.ts +64 -0
  64. package/dist/src/runner.d.ts +6 -0
  65. package/dist/src/source.d.ts +109 -0
  66. package/dist/src/spec.d.ts +57 -0
  67. package/dist/src/text-to-i18n.d.ts +46 -0
  68. package/dist/src/translate/ai.d.ts +2 -0
  69. package/dist/src/translate/drivers.d.ts +26 -0
  70. package/dist/src/translate/engine.d.ts +33 -0
  71. package/dist/src/translate/protect.d.ts +11 -0
  72. package/package.json +71 -3
@@ -0,0 +1,100 @@
1
+ import { F as urlParts, h as CliError } from "./json-BV_apCMu.mjs";
2
+ // src/http.ts
3
+ var RETRYABLE = /* @__PURE__ */ new Set([
4
+ 408,
5
+ 429,
6
+ 500,
7
+ 502,
8
+ 503,
9
+ 504
10
+ ]);
11
+ /** A POST is a billed translation: after anything else it may have run, so it is not sent twice. */
12
+ var NOT_PROCESSED = /* @__PURE__ */ new Set([429, 503]);
13
+ /** Asked to wait longer, a run has hit a quota, not a hiccup: it fails instead of sleeping in silence. */
14
+ var MAX_WAIT_MS = 6e4;
15
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ /** What a URL may show in an error: query strings carry API keys (`?key=`). */
17
+ function safeUrl(url) {
18
+ const { scheme, host } = urlParts(url);
19
+ return scheme + host;
20
+ }
21
+ function messageFromBody(body) {
22
+ try {
23
+ const parsed = JSON.parse(body);
24
+ const nested = parsed.error?.message;
25
+ const found = [
26
+ parsed.message,
27
+ nested,
28
+ parsed.error,
29
+ parsed.error_msg,
30
+ parsed.errorMessage
31
+ ].find((value) => typeof value === "string");
32
+ if (found) return found;
33
+ } catch {}
34
+ return body.slice(0, 300);
35
+ }
36
+ function httpError(response, url, body) {
37
+ const hints = response.status === 401 || response.status === 403 ? ["Check the API token and its permissions."] : response.status === 429 ? ["Rate limit reached: lower --chunkSize or set --options retries:5,retryDelayMs:2000."] : [];
38
+ return new CliError(messageFromBody(body) || `HTTP ${response.status}`, {
39
+ code: "HTTP_ERROR",
40
+ hints,
41
+ details: {
42
+ "HTTP status": String(response.status),
43
+ URL: safeUrl(url)
44
+ }
45
+ });
46
+ }
47
+ var retryAfterMs = (response) => {
48
+ const seconds = Number(response.headers.get("retry-after"));
49
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
50
+ };
51
+ async function attempt(url, options, retriesLeft, delayMs) {
52
+ const { timeoutMs = 3e4, retries: _retries, retryDelayMs: _delay, ...init } = options;
53
+ const once = init.method === "POST";
54
+ let response;
55
+ try {
56
+ response = await fetch(url, {
57
+ ...init,
58
+ signal: AbortSignal.timeout(timeoutMs)
59
+ });
60
+ } catch (error) {
61
+ if (!once && retriesLeft > 0) {
62
+ await sleep(delayMs);
63
+ return attempt(url, options, retriesLeft - 1, delayMs * 2);
64
+ }
65
+ const scrub = (text) => text.replaceAll(url, safeUrl(url));
66
+ const reason = [error.message, error.cause?.message].filter(Boolean).join(": ");
67
+ const cause = new Error(scrub(reason));
68
+ throw new CliError(`Request failed: ${cause.message}`, {
69
+ code: "HTTP_ERROR",
70
+ details: { URL: safeUrl(url) },
71
+ cause
72
+ });
73
+ }
74
+ if (response.ok) return response;
75
+ const asked = retryAfterMs(response);
76
+ if ((once ? NOT_PROCESSED : RETRYABLE).has(response.status) && retriesLeft > 0 && (asked ?? 0) <= MAX_WAIT_MS) {
77
+ await sleep(asked ?? delayMs);
78
+ return attempt(url, options, retriesLeft - 1, delayMs * 2);
79
+ }
80
+ throw httpError(response, url, await response.text());
81
+ }
82
+ var request = (url, options = {}) => attempt(url, options, options.retries ?? 2, options.retryDelayMs ?? 500);
83
+ async function requestJson(url, options = {}) {
84
+ const text = await (await request(url, options)).text();
85
+ if (text === "") return void 0;
86
+ try {
87
+ return JSON.parse(text);
88
+ } catch (error) {
89
+ throw new CliError("The service did not return JSON.", {
90
+ code: "HTTP_ERROR",
91
+ details: { URL: safeUrl(url) },
92
+ cause: error
93
+ });
94
+ }
95
+ }
96
+ var jsonBody = (value) => ({
97
+ body: JSON.stringify(value),
98
+ headers: { "Content-Type": "application/json" }
99
+ });
100
+ export { requestJson as n, jsonBody as t };
@@ -0,0 +1,99 @@
1
+ import { a as parseCsv, i as parsePo, n as readManifest, t as defaultExchangeDir } from "./export-C2J-pagB.mjs";
2
+ import { A as log, M as plural, S as validationError, _ as findingsError, j as pick, v as notFoundError } from "./json-BV_apCMu.mjs";
3
+ import { c as readText, m as walkFiles, u as relativePath } from "./nuxt-dKxG087V.mjs";
4
+ import { t as Project } from "./project-DgWgr7pF.mjs";
5
+ import { existsSync } from "node:fs";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ // src/commands/import.ts
8
+ /** Only where `export` puts a catalog: a backup in a subfolder must not land on the global file. */
9
+ function poScope(file, dir) {
10
+ const parts = relativePath(dir, dirname(file)).split("/").filter(Boolean);
11
+ if (parts.length === 0) return "";
12
+ if (parts[0] === "pages" && parts.length > 1) return parts.slice(1).join("/");
13
+ throw validationError(`Not a place for a catalog: ${relativePath(dir, file)}`, ["Expected <locale>.po or pages/<page>/<locale>.po."]);
14
+ }
15
+ /** A page's catalog the last export did not write: the page is gone, and importing would bring it back. */
16
+ function assertExported(file, dir, exported) {
17
+ const name = relativePath(dir, file);
18
+ if (exported.length === 0 || exported.includes(name)) return;
19
+ throw validationError(`Not part of the last export: ${name}`, ["Delete the file, or restore the page and export again."]);
20
+ }
21
+ function readPo(project, dir) {
22
+ const files = walkFiles(dir, (path) => path.endsWith(".po"));
23
+ if (files.length === 0) throw notFoundError(`No PO files in ${dir}`);
24
+ const exported = Object.keys(readManifest(dir)).filter((name) => name.endsWith(".po"));
25
+ return files.flatMap((file) => {
26
+ const code = basename(file, ".po");
27
+ if (!project.codes.includes(code)) {
28
+ log.warn(`Skipping ${file}: ${code} is not a configured locale.`);
29
+ return [];
30
+ }
31
+ const { messages, fuzzy } = parsePo(readText(file));
32
+ const guesses = Object.keys(fuzzy);
33
+ if (guesses.length > 0) log.warn(`Skipping ${plural(guesses.length, "fuzzy entry", "fuzzy entries")} in ${file}: ${guesses.join(", ")}`);
34
+ const scope = poScope(file, dir);
35
+ if (scope !== "") assertExported(file, dir, exported);
36
+ return Object.entries(messages).map(([key, value]) => ({
37
+ code,
38
+ scope,
39
+ key,
40
+ value
41
+ }));
42
+ });
43
+ }
44
+ /** The column has to name this locale: a row of another one would land in the wrong file. */
45
+ function csvScope(file, code, path) {
46
+ const name = file.replaceAll("\\", "/");
47
+ if (name === `${code}.json`) return "";
48
+ const slash = name.lastIndexOf("/");
49
+ if (name.startsWith("pages/") && slash >= 7 && name.slice(slash + 1) === `${code}.json`) return name.slice(6, slash);
50
+ throw validationError(`Row of another file in ${path}: ${file}`, [`Expected ${code}.json or pages/<page>/${code}.json.`]);
51
+ }
52
+ function csvColumns(header, path) {
53
+ const columns = {
54
+ file: header?.indexOf("File") ?? -1,
55
+ key: header?.indexOf("Key") ?? -1,
56
+ translation: header?.indexOf("Translation") ?? -1
57
+ };
58
+ if (Object.values(columns).includes(-1)) throw validationError(`Malformed header in ${path}: expected File, Key and Translation columns`);
59
+ return columns;
60
+ }
61
+ function readCsvFile(path, code) {
62
+ const [header, ...rows] = parseCsv(readText(path), path);
63
+ const columns = csvColumns(header, path);
64
+ return rows.map((row) => {
65
+ const [file, key, value] = [
66
+ row[columns.file],
67
+ row[columns.key],
68
+ row[columns.translation]
69
+ ];
70
+ if (!file || !key || value === void 0) throw validationError(`Malformed row in ${path}: ${row.join(",")}`);
71
+ return {
72
+ code,
73
+ scope: csvScope(file, code, path),
74
+ key,
75
+ value
76
+ };
77
+ });
78
+ }
79
+ function readCsv(project, dir) {
80
+ const present = project.codes.filter((code) => existsSync(join(dir, `${code}.csv`)));
81
+ if (present.length === 0) throw notFoundError(`No CSV files in ${dir}`);
82
+ return present.flatMap((code) => readCsvFile(join(dir, `${code}.csv`), code));
83
+ }
84
+ async function run(args) {
85
+ const project = await Project.load(args);
86
+ const dir = resolve(project.config.rootDir, args.dir ?? defaultExchangeDir(args.format));
87
+ const filled = (args.format === "csv" ? readCsv(project, dir) : readPo(project, dir)).filter((entry) => entry.value !== "");
88
+ if (project.config.disablePageLocales && filled.some((entry) => entry.scope !== "")) throw validationError("disablePageLocales is on: the app would never read page translations.", ["Remove the page entries, or turn the option off."]);
89
+ const conflicts = filled.flatMap(({ code, scope, key, value }) => {
90
+ const conflict = project.locale(code).set(key, value, scope);
91
+ return conflict ? [`${code} ${key}: ${conflict} already holds a value`] : [];
92
+ });
93
+ const imported = filled.length - conflicts.length;
94
+ if (!args.dryRun) project.save();
95
+ log.list("Not imported", conflicts);
96
+ log.success(`${args.dryRun ? "Dry run: would import" : "Imported"} ${plural(imported, "translation")}.`);
97
+ if (conflicts.length > 0) throw findingsError(`${plural(conflicts.length, "entry", "entries")} ${pick(conflicts.length, "was", "were")} not imported.`, ["Rename the key or remove the value that blocks it."]);
98
+ }
99
+ export { run as default };
@@ -0,0 +1,32 @@
1
+ import { t as version } from "./package-f9OTcDxl.mjs";
2
+ import { A as log } from "./json-BV_apCMu.mjs";
3
+ import { t as loadProjectConfig } from "./nuxt-dKxG087V.mjs";
4
+ // src/commands/info.ts
5
+ async function run(args) {
6
+ const config = await loadProjectConfig(args.cwd ?? ".");
7
+ const locales = config.locales.map((locale) => locale.code);
8
+ const { rootDir, srcDir, defaultLocale, translationDir, translationDirs, disablePageLocales } = config;
9
+ if (args.json) {
10
+ log.json({
11
+ version,
12
+ rootDir,
13
+ srcDir,
14
+ defaultLocale,
15
+ locales,
16
+ translationDir,
17
+ translationDirs,
18
+ disablePageLocales
19
+ });
20
+ return;
21
+ }
22
+ log.rows([
23
+ ["CLI", version],
24
+ ["Root", rootDir],
25
+ ["Default locale", defaultLocale],
26
+ ["Locales", locales.join(", ")],
27
+ ["Translation directory", translationDir],
28
+ ["Page locales", disablePageLocales ? "disabled" : "enabled"]
29
+ ]);
30
+ log.list("Read from, last wins", translationDirs);
31
+ }
32
+ export { run as default };
@@ -0,0 +1,439 @@
1
+ import { createInterface } from "node:readline";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ // src/log.ts
4
+ var silent = false;
5
+ var secrets = /* @__PURE__ */ new Set();
6
+ /** Anything printed from now on shows `***` instead. Shorter than a real key, it would only garble the text. */
7
+ function addSecret(value) {
8
+ if (!value || value.length < 4) return;
9
+ secrets.add(value);
10
+ secrets.add(encodeURIComponent(value));
11
+ }
12
+ /**
13
+ * A URL up to its query, split around the last `@` there: one in the query is no part of the address.
14
+ * Not a `.*` regex: that stops at a line end and leaves the password in the text.
15
+ */
16
+ function urlParts(url) {
17
+ const end = url.search(/[?#;]/);
18
+ const address = end === -1 ? url : url.slice(0, end);
19
+ const at = address.lastIndexOf("@");
20
+ if (at === -1) return {
21
+ scheme: "",
22
+ host: address
23
+ };
24
+ const slashes = address.indexOf("://");
25
+ const start = slashes === -1 || slashes > at ? 0 : slashes + 3;
26
+ return {
27
+ scheme: address.slice(0, start),
28
+ credentials: address.slice(start, at),
29
+ host: address.slice(at + 1)
30
+ };
31
+ }
32
+ /** A URL somebody typed: what is after its path and before its host can be a key, whatever a server echoes back. */
33
+ function addUrlSecrets(url) {
34
+ if (!url) return;
35
+ const [, ...tail] = url.split(/[?#;&]/);
36
+ for (const pair of tail) addSecret(pair.slice(pair.indexOf("=") + 1));
37
+ const { credentials } = urlParts(url);
38
+ if (!credentials) return;
39
+ addSecret(credentials);
40
+ const colon = credentials.indexOf(":");
41
+ addSecret(colon === -1 ? credentials : credentials.slice(0, colon));
42
+ if (colon !== -1) addSecret(credentials.slice(colon + 1));
43
+ }
44
+ var URL_CREDENTIALS = /(\/\/)[^/\s]+@/g;
45
+ /** The address first and the longest secret first: a part replaced earlier would leave the rest of a longer one in view. */
46
+ var redact = (text) => [...secrets].sort((a, b) => b.length - a.length).reduce((out, secret) => out.replaceAll(secret, "***"), text.replace(URL_CREDENTIALS, "$1***@"));
47
+ var env = process.env;
48
+ /** A live terminal: a pipe or a CI log gets no header, spinner or timing, so its text stays the same run after run. */
49
+ var isInteractive = (stream = process.stdout) => Boolean(stream.isTTY) && !env.CI;
50
+ /** `hasColors` exists on terminals only, and answers NO_COLOR, FORCE_COLOR and TERM=dumb. */
51
+ var hasColors = (stream) => Boolean(stream.isTTY && stream.hasColors?.());
52
+ /** The symbols of @clack/prompts, which nuxi prints its log lines with. */
53
+ var symbols = !(process.platform === "win32" && process.stdout.isTTY && !env.WT_SESSION && !env.TERM_PROGRAM) ? {
54
+ info: "●",
55
+ success: "◆",
56
+ warn: "▲",
57
+ error: "■",
58
+ step: "◇",
59
+ arrow: "→",
60
+ frames: [
61
+ "◒",
62
+ "◐",
63
+ "◓",
64
+ "◑"
65
+ ]
66
+ } : {
67
+ info: "•",
68
+ success: "*",
69
+ warn: "!",
70
+ error: "x",
71
+ step: "o",
72
+ arrow: ">",
73
+ frames: [
74
+ "|",
75
+ "/",
76
+ "-",
77
+ "\\"
78
+ ]
79
+ };
80
+ var SGR = {
81
+ bold: [1, 22],
82
+ dim: [2, 22],
83
+ underline: [4, 24],
84
+ red: [31, 39],
85
+ green: [32, 39],
86
+ yellow: [33, 39],
87
+ blue: [34, 39],
88
+ magenta: [35, 39],
89
+ cyan: [36, 39],
90
+ gray: [90, 39],
91
+ black: [30, 39],
92
+ bgRed: [41, 49],
93
+ bgYellow: [43, 49]
94
+ };
95
+ /** Always styled: the writer strips it again for a stream without colors. */
96
+ var style = (names, text) => [names].flat().reduce((out, name) => `\u001B[${SGR[name][0]}m${out}\u001B[${SGR[name][1]}m`, text);
97
+ var indentLines = (text) => text.replaceAll("\n", "\n ");
98
+ /** Columns padded to the widest cell; the last one is not. The first is the subject, the third on is context. */
99
+ function columns(rows) {
100
+ const widths = rows[0]?.map((_cell, index) => Math.max(...rows.map((row) => row[index]?.length ?? 0))) ?? [];
101
+ const tone = (index, cell) => index === 0 ? style("cyan", cell) : index >= 2 ? style("gray", cell) : cell;
102
+ return rows.map((row) => row.map((cell, index) => tone(index, index === row.length - 1 ? cell : cell.padEnd(widths[index]))).join(" "));
103
+ }
104
+ var frame = 0;
105
+ var label = "";
106
+ var timer;
107
+ /** A frame is on the screen, on the line the next output would start on. */
108
+ var drawn = false;
109
+ var drawSpinner = () => {
110
+ const glyph = symbols.frames[frame++ % symbols.frames.length];
111
+ process.stderr.write(`\r\u001B[2K${hasColors(process.stderr) ? style("magenta", glyph) : glyph} ${label}...`);
112
+ drawn = true;
113
+ };
114
+ /** On stderr, and on a live terminal only: stdout stays clean for `--json` and pipes. */
115
+ var spinner = {
116
+ start(text) {
117
+ if (silent || !isInteractive(process.stderr)) return;
118
+ label = text;
119
+ timer ??= setInterval(drawSpinner, 80);
120
+ timer.unref();
121
+ drawSpinner();
122
+ },
123
+ update(text) {
124
+ label = text;
125
+ },
126
+ /** Wipes the frame; the next tick draws it again under what was printed. */
127
+ clear() {
128
+ if (drawn) process.stderr.write("\r\x1B[2K");
129
+ drawn = false;
130
+ },
131
+ stop() {
132
+ clearInterval(timer);
133
+ timer = void 0;
134
+ spinner.clear();
135
+ }
136
+ };
137
+ /** A line with a secret in it loses its colors: escapes between its characters would let the secret through. */
138
+ function write(stream, sink, text) {
139
+ const plain = stripVTControlCharacters(text);
140
+ const safe = redact(plain);
141
+ spinner.clear();
142
+ sink(hasColors(stream) && safe === plain ? text : safe);
143
+ }
144
+ var out = (text) => write(process.stdout, console.log, text);
145
+ var err = (text) => write(process.stderr, console.error, text);
146
+ /** `4.2ms`, `420ms`, `4.20s`, `42.0s`, `4m 02s`: the precision nuxi gives its timings. */
147
+ function formatDuration(ms) {
148
+ if (ms < 10) return `${ms.toFixed(1)}ms`;
149
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
150
+ if (ms < 1e4) return `${(Math.floor(ms / 10) / 100).toFixed(2)}s`;
151
+ if (ms < 6e4) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`;
152
+ const total = Math.floor(ms / 1e3);
153
+ return `${Math.floor(total / 60)}m ${String(total % 60).padStart(2, "0")}s`;
154
+ }
155
+ /** The word for a count: `pick(1, 'was', 'were')`. */
156
+ var pick = (count, one, many) => count === 1 ? one : many;
157
+ /** A count and its noun, in agreement: `1 key`, `2 keys`. */
158
+ var plural = (count, one, many = `${one}s`) => `${count} ${pick(count, one, many)}`;
159
+ /** A table or a group was printed last: the line after it is a summary and stands apart. */
160
+ var afterBlock = false;
161
+ var heading = (title, count) => `\n${style("cyan", symbols.step)} ${style("bold", title)}${count === void 0 ? "" : style("gray", ` (${count})`)}`;
162
+ var log = {
163
+ setSilent(value) {
164
+ silent = value;
165
+ },
166
+ /** A bare line, for text that is part of a block. */
167
+ info(message) {
168
+ if (!silent) out(message);
169
+ },
170
+ /** A line of its own: what the command is doing or found. */
171
+ note(message) {
172
+ if (!silent) out(`${log.gap()}${style("blue", symbols.info)} ${indentLines(message)}`);
173
+ },
174
+ success(message) {
175
+ if (!silent) out(`${log.gap()}${style("green", symbols.success)} ${indentLines(message)}`);
176
+ },
177
+ warn(message) {
178
+ err(`${log.gap()}${style("yellow", symbols.warn)} ${indentLines(message)}`);
179
+ },
180
+ /** A blank line when a group or a table came last, once. */
181
+ gap() {
182
+ const gap = afterBlock ? "\n" : "";
183
+ afterBlock = false;
184
+ return gap;
185
+ },
186
+ error(message) {
187
+ err(style("red", message));
188
+ },
189
+ /** Stderr as is: the error report builds its own lines. */
190
+ raw(message) {
191
+ err(message);
192
+ },
193
+ /** Machine-readable output: not muted by `--logLevel silent`. */
194
+ json(value) {
195
+ spinner.clear();
196
+ console.log(redact(JSON.stringify(value, null, 2)));
197
+ },
198
+ section(title) {
199
+ log.info(heading(title));
200
+ },
201
+ rows(rows) {
202
+ const width = Math.max(0, ...rows.map(([label]) => label.length));
203
+ const tint = (value) => value.includes("\x1B") ? value : style("cyan", value);
204
+ for (const [label, value] of rows) log.info(` ${style("bold", `${label}:`.padEnd(width + 2))}${tint(String(value))}`);
205
+ afterBlock = !silent;
206
+ },
207
+ /** A titled group with its count; cells of a row are aligned as a table. */
208
+ list(title, items) {
209
+ if (items.length === 0) return;
210
+ log.info(heading(title, items.length));
211
+ const lines = items.every((item) => Array.isArray(item)) ? columns(items) : items.map((item) => [item].flat().join(" "));
212
+ for (const line of lines) log.info(` ${line}`);
213
+ afterBlock = !silent;
214
+ }
215
+ };
216
+ var prompt;
217
+ var lines;
218
+ /**
219
+ * One line of input. Closed stdin (CI, a pipe that ended) answers `null`, never hangs.
220
+ *
221
+ * A single reader for the whole run: readline buffers ahead, so a second one would miss
222
+ * lines a pipe already delivered.
223
+ */
224
+ async function ask(question) {
225
+ prompt ??= createInterface({
226
+ input: process.stdin,
227
+ terminal: false
228
+ });
229
+ lines ??= prompt[Symbol.asyncIterator]();
230
+ spinner.stop();
231
+ process.stdout.write(`${question} `);
232
+ const line = await lines.next();
233
+ return line.done ? null : String(line.value).trim();
234
+ }
235
+ function closePrompts() {
236
+ prompt?.close();
237
+ prompt = void 0;
238
+ lines = void 0;
239
+ }
240
+ async function choose(question, options) {
241
+ for (const [index, option] of options.entries()) log.info(` ${index + 1}. ${option}`);
242
+ const answer = await ask(`${question} [1-${options.length}]`);
243
+ return options[Number(answer) - 1] ?? null;
244
+ }
245
+ // src/errors.ts
246
+ /** An expected failure: printed as message, details and hints, never as a stack trace. */
247
+ var CliError = class extends Error {
248
+ code;
249
+ hints;
250
+ details;
251
+ constructor(message, options) {
252
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
253
+ this.name = "CliError";
254
+ this.code = options.code;
255
+ this.hints = options.hints ?? [];
256
+ this.details = options.details ?? {};
257
+ }
258
+ };
259
+ var factory = (code) => (message, hints = [], details) => new CliError(message, {
260
+ code,
261
+ hints,
262
+ details
263
+ });
264
+ var usageError = factory("USAGE_ERROR");
265
+ var validationError = factory("VALIDATION_ERROR");
266
+ var notFoundError = factory("NOT_FOUND");
267
+ var commandFailed = factory("COMMAND_FAILED");
268
+ var remoteConfigError = factory("REMOTE_CONFIG_ERROR");
269
+ /** The command worked and found something wrong: exit code 1, not a failure of the tool. */
270
+ var findingsError = factory("FINDINGS");
271
+ function fromSystemError(error) {
272
+ if (error.code === "ENOENT") return new CliError(`Path not found: ${error.path ?? "unknown"}`, {
273
+ code: "FILE_NOT_FOUND",
274
+ cause: error
275
+ });
276
+ if (error.code === "EACCES" || error.code === "EPERM") return new CliError(`Permission denied: ${error.path ?? "unknown"}`, {
277
+ code: "PERMISSION_DENIED",
278
+ cause: error
279
+ });
280
+ return null;
281
+ }
282
+ function normalize(error) {
283
+ if (error instanceof CliError) return error;
284
+ const system = error instanceof Error ? fromSystemError(error) : null;
285
+ if (system) return system;
286
+ return new CliError(error instanceof Error ? error.message : String(error), {
287
+ code: "COMMAND_FAILED",
288
+ hints: ["Run with --debug for a stack trace."],
289
+ cause: error
290
+ });
291
+ }
292
+ /** nuxi's look: a red ` ERROR ` badge on a terminal, the clack error symbol in a log; hints after an arrow. */
293
+ function reportError(error, debug) {
294
+ const normalized = normalize(error);
295
+ const findings = normalized.code === "FINDINGS";
296
+ const badge = isInteractive(process.stderr) ? style(findings ? ["bgYellow", "black"] : ["bgRed", "black"], findings ? " FAIL " : " ERROR ") : style(findings ? "yellow" : "red", symbols.error);
297
+ log.raw(`${log.gap()}${badge} ${normalized.message}`);
298
+ for (const [label, value] of Object.entries(normalized.details)) log.raw(` ${style("gray", `${label}:`)} ${value}`);
299
+ for (const hint of normalized.hints) log.raw(` ${style("cyan", symbols.arrow)} ${hint}`);
300
+ if (debug) log.raw(style("gray", String(normalized.cause?.stack ?? normalized.stack)));
301
+ process.exitCode = findings ? 1 : 2;
302
+ }
303
+ // src/json.ts
304
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
305
+ /** A lookup by a name from outside: `constructor` is not an entry of a table. */
306
+ var ownValue = (table, name) => Object.hasOwn(table, name) ? table[name] : void 0;
307
+ var isEmptyObject = (value) => isObject(value) && Object.keys(value).length === 0;
308
+ /**
309
+ * `{ a: { b: 'x' } }` -> `{ 'a.b': 'x' }`. Arrays and empty objects are leaves. No prototype:
310
+ * callers look keys up in the result, and `constructor` is a key like any other.
311
+ */
312
+ function flatten(tree, prefix = "", out = Object.create(null)) {
313
+ for (const [key, value] of Object.entries(tree)) {
314
+ const path = prefix ? `${prefix}.${key}` : key;
315
+ if (isObject(value) && !isEmptyObject(value)) flatten(value, path, out);
316
+ else out[path] = value;
317
+ }
318
+ return out;
319
+ }
320
+ /** Only the string leaves: the part of a tree that is text to translate. */
321
+ function flattenStrings(tree) {
322
+ const out = Object.create(null);
323
+ for (const [key, value] of Object.entries(flatten(tree))) if (typeof value === "string") out[key] = value;
324
+ return out;
325
+ }
326
+ /**
327
+ * The object and key holding `path`. Dotted names are valid keys and the runtime reads them
328
+ * first, so `{ "a.b": 1 }` and `{ a: { "b.c": 1 } }` resolve like nested ones.
329
+ */
330
+ function locate(tree, path) {
331
+ if (Object.hasOwn(tree, path)) return [tree, path];
332
+ for (let index = path.indexOf("."); index !== -1; index = path.indexOf(".", index + 1)) {
333
+ const head = path.slice(0, index);
334
+ const child = Object.hasOwn(tree, head) ? tree[head] : void 0;
335
+ const found = isObject(child) ? locate(child, path.slice(index + 1)) : null;
336
+ if (found) return found;
337
+ }
338
+ return null;
339
+ }
340
+ function getPath(tree, path) {
341
+ const found = locate(tree, path);
342
+ return found ? found[0][found[1]] : void 0;
343
+ }
344
+ /** The leaf a new `path` would have to replace: `a` when `a` is text and `a.b` is wanted. */
345
+ function blockingLeaf(tree, path) {
346
+ const parts = path.split(".");
347
+ for (let length = 1; length < parts.length; length += 1) {
348
+ const prefix = parts.slice(0, length).join(".");
349
+ const value = getPath(tree, prefix);
350
+ if (value !== void 0 && !isObject(value)) return prefix;
351
+ }
352
+ return null;
353
+ }
354
+ /**
355
+ * What writing `path` would destroy: a text above it, or the keys below it. Every write that
356
+ * comes from outside the file (import, pull, translation, generated key) asks this first.
357
+ */
358
+ function pathConflict(tree, path) {
359
+ const current = getPath(tree, path);
360
+ if (current === void 0) return blockingLeaf(tree, path);
361
+ return isObject(current) && !isEmptyObject(current) ? path : null;
362
+ }
363
+ /** What can go between quotes in a template and a script, and be stored. */
364
+ var isWritableKey = (key) => !/['"`\\\r\n]|\}\}|\$\{|&#?\w+;/.test(key) && key.split(".").every((segment) => segment !== "" && segment !== "__proto__");
365
+ function setPath(tree, path, value) {
366
+ const found = locate(tree, path);
367
+ if (found) {
368
+ found[0][found[1]] = value;
369
+ return;
370
+ }
371
+ const parts = path.split(".");
372
+ const last = parts.pop();
373
+ if ([...parts, last].includes("__proto__")) return;
374
+ let current = tree;
375
+ for (const part of parts) {
376
+ if (!isObject(current[part])) current[part] = {};
377
+ current = current[part];
378
+ }
379
+ current[last] = value;
380
+ }
381
+ /** Remove one leaf and the parents it leaves empty. Everything else stays exactly as written. */
382
+ function deletePath(tree, path) {
383
+ if (Object.hasOwn(tree, path)) return delete tree[path];
384
+ for (let index = path.indexOf("."); index !== -1; index = path.indexOf(".", index + 1)) {
385
+ const head = path.slice(0, index);
386
+ const child = Object.hasOwn(tree, head) ? tree[head] : void 0;
387
+ if (!isObject(child) || !deletePath(child, path.slice(index + 1))) continue;
388
+ if (isEmptyObject(child)) delete tree[head];
389
+ return true;
390
+ }
391
+ return false;
392
+ }
393
+ /** The string is unrolled: an alternation in a loop overflows the regexp stack on a value of megabytes. */
394
+ var JSON_TOKEN = /"[^"\\]*(?:\\.[^"\\]*)*"|[{}[\]:]/g;
395
+ /** The first key an object repeats. `JSON.parse` keeps the last and says nothing, and a save would lose the other. */
396
+ function findDuplicateKey(text) {
397
+ const stack = [];
398
+ let lastString = "";
399
+ for (const [token] of text.matchAll(JSON_TOKEN)) if (token === "{") stack.push(/* @__PURE__ */ new Set());
400
+ else if (token === "[") stack.push(null);
401
+ else if (token === "}" || token === "]") stack.pop();
402
+ else if (token !== ":") lastString = token.includes("\\") ? JSON.parse(token) : token.slice(1, -1);
403
+ else if (stack.at(-1)?.has(lastString)) return lastString;
404
+ else stack.at(-1)?.add(lastString);
405
+ return null;
406
+ }
407
+ /** The merge and the writers skip this key, so it would vanish on a save. */
408
+ function hasProtoKey(value) {
409
+ if (typeof value !== "object" || value === null) return false;
410
+ return Object.hasOwn(value, "__proto__") || Object.values(value).some(hasProtoKey);
411
+ }
412
+ /** The path of an integer `JSON.parse` could not hold exactly. */
413
+ function findUnsafeInteger(value, path = "") {
414
+ if (typeof value === "number") return Number.isInteger(value) && !Number.isSafeInteger(value) ? path : null;
415
+ if (typeof value !== "object" || value === null) return null;
416
+ for (const [key, child] of Object.entries(value)) {
417
+ const found = findUnsafeInteger(child, path ? `${path}.${key}` : key);
418
+ if (found !== null) return found;
419
+ }
420
+ return null;
421
+ }
422
+ /** `Map.groupBy`, which Node 20 does not have. */
423
+ function groupBy(items, keyOf) {
424
+ const groups = /* @__PURE__ */ new Map();
425
+ for (const item of items) {
426
+ const key = keyOf(item);
427
+ if (groups.has(key)) groups.get(key).push(item);
428
+ else groups.set(key, [item]);
429
+ }
430
+ return groups;
431
+ }
432
+ function sortKeys(value) {
433
+ if (Array.isArray(value)) return value.map(sortKeys);
434
+ if (!isObject(value)) return value;
435
+ const sorted = {};
436
+ for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b, "en"))) sorted[key] = sortKeys(value[key]);
437
+ return sorted;
438
+ }
439
+ export { log as A, addSecret as C, closePrompts as D, choose as E, urlParts as F, plural as M, spinner as N, formatDuration as O, style as P, validationError as S, ask as T, findingsError as _, flattenStrings as a, reportError as b, hasProtoKey as c, ownValue as d, pathConflict as f, commandFailed as g, CliError as h, flatten as i, pick as j, isInteractive as k, isObject as l, sortKeys as m, findDuplicateKey as n, getPath as o, setPath as p, findUnsafeInteger as r, groupBy as s, deletePath as t, isWritableKey as u, notFoundError as v, addUrlSecrets as w, usageError as x, remoteConfigError as y };