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 +1 -1
- package/src/info.ts +87 -36
- package/src/parser.ts +45 -1
- package/src/schema.ts +10 -0
package/package.json
CHANGED
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
|
-
|
|
41
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
|
100
|
-
for (const
|
|
101
|
-
|
|
102
|
-
out.push(` ${
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 (
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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(),
|