@kopynator/cli 1.6.1 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
 
26
26
  // src/index.ts
27
27
  var import_commander = require("commander");
28
- var import_chalk6 = __toESM(require("chalk"));
28
+ var import_chalk7 = __toESM(require("chalk"));
29
29
 
30
30
  // src/commands/init.ts
31
31
  var import_inquirer = __toESM(require("inquirer"));
@@ -272,6 +272,7 @@ async function initCommand() {
272
272
  var import_chalk2 = __toESM(require("chalk"));
273
273
  var import_fs4 = __toESM(require("fs"));
274
274
  var import_path4 = __toESM(require("path"));
275
+ var readline = __toESM(require("readline"));
275
276
 
276
277
  // src/lib/project.ts
277
278
  var import_fs2 = __toESM(require("fs"));
@@ -455,6 +456,117 @@ function findGlobalDuplicates(values, usedKeys) {
455
456
  }
456
457
 
457
458
  // src/commands/check.ts
459
+ function loadKopyConfig() {
460
+ const cwd = process.cwd();
461
+ for (const rel of ["kopynator.config.json", "src/kopynator.config.json"]) {
462
+ const p = import_path4.default.join(cwd, rel);
463
+ if (import_fs4.default.existsSync(p)) {
464
+ try {
465
+ const c = JSON.parse(import_fs4.default.readFileSync(p, "utf-8"));
466
+ const key = c.apiKey ?? c.api_key;
467
+ if (key) return { apiKey: key, baseUrl: c.baseUrl };
468
+ } catch {
469
+ }
470
+ }
471
+ }
472
+ for (const rel of ["src/app/app.config.ts", "src/app/app.module.ts"]) {
473
+ const p = import_path4.default.join(cwd, rel);
474
+ if (import_fs4.default.existsSync(p)) {
475
+ const m = import_fs4.default.readFileSync(p, "utf-8").match(/apiKey:\s*['"]([^'"]+)['"]/);
476
+ if (m) return { apiKey: m[1] };
477
+ }
478
+ }
479
+ const envKey = process.env.KOPYNATOR_API_KEY?.trim();
480
+ if (envKey) return { apiKey: envKey, baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() || void 0 };
481
+ return null;
482
+ }
483
+ function ask(question) {
484
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
485
+ return new Promise((resolve) => rl.question(question, (ans) => {
486
+ rl.close();
487
+ resolve(ans.trim());
488
+ }));
489
+ }
490
+ async function checkRemoteOrphans(localKeys) {
491
+ const config = loadKopyConfig();
492
+ if (!config) {
493
+ console.log(import_chalk2.default.gray("\n (Skipping remote check \u2014 no API key found)\n"));
494
+ return;
495
+ }
496
+ const baseUrl = config.baseUrl || "https://api.kopynator.com/tokens";
497
+ const token = config.apiKey;
498
+ console.log(import_chalk2.default.bold.blue("\n\u2601\uFE0F Checking for server-only keys...\n"));
499
+ let languages = [];
500
+ try {
501
+ const res = await fetch(`${baseUrl}/languages`, {
502
+ headers: { "x-api-token": token, "x-kopynator-version": "1.6.3" }
503
+ });
504
+ if (!res.ok) {
505
+ console.log(import_chalk2.default.yellow(` Could not fetch languages from server (${res.status}). Skipping.
506
+ `));
507
+ return;
508
+ }
509
+ languages = await res.json();
510
+ } catch {
511
+ console.log(import_chalk2.default.yellow(" Could not reach server. Skipping remote check.\n"));
512
+ return;
513
+ }
514
+ if (languages.length === 0) {
515
+ console.log(import_chalk2.default.gray(" No languages found on server.\n"));
516
+ return;
517
+ }
518
+ const remoteKeys = /* @__PURE__ */ new Set();
519
+ for (const lang of languages) {
520
+ try {
521
+ const res = await fetch(`${baseUrl}/fetch?langs=${lang}&nested=false&includeLangKey=false`, {
522
+ headers: { "x-api-token": token, "x-kopynator-version": "1.6.3" }
523
+ });
524
+ if (!res.ok) continue;
525
+ const data = await res.json();
526
+ Object.keys(data).forEach((k) => remoteKeys.add(k));
527
+ } catch {
528
+ }
529
+ }
530
+ const orphans = [...remoteKeys].filter((k) => !localKeys.has(k)).sort();
531
+ if (orphans.length === 0) {
532
+ console.log(import_chalk2.default.green("\u2705 No server-only keys found. Everything is in sync.\n"));
533
+ return;
534
+ }
535
+ console.log(import_chalk2.default.yellow(`\u26A0\uFE0F ${orphans.length} key(s) exist on the server but are NOT in your local files:
536
+ `));
537
+ orphans.forEach((k) => console.log(import_chalk2.default.yellow(` - ${k}`)));
538
+ const answer = await ask(import_chalk2.default.bold("\nDelete these keys from the server? [y/N] "));
539
+ if (answer.toLowerCase() !== "y") {
540
+ console.log(import_chalk2.default.gray("\n Skipped. Server keys were not deleted.\n"));
541
+ return;
542
+ }
543
+ try {
544
+ const res = await fetch(`${baseUrl}/keys`, {
545
+ method: "DELETE",
546
+ headers: {
547
+ "Content-Type": "application/json",
548
+ "x-api-token": token,
549
+ "x-kopynator-version": "1.6.3"
550
+ },
551
+ body: JSON.stringify({ keys: orphans })
552
+ });
553
+ if (!res.ok) {
554
+ const err = await res.text().catch(() => res.statusText);
555
+ console.log(import_chalk2.default.red(`
556
+ \u274C Failed to delete keys: ${res.status} ${err}
557
+ `));
558
+ return;
559
+ }
560
+ const result = await res.json().catch(() => ({}));
561
+ console.log(import_chalk2.default.green(`
562
+ \u2705 Deleted ${result.deleted ?? orphans.length} key(s) from the server.
563
+ `));
564
+ } catch (e) {
565
+ console.log(import_chalk2.default.red(`
566
+ \u274C Network error: ${e.message}
567
+ `));
568
+ }
569
+ }
458
570
  async function checkCommand(opts = {}) {
459
571
  if (process.env.KOPYNATOR_I18N_SKIP === "1") {
460
572
  console.log(import_chalk2.default.yellow("\u26A0\uFE0F KOPYNATOR_I18N_SKIP=1 \u2014 skipping i18n check."));
@@ -554,6 +666,7 @@ All missing keys (${missing.length}):`));
554
666
  }
555
667
  console.log(import_chalk2.default.bold.green(`
556
668
  \u2728 i18n check passed (${files.length} locale file(s); baseline=${baselineSet.size}; scanned ${sourceFiles.length} source file(s)).`));
669
+ await checkRemoteOrphans(definedKeys);
557
670
  }
558
671
 
559
672
  // src/commands/sync.ts
@@ -910,16 +1023,155 @@ async function uploadCommand(options) {
910
1023
  }
911
1024
  }
912
1025
 
913
- // src/commands/limits.ts
1026
+ // src/commands/push.ts
914
1027
  var import_chalk5 = __toESM(require("chalk"));
915
1028
  var import_fs7 = __toESM(require("fs"));
916
1029
  var import_path7 = __toESM(require("path"));
1030
+ var import_ora3 = __toESM(require("ora"));
1031
+ var BATCH_SIZE2 = 500;
1032
+ function flatten3(data, prefix = "") {
1033
+ const result = {};
1034
+ for (const key in data) {
1035
+ const fullKey = prefix ? `${prefix}.${key}` : key;
1036
+ const value = data[key];
1037
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1038
+ Object.assign(result, flatten3(value, fullKey));
1039
+ } else {
1040
+ result[fullKey] = String(value);
1041
+ }
1042
+ }
1043
+ return result;
1044
+ }
1045
+ function loadJsonConfig3(jsonPath) {
1046
+ if (!import_fs7.default.existsSync(jsonPath)) return null;
1047
+ try {
1048
+ const config = JSON.parse(import_fs7.default.readFileSync(jsonPath, "utf-8"));
1049
+ const key = config.apiKey ?? config.api_key;
1050
+ if (key && typeof key === "string") return { apiKey: key, baseUrl: config.baseUrl };
1051
+ } catch {
1052
+ }
1053
+ return null;
1054
+ }
1055
+ function extractApiKey3() {
1056
+ const cwd = process.cwd();
1057
+ const configFromRoot = loadJsonConfig3(import_path7.default.join(cwd, "kopynator.config.json"));
1058
+ if (configFromRoot) return configFromRoot;
1059
+ const configFromSrc = loadJsonConfig3(import_path7.default.join(cwd, "src/kopynator.config.json"));
1060
+ if (configFromSrc) return configFromSrc;
1061
+ for (const rel of ["src/app/app.config.ts", "src/app/app.module.ts"]) {
1062
+ const p = import_path7.default.join(cwd, rel);
1063
+ if (import_fs7.default.existsSync(p)) {
1064
+ const m = import_fs7.default.readFileSync(p, "utf-8").match(/apiKey:\s*['"]([^'"]+)['"]/);
1065
+ if (m) return { apiKey: m[1] };
1066
+ }
1067
+ }
1068
+ const envKey = process.env.KOPYNATOR_API_KEY?.trim();
1069
+ if (envKey) return { apiKey: envKey, baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() || void 0 };
1070
+ return null;
1071
+ }
1072
+ function inferLang(filePath) {
1073
+ return import_path7.default.basename(filePath, import_path7.default.extname(filePath));
1074
+ }
1075
+ async function pushFile(filePath, token, baseUrl) {
1076
+ const lang = inferLang(filePath);
1077
+ let raw;
1078
+ try {
1079
+ raw = JSON.parse(import_fs7.default.readFileSync(filePath, "utf-8"));
1080
+ } catch {
1081
+ return { lang, pushed: 0, skipped: 0, error: "Invalid JSON" };
1082
+ }
1083
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1084
+ return { lang, pushed: 0, skipped: 0, error: "JSON root must be an object" };
1085
+ }
1086
+ const flat = flatten3(raw);
1087
+ const entries = Object.entries(flat);
1088
+ if (entries.length === 0) return { lang, pushed: 0, skipped: 0 };
1089
+ const batches = [];
1090
+ for (let i = 0; i < entries.length; i += BATCH_SIZE2) {
1091
+ batches.push(Object.fromEntries(entries.slice(i, i + BATCH_SIZE2)));
1092
+ }
1093
+ let pushed = 0;
1094
+ let skipped = 0;
1095
+ for (const batch of batches) {
1096
+ const res = await fetch(`${baseUrl}/import`, {
1097
+ method: "POST",
1098
+ headers: {
1099
+ "Content-Type": "application/json",
1100
+ "x-api-token": token,
1101
+ "x-kopynator-version": "1.6.1",
1102
+ // merge mode: server wins on conflict (existing keys are not overwritten)
1103
+ "x-import-mode": "merge"
1104
+ },
1105
+ body: JSON.stringify({ lang, data: batch, mode: "merge" })
1106
+ });
1107
+ if (!res.ok) {
1108
+ const err = await res.text().catch(() => res.statusText);
1109
+ return { lang, pushed, skipped, error: `${res.status}: ${err}` };
1110
+ }
1111
+ const result = await res.json().catch(() => ({}));
1112
+ pushed += result.imported ?? Object.keys(batch).length;
1113
+ skipped += result.skipped ?? 0;
1114
+ }
1115
+ return { lang, pushed, skipped };
1116
+ }
1117
+ async function pushCommand() {
1118
+ console.log(import_chalk5.default.bold.blue("\n\u2601\uFE0F Pushing local translations to Kopynator Cloud (merge mode)...\n"));
1119
+ console.log(import_chalk5.default.gray(" Keys that already exist on the server are NOT overwritten.\n"));
1120
+ const config = extractApiKey3();
1121
+ if (!config) {
1122
+ console.log(import_chalk5.default.red("\u274C Could not find API key. Run `npx kopynator init` first."));
1123
+ return;
1124
+ }
1125
+ const framework = detectFramework();
1126
+ const assetsDir = getTranslationDir(framework);
1127
+ if (!import_fs7.default.existsSync(assetsDir)) {
1128
+ console.log(import_chalk5.default.red(`\u274C Translation directory not found: ${assetsDir}`));
1129
+ return;
1130
+ }
1131
+ const files = import_fs7.default.readdirSync(assetsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((f) => import_path7.default.join(assetsDir, f));
1132
+ if (files.length === 0) {
1133
+ console.log(import_chalk5.default.yellow("\u26A0\uFE0F No JSON translation files found."));
1134
+ return;
1135
+ }
1136
+ const baseUrl = config.baseUrl || "https://api.kopynator.com/tokens";
1137
+ const token = config.apiKey;
1138
+ console.log(import_chalk5.default.cyan(`\u{1F4C2} Found ${files.length} file(s) in ${assetsDir}
1139
+ `));
1140
+ let totalPushed = 0;
1141
+ let totalSkipped = 0;
1142
+ let totalErrors = 0;
1143
+ for (const filePath of files) {
1144
+ const spinner = (0, import_ora3.default)(`Pushing ${import_path7.default.basename(filePath)}...`).start();
1145
+ const result = await pushFile(filePath, token, baseUrl);
1146
+ if (result.error) {
1147
+ spinner.fail(`${import_chalk5.default.bold(result.lang)}: ${import_chalk5.default.red(result.error)}`);
1148
+ totalErrors++;
1149
+ } else {
1150
+ spinner.succeed(
1151
+ `${import_chalk5.default.bold(result.lang)}: ${import_chalk5.default.green(`${result.pushed} pushed`)}` + (result.skipped ? import_chalk5.default.gray(`, ${result.skipped} skipped (already on server)`) : "")
1152
+ );
1153
+ totalPushed += result.pushed;
1154
+ totalSkipped += result.skipped;
1155
+ }
1156
+ }
1157
+ console.log("");
1158
+ if (totalErrors === 0) {
1159
+ console.log(import_chalk5.default.green(`\u2705 Push complete \u2014 ${totalPushed} keys pushed, ${totalSkipped} already existed on server.`));
1160
+ } else {
1161
+ console.log(import_chalk5.default.yellow(`\u26A0\uFE0F Push finished with ${totalErrors} error(s). ${totalPushed} keys pushed.`));
1162
+ }
1163
+ }
1164
+
1165
+ // src/commands/limits.ts
1166
+ var import_chalk6 = __toESM(require("chalk"));
1167
+ var import_fs8 = __toESM(require("fs"));
1168
+ var import_path8 = __toESM(require("path"));
917
1169
  function resolveApiKey() {
918
1170
  const cwd = process.cwd();
919
1171
  const fromJson = (p) => {
920
1172
  try {
921
- if (import_fs7.default.existsSync(p)) {
922
- const cfg = JSON.parse(import_fs7.default.readFileSync(p, "utf-8"));
1173
+ if (import_fs8.default.existsSync(p)) {
1174
+ const cfg = JSON.parse(import_fs8.default.readFileSync(p, "utf-8"));
923
1175
  if (cfg && cfg.apiKey) return { apiKey: cfg.apiKey, baseUrl: cfg.baseUrl };
924
1176
  }
925
1177
  } catch {
@@ -928,8 +1180,8 @@ function resolveApiKey() {
928
1180
  };
929
1181
  const fromAppFile = (p) => {
930
1182
  try {
931
- if (import_fs7.default.existsSync(p)) {
932
- const content = import_fs7.default.readFileSync(p, "utf-8");
1183
+ if (import_fs8.default.existsSync(p)) {
1184
+ const content = import_fs8.default.readFileSync(p, "utf-8");
933
1185
  const match = content.match(/apiKey:\s*['"]([^'"]+)['"]/);
934
1186
  if (match) return { apiKey: match[1] };
935
1187
  }
@@ -937,7 +1189,7 @@ function resolveApiKey() {
937
1189
  }
938
1190
  return null;
939
1191
  };
940
- return fromJson(import_path7.default.join(cwd, "kopynator.config.json")) || fromJson(import_path7.default.join(cwd, "src/kopynator.config.json")) || fromAppFile(import_path7.default.join(cwd, "src/app/app.config.ts")) || fromAppFile(import_path7.default.join(cwd, "src/app/app.module.ts")) || (process.env.KOPYNATOR_API_KEY ? { apiKey: process.env.KOPYNATOR_API_KEY.trim(), baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() } : null);
1192
+ return fromJson(import_path8.default.join(cwd, "kopynator.config.json")) || fromJson(import_path8.default.join(cwd, "src/kopynator.config.json")) || fromAppFile(import_path8.default.join(cwd, "src/app/app.config.ts")) || fromAppFile(import_path8.default.join(cwd, "src/app/app.module.ts")) || (process.env.KOPYNATOR_API_KEY ? { apiKey: process.env.KOPYNATOR_API_KEY.trim(), baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() } : null);
941
1193
  }
942
1194
  function formatLimit(value) {
943
1195
  return value === -1 ? "Unlimited" : String(value);
@@ -945,8 +1197,8 @@ function formatLimit(value) {
945
1197
  async function limitsCommand() {
946
1198
  const resolved = resolveApiKey();
947
1199
  if (!resolved) {
948
- console.log(import_chalk5.default.red("\n\u2716 No API key found."));
949
- console.log(import_chalk5.default.gray(" Add it to kopynator.config.json or set KOPYNATOR_API_KEY.\n"));
1200
+ console.log(import_chalk6.default.red("\n\u2716 No API key found."));
1201
+ console.log(import_chalk6.default.gray(" Add it to kopynator.config.json or set KOPYNATOR_API_KEY.\n"));
950
1202
  process.exit(1);
951
1203
  return;
952
1204
  }
@@ -956,7 +1208,7 @@ async function limitsCommand() {
956
1208
  const res = await fetch(url, { headers: { "x-kopynator-version": "1.6.1" } });
957
1209
  if (!res.ok) {
958
1210
  const body = await res.text().catch(() => "");
959
- console.log(import_chalk5.default.red(`
1211
+ console.log(import_chalk6.default.red(`
960
1212
  \u2716 Could not fetch limits (HTTP ${res.status}). ${body}
961
1213
  `));
962
1214
  process.exit(1);
@@ -969,12 +1221,12 @@ async function limitsCommand() {
969
1221
  const usedPart = used === void 0 ? "" : `${used} / `;
970
1222
  const text = ` ${label.padEnd(9)} ${usedPart}${formatLimit(limit)}`;
971
1223
  const reached = limit !== -1 && used !== void 0 && used >= limit;
972
- return reached ? import_chalk5.default.red(`${text} (limit reached \u2014 upgrade your plan)`) : import_chalk5.default.green(text);
1224
+ return reached ? import_chalk6.default.red(`${text} (limit reached \u2014 upgrade your plan)`) : import_chalk6.default.green(text);
973
1225
  };
974
1226
  console.log("");
975
- console.log(import_chalk5.default.bold("\u{1F4CA} Kopynator \u2014 plan limits (per organization)"));
976
- const planLabel = import_chalk5.default.cyan((data.plan || "free").toUpperCase());
977
- const statusLabel = data.active ? import_chalk5.default.green("active") : import_chalk5.default.yellow("inactive/expired \u2192 free limits apply");
1227
+ console.log(import_chalk6.default.bold("\u{1F4CA} Kopynator \u2014 plan limits (per organization)"));
1228
+ const planLabel = import_chalk6.default.cyan((data.plan || "free").toUpperCase());
1229
+ const statusLabel = data.active ? import_chalk6.default.green("active") : import_chalk6.default.yellow("inactive/expired \u2192 free limits apply");
978
1230
  console.log(` Plan: ${planLabel} (${statusLabel})`);
979
1231
  console.log("");
980
1232
  console.log(row("Projects", usage.projects, limits.projects));
@@ -982,7 +1234,7 @@ async function limitsCommand() {
982
1234
  console.log(row("Members", void 0, limits.members));
983
1235
  console.log("");
984
1236
  } catch (error) {
985
- console.log(import_chalk5.default.red(`
1237
+ console.log(import_chalk6.default.red(`
986
1238
  \u2716 Request failed: ${error?.message || error}
987
1239
  `));
988
1240
  process.exit(1);
@@ -991,14 +1243,15 @@ async function limitsCommand() {
991
1243
 
992
1244
  // src/index.ts
993
1245
  var program = new import_commander.Command();
994
- program.name("kopynator").description("Kopynator CLI - Manage your i18n workflow").version("1.6.1", "-v, --version").helpOption("-h, --help", "Display help for command").addHelpText("beforeAll", import_chalk6.default.blue("\n\u{1F44B} Welcome to Kopynator CLI!\n"));
1246
+ program.name("kopynator").description("Kopynator CLI - Manage your i18n workflow").version("1.6.1", "-v, --version").helpOption("-h, --help", "Display help for command").addHelpText("beforeAll", import_chalk7.default.blue("\n\u{1F44B} Welcome to Kopynator CLI!\n"));
995
1247
  program.command("init").description("Initialize Kopynator in your project").action(initCommand);
996
1248
  program.command("check").description("Validate translation files: JSON syntax, broken references and duplicate global keys").option("--base-ref <ref>", "Git ref to diff against when detecting new duplicate keys", "master").option("--update-baseline", "Accept all currently-missing keys as backlog (writes kopynator.i18n-baseline.json)").option("--all", "List every missing key, including ones already accepted in the baseline").action((opts) => checkCommand({ baseRef: opts.baseRef, updateBaseline: opts.updateBaseline, all: opts.all }));
997
1249
  program.command("sync").description("Sync your translations with the Kopynator Cloud").action(syncCommand);
998
1250
  program.command("upload").description("Upload a JSON translation file to Kopynator Cloud").option("-f, --file <path>", "Path to the JSON file (e.g. es.json)").option("-l, --lang <code>", "Language code (default: inferred from filename)").action((opts) => uploadCommand({ file: opts.file, lang: opts.lang }));
1251
+ program.command("push").description("Push all local JSON translation files to the cloud (merge mode \u2014 existing server keys are NOT overwritten)").action(pushCommand);
999
1252
  program.command("limits").description("Show your plan limits and current usage (projects, keys, members)").action(limitsCommand);
1000
1253
  program.command("help").description("Show help for all commands").action(() => {
1001
- console.log(import_chalk6.default.blue("\u{1F44B} Kopynator CLI - Comandos disponibles:\n"));
1254
+ console.log(import_chalk7.default.blue("\u{1F44B} Kopynator CLI - Comandos disponibles:\n"));
1002
1255
  program.outputHelp();
1003
1256
  });
1004
1257
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kopynator/cli",
3
- "version": "1.6.1",
3
+ "version": "1.6.4",
4
4
  "description": "CLI tool for Kopynator - The i18n management solution",
5
5
  "bin": {
6
6
  "kopynator": "dist/index.js"
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import * as readline from 'readline';
4
5
  import { detectFramework, getSourceRoot, getTranslationDir } from '../lib/project';
5
6
  import {
6
7
  findGlobalDuplicates,
@@ -14,6 +15,121 @@ import {
14
15
  walkSourceFiles,
15
16
  } from '../lib/i18n-guardian';
16
17
 
18
+ interface KopyConfig { apiKey: string; baseUrl?: string; }
19
+
20
+ function loadKopyConfig(): KopyConfig | null {
21
+ const cwd = process.cwd();
22
+ for (const rel of ['kopynator.config.json', 'src/kopynator.config.json']) {
23
+ const p = path.join(cwd, rel);
24
+ if (fs.existsSync(p)) {
25
+ try {
26
+ const c = JSON.parse(fs.readFileSync(p, 'utf-8'));
27
+ const key = c.apiKey ?? c.api_key;
28
+ if (key) return { apiKey: key, baseUrl: c.baseUrl };
29
+ } catch { /* ignore */ }
30
+ }
31
+ }
32
+ for (const rel of ['src/app/app.config.ts', 'src/app/app.module.ts']) {
33
+ const p = path.join(cwd, rel);
34
+ if (fs.existsSync(p)) {
35
+ const m = fs.readFileSync(p, 'utf-8').match(/apiKey:\s*['"]([^'"]+)['"]/);
36
+ if (m) return { apiKey: m[1] };
37
+ }
38
+ }
39
+ const envKey = process.env.KOPYNATOR_API_KEY?.trim();
40
+ if (envKey) return { apiKey: envKey, baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() || undefined };
41
+ return null;
42
+ }
43
+
44
+ function ask(question: string): Promise<string> {
45
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
46
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
47
+ }
48
+
49
+ async function checkRemoteOrphans(localKeys: Set<string>): Promise<void> {
50
+ const config = loadKopyConfig();
51
+ if (!config) {
52
+ console.log(chalk.gray('\n (Skipping remote check — no API key found)\n'));
53
+ return;
54
+ }
55
+
56
+ const baseUrl = config.baseUrl || 'https://api.kopynator.com/tokens';
57
+ const token = config.apiKey;
58
+
59
+ console.log(chalk.bold.blue('\n☁️ Checking for server-only keys...\n'));
60
+
61
+ // Fetch all languages
62
+ let languages: string[] = [];
63
+ try {
64
+ const res = await fetch(`${baseUrl}/languages`, {
65
+ headers: { 'x-api-token': token, 'x-kopynator-version': '1.6.3' },
66
+ });
67
+ if (!res.ok) {
68
+ console.log(chalk.yellow(` Could not fetch languages from server (${res.status}). Skipping.\n`));
69
+ return;
70
+ }
71
+ languages = await res.json() as string[];
72
+ } catch {
73
+ console.log(chalk.yellow(' Could not reach server. Skipping remote check.\n'));
74
+ return;
75
+ }
76
+
77
+ if (languages.length === 0) {
78
+ console.log(chalk.gray(' No languages found on server.\n'));
79
+ return;
80
+ }
81
+
82
+ // Collect all keys present on the server (union across all langs)
83
+ const remoteKeys = new Set<string>();
84
+ for (const lang of languages) {
85
+ try {
86
+ const res = await fetch(`${baseUrl}/fetch?langs=${lang}&nested=false&includeLangKey=false`, {
87
+ headers: { 'x-api-token': token, 'x-kopynator-version': '1.6.3' },
88
+ });
89
+ if (!res.ok) continue;
90
+ const data = await res.json() as Record<string, unknown>;
91
+ Object.keys(data).forEach(k => remoteKeys.add(k));
92
+ } catch { /* skip lang on error */ }
93
+ }
94
+
95
+ const orphans = [...remoteKeys].filter(k => !localKeys.has(k)).sort();
96
+
97
+ if (orphans.length === 0) {
98
+ console.log(chalk.green('✅ No server-only keys found. Everything is in sync.\n'));
99
+ return;
100
+ }
101
+
102
+ console.log(chalk.yellow(`⚠️ ${orphans.length} key(s) exist on the server but are NOT in your local files:\n`));
103
+ orphans.forEach(k => console.log(chalk.yellow(` - ${k}`)));
104
+
105
+ const answer = await ask(chalk.bold('\nDelete these keys from the server? [y/N] '));
106
+ if (answer.toLowerCase() !== 'y') {
107
+ console.log(chalk.gray('\n Skipped. Server keys were not deleted.\n'));
108
+ return;
109
+ }
110
+
111
+ try {
112
+ const res = await fetch(`${baseUrl}/keys`, {
113
+ method: 'DELETE',
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ 'x-api-token': token,
117
+ 'x-kopynator-version': '1.6.3',
118
+ },
119
+ body: JSON.stringify({ keys: orphans }),
120
+ });
121
+ if (!res.ok) {
122
+ const err = await res.text().catch(() => res.statusText);
123
+ console.log(chalk.red(`\n❌ Failed to delete keys: ${res.status} ${err}\n`));
124
+ return;
125
+ }
126
+ const result = await res.json().catch(() => ({})) as { deleted?: number };
127
+ console.log(chalk.green(`\n✅ Deleted ${result.deleted ?? orphans.length} key(s) from the server.\n`));
128
+ } catch (e: any) {
129
+ console.log(chalk.red(`\n❌ Network error: ${e.message}\n`));
130
+ }
131
+ }
132
+
17
133
  export interface CheckOptions {
18
134
  baseRef?: string;
19
135
  updateBaseline?: boolean;
@@ -148,4 +264,6 @@ export async function checkCommand(opts: CheckOptions = {}) {
148
264
  }
149
265
 
150
266
  console.log(chalk.bold.green(`\n✨ i18n check passed (${files.length} locale file(s); baseline=${baselineSet.size}; scanned ${sourceFiles.length} source file(s)).`));
267
+
268
+ await checkRemoteOrphans(definedKeys);
151
269
  }
@@ -2,4 +2,5 @@ export * from './init';
2
2
  export * from './check';
3
3
  export * from './sync';
4
4
  export * from './upload';
5
+ export * from './push';
5
6
  export * from './limits';
@@ -0,0 +1,178 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import ora from 'ora';
5
+ import { detectFramework, getTranslationDir } from '../lib/project';
6
+
7
+ const BATCH_SIZE = 500;
8
+
9
+ interface KopyConfig {
10
+ apiKey: string;
11
+ baseUrl?: string;
12
+ }
13
+
14
+ function flatten(data: Record<string, unknown>, prefix = ''): Record<string, string> {
15
+ const result: Record<string, string> = {};
16
+ for (const key in data) {
17
+ const fullKey = prefix ? `${prefix}.${key}` : key;
18
+ const value = data[key];
19
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
20
+ Object.assign(result, flatten(value as Record<string, unknown>, fullKey));
21
+ } else {
22
+ result[fullKey] = String(value);
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+
28
+ function loadJsonConfig(jsonPath: string): KopyConfig | null {
29
+ if (!fs.existsSync(jsonPath)) return null;
30
+ try {
31
+ const config = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
32
+ const key = config.apiKey ?? config.api_key;
33
+ if (key && typeof key === 'string') return { apiKey: key, baseUrl: config.baseUrl };
34
+ } catch {
35
+ // ignore
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function extractApiKey(): KopyConfig | null {
41
+ const cwd = process.cwd();
42
+ const configFromRoot = loadJsonConfig(path.join(cwd, 'kopynator.config.json'));
43
+ if (configFromRoot) return configFromRoot;
44
+ const configFromSrc = loadJsonConfig(path.join(cwd, 'src/kopynator.config.json'));
45
+ if (configFromSrc) return configFromSrc;
46
+
47
+ for (const rel of ['src/app/app.config.ts', 'src/app/app.module.ts']) {
48
+ const p = path.join(cwd, rel);
49
+ if (fs.existsSync(p)) {
50
+ const m = fs.readFileSync(p, 'utf-8').match(/apiKey:\s*['"]([^'"]+)['"]/);
51
+ if (m) return { apiKey: m[1] };
52
+ }
53
+ }
54
+
55
+ const envKey = process.env.KOPYNATOR_API_KEY?.trim();
56
+ if (envKey) return { apiKey: envKey, baseUrl: process.env.KOPYNATOR_BASE_URL?.trim() || undefined };
57
+ return null;
58
+ }
59
+
60
+ function inferLang(filePath: string): string {
61
+ return path.basename(filePath, path.extname(filePath));
62
+ }
63
+
64
+ async function pushFile(
65
+ filePath: string,
66
+ token: string,
67
+ baseUrl: string,
68
+ ): Promise<{ lang: string; pushed: number; skipped: number; error?: string }> {
69
+ const lang = inferLang(filePath);
70
+ let raw: unknown;
71
+ try {
72
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
73
+ } catch {
74
+ return { lang, pushed: 0, skipped: 0, error: 'Invalid JSON' };
75
+ }
76
+
77
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
78
+ return { lang, pushed: 0, skipped: 0, error: 'JSON root must be an object' };
79
+ }
80
+
81
+ const flat = flatten(raw as Record<string, unknown>);
82
+ const entries = Object.entries(flat);
83
+ if (entries.length === 0) return { lang, pushed: 0, skipped: 0 };
84
+
85
+ const batches: Record<string, string>[] = [];
86
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
87
+ batches.push(Object.fromEntries(entries.slice(i, i + BATCH_SIZE)));
88
+ }
89
+
90
+ let pushed = 0;
91
+ let skipped = 0;
92
+
93
+ for (const batch of batches) {
94
+ const res = await fetch(`${baseUrl}/import`, {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ 'x-api-token': token,
99
+ 'x-kopynator-version': '1.6.1',
100
+ // merge mode: server wins on conflict (existing keys are not overwritten)
101
+ 'x-import-mode': 'merge',
102
+ },
103
+ body: JSON.stringify({ lang, data: batch, mode: 'merge' }),
104
+ });
105
+
106
+ if (!res.ok) {
107
+ const err = await res.text().catch(() => res.statusText);
108
+ return { lang, pushed, skipped, error: `${res.status}: ${err}` };
109
+ }
110
+
111
+ const result = await res.json().catch(() => ({})) as any;
112
+ pushed += result.imported ?? Object.keys(batch).length;
113
+ skipped += result.skipped ?? 0;
114
+ }
115
+
116
+ return { lang, pushed, skipped };
117
+ }
118
+
119
+ export async function pushCommand() {
120
+ console.log(chalk.bold.blue('\n☁️ Pushing local translations to Kopynator Cloud (merge mode)...\n'));
121
+ console.log(chalk.gray(' Keys that already exist on the server are NOT overwritten.\n'));
122
+
123
+ const config = extractApiKey();
124
+ if (!config) {
125
+ console.log(chalk.red('❌ Could not find API key. Run `npx kopynator init` first.'));
126
+ return;
127
+ }
128
+
129
+ const framework = detectFramework();
130
+ const assetsDir = getTranslationDir(framework);
131
+
132
+ if (!fs.existsSync(assetsDir)) {
133
+ console.log(chalk.red(`❌ Translation directory not found: ${assetsDir}`));
134
+ return;
135
+ }
136
+
137
+ const files = fs.readdirSync(assetsDir)
138
+ .filter(f => f.endsWith('.json') && !f.startsWith('.'))
139
+ .map(f => path.join(assetsDir, f));
140
+
141
+ if (files.length === 0) {
142
+ console.log(chalk.yellow('⚠️ No JSON translation files found.'));
143
+ return;
144
+ }
145
+
146
+ const baseUrl = config.baseUrl || 'https://api.kopynator.com/tokens';
147
+ const token = config.apiKey;
148
+
149
+ console.log(chalk.cyan(`📂 Found ${files.length} file(s) in ${assetsDir}\n`));
150
+
151
+ let totalPushed = 0;
152
+ let totalSkipped = 0;
153
+ let totalErrors = 0;
154
+
155
+ for (const filePath of files) {
156
+ const spinner = ora(`Pushing ${path.basename(filePath)}...`).start();
157
+ const result = await pushFile(filePath, token, baseUrl);
158
+
159
+ if (result.error) {
160
+ spinner.fail(`${chalk.bold(result.lang)}: ${chalk.red(result.error)}`);
161
+ totalErrors++;
162
+ } else {
163
+ spinner.succeed(
164
+ `${chalk.bold(result.lang)}: ${chalk.green(`${result.pushed} pushed`)}` +
165
+ (result.skipped ? chalk.gray(`, ${result.skipped} skipped (already on server)`) : ''),
166
+ );
167
+ totalPushed += result.pushed;
168
+ totalSkipped += result.skipped;
169
+ }
170
+ }
171
+
172
+ console.log('');
173
+ if (totalErrors === 0) {
174
+ console.log(chalk.green(`✅ Push complete — ${totalPushed} keys pushed, ${totalSkipped} already existed on server.`));
175
+ } else {
176
+ console.log(chalk.yellow(`⚠️ Push finished with ${totalErrors} error(s). ${totalPushed} keys pushed.`));
177
+ }
178
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
- import { initCommand, checkCommand, syncCommand, uploadCommand, limitsCommand } from './commands';
4
+ import { initCommand, checkCommand, syncCommand, uploadCommand, pushCommand, limitsCommand } from './commands';
5
5
 
6
6
  const program = new Command();
7
7
 
@@ -37,6 +37,11 @@ program
37
37
  .option('-l, --lang <code>', 'Language code (default: inferred from filename)')
38
38
  .action((opts) => uploadCommand({ file: opts.file, lang: opts.lang }));
39
39
 
40
+ program
41
+ .command('push')
42
+ .description('Push all local JSON translation files to the cloud (merge mode — existing server keys are NOT overwritten)')
43
+ .action(pushCommand);
44
+
40
45
  program
41
46
  .command('limits')
42
47
  .description('Show your plan limits and current usage (projects, keys, members)')