orch-mini 0.1.2 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orch-mini",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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
@@ -3,7 +3,6 @@ import { resolve } from 'node:path';
3
3
  import type { LoadedStack } from './parser.js';
4
4
  import { hasRepo, type Service, type Stack } from './schema.js';
5
5
 
6
- // Patrones que sugieren "el usuario debería completar esto antes de prod".
7
6
  const PLACEHOLDER_PATTERNS = [
8
7
  /^changeit-/i,
9
8
  /^dev-default-/i,
@@ -13,6 +12,9 @@ const PLACEHOLDER_PATTERNS = [
13
12
  /^your-/i,
14
13
  ];
15
14
 
15
+ const WRAP_WIDTH = 78;
16
+ const DESC_INDENT = ' '; // 7 espacios — alineado con texto post-marker
17
+
16
18
  const C = supportsColor()
17
19
  ? {
18
20
  reset: '\x1b[0m',
@@ -22,8 +24,9 @@ const C = supportsColor()
22
24
  yellow: '\x1b[33m',
23
25
  green: '\x1b[32m',
24
26
  red: '\x1b[31m',
27
+ magenta: '\x1b[35m',
25
28
  }
26
- : { reset: '', dim: '', bold: '', cyan: '', yellow: '', green: '', red: '' };
29
+ : { reset: '', dim: '', bold: '', cyan: '', yellow: '', green: '', red: '', magenta: '' };
27
30
 
28
31
  function supportsColor(): boolean {
29
32
  return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
@@ -37,8 +40,9 @@ export function renderInfo(loaded: LoadedStack): string {
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
+ }${C.reset}`,
42
46
  );
43
47
 
44
48
  out.push('');
@@ -73,9 +77,7 @@ export function renderInfo(loaded: LoadedStack): string {
73
77
  const sorted = [...gw.routes].sort(
74
78
  (a, b) => effectiveLen(b.path) - effectiveLen(a.path),
75
79
  );
76
- const maxUrl = Math.max(
77
- ...sorted.map((r) => urlFor(gw.port, r.path).length),
78
- );
80
+ const maxUrl = Math.max(...sorted.map((r) => urlFor(gw.port, r.path).length));
79
81
  for (const route of sorted) {
80
82
  const url = urlFor(gw.port, route.path);
81
83
  const note = route.strip_prefix ? ` ${C.dim}(strip prefix)${C.reset}` : '';
@@ -86,20 +88,31 @@ export function renderInfo(loaded: LoadedStack): string {
86
88
  const debugConfigs = services.filter(([, s]) => s.debug_port !== undefined);
87
89
  if (debugConfigs.length > 0) {
88
90
  out.push('');
89
- out.push(section('Debug (VS Code attach via om vscode)'));
91
+ out.push(section('Debug') + ` ${C.dim}(om vscode genera launch.json)${C.reset}`);
92
+ const maxName = Math.max(...debugConfigs.map(([n]) => n.length));
90
93
  for (const [name, svc] of debugConfigs) {
91
- out.push(` ${name.padEnd(30)} attach → localhost:${svc.debug_port}`);
94
+ out.push(
95
+ ` ${name.padEnd(maxName + 2)}${C.dim}attach →${C.reset} localhost:${svc.debug_port}`,
96
+ );
92
97
  }
93
98
  }
94
99
 
95
- const concerns = collectConcerns(stack, loaded.workDir);
100
+ const concerns = collectConcerns(stack);
96
101
  if (concerns.length > 0) {
97
102
  out.push('');
98
103
  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
- out.push(` ${k}${C.dim}${c.reason}${C.reset}`);
104
+ const grouped = groupByService(concerns);
105
+ for (const [svcName, items] of grouped) {
106
+ out.push('');
107
+ out.push(` ${C.cyan}${svcName}${C.reset}`);
108
+ for (const c of items) {
109
+ out.push(` ${renderConcernLine(c)}`);
110
+ if (c.description) {
111
+ for (const line of wrapText(c.description, WRAP_WIDTH - DESC_INDENT.length)) {
112
+ out.push(` ${DESC_INDENT}${C.dim}${line}${C.reset}`);
113
+ }
114
+ }
115
+ }
103
116
  }
104
117
  } else {
105
118
  out.push('');
@@ -109,8 +122,45 @@ export function renderInfo(loaded: LoadedStack): string {
109
122
  return out.join('\n') + '\n';
110
123
  }
111
124
 
112
- function header(text: string): string {
113
- return `${C.bold}${text}${C.reset}`;
125
+ function renderConcernLine(c: Concern): string {
126
+ const marker = c.required
127
+ ? `${C.red}✗${C.reset}`
128
+ : c.reason.startsWith('placeholder')
129
+ ? `${C.yellow}!${C.reset}`
130
+ : `${C.dim}·${C.reset}`;
131
+ const tag = c.required ? ` ${C.red}[required]${C.reset}` : '';
132
+ return `${marker}${tag} ${C.bold}${c.key}${C.reset} ${C.dim}— ${c.reason}${C.reset}`;
133
+ }
134
+
135
+ function groupByService(concerns: Concern[]): Map<string, Concern[]> {
136
+ const grouped = new Map<string, Concern[]>();
137
+ for (const c of concerns) {
138
+ const list = grouped.get(c.service) ?? [];
139
+ list.push(c);
140
+ grouped.set(c.service, list);
141
+ }
142
+ // Ordenar items de cada service: required arriba.
143
+ for (const list of grouped.values()) {
144
+ list.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0));
145
+ }
146
+ return grouped;
147
+ }
148
+
149
+ function wrapText(text: string, width: number): string[] {
150
+ if (text.length <= width) return [text];
151
+ const words = text.split(/\s+/);
152
+ const lines: string[] = [];
153
+ let current = '';
154
+ for (const word of words) {
155
+ if ((current + ' ' + word).trim().length > width) {
156
+ if (current) lines.push(current);
157
+ current = word;
158
+ } else {
159
+ current = (current + ' ' + word).trim();
160
+ }
161
+ }
162
+ if (current) lines.push(current);
163
+ return lines;
114
164
  }
115
165
 
116
166
  function section(text: string): string {
@@ -129,11 +179,7 @@ function formatServiceRow(name: string, svc: Service): string {
129
179
  }
130
180
 
131
181
  function shortenRepo(repo: string): string {
132
- // github.com/logieinc/foo.git foo, /local → local
133
- return repo
134
- .replace(/\.git$/, '')
135
- .split(/[/:]/)
136
- .pop() ?? repo;
182
+ return repo.replace(/\.git$/, '').split(/[/:]/).pop() ?? repo;
137
183
  }
138
184
 
139
185
  function urlFor(port: number, path: string): string {
@@ -151,10 +197,7 @@ function collectRepos(stack: Stack): RepoEntry[] {
151
197
  const seen = new Map<string, RepoEntry>();
152
198
  for (const svc of Object.values(stack.services)) {
153
199
  if (!hasRepo(svc)) continue;
154
- const slug = svc.repo
155
- .replace(/\.git$/, '')
156
- .split(/[/:]/)
157
- .pop()!;
200
+ const slug = svc.repo.replace(/\.git$/, '').split(/[/:]/).pop()!;
158
201
  if (!seen.has(slug)) seen.set(slug, { slug, ref: svc.ref });
159
202
  }
160
203
  return [...seen.values()];
@@ -170,27 +213,35 @@ function collectDatabases(stack: Stack): Array<{ service: string; names: string[
170
213
  return out;
171
214
  }
172
215
 
173
- type Concern = { service: string; key: string; reason: string };
216
+ type Concern = {
217
+ service: string;
218
+ key: string;
219
+ reason: string;
220
+ description?: string;
221
+ required?: boolean;
222
+ };
174
223
 
175
- function collectConcerns(stack: Stack, workDir: string): Concern[] {
224
+ function collectConcerns(stack: Stack): Concern[] {
176
225
  const out: Concern[] = [];
177
226
 
178
227
  for (const [svcName, svc] of Object.entries(stack.services)) {
228
+ const meta = svc.env_meta ?? {};
179
229
  for (const [k, vRaw] of Object.entries(svc.env ?? {})) {
180
230
  const v = String(vRaw);
231
+ const m = meta[k] ?? {};
181
232
 
182
- if (v === '') {
183
- out.push({ service: svcName, key: k, reason: 'vacío' });
184
- continue;
233
+ let reason: string | null = null;
234
+ if (v === '') reason = 'vacío';
235
+ else if (PLACEHOLDER_PATTERNS.some((re) => re.test(v))) {
236
+ reason = `placeholder "${truncate(v, 30)}"`;
185
237
  }
186
238
 
187
- if (PLACEHOLDER_PATTERNS.some((re) => re.test(v))) {
188
- out.push({ service: svcName, key: k, reason: `placeholder: "${truncate(v, 30)}"` });
189
- continue;
239
+ if (reason !== null) {
240
+ const concern: Concern = { service: svcName, key: k, reason };
241
+ if (m.description) concern.description = m.description;
242
+ if (m.required) concern.required = m.required;
243
+ out.push(concern);
190
244
  }
191
-
192
- // ${file:...} ya fue expandido en el parse — si llegamos acá y no
193
- // existe, el parse hubiera tirado error. No re-chequear acá.
194
245
  }
195
246
  }
196
247
 
package/src/parser.ts CHANGED
@@ -49,7 +49,11 @@ export function loadStack(stackPath?: string): LoadedStack {
49
49
  // Expandir ${file:path} antes de validar — paths se resuelven relativo al workDir.
50
50
  const expanded = expandFileRefs(doc, workDir);
51
51
 
52
- const result = stackSchema.safeParse(expanded);
52
+ // Normalizar el formato mixed de env (string | {value, description, required})
53
+ // a dos campos planos: env (Record<string,string>) + env_meta (descripciones).
54
+ const normalized = normalizeEnvMetadata(expanded);
55
+
56
+ const result = stackSchema.safeParse(normalized);
53
57
  if (!result.success) {
54
58
  throw new Error(formatZodError(result.error, absPath));
55
59
  }
@@ -74,6 +78,46 @@ function computeWorkspaceRoot(workDir: string): string {
74
78
  return workDir;
75
79
  }
76
80
 
81
+ // Recorre stack.services.*.env y si algún valor es un objeto con shape
82
+ // { value, description?, required? }, splittea: env[k] = String(value),
83
+ // env_meta[k] = { description?, required? }. Los valores que ya son string,
84
+ // number o boolean pasan tal cual.
85
+ function normalizeEnvMetadata(doc: unknown): unknown {
86
+ if (!isPlainObject(doc)) return doc;
87
+ const services = isPlainObject(doc.services) ? doc.services : undefined;
88
+ if (!services) return doc;
89
+
90
+ const newServices: Record<string, unknown> = {};
91
+ for (const [svcName, svc] of Object.entries(services)) {
92
+ if (!isPlainObject(svc) || !isPlainObject(svc.env)) {
93
+ newServices[svcName] = svc;
94
+ continue;
95
+ }
96
+ const env: Record<string, unknown> = {};
97
+ const env_meta: Record<string, { description?: string; required?: boolean }> = {};
98
+ for (const [k, v] of Object.entries(svc.env)) {
99
+ if (isPlainObject(v) && ('value' in v || 'description' in v || 'required' in v)) {
100
+ env[k] = v.value ?? '';
101
+ const meta: { description?: string; required?: boolean } = {};
102
+ if (typeof v.description === 'string') meta.description = v.description;
103
+ if (typeof v.required === 'boolean') meta.required = v.required;
104
+ if (Object.keys(meta).length > 0) env_meta[k] = meta;
105
+ } else {
106
+ env[k] = v;
107
+ }
108
+ }
109
+ const newSvc: Record<string, unknown> = { ...svc, env };
110
+ if (Object.keys(env_meta).length > 0) newSvc.env_meta = env_meta;
111
+ newServices[svcName] = newSvc;
112
+ }
113
+
114
+ return { ...doc, services: newServices };
115
+ }
116
+
117
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
118
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
119
+ }
120
+
77
121
  function formatZodError(err: z.ZodError, source: string): string {
78
122
  const lines = err.issues.map((issue) => {
79
123
  const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
package/src/schema.ts CHANGED
@@ -8,6 +8,15 @@ const serviceNameSchema = z
8
8
  const envValueSchema = z.union([z.string(), z.number(), z.boolean()]).transform(String);
9
9
  const envMapSchema = z.record(z.string(), envValueSchema);
10
10
 
11
+ // Metadata opcional por env var: description + required.
12
+ // El parser pre-normaliza el formato mixed (string vs {value,description,required})
13
+ // dejando `env` como Record<string,string> y `env_meta` como esta estructura.
14
+ const envMetaEntrySchema = z.object({
15
+ description: z.string().optional(),
16
+ required: z.boolean().optional(),
17
+ });
18
+ const envMetaMapSchema = z.record(z.string(), envMetaEntrySchema);
19
+
11
20
  const routeSchema = z.object({
12
21
  path: z.string().min(1),
13
22
  service: serviceNameSchema,
@@ -44,6 +53,7 @@ const serviceSchema = z
44
53
  port: z.number().int().positive().optional(),
45
54
  debug_port: z.number().int().positive().optional(),
46
55
  env: envMapSchema.optional(),
56
+ env_meta: envMetaMapSchema.optional(),
47
57
  needs: z.array(serviceNameSchema).optional(),
48
58
  expose_host: z.number().int().positive().optional(),
49
59
  volumes: z.array(z.string()).optional(),