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 +1 -1
- package/src/info.ts +92 -45
- package/src/parser.ts +49 -4
package/package.json
CHANGED
package/src/info.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
41
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
|
100
|
-
for (const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
44
|
+
const lineCounter = new LineCounter();
|
|
45
|
+
let docNode: ReturnType<typeof parseDocument>;
|
|
40
46
|
try {
|
|
41
|
-
|
|
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(
|
|
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.
|