orch-mini 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orch-mini",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Orquestador declarativo mínimo: un stack.yaml describe la arquitectura, om renderiza docker-compose + nginx + scripts y levanta el stack.",
5
5
  "type": "module",
6
6
  "engines": {
package/src/info.ts CHANGED
@@ -1,9 +1,7 @@
1
- import { existsSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { relative } from 'node:path';
3
2
  import type { LoadedStack } from './parser.js';
4
3
  import { hasRepo, type Service, type Stack } from './schema.js';
5
4
 
6
- // Patrones que sugieren "el usuario debería completar esto antes de prod".
7
5
  const PLACEHOLDER_PATTERNS = [
8
6
  /^changeit-/i,
9
7
  /^dev-default-/i,
@@ -13,6 +11,9 @@ const PLACEHOLDER_PATTERNS = [
13
11
  /^your-/i,
14
12
  ];
15
13
 
14
+ const WRAP_WIDTH = 78;
15
+ const DESC_INDENT = ' '; // 7 espacios — alineado con texto post-marker
16
+
16
17
  const C = supportsColor()
17
18
  ? {
18
19
  reset: '\x1b[0m',
@@ -22,8 +23,9 @@ const C = supportsColor()
22
23
  yellow: '\x1b[33m',
23
24
  green: '\x1b[32m',
24
25
  red: '\x1b[31m',
26
+ magenta: '\x1b[35m',
25
27
  }
26
- : { reset: '', dim: '', bold: '', cyan: '', yellow: '', green: '', red: '' };
28
+ : { reset: '', dim: '', bold: '', cyan: '', yellow: '', green: '', red: '', magenta: '' };
27
29
 
28
30
  function supportsColor(): boolean {
29
31
  return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
@@ -32,13 +34,15 @@ function supportsColor(): boolean {
32
34
  export function renderInfo(loaded: LoadedStack): string {
33
35
  const out: string[] = [];
34
36
  const { stack } = loaded;
37
+ const sourceLabel = relative(process.cwd(), loaded.sourcePath) || loaded.sourcePath;
35
38
 
36
39
  const services = Object.entries(stack.services);
37
40
  const gw = stack.gateway;
38
41
 
39
42
  out.push(
40
- header(`${stack.name}`) +
41
- ` ${C.dim}${services.length} services${gw ? ` · gateway en :${gw.port}` : ''}${C.reset}`,
43
+ `${C.bold}${stack.name}${C.reset} ${C.dim}${services.length} services${
44
+ gw ? ` · gateway en :${gw.port}` : ''
45
+ } · ${sourceLabel}${C.reset}`,
42
46
  );
43
47
 
44
48
  out.push('');
@@ -55,6 +59,7 @@ export function renderInfo(loaded: LoadedStack): string {
55
59
  for (const r of repos) {
56
60
  const refLabel = r.ref ?? `${C.dim}(default branch)${C.reset}`;
57
61
  out.push(` ${r.slug.padEnd(maxName + 2)}${refLabel}`);
62
+ out.push(` ${' '.repeat(maxName + 2)}${C.dim}${r.url}${C.reset}`);
58
63
  }
59
64
  }
60
65
 
@@ -73,9 +78,7 @@ export function renderInfo(loaded: LoadedStack): string {
73
78
  const sorted = [...gw.routes].sort(
74
79
  (a, b) => effectiveLen(b.path) - effectiveLen(a.path),
75
80
  );
76
- const maxUrl = Math.max(
77
- ...sorted.map((r) => urlFor(gw.port, r.path).length),
78
- );
81
+ const maxUrl = Math.max(...sorted.map((r) => urlFor(gw.port, r.path).length));
79
82
  for (const route of sorted) {
80
83
  const url = urlFor(gw.port, route.path);
81
84
  const note = route.strip_prefix ? ` ${C.dim}(strip prefix)${C.reset}` : '';
@@ -86,26 +89,44 @@ export function renderInfo(loaded: LoadedStack): string {
86
89
  const debugConfigs = services.filter(([, s]) => s.debug_port !== undefined);
87
90
  if (debugConfigs.length > 0) {
88
91
  out.push('');
89
- out.push(section('Debug (VS Code attach via om vscode)'));
92
+ out.push(section('Debug') + ` ${C.dim}(om vscode genera launch.json)${C.reset}`);
93
+ const maxName = Math.max(...debugConfigs.map(([n]) => n.length));
90
94
  for (const [name, svc] of debugConfigs) {
91
- out.push(` ${name.padEnd(30)} attach → localhost:${svc.debug_port}`);
95
+ out.push(
96
+ ` ${name.padEnd(maxName + 2)}${C.dim}attach →${C.reset} localhost:${svc.debug_port}`,
97
+ );
92
98
  }
93
99
  }
94
100
 
95
- const concerns = collectConcerns(stack, loaded.workDir);
101
+ const concerns = collectConcerns(loaded);
96
102
  if (concerns.length > 0) {
97
103
  out.push('');
98
104
  out.push(`${C.yellow}⚠${C.reset} ${C.bold}Probablemente quieras tocar${C.reset}`);
99
- const maxKey = Math.max(...concerns.map((c) => `${c.service}.${c.key}`.length));
100
- for (const c of concerns) {
101
- const k = `${c.service}.${c.key}`.padEnd(maxKey + 2);
102
- const tag = c.required ? `${C.red}[required]${C.reset} ` : '';
103
- out.push(` ${tag}${k}${C.dim}${c.reason}${C.reset}`);
104
- if (c.description) {
105
- // Indenta la descripción debajo, alineada con el nombre del key.
106
- out.push(` ${' '.repeat(maxKey + 2 + (c.required ? 11 : 0))}${C.dim}${c.description}${C.reset}`);
105
+ const grouped = groupByService(concerns);
106
+ for (const [svcName, items] of grouped) {
107
+ out.push('');
108
+ out.push(` ${C.cyan}${svcName}${C.reset}`);
109
+ for (const c of items) {
110
+ out.push(` ${renderConcernLine(c)}`);
111
+ if (c.location) {
112
+ out.push(` ${DESC_INDENT}${C.dim}${c.location}${C.reset}`);
113
+ }
114
+ if (c.description) {
115
+ for (const line of wrapText(c.description, WRAP_WIDTH - DESC_INDENT.length)) {
116
+ out.push(` ${DESC_INDENT}${C.dim}${line}${C.reset}`);
117
+ }
118
+ }
107
119
  }
108
120
  }
121
+
122
+ out.push('');
123
+ const requiredCount = concerns.filter((c) => c.required).length;
124
+ const optionalCount = concerns.length - requiredCount;
125
+ out.push(
126
+ ` ${C.dim}Total:${C.reset} ${
127
+ requiredCount > 0 ? `${C.red}${requiredCount} required${C.reset}` : `${C.green}0 required${C.reset}`
128
+ }${C.dim} · ${optionalCount} opcionales${C.reset}`,
129
+ );
109
130
  } else {
110
131
  out.push('');
111
132
  out.push(`${C.green}✓${C.reset} no detecté placeholders ni valores vacíos`);
@@ -114,8 +135,45 @@ export function renderInfo(loaded: LoadedStack): string {
114
135
  return out.join('\n') + '\n';
115
136
  }
116
137
 
117
- function header(text: string): string {
118
- return `${C.bold}${text}${C.reset}`;
138
+ function renderConcernLine(c: Concern): string {
139
+ const marker = c.required
140
+ ? `${C.red}✗${C.reset}`
141
+ : c.reason.startsWith('placeholder')
142
+ ? `${C.yellow}!${C.reset}`
143
+ : `${C.dim}·${C.reset}`;
144
+ const tag = c.required ? ` ${C.red}[required]${C.reset}` : '';
145
+ return `${marker}${tag} ${C.bold}${c.key}${C.reset} ${C.dim}— ${c.reason}${C.reset}`;
146
+ }
147
+
148
+ function groupByService(concerns: Concern[]): Map<string, Concern[]> {
149
+ const grouped = new Map<string, Concern[]>();
150
+ for (const c of concerns) {
151
+ const list = grouped.get(c.service) ?? [];
152
+ list.push(c);
153
+ grouped.set(c.service, list);
154
+ }
155
+ // Ordenar items de cada service: required arriba.
156
+ for (const list of grouped.values()) {
157
+ list.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0));
158
+ }
159
+ return grouped;
160
+ }
161
+
162
+ function wrapText(text: string, width: number): string[] {
163
+ if (text.length <= width) return [text];
164
+ const words = text.split(/\s+/);
165
+ const lines: string[] = [];
166
+ let current = '';
167
+ for (const word of words) {
168
+ if ((current + ' ' + word).trim().length > width) {
169
+ if (current) lines.push(current);
170
+ current = word;
171
+ } else {
172
+ current = (current + ' ' + word).trim();
173
+ }
174
+ }
175
+ if (current) lines.push(current);
176
+ return lines;
119
177
  }
120
178
 
121
179
  function section(text: string): string {
@@ -134,11 +192,7 @@ function formatServiceRow(name: string, svc: Service): string {
134
192
  }
135
193
 
136
194
  function shortenRepo(repo: string): string {
137
- // github.com/logieinc/foo.git foo, /local → local
138
- return repo
139
- .replace(/\.git$/, '')
140
- .split(/[/:]/)
141
- .pop() ?? repo;
195
+ return repo.replace(/\.git$/, '').split(/[/:]/).pop() ?? repo;
142
196
  }
143
197
 
144
198
  function urlFor(port: number, path: string): string {
@@ -150,17 +204,14 @@ function effectiveLen(path: string): number {
150
204
  return path.replace(/^(=|\^~)\s*/, '').length;
151
205
  }
152
206
 
153
- type RepoEntry = { slug: string; ref: string | undefined };
207
+ type RepoEntry = { slug: string; ref: string | undefined; url: string };
154
208
 
155
209
  function collectRepos(stack: Stack): RepoEntry[] {
156
210
  const seen = new Map<string, RepoEntry>();
157
211
  for (const svc of Object.values(stack.services)) {
158
212
  if (!hasRepo(svc)) continue;
159
- const slug = svc.repo
160
- .replace(/\.git$/, '')
161
- .split(/[/:]/)
162
- .pop()!;
163
- if (!seen.has(slug)) seen.set(slug, { slug, ref: svc.ref });
213
+ const slug = svc.repo.replace(/\.git$/, '').split(/[/:]/).pop()!;
214
+ if (!seen.has(slug)) seen.set(slug, { slug, ref: svc.ref, url: svc.repo });
164
215
  }
165
216
  return [...seen.values()];
166
217
  }
@@ -181,12 +232,14 @@ type Concern = {
181
232
  reason: string;
182
233
  description?: string;
183
234
  required?: boolean;
235
+ location?: string; // "arch/stack.yaml:42" — clickeable en VS Code terminal
184
236
  };
185
237
 
186
- function collectConcerns(stack: Stack, _workDir: string): Concern[] {
238
+ function collectConcerns(loaded: LoadedStack): Concern[] {
187
239
  const out: Concern[] = [];
240
+ const sourceRel = relative(process.cwd(), loaded.sourcePath) || loaded.sourcePath;
188
241
 
189
- for (const [svcName, svc] of Object.entries(stack.services)) {
242
+ for (const [svcName, svc] of Object.entries(loaded.stack.services)) {
190
243
  const meta = svc.env_meta ?? {};
191
244
  for (const [k, vRaw] of Object.entries(svc.env ?? {})) {
192
245
  const v = String(vRaw);
@@ -195,27 +248,21 @@ function collectConcerns(stack: Stack, _workDir: string): Concern[] {
195
248
  let reason: string | null = null;
196
249
  if (v === '') reason = 'vacío';
197
250
  else if (PLACEHOLDER_PATTERNS.some((re) => re.test(v))) {
198
- reason = `placeholder: "${truncate(v, 30)}"`;
199
- } else if (m.required === true) {
200
- // Marcado como required en la metadata, pero ya tiene valor — no es concern.
251
+ reason = `placeholder "${truncate(v, 30)}"`;
201
252
  }
202
253
 
203
254
  if (reason !== null) {
204
255
  const concern: Concern = { service: svcName, key: k, reason };
205
256
  if (m.description) concern.description = m.description;
206
257
  if (m.required) concern.required = m.required;
258
+ const loc = loaded.locations.get(`${svcName}.${k}`);
259
+ if (loc) concern.location = `${sourceRel}:${loc.line}:${loc.col}`;
207
260
  out.push(concern);
208
261
  }
209
262
  }
210
263
  }
211
264
 
212
- // Required arriba (los más urgentes), después el resto.
213
- return out.sort((a, b) => {
214
- if ((b.required ? 1 : 0) - (a.required ? 1 : 0) !== 0) {
215
- return (b.required ? 1 : 0) - (a.required ? 1 : 0);
216
- }
217
- return 0;
218
- });
265
+ return out;
219
266
  }
220
267
 
221
268
  function truncate(s: string, max: number): string {
package/src/parser.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { basename, dirname, resolve } from 'node:path';
3
- import { parse as parseYaml } from 'yaml';
3
+ import { isMap, isPair, isScalar, LineCounter, parseDocument } from 'yaml';
4
4
  import { z } from 'zod';
5
5
  import { expandFileRefs } from './file-refs.js';
6
6
  import { findStack, STACK_FILENAME, STACK_SUBDIRS } from './discover.js';
7
7
  import { stackSchema, type Stack } from './schema.js';
8
8
 
9
+ // Mapa de "service.envKey" → {line, col} del archivo fuente.
10
+ // Habilita reportes con archivo:línea clickeables en VS Code terminal.
11
+ export type Locations = Map<string, { line: number; col: number }>;
12
+
9
13
  export type LoadedStack = {
10
14
  stack: Stack;
11
15
  sourcePath: string;
12
16
  workDir: string; // dirname(stack.yaml)
13
17
  workspaceRoot: string; // donde van repos/ y .stack/ — padre del workDir si el yaml vive en arch/
14
18
  outDir: string; // workspaceRoot/.stack
19
+ locations: Locations;
15
20
  };
16
21
 
17
22
  export function loadStack(stackPath?: string): LoadedStack {
@@ -36,18 +41,23 @@ export function loadStack(stackPath?: string): LoadedStack {
36
41
  throw new Error(`no se pudo leer el stack en ${absPath}: ${msg}`);
37
42
  }
38
43
 
39
- let doc: unknown;
44
+ const lineCounter = new LineCounter();
45
+ let docNode: ReturnType<typeof parseDocument>;
40
46
  try {
41
- doc = parseYaml(raw);
47
+ docNode = parseDocument(raw, { lineCounter });
42
48
  } catch (err) {
43
49
  const msg = err instanceof Error ? err.message : String(err);
44
50
  throw new Error(`YAML inválido en ${absPath}: ${msg}`);
45
51
  }
46
52
 
47
53
  const workDir = resolve(absPath, '..');
54
+ const locations = collectLocations(docNode, lineCounter);
55
+
56
+ // Plain JS desde el AST para los pasos siguientes.
57
+ const docPlain = docNode.toJSON();
48
58
 
49
59
  // Expandir ${file:path} antes de validar — paths se resuelven relativo al workDir.
50
- const expanded = expandFileRefs(doc, workDir);
60
+ const expanded = expandFileRefs(docPlain, workDir);
51
61
 
52
62
  // Normalizar el formato mixed de env (string | {value, description, required})
53
63
  // a dos campos planos: env (Record<string,string>) + env_meta (descripciones).
@@ -66,9 +76,44 @@ export function loadStack(stackPath?: string): LoadedStack {
66
76
  workDir,
67
77
  workspaceRoot,
68
78
  outDir: resolve(workspaceRoot, '.stack'),
79
+ locations,
69
80
  };
70
81
  }
71
82
 
83
+ // Recorre el AST del YAML y extrae line/col de cada services.<svc>.env.<KEY>.
84
+ // El path es el del KEY del par (donde el usuario debería pararse para editar).
85
+ function collectLocations(
86
+ doc: ReturnType<typeof parseDocument>,
87
+ lineCounter: LineCounter,
88
+ ): Locations {
89
+ const locations: Locations = new Map();
90
+ const services = doc.get('services', true);
91
+ if (!isMap(services)) return locations;
92
+
93
+ for (const svcPair of services.items) {
94
+ if (!isPair(svcPair) || !isScalar(svcPair.key)) continue;
95
+ const svcName = String(svcPair.key.value);
96
+
97
+ const svcNode = svcPair.value;
98
+ if (!isMap(svcNode)) continue;
99
+
100
+ const envNode = svcNode.get('env', true);
101
+ if (!isMap(envNode)) continue;
102
+
103
+ for (const envPair of envNode.items) {
104
+ if (!isPair(envPair) || !isScalar(envPair.key)) continue;
105
+ const keyNode = envPair.key;
106
+ const key = String(keyNode.value);
107
+ const range = keyNode.range;
108
+ if (!range) continue;
109
+ const pos = lineCounter.linePos(range[0]);
110
+ locations.set(`${svcName}.${key}`, { line: pos.line, col: pos.col });
111
+ }
112
+ }
113
+
114
+ return locations;
115
+ }
116
+
72
117
  function computeWorkspaceRoot(workDir: string): string {
73
118
  // Si el stack.yaml vive en un subdir convencional (arch/), el workspaceRoot
74
119
  // es el padre — repos/ y .stack/ no se mezclan con la definición.