@sonenta/cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/bin/sonenta.js +5 -0
- package/bin/verbumia.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +964 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command15 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/export.ts
|
|
7
|
+
import { promises as fs3 } from "fs";
|
|
8
|
+
import { join as join2 } from "path";
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
import { promises as fs } from "fs";
|
|
13
|
+
import { dirname, resolve } from "path";
|
|
14
|
+
var CONFIG_FILENAME = "sonenta.config.json";
|
|
15
|
+
var LEGACY_CONFIG_FILENAME = "verbumia.config.json";
|
|
16
|
+
var warnedLegacy = false;
|
|
17
|
+
async function findConfigPath(startDir) {
|
|
18
|
+
let dir = resolve(startDir);
|
|
19
|
+
while (true) {
|
|
20
|
+
for (const name of [CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
|
|
21
|
+
const candidate = resolve(dir, name);
|
|
22
|
+
try {
|
|
23
|
+
await fs.access(candidate);
|
|
24
|
+
if (name === LEGACY_CONFIG_FILENAME && !warnedLegacy) {
|
|
25
|
+
warnedLegacy = true;
|
|
26
|
+
process.emitWarning(
|
|
27
|
+
`${LEGACY_CONFIG_FILENAME} is deprecated \u2014 rename it to ${CONFIG_FILENAME}. The legacy name still works for now.`,
|
|
28
|
+
{ type: "DeprecationWarning" }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return candidate;
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const parent = dirname(dir);
|
|
36
|
+
if (parent === dir) return null;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function readConfig(startDir = process.cwd()) {
|
|
41
|
+
const path = await findConfigPath(startDir);
|
|
42
|
+
if (!path) return { path: null, config: {} };
|
|
43
|
+
const raw = await fs.readFile(path, "utf8");
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
return { path, config: parsed };
|
|
46
|
+
}
|
|
47
|
+
async function writeConfig(config, targetDir = process.cwd()) {
|
|
48
|
+
const path = resolve(targetDir, CONFIG_FILENAME);
|
|
49
|
+
await fs.writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/credentials.ts
|
|
54
|
+
import { promises as fs2 } from "fs";
|
|
55
|
+
import { homedir } from "os";
|
|
56
|
+
import { join } from "path";
|
|
57
|
+
var credentialsDir = () => join(homedir(), ".verbumia");
|
|
58
|
+
var credentialsPath = () => join(credentialsDir(), "credentials");
|
|
59
|
+
var EMPTY = { hosts: {} };
|
|
60
|
+
async function readCredentials() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = await fs2.readFile(credentialsPath(), "utf8");
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
if (!parsed || typeof parsed !== "object" || !parsed.hosts) {
|
|
65
|
+
return EMPTY;
|
|
66
|
+
}
|
|
67
|
+
return parsed;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err?.code === "ENOENT") return { hosts: {} };
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function writeCredentials(creds) {
|
|
74
|
+
const dir = credentialsDir();
|
|
75
|
+
const path = credentialsPath();
|
|
76
|
+
await fs2.mkdir(dir, { recursive: true, mode: 448 });
|
|
77
|
+
const tmp = `${path}.tmp`;
|
|
78
|
+
await fs2.writeFile(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
|
|
79
|
+
await fs2.rename(tmp, path);
|
|
80
|
+
await fs2.chmod(path, 384);
|
|
81
|
+
await fs2.chmod(dir, 448).catch(() => {
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function setHostEntry(host, entry, options = {}) {
|
|
85
|
+
const creds = await readCredentials();
|
|
86
|
+
creds.hosts[host] = entry;
|
|
87
|
+
if (options.makeDefault || !creds.default) creds.default = host;
|
|
88
|
+
await writeCredentials(creds);
|
|
89
|
+
}
|
|
90
|
+
async function removeHost(host) {
|
|
91
|
+
const creds = await readCredentials();
|
|
92
|
+
if (!(host in creds.hosts)) return false;
|
|
93
|
+
delete creds.hosts[host];
|
|
94
|
+
if (creds.default === host) {
|
|
95
|
+
const remaining = Object.keys(creds.hosts);
|
|
96
|
+
creds.default = remaining[0];
|
|
97
|
+
}
|
|
98
|
+
await writeCredentials(creds);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function resolveHostEntry(creds, hostOverride) {
|
|
102
|
+
const host = hostOverride ?? creds.default;
|
|
103
|
+
if (!host) return null;
|
|
104
|
+
const entry = creds.hosts[host];
|
|
105
|
+
if (!entry) return null;
|
|
106
|
+
return { host, entry };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/api.ts
|
|
110
|
+
async function resolveContext(opts = {}) {
|
|
111
|
+
const { config } = await readConfig(opts.cwd ?? process.cwd());
|
|
112
|
+
const creds = await readCredentials();
|
|
113
|
+
const host = opts.hostOverride ?? config.host ?? creds.default;
|
|
114
|
+
if (!host) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"No host configured. Run `sonenta login --host <url>` or add `host` to sonenta.config.json."
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const envToken = process.env.SONENTA_TOKEN ?? process.env.VERBUMIA_TOKEN;
|
|
120
|
+
let apiKey = envToken && envToken.trim() ? envToken.trim() : void 0;
|
|
121
|
+
if (!apiKey) {
|
|
122
|
+
const resolved = resolveHostEntry(creds, host);
|
|
123
|
+
if (!resolved) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`No credentials for host ${host}. Set SONENTA_TOKEN or run \`sonenta login --host ${host}\`.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
apiKey = resolved.entry.api_key;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
host,
|
|
132
|
+
apiKey,
|
|
133
|
+
projectUuid: config.project_uuid,
|
|
134
|
+
versionSlug: config.version_slug ?? "main"
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function apiRequest(ctx, path, init = {}) {
|
|
138
|
+
const url = `${ctx.host.replace(/\/+$/, "")}${path}`;
|
|
139
|
+
const headers = new Headers(init.headers);
|
|
140
|
+
headers.set("Authorization", `ApiKey ${ctx.apiKey}`);
|
|
141
|
+
if (init.body && !headers.has("Content-Type")) {
|
|
142
|
+
headers.set("Content-Type", "application/json");
|
|
143
|
+
}
|
|
144
|
+
const res = await fetch(url, { ...init, headers });
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const text = await res.text().catch(() => "");
|
|
147
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} on ${path}: ${text.slice(0, 300)}`);
|
|
148
|
+
}
|
|
149
|
+
if (res.status === 204) return void 0;
|
|
150
|
+
return await res.json();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/i18next_tree.ts
|
|
154
|
+
function unflatten(flat, sep = ".") {
|
|
155
|
+
const root = {};
|
|
156
|
+
for (const [k, v] of Object.entries(flat)) {
|
|
157
|
+
const parts = k.split(sep);
|
|
158
|
+
let node = root;
|
|
159
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
160
|
+
const p = parts[i];
|
|
161
|
+
const existing = node[p];
|
|
162
|
+
if (typeof existing !== "object" || existing === null) node[p] = {};
|
|
163
|
+
node = node[p];
|
|
164
|
+
}
|
|
165
|
+
node[parts[parts.length - 1]] = v;
|
|
166
|
+
}
|
|
167
|
+
return root;
|
|
168
|
+
}
|
|
169
|
+
function sortDeep(value) {
|
|
170
|
+
if (Array.isArray(value)) return value.map((v) => sortDeep(v));
|
|
171
|
+
if (value && typeof value === "object") {
|
|
172
|
+
const src = value;
|
|
173
|
+
const out = {};
|
|
174
|
+
for (const k of Object.keys(src).sort((a, b) => a.localeCompare(b))) out[k] = sortDeep(src[k]);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/mcp.ts
|
|
181
|
+
function requireProject(ctx) {
|
|
182
|
+
if (!ctx.projectUuid) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
"no project configured \u2014 run `sonenta init --project <uuid>` (or set project_uuid in sonenta.config.json)."
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return ctx.projectUuid;
|
|
188
|
+
}
|
|
189
|
+
function projectBase(ctx) {
|
|
190
|
+
return `/v1/mcp/projects/${requireProject(ctx)}`;
|
|
191
|
+
}
|
|
192
|
+
async function listProjects(ctx, limit = 200) {
|
|
193
|
+
const data = await apiRequest(
|
|
194
|
+
ctx,
|
|
195
|
+
`/v1/mcp/projects?limit=${limit}`
|
|
196
|
+
);
|
|
197
|
+
return data.items ?? [];
|
|
198
|
+
}
|
|
199
|
+
async function getProjectInfo(ctx) {
|
|
200
|
+
const d = await apiRequest(ctx, projectBase(ctx));
|
|
201
|
+
const src = typeof d.source_language === "string" ? d.source_language : d.source_language?.code ?? "";
|
|
202
|
+
const langs = Array.isArray(d.languages) ? d.languages.map(
|
|
203
|
+
(l) => typeof l === "string" ? l : l.code ?? ""
|
|
204
|
+
) : [];
|
|
205
|
+
const ns = Array.isArray(d.namespaces) ? d.namespaces.map(
|
|
206
|
+
(n) => typeof n === "string" ? n : n.slug ?? ""
|
|
207
|
+
) : [];
|
|
208
|
+
return {
|
|
209
|
+
source_language: src,
|
|
210
|
+
languages: langs.filter(Boolean),
|
|
211
|
+
namespaces: ns.filter(Boolean)
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function listKeys(ctx, opts = {}) {
|
|
215
|
+
const out = [];
|
|
216
|
+
let cursor = null;
|
|
217
|
+
do {
|
|
218
|
+
const params = new URLSearchParams({ limit: "200" });
|
|
219
|
+
if (opts.languageCode) params.set("language_code", opts.languageCode);
|
|
220
|
+
if (opts.namespace) params.set("namespace", opts.namespace);
|
|
221
|
+
if (cursor) params.set("cursor", cursor);
|
|
222
|
+
const page = await apiRequest(
|
|
223
|
+
ctx,
|
|
224
|
+
`${projectBase(ctx)}/keys?${params.toString()}`
|
|
225
|
+
);
|
|
226
|
+
out.push(...page.items ?? []);
|
|
227
|
+
cursor = page.cursor_next ?? null;
|
|
228
|
+
} while (cursor);
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
async function importBundle(ctx, body) {
|
|
232
|
+
return apiRequest(ctx, `${projectBase(ctx)}/i18next/import`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: JSON.stringify(body)
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function publishCdn(ctx, body) {
|
|
238
|
+
return apiRequest(ctx, `${projectBase(ctx)}/cdn/releases`, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
body: JSON.stringify(body)
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
function summariseImport(r) {
|
|
244
|
+
const lines = [
|
|
245
|
+
`keys: ${r.keys_created} created, ${r.keys_reused} reused`,
|
|
246
|
+
`translations: ${r.translations_created} created, ${r.translations_updated} updated, ${r.translations_unchanged} unchanged`
|
|
247
|
+
];
|
|
248
|
+
if (r.plural_keys_marked) lines.push(`plural keys: ${r.plural_keys_marked} marked`);
|
|
249
|
+
if (r.errors?.length) {
|
|
250
|
+
lines.push(`errors: ${r.errors.length}`);
|
|
251
|
+
for (const e of r.errors) lines.push(` ! ${e.namespace}/${e.language_code}: ${e.code}`);
|
|
252
|
+
}
|
|
253
|
+
if (r.glossary_violations?.length) {
|
|
254
|
+
lines.push(`glossary: ${r.glossary_violations.length} violation(s)`);
|
|
255
|
+
for (const g of r.glossary_violations) {
|
|
256
|
+
lines.push(` \u26A0 ${g.namespace}/${g.language_code} ${g.key}: ${g.rule_type} "${g.term}"`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/commands/export.ts
|
|
263
|
+
async function collect(ctx, languages, namespace) {
|
|
264
|
+
const out = {};
|
|
265
|
+
for (const lang of languages) {
|
|
266
|
+
const items = await listKeys(ctx, { languageCode: lang, namespace });
|
|
267
|
+
for (const it of items) {
|
|
268
|
+
const tr = it.translations?.find((t) => t.language_code === lang);
|
|
269
|
+
if (!tr || tr.value === "") continue;
|
|
270
|
+
(out[lang] ??= {})[it.namespace_slug] ??= {};
|
|
271
|
+
out[lang][it.namespace_slug][it.key_name] = tr.value;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
var exportCommand = new Command("export").description(
|
|
277
|
+
"Export Verbumia translations as i18next JSON. Flat dot-notation by default (--nested for nested trees). Writes <out>/<lang>/<namespace>.json with --out, otherwise prints { locale: { namespace: tree } } to stdout."
|
|
278
|
+
).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--nested", "Emit nested JSON instead of flat dot-notation", false).option("--out <dir>", "Write files instead of printing to stdout").option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
279
|
+
async (opts) => {
|
|
280
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
281
|
+
const languages = opts.language ? [opts.language] : (await getProjectInfo(ctx)).languages;
|
|
282
|
+
const collected = await collect(ctx, languages, opts.namespace);
|
|
283
|
+
const shape = (flat) => opts.nested ? sortDeep(unflatten(flat)) : sortDeep(flat);
|
|
284
|
+
if (opts.out) {
|
|
285
|
+
let files = 0;
|
|
286
|
+
for (const [lang, nss] of Object.entries(collected)) {
|
|
287
|
+
for (const [ns, flat] of Object.entries(nss)) {
|
|
288
|
+
const dir = join2(opts.out, lang);
|
|
289
|
+
await fs3.mkdir(dir, { recursive: true });
|
|
290
|
+
const p = join2(dir, `${ns}.json`);
|
|
291
|
+
await fs3.writeFile(p, JSON.stringify(shape(flat), null, 2) + "\n", "utf8");
|
|
292
|
+
console.log(` ${p} ${Object.keys(flat).length} keys`);
|
|
293
|
+
files++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
console.log(`exported ${files} file(s)`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const tree = {};
|
|
300
|
+
for (const [lang, nss] of Object.entries(collected)) {
|
|
301
|
+
tree[lang] = {};
|
|
302
|
+
for (const [ns, flat] of Object.entries(nss)) tree[lang][ns] = shape(flat);
|
|
303
|
+
}
|
|
304
|
+
process.stdout.write(JSON.stringify(tree, null, 2) + "\n");
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// src/commands/import.ts
|
|
309
|
+
import { promises as fs4 } from "fs";
|
|
310
|
+
import { basename, dirname as dirname3 } from "path";
|
|
311
|
+
import { Command as Command2 } from "commander";
|
|
312
|
+
function resolveLangNs(filePath, optLang, optNs) {
|
|
313
|
+
const stem = basename(filePath).replace(/\.json$/i, "");
|
|
314
|
+
const parent = basename(dirname3(filePath));
|
|
315
|
+
const hasLangDir = parent !== "" && parent !== "." && parent !== "locales";
|
|
316
|
+
const lang = optLang ?? (hasLangDir ? parent : stem);
|
|
317
|
+
const ns = optNs ?? (hasLangDir ? stem : void 0);
|
|
318
|
+
if (!lang) throw new Error(`could not infer a language for ${filePath} \u2014 pass --language`);
|
|
319
|
+
if (!ns) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`could not infer a namespace for ${filePath} \u2014 pass --namespace, or lay files out as <lang>/<namespace>.json`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return { lang, ns };
|
|
325
|
+
}
|
|
326
|
+
async function readTree(filePath) {
|
|
327
|
+
const parsed = JSON.parse(await fs4.readFile(filePath, "utf8"));
|
|
328
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
329
|
+
throw new Error(`${filePath} is not a JSON object`);
|
|
330
|
+
}
|
|
331
|
+
return parsed;
|
|
332
|
+
}
|
|
333
|
+
function countLeaves(tree) {
|
|
334
|
+
let n = 0;
|
|
335
|
+
for (const v of Object.values(tree)) {
|
|
336
|
+
if (v && typeof v === "object" && !Array.isArray(v)) n += countLeaves(v);
|
|
337
|
+
else n += 1;
|
|
338
|
+
}
|
|
339
|
+
return n;
|
|
340
|
+
}
|
|
341
|
+
var importCommand = new Command2("import").description(
|
|
342
|
+
"Import i18next JSON file(s) into Verbumia in ONE call \u2014 creates missing keys and upserts translations (idempotent). Accepts nested or flat JSON. Language/namespace are inferred from the path (<lang>/<namespace>.json) or forced with --language / --namespace."
|
|
343
|
+
).argument("<files...>", "i18next JSON file(s), e.g. locales/fr/common.json or fr.json").option("--language <code>", "Force the language code for every file").option("--namespace <slug>", "Force the namespace slug for every file").option("--status <status>", "draft | translated (default: translated)").option("--version <slug>", "Target version slug (default: production version)").option("--dry-run", "Print what would be imported without sending", false).option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
344
|
+
async (files, opts) => {
|
|
345
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
346
|
+
const byNs = /* @__PURE__ */ new Map();
|
|
347
|
+
let totalLeaves = 0;
|
|
348
|
+
for (const f of files) {
|
|
349
|
+
const { lang, ns } = resolveLangNs(f, opts.language, opts.namespace);
|
|
350
|
+
const tree = await readTree(f);
|
|
351
|
+
totalLeaves += countLeaves(tree);
|
|
352
|
+
const langs = byNs.get(ns) ?? {};
|
|
353
|
+
if (langs[lang]) {
|
|
354
|
+
throw new Error(`two input files map to namespace=${ns} language=${lang} \u2014 merge them first`);
|
|
355
|
+
}
|
|
356
|
+
langs[lang] = tree;
|
|
357
|
+
byNs.set(ns, langs);
|
|
358
|
+
console.log(` ${f} -> ${ns} / ${lang}`);
|
|
359
|
+
}
|
|
360
|
+
const body = {
|
|
361
|
+
namespaces: [...byNs.entries()].map(([namespace, translations]) => ({
|
|
362
|
+
namespace,
|
|
363
|
+
translations
|
|
364
|
+
}))
|
|
365
|
+
};
|
|
366
|
+
if (opts.status === "draft" || opts.status === "translated") body.status = opts.status;
|
|
367
|
+
if (opts.version) body.version = opts.version;
|
|
368
|
+
if (opts.dryRun) {
|
|
369
|
+
console.log(
|
|
370
|
+
`
|
|
371
|
+
(dry-run) would import ${totalLeaves} value(s) across ${body.namespaces.length} namespace(s); nothing sent.`
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const report = await importBundle(ctx, body);
|
|
376
|
+
console.log("");
|
|
377
|
+
for (const line of summariseImport(report)) console.log(line);
|
|
378
|
+
if (report.errors?.length || report.glossary_violations?.length) process.exitCode = 1;
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// src/commands/init.ts
|
|
383
|
+
import { existsSync } from "fs";
|
|
384
|
+
import { resolve as resolve2 } from "path";
|
|
385
|
+
import { Command as Command3 } from "commander";
|
|
386
|
+
var initCommand = new Command3("init").description("Scaffold a sonenta.config.json in the current directory.").option("--host <url>", "API base URL", "https://api.sonenta.com").option("--project <uuid>", "Project UUID").option("--version <slug>", "Version slug (default: main)", "main").option("--force", "Overwrite an existing sonenta.config.json", false).action(
|
|
387
|
+
async (opts) => {
|
|
388
|
+
const path = resolve2(process.cwd(), CONFIG_FILENAME);
|
|
389
|
+
if (existsSync(path) && !opts.force) {
|
|
390
|
+
console.error(
|
|
391
|
+
`${CONFIG_FILENAME} already exists at ${path}. Pass --force to overwrite.`
|
|
392
|
+
);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
const written = await writeConfig({
|
|
396
|
+
host: opts.host,
|
|
397
|
+
project_uuid: opts.project,
|
|
398
|
+
version_slug: opts.version
|
|
399
|
+
});
|
|
400
|
+
console.log(`Wrote ${written}`);
|
|
401
|
+
if (!opts.project) {
|
|
402
|
+
console.log(
|
|
403
|
+
"Tip: pass --project <uuid> to bind this directory to a specific project (or edit project_uuid in the file later)."
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// src/commands/keys.ts
|
|
410
|
+
import { Command as Command4 } from "commander";
|
|
411
|
+
var keysCommand = new Command4("keys").description("Inspect translation keys for the current project.").addCommand(
|
|
412
|
+
new Command4("list").description("List keys for the configured project (addressed by namespace slug + key name).").option("--namespace <slug>", "Filter by namespace slug").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
|
|
413
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
414
|
+
const items = await listKeys(ctx, { namespace: opts.namespace });
|
|
415
|
+
console.log(`total: ${items.length}`);
|
|
416
|
+
for (const k of items) console.log(` ${k.namespace_slug}/${k.key_name}`);
|
|
417
|
+
})
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// src/commands/login.ts
|
|
421
|
+
import { Command as Command5 } from "commander";
|
|
422
|
+
|
|
423
|
+
// src/prompt.ts
|
|
424
|
+
import { createInterface } from "readline";
|
|
425
|
+
async function promptLine(message) {
|
|
426
|
+
if (!process.stdin.isTTY) return "";
|
|
427
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
428
|
+
try {
|
|
429
|
+
return await new Promise((resolve4) => {
|
|
430
|
+
rl.question(message, (answer) => resolve4(answer.trim()));
|
|
431
|
+
});
|
|
432
|
+
} finally {
|
|
433
|
+
rl.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
var CTRL_C = 3;
|
|
437
|
+
var BACKSPACE = 8;
|
|
438
|
+
var DEL = 127;
|
|
439
|
+
var CR = 13;
|
|
440
|
+
var LF = 10;
|
|
441
|
+
async function promptSecret(message) {
|
|
442
|
+
if (!process.stdin.isTTY) return "";
|
|
443
|
+
process.stdout.write(message);
|
|
444
|
+
process.stdin.setRawMode(true);
|
|
445
|
+
process.stdin.resume();
|
|
446
|
+
return await new Promise((resolve4) => {
|
|
447
|
+
let buffer = "";
|
|
448
|
+
const onData = (chunk) => {
|
|
449
|
+
for (const byte of chunk) {
|
|
450
|
+
if (byte === CR || byte === LF) {
|
|
451
|
+
process.stdout.write("\n");
|
|
452
|
+
process.stdin.removeListener("data", onData);
|
|
453
|
+
process.stdin.setRawMode(false);
|
|
454
|
+
process.stdin.pause();
|
|
455
|
+
resolve4(buffer);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (byte === CTRL_C) {
|
|
459
|
+
process.stdout.write("\n");
|
|
460
|
+
process.stdin.removeListener("data", onData);
|
|
461
|
+
process.stdin.setRawMode(false);
|
|
462
|
+
process.stdin.pause();
|
|
463
|
+
process.exit(130);
|
|
464
|
+
}
|
|
465
|
+
if (byte === BACKSPACE || byte === DEL) {
|
|
466
|
+
if (buffer.length > 0) {
|
|
467
|
+
buffer = buffer.slice(0, -1);
|
|
468
|
+
process.stdout.write("\b \b");
|
|
469
|
+
}
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
buffer += String.fromCharCode(byte);
|
|
473
|
+
process.stdout.write("*");
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
process.stdin.on("data", onData);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/commands/login.ts
|
|
481
|
+
var TOKEN_REGEX = /^vrb_[a-z]+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
482
|
+
var loginCommand = new Command5("login").description(
|
|
483
|
+
"Store an API key for a host. Token resolution order: --token, SONENTA_TOKEN env, then interactive prompt (TTY only)."
|
|
484
|
+
).option("--host <url>", "API base URL", "https://api.sonenta.com").option("--token <vrb_live_\u2026>", "API key token (prefix.secret form)").option("--email <email>", "User email associated with the token (optional)").option("--default", "Set this host as the default for future commands", false).action(async (opts) => {
|
|
485
|
+
let host = opts.host;
|
|
486
|
+
if (!host && process.stdin.isTTY) {
|
|
487
|
+
host = await promptLine("Host (default https://api.sonenta.com): ") || "https://api.sonenta.com";
|
|
488
|
+
}
|
|
489
|
+
let token = opts.token ?? (process.env.SONENTA_TOKEN ?? process.env.VERBUMIA_TOKEN) ?? "";
|
|
490
|
+
if (!token && process.stdin.isTTY) {
|
|
491
|
+
token = await promptSecret(`API token for ${host}: `);
|
|
492
|
+
}
|
|
493
|
+
if (!token) {
|
|
494
|
+
console.error(
|
|
495
|
+
"sonenta login: token required. Pass --token, set SONENTA_TOKEN, or run from a TTY for the interactive prompt."
|
|
496
|
+
);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
if (!TOKEN_REGEX.test(token)) {
|
|
500
|
+
console.error(
|
|
501
|
+
"sonenta login: token shape doesn't match `vrb_<env>_<prefix>.<secret>`. Generate one in the dashboard at Org Settings \u2192 API Keys."
|
|
502
|
+
);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
await setHostEntry(
|
|
506
|
+
host,
|
|
507
|
+
{ api_key: token, user_email: opts.email },
|
|
508
|
+
{ makeDefault: opts.default }
|
|
509
|
+
);
|
|
510
|
+
console.log(`Stored credentials for ${host}.`);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// src/commands/logout.ts
|
|
514
|
+
import { Command as Command6 } from "commander";
|
|
515
|
+
var logoutCommand = new Command6("logout").description("Remove stored credentials for a host (default: the current default host).").option("--host <url>", "Host to forget. Omit to forget the current default.").action(async (opts) => {
|
|
516
|
+
const creds = await readCredentials();
|
|
517
|
+
const target = opts.host ?? creds.default;
|
|
518
|
+
if (!target) {
|
|
519
|
+
console.log("Nothing to forget \u2014 no credentials stored.");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const removed = await removeHost(target);
|
|
523
|
+
if (!removed) {
|
|
524
|
+
console.log(`No credentials stored for ${target}.`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
console.log(`Forgot credentials for ${target}.`);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// src/commands/missing.ts
|
|
531
|
+
import { Command as Command7 } from "commander";
|
|
532
|
+
var missingCommand = new Command7("missing").description(
|
|
533
|
+
"List runtime-detected missing keys for the configured project. Requires the API key to carry the `mcp:*` scope."
|
|
534
|
+
).option("--namespace <slug>", "Filter by namespace slug").option("--language <code>", "Filter by language code").option("--status <state>", "Filter by status (open|resolved|...)").option("--limit <n>", "Page size (1-200, default 50)", "50").option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
535
|
+
async (opts) => {
|
|
536
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
537
|
+
if (!ctx.projectUuid) {
|
|
538
|
+
console.error(
|
|
539
|
+
"sonenta missing: no project_uuid configured. Run `sonenta init --project <uuid>`."
|
|
540
|
+
);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
const params = new URLSearchParams();
|
|
544
|
+
params.set("limit", opts.limit);
|
|
545
|
+
if (opts.namespace) params.set("namespace", opts.namespace);
|
|
546
|
+
if (opts.language) params.set("language_code", opts.language);
|
|
547
|
+
if (opts.status) params.set("status", opts.status);
|
|
548
|
+
const page = await apiRequest(
|
|
549
|
+
ctx,
|
|
550
|
+
`/v1/mcp/projects/${ctx.projectUuid}/missing-keys?${params.toString()}`
|
|
551
|
+
);
|
|
552
|
+
console.log(`total: ${page.total}`);
|
|
553
|
+
for (const m of page.items) {
|
|
554
|
+
const sample = m.source_value ? ` \u27F6 "${m.source_value}"` : "";
|
|
555
|
+
console.log(
|
|
556
|
+
` ${m.namespace_slug}/${m.key} (${m.language_code}, count=${m.count}, ${m.status})${sample}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (page.cursor_next) {
|
|
560
|
+
console.log(`(more \u2014 re-run with --cursor ${page.cursor_next})`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// src/commands/projects.ts
|
|
566
|
+
import { Command as Command8 } from "commander";
|
|
567
|
+
var projectsCommand = new Command8("projects").description("Inspect Verbumia projects accessible to your API key.").addCommand(
|
|
568
|
+
new Command8("list").description("List the projects this API key can reach.").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
|
|
569
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
570
|
+
const items = await listProjects(ctx);
|
|
571
|
+
if (items.length === 0) {
|
|
572
|
+
console.log("No projects visible to this key (scoped or revoked?).");
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
for (const p of items) {
|
|
576
|
+
const slug = p.slug ? ` ${p.slug}` : "";
|
|
577
|
+
const src = p.source_language ? ` src=${p.source_language}` : "";
|
|
578
|
+
console.log(` ${p.uuid}${slug} ${p.name ?? ""}${src}`);
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// src/commands/pull.ts
|
|
584
|
+
import { Command as Command9 } from "commander";
|
|
585
|
+
|
|
586
|
+
// src/locales.ts
|
|
587
|
+
import { promises as fs5 } from "fs";
|
|
588
|
+
import { join as join3, resolve as resolve3 } from "path";
|
|
589
|
+
var DEFAULT_LOCALES_DIR = "locales";
|
|
590
|
+
async function listLocaleFiles(rootDir) {
|
|
591
|
+
const root = resolve3(rootDir);
|
|
592
|
+
let langDirs;
|
|
593
|
+
try {
|
|
594
|
+
langDirs = await fs5.readdir(root);
|
|
595
|
+
} catch {
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
const out = [];
|
|
599
|
+
for (const lang of langDirs) {
|
|
600
|
+
const langPath = join3(root, lang);
|
|
601
|
+
let stat;
|
|
602
|
+
try {
|
|
603
|
+
stat = await fs5.stat(langPath);
|
|
604
|
+
} catch {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (!stat.isDirectory()) continue;
|
|
608
|
+
const files = await fs5.readdir(langPath);
|
|
609
|
+
for (const f of files) {
|
|
610
|
+
if (!f.endsWith(".json")) continue;
|
|
611
|
+
out.push({ lang, namespace: f.replace(/\.json$/, ""), path: join3(langPath, f) });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return out;
|
|
615
|
+
}
|
|
616
|
+
async function readLocaleFile(path) {
|
|
617
|
+
const raw = await fs5.readFile(path, "utf8");
|
|
618
|
+
const parsed = JSON.parse(raw);
|
|
619
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
620
|
+
throw new Error(`${path} is not a flat object`);
|
|
621
|
+
}
|
|
622
|
+
const out = {};
|
|
623
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
624
|
+
if (typeof v !== "string") {
|
|
625
|
+
throw new Error(`${path}: key "${k}" is not a string (V1 flat layout only)`);
|
|
626
|
+
}
|
|
627
|
+
out[k] = v;
|
|
628
|
+
}
|
|
629
|
+
return out;
|
|
630
|
+
}
|
|
631
|
+
async function writeLocaleFile(rootDir, lang, namespace, values) {
|
|
632
|
+
const dir = join3(rootDir, lang);
|
|
633
|
+
await fs5.mkdir(dir, { recursive: true });
|
|
634
|
+
const path = join3(dir, `${namespace}.json`);
|
|
635
|
+
const sorted = Object.fromEntries(
|
|
636
|
+
Object.entries(values).sort(([a], [b]) => a.localeCompare(b))
|
|
637
|
+
);
|
|
638
|
+
await fs5.writeFile(path, JSON.stringify(sorted, null, 2) + "\n", "utf8");
|
|
639
|
+
return path;
|
|
640
|
+
}
|
|
641
|
+
function diffFlat(local, remote) {
|
|
642
|
+
const added = [];
|
|
643
|
+
const removed = [];
|
|
644
|
+
const changed = [];
|
|
645
|
+
let unchanged = 0;
|
|
646
|
+
for (const [k, v] of Object.entries(local)) {
|
|
647
|
+
if (!(k in remote)) {
|
|
648
|
+
added.push(k);
|
|
649
|
+
} else if (remote[k] !== v) {
|
|
650
|
+
changed.push({ key: k, local: v, remote: remote[k] });
|
|
651
|
+
} else {
|
|
652
|
+
unchanged++;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const k of Object.keys(remote)) {
|
|
656
|
+
if (!(k in local)) removed.push(k);
|
|
657
|
+
}
|
|
658
|
+
return { added, removed, changed, unchanged };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/commands/pull.ts
|
|
662
|
+
var pullCommand = new Command9("pull").description(
|
|
663
|
+
"Pull translations from Verbumia into locales/<lang>/<namespace>.json (flat dot-notation). Overwrites local files \u2014 pair with `git status`."
|
|
664
|
+
).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--dest <dir>", "Output directory", DEFAULT_LOCALES_DIR).option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
665
|
+
async (opts) => {
|
|
666
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
667
|
+
let languages;
|
|
668
|
+
if (opts.language) {
|
|
669
|
+
languages = [opts.language];
|
|
670
|
+
} else {
|
|
671
|
+
languages = (await getProjectInfo(ctx)).languages;
|
|
672
|
+
if (languages.length === 0) {
|
|
673
|
+
console.error("sonenta pull: the project reports no languages.");
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
let totalKeys = 0;
|
|
678
|
+
let totalFiles = 0;
|
|
679
|
+
for (const lang of languages) {
|
|
680
|
+
const items = await listKeys(ctx, { languageCode: lang, namespace: opts.namespace });
|
|
681
|
+
const byNs = /* @__PURE__ */ new Map();
|
|
682
|
+
for (const it of items) {
|
|
683
|
+
const tr = it.translations?.find((t) => t.language_code === lang);
|
|
684
|
+
if (!tr || tr.value === "") continue;
|
|
685
|
+
const map = byNs.get(it.namespace_slug) ?? {};
|
|
686
|
+
map[it.key_name] = tr.value;
|
|
687
|
+
byNs.set(it.namespace_slug, map);
|
|
688
|
+
}
|
|
689
|
+
for (const [ns, map] of byNs) {
|
|
690
|
+
const path = await writeLocaleFile(opts.dest, lang, ns, map);
|
|
691
|
+
totalKeys += Object.keys(map).length;
|
|
692
|
+
totalFiles++;
|
|
693
|
+
console.log(` ${path} ${Object.keys(map).length} keys`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
console.log(`pulled ${totalKeys} translation(s) across ${totalFiles} file(s)`);
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// src/commands/push.ts
|
|
701
|
+
import { Command as Command10 } from "commander";
|
|
702
|
+
var pushCommand = new Command10("push").description(
|
|
703
|
+
"Push the whole local locales/ tree to Verbumia in ONE import call \u2014 creates missing keys and upserts translations (idempotent). Reads locales/<lang>/<namespace>.json (flat dot-notation)."
|
|
704
|
+
).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--src <dir>", "Locales directory", DEFAULT_LOCALES_DIR).option("--status <status>", "draft | translated (default: translated)").option("--version <slug>", "Target version slug (default: production version)").option("--dry-run", "Print what would be pushed without sending", false).option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
705
|
+
async (opts) => {
|
|
706
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
707
|
+
const files = (await listLocaleFiles(opts.src)).filter((f) => {
|
|
708
|
+
if (opts.language && f.lang !== opts.language) return false;
|
|
709
|
+
if (opts.namespace && f.namespace !== opts.namespace) return false;
|
|
710
|
+
return true;
|
|
711
|
+
});
|
|
712
|
+
if (files.length === 0) {
|
|
713
|
+
console.log(`No locale files found under ${opts.src}/`);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const byNs = /* @__PURE__ */ new Map();
|
|
717
|
+
let totalKeys = 0;
|
|
718
|
+
for (const f of files) {
|
|
719
|
+
const flat = await readLocaleFile(f.path);
|
|
720
|
+
totalKeys += Object.keys(flat).length;
|
|
721
|
+
const langs = byNs.get(f.namespace) ?? {};
|
|
722
|
+
langs[f.lang] = flat;
|
|
723
|
+
byNs.set(f.namespace, langs);
|
|
724
|
+
console.log(` ${f.lang}/${f.namespace}.json ${Object.keys(flat).length} keys`);
|
|
725
|
+
}
|
|
726
|
+
const body = {
|
|
727
|
+
namespaces: [...byNs.entries()].map(([namespace, translations]) => ({
|
|
728
|
+
namespace,
|
|
729
|
+
translations
|
|
730
|
+
}))
|
|
731
|
+
};
|
|
732
|
+
if (opts.status === "draft" || opts.status === "translated") body.status = opts.status;
|
|
733
|
+
if (opts.version) body.version = opts.version;
|
|
734
|
+
if (opts.dryRun) {
|
|
735
|
+
console.log(
|
|
736
|
+
`
|
|
737
|
+
(dry-run) would push ${totalKeys} key(s) across ${body.namespaces.length} namespace(s); nothing sent.`
|
|
738
|
+
);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const report = await importBundle(ctx, body);
|
|
742
|
+
console.log("");
|
|
743
|
+
for (const line of summariseImport(report)) console.log(line);
|
|
744
|
+
if (report.errors?.length || report.glossary_violations?.length) process.exitCode = 1;
|
|
745
|
+
}
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
// src/commands/releases.ts
|
|
749
|
+
import { Command as Command11 } from "commander";
|
|
750
|
+
var releasesCommand = new Command11("releases").description("Manage CDN releases for the project.").addCommand(
|
|
751
|
+
new Command11("publish").description(
|
|
752
|
+
"Trigger a CDN release: build bundles for every (language, namespace) and push them to the public CDN. Idempotent \u2014 unchanged bundles are reused. Subscribed SDKs receive a live `translations_published` event."
|
|
753
|
+
).option("--language <code>", "Restrict to one language (default: all)").option("--namespace <slug>", "Restrict to one namespace (default: all)").option("--version <slug>", "Version slug (default: production version)").option("--dry-run", "Print the planned release without publishing", false).option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
754
|
+
async (opts) => {
|
|
755
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
756
|
+
const scope = [
|
|
757
|
+
opts.language && `language=${opts.language}`,
|
|
758
|
+
opts.namespace && `namespace=${opts.namespace}`,
|
|
759
|
+
opts.version && `version=${opts.version}`
|
|
760
|
+
].filter(Boolean).join(", ") || "ALL languages + namespaces";
|
|
761
|
+
if (opts.dryRun) {
|
|
762
|
+
console.log(`(dry-run) would publish a CDN release for ${scope}; nothing sent.`);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const body = {};
|
|
766
|
+
if (opts.language) body.language_code = opts.language;
|
|
767
|
+
if (opts.namespace) body.namespace = opts.namespace;
|
|
768
|
+
if (opts.version) body.version_slug = opts.version;
|
|
769
|
+
const res = await publishCdn(ctx, body);
|
|
770
|
+
console.log(`published CDN release for ${scope}`);
|
|
771
|
+
console.log(JSON.stringify(res, null, 2));
|
|
772
|
+
}
|
|
773
|
+
)
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
// src/commands/snapshot.ts
|
|
777
|
+
import { promises as fs6 } from "fs";
|
|
778
|
+
import { Command as Command12 } from "commander";
|
|
779
|
+
function bundleUrl(cdnBase, project, version, lang, ns) {
|
|
780
|
+
return `${cdnBase.replace(/\/+$/, "")}/p/${project}/${version}/latest/${lang}/${ns}.json`;
|
|
781
|
+
}
|
|
782
|
+
function renderSnapshot(bundles, meta, format) {
|
|
783
|
+
const json = JSON.stringify(bundles, null, 2);
|
|
784
|
+
if (format === "json") return json + "\n";
|
|
785
|
+
return `// Generated by \`sonenta snapshot\` \u2014 do not edit by hand.
|
|
786
|
+
// Build-time fallback for @sonenta/react-i18next:
|
|
787
|
+
// import { bundles } from "./<this-file>";
|
|
788
|
+
// <VerbumiaProvider initialBundles={bundles} />
|
|
789
|
+
export const bundles = ${json} as const;
|
|
790
|
+
|
|
791
|
+
export const meta = ${JSON.stringify(meta, null, 2)} as const;
|
|
792
|
+
|
|
793
|
+
export default bundles;
|
|
794
|
+
`;
|
|
795
|
+
}
|
|
796
|
+
async function fetchBundle(url) {
|
|
797
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
798
|
+
if (res.status === 404) return null;
|
|
799
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
|
|
800
|
+
return await res.json();
|
|
801
|
+
}
|
|
802
|
+
var snapshotCommand = new Command12("snapshot").description(
|
|
803
|
+
"Generate a build-time translations snapshot for @sonenta/react-i18next `initialBundles` (offline-first fallback). Fetches the PUBLIC CDN bundles (exactly what the SDK loads at runtime) and assembles Record<locale, Record<namespace, tree>>. Emits a .ts module (default) or .json."
|
|
804
|
+
).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--version <slug>", "Version slug (default: the configured version_slug)").option("--format <fmt>", "ts | json (default: ts)", "ts").option("--cdn <base>", "CDN base URL", "https://cdn.sonenta.com").option("--out <file>", "Write to a file instead of stdout").option("--host <url>", "Override host (used to discover languages/namespaces)").action(
|
|
805
|
+
async (opts) => {
|
|
806
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
807
|
+
const project = requireProject(ctx);
|
|
808
|
+
const version = opts.version ?? ctx.versionSlug;
|
|
809
|
+
let languages;
|
|
810
|
+
let namespaces;
|
|
811
|
+
if (opts.language && opts.namespace) {
|
|
812
|
+
languages = [opts.language];
|
|
813
|
+
namespaces = [opts.namespace];
|
|
814
|
+
} else {
|
|
815
|
+
const info = await getProjectInfo(ctx);
|
|
816
|
+
languages = opts.language ? [opts.language] : info.languages;
|
|
817
|
+
namespaces = opts.namespace ? [opts.namespace] : info.namespaces;
|
|
818
|
+
if (languages.length === 0 || namespaces.length === 0) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
"could not enumerate languages/namespaces from project-info \u2014 pass --language and --namespace explicitly."
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const bundles = {};
|
|
825
|
+
let fetched = 0;
|
|
826
|
+
let missing = 0;
|
|
827
|
+
for (const lang of languages) {
|
|
828
|
+
for (const ns of namespaces) {
|
|
829
|
+
const tree = await fetchBundle(bundleUrl(opts.cdn, project, version, lang, ns));
|
|
830
|
+
if (!tree) {
|
|
831
|
+
missing++;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
(bundles[lang] ??= {})[ns] = tree;
|
|
835
|
+
fetched++;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const output = renderSnapshot(
|
|
839
|
+
bundles,
|
|
840
|
+
{ project, version, cdn: opts.cdn.replace(/\/+$/, "") },
|
|
841
|
+
opts.format === "json" ? "json" : "ts"
|
|
842
|
+
);
|
|
843
|
+
if (opts.out) {
|
|
844
|
+
await fs6.writeFile(opts.out, output, "utf8");
|
|
845
|
+
console.log(
|
|
846
|
+
`wrote ${opts.out}: ${fetched} bundle(s)` + (missing ? `, ${missing} not published (404)` : "")
|
|
847
|
+
);
|
|
848
|
+
} else {
|
|
849
|
+
process.stdout.write(output);
|
|
850
|
+
if (missing) console.error(`(${missing} bundle(s) not published)`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
// src/commands/status.ts
|
|
856
|
+
import { Command as Command13 } from "commander";
|
|
857
|
+
var statusCommand = new Command13("status").description("Diff local locales/ against the remote project state.").option("--language <code>", "Restrict to one language code").option("--namespace <slug>", "Restrict to one namespace slug").option("--src <dir>", "Locales directory", DEFAULT_LOCALES_DIR).option("--host <url>", "Override host (otherwise from config/credentials)").action(
|
|
858
|
+
async (opts) => {
|
|
859
|
+
const ctx = await resolveContext({ hostOverride: opts.host });
|
|
860
|
+
const knownLangs = new Set((await getProjectInfo(ctx)).languages);
|
|
861
|
+
const files = (await listLocaleFiles(opts.src)).filter((f) => {
|
|
862
|
+
if (opts.language && f.lang !== opts.language) return false;
|
|
863
|
+
if (opts.namespace && f.namespace !== opts.namespace) return false;
|
|
864
|
+
return true;
|
|
865
|
+
});
|
|
866
|
+
const remoteByLang = /* @__PURE__ */ new Map();
|
|
867
|
+
async function remoteFor(ctx2, lang) {
|
|
868
|
+
const cached = remoteByLang.get(lang);
|
|
869
|
+
if (cached) return cached;
|
|
870
|
+
const m = /* @__PURE__ */ new Map();
|
|
871
|
+
for (const it of await listKeys(ctx2, { languageCode: lang })) {
|
|
872
|
+
const tr = it.translations?.find((t) => t.language_code === lang);
|
|
873
|
+
if (!tr || tr.value === "") continue;
|
|
874
|
+
const map = m.get(it.namespace_slug) ?? {};
|
|
875
|
+
map[it.key_name] = tr.value;
|
|
876
|
+
m.set(it.namespace_slug, map);
|
|
877
|
+
}
|
|
878
|
+
remoteByLang.set(lang, m);
|
|
879
|
+
return m;
|
|
880
|
+
}
|
|
881
|
+
let totalAdded = 0;
|
|
882
|
+
let totalRemoved = 0;
|
|
883
|
+
let totalChanged = 0;
|
|
884
|
+
let totalUnchanged = 0;
|
|
885
|
+
for (const f of files) {
|
|
886
|
+
if (knownLangs.size > 0 && !knownLangs.has(f.lang)) {
|
|
887
|
+
console.log(`! ${f.lang}/${f.namespace}.json \u2014 language not in project, skipped`);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const local = await readLocaleFile(f.path);
|
|
891
|
+
const remote = (await remoteFor(ctx, f.lang)).get(f.namespace) ?? {};
|
|
892
|
+
const d = diffFlat(local, remote);
|
|
893
|
+
totalAdded += d.added.length;
|
|
894
|
+
totalRemoved += d.removed.length;
|
|
895
|
+
totalChanged += d.changed.length;
|
|
896
|
+
totalUnchanged += d.unchanged;
|
|
897
|
+
if (d.added.length === 0 && d.removed.length === 0 && d.changed.length === 0) {
|
|
898
|
+
console.log(`= ${f.lang}/${f.namespace}.json (in sync, ${d.unchanged} keys)`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
console.log(
|
|
902
|
+
`~ ${f.lang}/${f.namespace}.json +${d.added.length} -${d.removed.length} ~${d.changed.length}`
|
|
903
|
+
);
|
|
904
|
+
for (const k of d.added) console.log(` + ${k}`);
|
|
905
|
+
for (const k of d.removed) console.log(` - ${k} (remote-only \u2014 push leaves untouched)`);
|
|
906
|
+
for (const c of d.changed) console.log(` ~ ${c.key} "${c.local}" <- "${c.remote}"`);
|
|
907
|
+
}
|
|
908
|
+
console.log(
|
|
909
|
+
`summary: +${totalAdded} -${totalRemoved} ~${totalChanged} unchanged=${totalUnchanged}`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
// src/commands/whoami.ts
|
|
915
|
+
import { Command as Command14 } from "commander";
|
|
916
|
+
var whoamiCommand = new Command14("whoami").description("Show the configured default host + which API key is in use.").option("--host <url>", "Inspect a specific host instead of the default").action(async (opts) => {
|
|
917
|
+
const creds = await readCredentials();
|
|
918
|
+
if (!creds.default && Object.keys(creds.hosts).length === 0) {
|
|
919
|
+
console.log("Not logged in. Run `sonenta login --host <url> --token <\u2026>`.");
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
const target = opts.host ?? creds.default;
|
|
923
|
+
if (!target) {
|
|
924
|
+
console.log("No default host set.");
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
927
|
+
const entry = creds.hosts[target];
|
|
928
|
+
if (!entry) {
|
|
929
|
+
console.log(`No credentials stored for ${target}.`);
|
|
930
|
+
process.exit(1);
|
|
931
|
+
}
|
|
932
|
+
const masked = entry.api_key.replace(/\.[\s\S]+$/, ".\u2022\u2022\u2022");
|
|
933
|
+
const envOverride = (process.env.SONENTA_TOKEN ?? process.env.VERBUMIA_TOKEN)?.trim();
|
|
934
|
+
console.log(`host: ${target}${target === creds.default ? " (default)" : ""}`);
|
|
935
|
+
console.log(`api_key: ${masked}${envOverride ? " (overridden by SONENTA_TOKEN env)" : ""}`);
|
|
936
|
+
if (entry.user_email) console.log(`email: ${entry.user_email}`);
|
|
937
|
+
const others = Object.keys(creds.hosts).filter((h) => h !== target);
|
|
938
|
+
if (others.length) {
|
|
939
|
+
console.log(`other: ${others.join(", ")}`);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// src/index.ts
|
|
944
|
+
var program = new Command15();
|
|
945
|
+
program.name("sonenta").description("CLI for Sonenta translation management.").version("0.3.0");
|
|
946
|
+
program.addCommand(loginCommand);
|
|
947
|
+
program.addCommand(logoutCommand);
|
|
948
|
+
program.addCommand(whoamiCommand);
|
|
949
|
+
program.addCommand(initCommand);
|
|
950
|
+
program.addCommand(projectsCommand);
|
|
951
|
+
program.addCommand(keysCommand);
|
|
952
|
+
program.addCommand(importCommand);
|
|
953
|
+
program.addCommand(pushCommand);
|
|
954
|
+
program.addCommand(pullCommand);
|
|
955
|
+
program.addCommand(exportCommand);
|
|
956
|
+
program.addCommand(statusCommand);
|
|
957
|
+
program.addCommand(releasesCommand);
|
|
958
|
+
program.addCommand(snapshotCommand);
|
|
959
|
+
program.addCommand(missingCommand);
|
|
960
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
961
|
+
console.error(err instanceof Error ? err.message : err);
|
|
962
|
+
process.exit(1);
|
|
963
|
+
});
|
|
964
|
+
//# sourceMappingURL=index.js.map
|