komplian 0.4.0 → 0.4.3

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/README.md CHANGED
@@ -10,17 +10,29 @@
10
10
 
11
11
  1. En [Postman](https://postman.com) usa una cuenta con email **`@komplian.com`** (o añade ese email a tu perfil).
12
12
  2. Crea una **API key**: Settings → **API keys** → Generate.
13
- 3. En la terminal:
13
+ 3. **Una vez por máquina** (guarda la clave en `~/.komplian/postman-api-key`; no hace falta `export` después):
14
+
15
+ ```bash
16
+ npx komplian postman login
17
+ ```
18
+
19
+ 4. Cuando quieras sincronizar la colección:
14
20
 
15
21
  ```bash
16
- export POSTMAN_API_KEY=pmak-…
17
22
  npx komplian postman --yes
18
23
  ```
19
24
 
20
- El comando llama a `GET https://api.getpostman.com/me` y **solo continúa** si el email de la cuenta es `@komplian.com`. Crea la colección **Komplian API** (carpetas como en el workspace interno), los entornos **Komplian — Local Dev** y **Komplian — Production** (variables `baseUrl`, `apiKey`, `adminApiKey`, IDs, etc.), y deja copia en `./komplian-postman/*.json` por si hace falta importar a mano.
25
+ Opcional: `export POSTMAN_API_KEY=…` solo para la sesión actual (tiene prioridad sobre el archivo).
26
+
27
+ El comando llama a `GET https://api.getpostman.com/me` y **solo continúa** si el email de la cuenta es `@komplian.com`. **Si ya existen** la colección **Komplian API** y los entornos con el mismo nombre en ese workspace, se **actualizan**; si no, se crean.
28
+
29
+ **Variables de la API Komplian** (`apiKey`, `adminApiKey`, `workspaceId`, …) se rellenan en Postman automáticamente si están en `process.env` o en archivos `.env` (busca `api/.env`, `.env`, etc.; o `KOMPLIAN_DOTENV` / `--dotenv ruta`). Nombres típicos: `API_KEY`, `ADMIN_API_KEY`, `KOMPLIAN_WORKSPACE_ID`. Los JSON exportados en `./komplian-postman/` **no** incluyen secretos (para no commitearlos).
21
30
 
22
31
  - Solo exportar archivos (sin subir por API): `npx komplian postman --yes --export-only`
23
- - Otro workspace: `POSTMAN_WORKSPACE_ID=<id>` (si no, se usa un workspace por defecto con permiso de escritura)
32
+ - Otro workspace: `POSTMAN_WORKSPACE_ID=<id>`
33
+ - Ruta alternativa del archivo de clave Postman: `KOMPLIAN_POSTMAN_KEY_FILE`
34
+
35
+ **Seguridad (CLI Postman):** clave en `~/.komplian/` (permisos reforzados por el CLI), prompt sin eco, errores redactados; detalle técnico arriba. **Normativa del equipo:** archivo **`SECURITY.md`** en la raíz del monorepo Komplian (no va en el paquete npm).
24
36
 
25
37
  No OAuth App registration — `gh` uses GitHub’s built-in flow. **Default workspace:** current working directory (`process.cwd()`), not `~/komplian`. Pass a path as last argument to clone elsewhere.
26
38
  **Dependencies:** repos with `package-lock.json` use **`npm ci`** (does not modify the lockfile, so no spurious git changes). Repos without a lockfile use **`npm install --no-package-lock`** so onboarding does not create a new `package-lock.json`. Yarn / pnpm repos use frozen lock installs when `yarn` / `pnpm` is on PATH. Unless `KOMPLIAN_NPM_AUDIT=1`, npm runs with `--no-audit --no-fund`.
@@ -396,7 +396,8 @@ function npmInstallEach(workspace) {
396
396
  function usage() {
397
397
  log(`Uso: komplian onboard [opciones] [carpeta] | komplian postman [opciones]`);
398
398
  log(` npx komplian onboard --yes`);
399
- log(` npx komplian postman --yes ${c.dim}(POSTMAN_API_KEY + email @komplian.com)${c.reset}`);
399
+ log(` npx komplian postman login ${c.dim}(una vez · guarda API key)${c.reset}`);
400
+ log(` npx komplian postman --yes ${c.dim}(email @komplian.com)${c.reset}`);
400
401
  log(``);
401
402
  log(` Antes (una vez): gh auth login -h github.com -s repo -s read:org -w`);
402
403
  log(` Requisitos: Node 18+, git, GitHub CLI (gh)`);
@@ -2,17 +2,24 @@
2
2
  /**
3
3
  * Komplian Postman — API key + email @komplian.com (Postman /me), luego colección + entornos.
4
4
  *
5
- * Requisitos: Node 18+, variable POSTMAN_API_KEY (Postman Settings → API keys)
5
+ * Requisitos: Node 18+. Primera vez: npx komplian postman login
6
6
  *
7
- * export POSTMAN_API_KEY=pmak-...
8
- * npx komplian postman --yes
9
- *
10
- * Opcional: POSTMAN_WORKSPACE_ID (si no, usa el primer workspace con permiso de escritura)
11
- * Opcional: KOMPLIAN_EMAIL_DOMAIN=komplian.com (solo para pruebas internas del script)
7
+ * Clave: POSTMAN_API_KEY o ~/.komplian/postman-api-key (npx komplian postman login)
8
+ * Seguridad: no registrar secretos; errores API redactados; login sin eco (TTY); ~/.komplian 700 + key 600.
9
+ * Opcional: POSTMAN_WORKSPACE_ID, KOMPLIAN_EMAIL_DOMAIN, KOMPLIAN_POSTMAN_KEY_FILE, KOMPLIAN_DEBUG
12
10
  */
13
11
 
14
- import { writeFileSync, mkdirSync } from "node:fs";
12
+ import {
13
+ writeFileSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ existsSync,
17
+ chmodSync,
18
+ } from "node:fs";
15
19
  import { join, resolve } from "node:path";
20
+ import { homedir } from "node:os";
21
+ import { stdin as input, stdout as output } from "node:process";
22
+ import { createInterface } from "node:readline/promises";
16
23
 
17
24
  const POSTMAN_API = "https://api.getpostman.com";
18
25
 
@@ -30,6 +37,300 @@ function log(s = "") {
30
37
  console.log(s);
31
38
  }
32
39
 
40
+ /** Ruta para logs sin exponer home completo (solo prefijo ~). */
41
+ function formatHomePath(absPath) {
42
+ const h = homedir();
43
+ if (absPath.startsWith(h)) return `~${absPath.slice(h.length)}`;
44
+ return absPath;
45
+ }
46
+
47
+ function maskEmail(email) {
48
+ if (!email || typeof email !== "string" || !email.includes("@")) {
49
+ return "[sin email]";
50
+ }
51
+ const [user, domain] = email.split("@");
52
+ if (!domain) return "[sin email]";
53
+ const u =
54
+ user.length <= 1 ? "*" : `${user[0]}${"*".repeat(Math.min(3, user.length - 1))}`;
55
+ return `${u}@${domain}`;
56
+ }
57
+
58
+ function looksLikeSecretString(s) {
59
+ return (
60
+ typeof s === "string" &&
61
+ s.length >= 24 &&
62
+ /^[A-Za-z0-9_\-.+/=]+$/.test(s)
63
+ );
64
+ }
65
+
66
+ /** Solo mensajes genéricos; nunca volcar cuerpos crudos de la API (pueden incluir tokens). */
67
+ function safeApiErrorHint(body) {
68
+ if (body == null || typeof body !== "object") return "";
69
+ const parts = [];
70
+ const code = body.error?.name || body.error?.code || body.error?.type;
71
+ if (typeof code === "string" && code.length < 64 && !looksLikeSecretString(code)) {
72
+ parts.push(code);
73
+ }
74
+ const msg = body.error?.message || body.message;
75
+ if (
76
+ typeof msg === "string" &&
77
+ msg.length > 0 &&
78
+ msg.length < 160 &&
79
+ !looksLikeSecretString(msg)
80
+ ) {
81
+ parts.push(msg);
82
+ }
83
+ return parts.join(" · ").slice(0, 200);
84
+ }
85
+
86
+ function sanitizeForLog(value, depth = 0) {
87
+ if (depth > 8) return "[…]";
88
+ if (value == null) return value;
89
+ if (typeof value === "string") {
90
+ if (looksLikeSecretString(value)) return "[redacted]";
91
+ return value.length > 80 ? `${value.slice(0, 40)}…` : value;
92
+ }
93
+ if (typeof value !== "object") return value;
94
+ if (Array.isArray(value)) {
95
+ return value.slice(0, 15).map((v) => sanitizeForLog(v, depth + 1));
96
+ }
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(value)) {
99
+ const lower = k.toLowerCase();
100
+ if (
101
+ /(password|secret|token|apikey|api_key|authorization|credential|bearer|pmak|pm_)/i.test(
102
+ k
103
+ ) ||
104
+ lower === "value" ||
105
+ (lower === "key" && depth > 0)
106
+ ) {
107
+ out[k] = "[redacted]";
108
+ } else {
109
+ out[k] = sanitizeForLog(v, depth + 1);
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function logApiFailure(context, status, body) {
116
+ const hint = safeApiErrorHint(body);
117
+ let extra = "";
118
+ if (
119
+ process.env.KOMPLIAN_DEBUG === "1" ||
120
+ process.env.KOMPLIAN_DEBUG === "true"
121
+ ) {
122
+ try {
123
+ extra = ` ${JSON.stringify(sanitizeForLog(body)).slice(0, 480)}`;
124
+ } catch {
125
+ extra = " [debug: no serializable]";
126
+ }
127
+ }
128
+ log(
129
+ `${c.yellow}○${c.reset} ${context}: HTTP ${status}${hint ? ` — ${hint}` : ""}${extra}`
130
+ );
131
+ }
132
+
133
+ function ensureSecureKomplianDir() {
134
+ const dir = join(homedir(), ".komplian");
135
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
136
+ try {
137
+ chmodSync(dir, 0o700);
138
+ } catch {
139
+ /* Windows u otros */
140
+ }
141
+ return dir;
142
+ }
143
+
144
+ /**
145
+ * Lectura de API key sin eco (TTY). Fallback con aviso si no hay raw mode (p. ej. algunos Windows).
146
+ */
147
+ async function readPasswordLine(promptText) {
148
+ const prompt = `${c.cyan}${promptText}${c.reset}`;
149
+ if (!input.isTTY) {
150
+ return new Promise((resolve, reject) => {
151
+ const chunks = [];
152
+ input.on("data", (d) => chunks.push(d));
153
+ input.on("end", () => {
154
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
155
+ });
156
+ input.on("error", reject);
157
+ });
158
+ }
159
+ if (typeof input.setRawMode !== "function") {
160
+ log(
161
+ `${c.dim}○ Entrada visible (terminal sin modo oculto). No compartas pantalla con la clave.${c.reset}`
162
+ );
163
+ const rl = createInterface({ input, output });
164
+ const line = await rl.question(
165
+ `${c.cyan}Postman API key${c.reset} (Settings → API keys): `
166
+ );
167
+ rl.close();
168
+ return line.trim();
169
+ }
170
+ return new Promise((resolve) => {
171
+ process.stderr.write(prompt);
172
+ let buf = "";
173
+ const cleanup = () => {
174
+ try {
175
+ input.setRawMode(false);
176
+ } catch {
177
+ /* ignore */
178
+ }
179
+ input.removeListener("data", onData);
180
+ };
181
+ const onData = (chunk) => {
182
+ const s = chunk.toString("utf8");
183
+ for (let i = 0; i < s.length; i++) {
184
+ const code = s.charCodeAt(i);
185
+ if (code === 13 || code === 10) {
186
+ cleanup();
187
+ process.stderr.write("\n");
188
+ resolve(buf.trim());
189
+ return;
190
+ }
191
+ if (code === 3) {
192
+ cleanup();
193
+ process.exit(130);
194
+ }
195
+ if (code === 127 || code === 8) {
196
+ buf = buf.slice(0, -1);
197
+ continue;
198
+ }
199
+ buf += s[i];
200
+ }
201
+ };
202
+ input.setRawMode(true);
203
+ input.resume();
204
+ input.setEncoding("utf8");
205
+ input.on("data", onData);
206
+ });
207
+ }
208
+
209
+ function defaultKeyPath() {
210
+ const override = process.env.KOMPLIAN_POSTMAN_KEY_FILE?.trim();
211
+ if (override) return resolve(override);
212
+ return join(homedir(), ".komplian", "postman-api-key");
213
+ }
214
+
215
+ /**
216
+ * Orden: POSTMAN_API_KEY → ~/.komplian/postman-api-key (o KOMPLIAN_POSTMAN_KEY_FILE).
217
+ */
218
+ function resolveApiKey() {
219
+ const env = process.env.POSTMAN_API_KEY?.trim();
220
+ if (env) return { key: env, source: "POSTMAN_API_KEY" };
221
+ const path = defaultKeyPath();
222
+ if (existsSync(path)) {
223
+ const k = readFileSync(path, "utf8").trim();
224
+ if (k) return { key: k, source: path };
225
+ }
226
+ return { key: "", source: null };
227
+ }
228
+
229
+ function printMissingKeyHelp() {
230
+ log(`${c.red}✗${c.reset} No hay API key de Postman.`);
231
+ log(``);
232
+ log(` ${c.bold}Una vez por máquina:${c.reset}`);
233
+ log(` ${c.cyan}npx komplian postman login${c.reset}`);
234
+ log(` (pega la clave de Postman → Settings → API keys → Generate)`);
235
+ log(``);
236
+ log(` ${c.dim}O en la sesión actual: export POSTMAN_API_KEY=…${c.reset}`);
237
+ log(
238
+ `${c.dim} Archivo manual: ${formatHomePath(defaultKeyPath())}${c.reset}`
239
+ );
240
+ process.exit(1);
241
+ }
242
+
243
+ /** Parseo mínimo .env (sin dependencia dotenv). */
244
+ function parseEnvFile(text) {
245
+ const out = {};
246
+ for (const line of text.split("\n")) {
247
+ const t = line.trim();
248
+ if (!t || t.startsWith("#")) continue;
249
+ const eq = t.indexOf("=");
250
+ if (eq < 1) continue;
251
+ const key = t.slice(0, eq).trim();
252
+ let val = t.slice(eq + 1).trim();
253
+ if (
254
+ (val.startsWith('"') && val.endsWith('"')) ||
255
+ (val.startsWith("'") && val.endsWith("'"))
256
+ ) {
257
+ val = val.slice(1, -1);
258
+ }
259
+ out[key] = val;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ function discoverDotenvPaths(cwd, extraPath) {
265
+ const paths = [];
266
+ if (extraPath?.trim()) paths.push(resolve(extraPath.trim()));
267
+ if (process.env.KOMPLIAN_DOTENV?.trim()) {
268
+ paths.push(resolve(process.env.KOMPLIAN_DOTENV.trim()));
269
+ }
270
+ for (const rel of [
271
+ "api/.env",
272
+ "../api/.env",
273
+ ".env",
274
+ "app/.env",
275
+ "../app/.env",
276
+ ]) {
277
+ paths.push(resolve(cwd, rel));
278
+ }
279
+ return [...new Set(paths)];
280
+ }
281
+
282
+ function loadMergedEnvFiles(cwd, extraDotenv) {
283
+ const merged = {};
284
+ for (const p of discoverDotenvPaths(cwd, extraDotenv)) {
285
+ if (!existsSync(p)) continue;
286
+ try {
287
+ Object.assign(merged, parseEnvFile(readFileSync(p, "utf8")));
288
+ } catch {
289
+ /* ignore */
290
+ }
291
+ }
292
+ return merged;
293
+ }
294
+
295
+ /**
296
+ * Valores para variables de Postman (API Komplian): API_KEY, ADMIN_API_KEY, IDs, etc.
297
+ * Orden: process.env por cada alias, luego claves en .env fusionados.
298
+ */
299
+ function loadKomplianSecrets(cwd, extraDotenv) {
300
+ const file = loadMergedEnvFiles(cwd, extraDotenv);
301
+ const get = (...keys) => {
302
+ for (const k of keys) {
303
+ const e = process.env[k];
304
+ if (e != null && String(e).trim()) return String(e).trim();
305
+ }
306
+ for (const k of keys) {
307
+ const f = file[k];
308
+ if (f != null && String(f).trim()) return String(f).trim();
309
+ }
310
+ return "";
311
+ };
312
+ return {
313
+ apiKey: get("KOMPLIAN_API_KEY", "API_KEY"),
314
+ adminApiKey: get("KOMPLIAN_ADMIN_API_KEY", "ADMIN_API_KEY"),
315
+ workspaceId: get("KOMPLIAN_WORKSPACE_ID", "WORKSPACE_ID"),
316
+ widgetId: get("KOMPLIAN_WIDGET_ID", "WIDGET_ID"),
317
+ sessionId: get("KOMPLIAN_SESSION_ID", "SESSION_ID"),
318
+ leadId: get("KOMPLIAN_LEAD_ID", "LEAD_ID"),
319
+ flowId: get("KOMPLIAN_FLOW_ID", "FLOW_ID"),
320
+ sourceId: get("KOMPLIAN_SOURCE_ID", "SOURCE_ID"),
321
+ documentId: get("KOMPLIAN_DOCUMENT_ID", "DOCUMENT_ID"),
322
+ queryId: get("KOMPLIAN_QUERY_ID", "QUERY_ID"),
323
+ userId: get("KOMPLIAN_USER_ID", "USER_ID"),
324
+ entryId: get("KOMPLIAN_ENTRY_ID", "ENTRY_ID"),
325
+ };
326
+ }
327
+
328
+ function summarizeSecretsLoaded(s) {
329
+ return Object.keys(s).filter(
330
+ (k) => s[k] != null && String(s[k]).trim() !== ""
331
+ );
332
+ }
333
+
33
334
  function headers(apiKey) {
34
335
  return {
35
336
  "X-API-Key": apiKey,
@@ -239,52 +540,50 @@ function buildCollection() {
239
540
  };
240
541
  }
241
542
 
242
- function buildEnvironments() {
243
- const secret = (key) => ({
244
- key,
245
- value: "",
246
- type: "secret",
247
- enabled: true,
248
- });
249
- const plain = (key, value) => ({
250
- key,
251
- value,
252
- type: "default",
253
- enabled: true,
254
- });
543
+ /**
544
+ * @param {Record<string, string>} secrets - desde API_KEY / api/.env (no subir a git los JSON locales).
545
+ */
546
+ function buildEnvironments(secrets = {}) {
547
+ const pick = (key, fallback = "") => {
548
+ const v = secrets[key];
549
+ if (v != null && String(v).trim() !== "") return String(v).trim();
550
+ return fallback;
551
+ };
552
+ const row = (key, def = "") => {
553
+ const val = pick(key, def);
554
+ const isSecret = key === "apiKey" || key === "adminApiKey";
555
+ return {
556
+ key,
557
+ value: val,
558
+ type: isSecret ? "secret" : "default",
559
+ enabled: true,
560
+ };
561
+ };
255
562
 
256
- const common = [
257
- plain("baseUrl", ""),
258
- secret("apiKey"),
259
- secret("adminApiKey"),
260
- plain("workspaceId", ""),
261
- plain("widgetId", ""),
262
- plain("sessionId", ""),
263
- plain("leadId", ""),
264
- plain("flowId", ""),
265
- plain("sourceId", ""),
266
- plain("documentId", ""),
267
- plain("queryId", ""),
268
- plain("userId", ""),
269
- plain("entryId", ""),
563
+ const rowsForBase = (baseUrl) => [
564
+ row("baseUrl", baseUrl),
565
+ row("apiKey"),
566
+ row("adminApiKey"),
567
+ row("workspaceId"),
568
+ row("widgetId"),
569
+ row("sessionId"),
570
+ row("leadId"),
571
+ row("flowId"),
572
+ row("sourceId"),
573
+ row("documentId"),
574
+ row("queryId"),
575
+ row("userId"),
576
+ row("entryId"),
270
577
  ];
271
578
 
272
579
  return [
273
580
  {
274
581
  name: "Komplian — Local Dev",
275
- values: common.map((v) =>
276
- v.key === "baseUrl"
277
- ? { ...v, value: "http://localhost:4000", type: "default" }
278
- : { ...v }
279
- ),
582
+ values: rowsForBase("http://localhost:4000"),
280
583
  },
281
584
  {
282
585
  name: "Komplian — Production",
283
- values: common.map((v) =>
284
- v.key === "baseUrl"
285
- ? { ...v, value: "https://api.komplian.com", type: "default" }
286
- : { ...v }
287
- ),
586
+ values: rowsForBase("https://api.komplian.com"),
288
587
  },
289
588
  ];
290
589
  }
@@ -305,7 +604,7 @@ async function verifyKomplianEmail(apiKey, domain) {
305
604
  const { ok, status, body } = await pmFetch(apiKey, "/me");
306
605
  if (!ok) {
307
606
  log(
308
- `${c.red}✗${c.reset} POSTMAN_API_KEY inválida o sin acceso (${status}).`
607
+ `${c.red}✗${c.reset} API key de Postman inválida o sin acceso (${status}).`
309
608
  );
310
609
  process.exit(1);
311
610
  }
@@ -318,7 +617,7 @@ async function verifyKomplianEmail(apiKey, domain) {
318
617
  }
319
618
  if (!emailAllowed(email, domain)) {
320
619
  log(
321
- `${c.red}✗${c.reset} Este comando solo permite cuentas **@${domain}**. Sesión Postman: ${c.bold}${email}${c.reset}`
620
+ `${c.red}✗${c.reset} Este comando solo permite cuentas **@${domain}**. Sesión: ${c.bold}${maskEmail(email)}${c.reset}`
322
621
  );
323
622
  log(
324
623
  `${c.dim} Crea la API key desde una cuenta @${domain} o añade ese email a tu usuario en Postman.${c.reset}`
@@ -326,7 +625,7 @@ async function verifyKomplianEmail(apiKey, domain) {
326
625
  process.exit(1);
327
626
  }
328
627
  log(
329
- `${c.green}✓${c.reset} Postman: ${c.bold}${email}${c.reset} (${c.dim}dominio permitido${c.reset})`
628
+ `${c.green}✓${c.reset} Postman: ${c.bold}${maskEmail(email)}${c.reset} (${c.dim}dominio permitido${c.reset})`
330
629
  );
331
630
  return email;
332
631
  }
@@ -347,13 +646,93 @@ async function createEnvironment(apiKey, workspaceId, env) {
347
646
  });
348
647
  }
349
648
 
649
+ async function listCollections(apiKey, workspaceId) {
650
+ const q = new URLSearchParams({ workspace: workspaceId });
651
+ return pmFetch(apiKey, `/collections?${q}`);
652
+ }
653
+
654
+ async function listEnvironments(apiKey, workspaceId) {
655
+ const q = new URLSearchParams({ workspace: workspaceId });
656
+ return pmFetch(apiKey, `/environments?${q}`);
657
+ }
658
+
659
+ function firstArray(...candidates) {
660
+ for (const c of candidates) {
661
+ if (Array.isArray(c)) return c;
662
+ }
663
+ return [];
664
+ }
665
+
666
+ function findCollectionByName(body, name) {
667
+ const arr = firstArray(
668
+ body.collections,
669
+ body.data?.collections,
670
+ body.data,
671
+ Array.isArray(body) ? body : null
672
+ );
673
+ return arr.find(
674
+ (c) =>
675
+ c.name === name ||
676
+ c.collection?.name === name ||
677
+ c.info?.name === name
678
+ );
679
+ }
680
+
681
+ function findEnvironmentByName(body, name) {
682
+ const arr = firstArray(
683
+ body.environments,
684
+ body.data?.environments,
685
+ body.data,
686
+ Array.isArray(body) ? body : null
687
+ );
688
+ return arr.find((e) => e.name === name);
689
+ }
690
+
691
+ async function upsertCollection(apiKey, workspaceId, collection) {
692
+ const name = collection.info?.name;
693
+ const list = await listCollections(apiKey, workspaceId);
694
+ if (!list.ok) {
695
+ return { ok: false, status: list.status, body: list.body };
696
+ }
697
+ const existing = findCollectionByName(list.body, name);
698
+ if (!existing) {
699
+ const r = await createCollection(apiKey, workspaceId, collection);
700
+ return { ...r, op: "create" };
701
+ }
702
+ const uid = existing.uid ?? existing.id;
703
+ const r = await pmFetch(apiKey, `/collections/${uid}`, {
704
+ method: "PUT",
705
+ body: JSON.stringify({ collection }),
706
+ });
707
+ return { ...r, op: "update" };
708
+ }
709
+
710
+ async function upsertEnvironment(apiKey, workspaceId, env) {
711
+ const list = await listEnvironments(apiKey, workspaceId);
712
+ if (!list.ok) {
713
+ return { ok: false, status: list.status, body: list.body };
714
+ }
715
+ const existing = findEnvironmentByName(list.body, env.name);
716
+ if (!existing) {
717
+ const r = await createEnvironment(apiKey, workspaceId, env);
718
+ return { ...r, op: "create" };
719
+ }
720
+ const uid = existing.uid ?? existing.id;
721
+ const r = await pmFetch(apiKey, `/environments/${uid}`, {
722
+ method: "PUT",
723
+ body: JSON.stringify({ environment: env }),
724
+ });
725
+ return { ...r, op: "update" };
726
+ }
727
+
350
728
  function parseArgs(argv) {
351
- const out = { yes: false, exportOnly: false, outDir: "", help: false };
729
+ const out = { yes: false, exportOnly: false, outDir: "", help: false, dotenv: "" };
352
730
  for (let i = 0; i < argv.length; i++) {
353
731
  const a = argv[i];
354
732
  if (a === "--yes" || a === "-y") out.yes = true;
355
733
  else if (a === "--export-only") out.exportOnly = true;
356
734
  else if (a === "--out") out.outDir = argv[++i] || "";
735
+ else if (a === "--dotenv") out.dotenv = argv[++i] || "";
357
736
  else if (a === "-h" || a === "--help") out.help = true;
358
737
  else if (a.startsWith("-")) {
359
738
  log(`${c.red}✗${c.reset} Opción desconocida: ${a}`);
@@ -363,40 +742,133 @@ function parseArgs(argv) {
363
742
  return out;
364
743
  }
365
744
 
745
+ function parseLoginArgs(argv) {
746
+ const out = { help: false };
747
+ for (const a of argv) {
748
+ if (a === "-h" || a === "--help") out.help = true;
749
+ else if (a.startsWith("-")) {
750
+ log(`${c.red}✗${c.reset} Opción desconocida: ${a}`);
751
+ process.exit(1);
752
+ }
753
+ }
754
+ return out;
755
+ }
756
+
366
757
  function usage() {
367
- log(`Uso: komplian postman [opciones]`);
368
- log(` ${c.dim}POSTMAN_API_KEY${c.reset} Obligatoria (Postman Settings API keys)`);
369
- log(` ${c.dim}Dominio email${c.reset} Solo cuentas @komplian.com (verificado vía GET /me)`);
758
+ log(`Uso: komplian postman [opciones] | komplian postman login`);
759
+ log(` Clave: ${c.dim}POSTMAN_API_KEY${c.reset} o archivo ${c.dim}~/.komplian/postman-api-key${c.reset} (véase ${c.cyan}postman login${c.reset})`);
760
+ log(` Dominio email: solo @komplian.com (GET /me)`);
370
761
  log(``);
371
- log(` -y, --yes Sin confirmaciones interactivas`);
762
+ log(` -y, --yes Sin prompts extra`);
372
763
  log(` --export-only Solo escribe JSON en disco (no llama a la API de Postman)`);
373
764
  log(` --out <dir> Carpeta para export (por defecto: ./komplian-postman)`);
765
+ log(` --dotenv <ruta> .env extra (además de api/.env, .env, KOMPLIAN_DOTENV)`);
374
766
  log(` -h, --help`);
375
767
  log(``);
376
- log(` Opcional: POSTMAN_WORKSPACE_ID, KOMPLIAN_EMAIL_DOMAIN (defecto: komplian.com)`);
768
+ log(` Variables Komplian en Postman: API_KEY, ADMIN_API_KEY, WORKSPACE_ID, … (env o .env)`);
769
+ log(` Opcional: POSTMAN_WORKSPACE_ID, KOMPLIAN_EMAIL_DOMAIN, KOMPLIAN_POSTMAN_KEY_FILE`);
770
+ }
771
+
772
+ function usageLogin() {
773
+ log(`Uso: komplian postman login`);
774
+ log(` Guarda la API key de Postman en ${c.dim}~/.komplian/postman-api-key${c.reset} (permiso 600).`);
775
+ log(` Tras esto: ${c.cyan}npx komplian postman --yes${c.reset} sin exportar variables.`);
776
+ log(``);
777
+ log(` Postman → Settings (avatar) → API keys → Generate`);
778
+ }
779
+
780
+ async function runPostmanLogin(argv) {
781
+ const la = parseLoginArgs(argv);
782
+ if (la.help) {
783
+ usageLogin();
784
+ return;
785
+ }
786
+
787
+ log(`${c.cyan}━━ Postman login (Komplian) ━━${c.reset}`);
788
+ log(
789
+ `${c.dim}Cuenta con email @komplian.com. Genera la clave en Postman → Settings → API keys.${c.reset}`
790
+ );
791
+ log("");
792
+
793
+ const domain = (process.env.KOMPLIAN_EMAIL_DOMAIN || "komplian.com").trim();
794
+ const keyPath = defaultKeyPath();
795
+
796
+ let apiKey = await readPasswordLine(
797
+ "Postman API key (no se muestra al escribir; Settings → API keys): "
798
+ );
799
+ if (!apiKey) {
800
+ log(`${c.red}✗${c.reset} Clave vacía.`);
801
+ process.exit(1);
802
+ }
803
+
804
+ await verifyKomplianEmail(apiKey, domain);
805
+
806
+ ensureSecureKomplianDir();
807
+ writeFileSync(keyPath, `${apiKey}\n`, { encoding: "utf8", mode: 0o600 });
808
+ try {
809
+ chmodSync(keyPath, 0o600);
810
+ } catch {
811
+ /* Windows puede ignorar chmod */
812
+ }
813
+
814
+ log("");
815
+ log(
816
+ `${c.green}✓${c.reset} Guardada en ${c.bold}${formatHomePath(keyPath)}${c.reset} (permiso 600; no subir a git).`
817
+ );
818
+ log(
819
+ `${c.dim} Siguiente: ${c.reset}${c.cyan}npx komplian postman --yes${c.reset}`
820
+ );
821
+ }
822
+
823
+ function normalizePostmanArgv(argv) {
824
+ const a = [...argv];
825
+ if (a[0] === "login") {
826
+ return { mode: "login", rest: a.slice(1) };
827
+ }
828
+ return { mode: "sync", rest: a };
377
829
  }
378
830
 
379
831
  export async function runPostman(argv) {
380
- const args = parseArgs(argv);
832
+ const { mode, rest } = normalizePostmanArgv(argv);
833
+ if (mode === "login") {
834
+ await runPostmanLogin(rest);
835
+ return;
836
+ }
837
+
838
+ const args = parseArgs(rest);
381
839
  if (args.help) {
382
840
  usage();
383
841
  return;
384
842
  }
385
843
 
386
- const apiKey = process.env.POSTMAN_API_KEY?.trim();
844
+ const { key: apiKey, source } = resolveApiKey();
387
845
  if (!apiKey) {
388
- log(`${c.red}✗${c.reset} Falta ${c.bold}POSTMAN_API_KEY${c.reset} en el entorno.`);
389
- log(
390
- `${c.dim} Postman → Settings (avatar) API keys Generate.${c.reset}`
391
- );
392
- process.exit(1);
846
+ printMissingKeyHelp();
847
+ }
848
+ if (source && source !== "POSTMAN_API_KEY") {
849
+ log(`${c.dim}→ API key Postman desde: ${formatHomePath(source)}${c.reset}`);
393
850
  }
394
851
 
395
852
  const domain = (process.env.KOMPLIAN_EMAIL_DOMAIN || "komplian.com").trim();
396
853
  await verifyKomplianEmail(apiKey, domain);
397
854
 
398
855
  const collection = buildCollection();
399
- const envs = buildEnvironments();
856
+ const cwd = process.cwd();
857
+ const secrets = loadKomplianSecrets(cwd, args.dotenv);
858
+ const envsForUpload = buildEnvironments(secrets);
859
+ const envsForExport = buildEnvironments({});
860
+
861
+ const filled = summarizeSecretsLoaded(secrets);
862
+ if (filled.length) {
863
+ log(
864
+ `${c.green}✓${c.reset} Komplian → Postman: ${c.dim}${filled.join(", ")}${c.reset} (process.env / .env)`
865
+ );
866
+ } else {
867
+ log(
868
+ `${c.dim}○ Sin API_KEY / ADMIN_API_KEY / IDs en env o .env — los entornos en Postman quedarán vacíos en esos campos.${c.reset}`
869
+ );
870
+ }
871
+
400
872
  const workspaceId = await pickWorkspaceId(
401
873
  apiKey,
402
874
  process.env.POSTMAN_WORKSPACE_ID
@@ -414,10 +886,10 @@ export async function runPostman(argv) {
414
886
  writeFileSync(collPath, JSON.stringify(collection, null, 2), "utf8");
415
887
  log(`${c.green}✓${c.reset} Export: ${c.dim}${collPath}${c.reset}`);
416
888
 
417
- for (const env of envs) {
889
+ for (const env of envsForExport) {
418
890
  const safe = env.name.replace(/[\\/]/g, "-") + ".postman_environment.json";
419
891
  writeFileSync(join(outBase, safe), JSON.stringify({ ...env }, null, 2), "utf8");
420
- log(`${c.green}✓${c.reset} Export: ${c.dim}${join(outBase, safe)}${c.reset}`);
892
+ log(`${c.green}✓${c.reset} Export: ${c.dim}${join(outBase, safe)}${c.reset} ${c.dim}(sin secretos; no commitear)${c.reset}`);
421
893
  }
422
894
 
423
895
  if (args.exportOnly) {
@@ -429,38 +901,40 @@ export async function runPostman(argv) {
429
901
  }
430
902
 
431
903
  log("");
432
- log(`${c.cyan}━━ Subiendo a Postman (API) ━━${c.reset}`);
904
+ log(`${c.cyan}━━ Sincronizar Postman (crear o actualizar) ━━${c.reset}`);
433
905
 
434
- const cr = await createCollection(apiKey, workspaceId, collection);
906
+ const cr = await upsertCollection(apiKey, workspaceId, collection);
435
907
  if (!cr.ok) {
908
+ logApiFailure("Colección API", cr.status, cr.body);
436
909
  log(
437
- `${c.yellow}○${c.reset} Colección API: ${cr.status} ${JSON.stringify(cr.body).slice(0, 400)}`
438
- );
439
- log(
440
- `${c.dim} Si el nombre ya existe, borra la colección antigua en Postman o importa el JSON exportado.${c.reset}`
910
+ `${c.dim} Revisa permisos del workspace o importa el JSON exportado. Depuración: KOMPLIAN_DEBUG=1 (salida redactada).${c.reset}`
441
911
  );
442
912
  } else {
913
+ const verb = cr.op === "update" ? "actualizada" : "creada";
443
914
  const uid = cr.body?.collection?.uid || cr.body?.collection?.id || "?";
444
- log(`${c.green}✓${c.reset} Colección creada en Postman (${uid})`);
915
+ log(`${c.green}✓${c.reset} Colección ${verb}: ${c.bold}Komplian API${c.reset} (${uid})`);
445
916
  }
446
917
 
447
- for (const env of envs) {
448
- const er = await createEnvironment(apiKey, workspaceId, {
918
+ for (const env of envsForUpload) {
919
+ const er = await upsertEnvironment(apiKey, workspaceId, {
449
920
  name: env.name,
450
921
  values: env.values,
451
922
  });
452
923
  if (!er.ok) {
924
+ logApiFailure(`Entorno "${env.name}"`, er.status, er.body);
925
+ } else {
926
+ const verb = er.op === "update" ? "actualizado" : "creado";
453
927
  log(
454
- `${c.yellow}○${c.reset} Entorno "${env.name}": ${er.status}${JSON.stringify(er.body).slice(0, 200)}`
928
+ `${c.green}✓${c.reset} Entorno ${verb}: ${c.bold}${env.name}${c.reset}`
455
929
  );
456
- } else {
457
- log(`${c.green}✓${c.reset} Entorno: ${c.bold}${env.name}${c.reset}`);
458
930
  }
459
931
  }
460
932
 
461
933
  log("");
462
- log(`${c.green}✓${c.reset} Abre Postman workspace → revisa ${c.bold}Komplian API${c.reset} y los entornos.`);
463
- log(
464
- `${c.dim} Rellena apiKey / adminApiKey / IDs en el entorno (secret).${c.reset}`
465
- );
934
+ log(`${c.green}✓${c.reset} En Postman: elige entorno ${c.dim}(Local Dev / Production)${c.reset} y prueba las peticiones.`);
935
+ if (!filled.length) {
936
+ log(
937
+ `${c.dim} Para rellenar apiKey: exporta API_KEY o pon api/.env y vuelve a ejecutar este comando.${c.reset}`
938
+ );
939
+ }
466
940
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "komplian",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "Komplian developer workspace: GitHub clone (onboard) + Postman collection/environments (postman). Node 18+.",
5
5
  "type": "module",
6
6
  "engines": {