orch-mini 0.1.0
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/LICENSE +21 -0
- package/README.md +191 -0
- package/bin/om.cjs +7 -0
- package/package.json +55 -0
- package/src/cli.ts +262 -0
- package/src/discover.ts +26 -0
- package/src/file-refs.ts +34 -0
- package/src/init.ts +91 -0
- package/src/parser.ts +83 -0
- package/src/renderer/compose.ts +153 -0
- package/src/renderer/db-init.ts +44 -0
- package/src/renderer/env.ts +15 -0
- package/src/renderer/nginx.ts +93 -0
- package/src/renderer/scripts.ts +139 -0
- package/src/renderer/vscode.ts +93 -0
- package/src/repo.ts +30 -0
- package/src/schema.ts +170 -0
- package/src/sync.ts +125 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { expandFileRefs } from './file-refs.js';
|
|
6
|
+
import { findStack, STACK_FILENAME, STACK_SUBDIRS } from './discover.js';
|
|
7
|
+
import { stackSchema, type Stack } from './schema.js';
|
|
8
|
+
|
|
9
|
+
export type LoadedStack = {
|
|
10
|
+
stack: Stack;
|
|
11
|
+
sourcePath: string;
|
|
12
|
+
workDir: string; // dirname(stack.yaml)
|
|
13
|
+
workspaceRoot: string; // donde van repos/ y .stack/ — padre del workDir si el yaml vive en arch/
|
|
14
|
+
outDir: string; // workspaceRoot/.stack
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function loadStack(stackPath?: string): LoadedStack {
|
|
18
|
+
let absPath: string;
|
|
19
|
+
if (stackPath) {
|
|
20
|
+
absPath = resolve(process.cwd(), stackPath);
|
|
21
|
+
} else {
|
|
22
|
+
const found = findStack();
|
|
23
|
+
if (!found) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`no se encontró ${STACK_FILENAME} en ${process.cwd()} ni en ninguna carpeta superior`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
absPath = found;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let raw: string;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(absPath, 'utf8');
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
36
|
+
throw new Error(`no se pudo leer el stack en ${absPath}: ${msg}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let doc: unknown;
|
|
40
|
+
try {
|
|
41
|
+
doc = parseYaml(raw);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
throw new Error(`YAML inválido en ${absPath}: ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const workDir = resolve(absPath, '..');
|
|
48
|
+
|
|
49
|
+
// Expandir ${file:path} antes de validar — paths se resuelven relativo al workDir.
|
|
50
|
+
const expanded = expandFileRefs(doc, workDir);
|
|
51
|
+
|
|
52
|
+
const result = stackSchema.safeParse(expanded);
|
|
53
|
+
if (!result.success) {
|
|
54
|
+
throw new Error(formatZodError(result.error, absPath));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const workspaceRoot = computeWorkspaceRoot(workDir);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
stack: result.data,
|
|
61
|
+
sourcePath: absPath,
|
|
62
|
+
workDir,
|
|
63
|
+
workspaceRoot,
|
|
64
|
+
outDir: resolve(workspaceRoot, '.stack'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function computeWorkspaceRoot(workDir: string): string {
|
|
69
|
+
// Si el stack.yaml vive en un subdir convencional (arch/), el workspaceRoot
|
|
70
|
+
// es el padre — repos/ y .stack/ no se mezclan con la definición.
|
|
71
|
+
if ((STACK_SUBDIRS as readonly string[]).includes(basename(workDir))) {
|
|
72
|
+
return dirname(workDir);
|
|
73
|
+
}
|
|
74
|
+
return workDir;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatZodError(err: z.ZodError, source: string): string {
|
|
78
|
+
const lines = err.issues.map((issue) => {
|
|
79
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
|
80
|
+
return ` - ${path}: ${issue.message}`;
|
|
81
|
+
});
|
|
82
|
+
return `stack inválido (${source}):\n${lines.join('\n')}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
import { repoSlug } from '../repo.js';
|
|
3
|
+
import { hasBuild, type Stack } from '../schema.js';
|
|
4
|
+
import { renderDbInit } from './db-init.js';
|
|
5
|
+
|
|
6
|
+
const REPOS_DIR_VAR = '${REPOS_DIR}';
|
|
7
|
+
|
|
8
|
+
export function renderCompose(stack: Stack): string {
|
|
9
|
+
const services: Record<string, unknown> = {};
|
|
10
|
+
const namedVolumes = new Set<string>();
|
|
11
|
+
|
|
12
|
+
// Pre-calcular qué services son oneshot — necesario para decidir el formato
|
|
13
|
+
// de depends_on cuando otros services los referencian.
|
|
14
|
+
const oneshotSet = new Set(
|
|
15
|
+
Object.entries(stack.services)
|
|
16
|
+
.filter(([, svc]) => svc.kind === 'oneshot')
|
|
17
|
+
.map(([name]) => name),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// db-init: auto-mount de scripts SQL para services postgres con databases:.
|
|
21
|
+
const dbInitFiles = renderDbInit(stack);
|
|
22
|
+
const dbInitByService = new Map(dbInitFiles.map((f) => [f.service, f]));
|
|
23
|
+
|
|
24
|
+
for (const [name, svc] of Object.entries(stack.services)) {
|
|
25
|
+
const entry: Record<string, unknown> = {
|
|
26
|
+
container_name: `${stack.name}-${name}`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (hasBuild(svc)) {
|
|
30
|
+
entry.build = {
|
|
31
|
+
context: `${REPOS_DIR_VAR}/${repoSlug(svc.repo!)}`,
|
|
32
|
+
dockerfile: svc.build,
|
|
33
|
+
};
|
|
34
|
+
} else {
|
|
35
|
+
entry.image = svc.image;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (svc.working_dir !== undefined) {
|
|
39
|
+
entry.working_dir = svc.working_dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (svc.port !== undefined) {
|
|
43
|
+
entry.expose = [String(svc.port)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ports: string[] = [];
|
|
47
|
+
if (svc.expose_host !== undefined && svc.port !== undefined) {
|
|
48
|
+
ports.push(`${svc.expose_host}:${svc.port}`);
|
|
49
|
+
}
|
|
50
|
+
if (svc.debug_port !== undefined) ports.push(`${svc.debug_port}:${svc.debug_port}`);
|
|
51
|
+
if (ports.length > 0) entry.ports = ports;
|
|
52
|
+
|
|
53
|
+
const environment: Record<string, string> = { ...(svc.env ?? {}) };
|
|
54
|
+
if (svc.debug_port !== undefined) {
|
|
55
|
+
const inspectFlag = `--inspect=0.0.0.0:${svc.debug_port}`;
|
|
56
|
+
environment.NODE_OPTIONS = environment.NODE_OPTIONS
|
|
57
|
+
? `${environment.NODE_OPTIONS} ${inspectFlag}`
|
|
58
|
+
: inspectFlag;
|
|
59
|
+
}
|
|
60
|
+
if (Object.keys(environment).length > 0) {
|
|
61
|
+
entry.environment = environment;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Volumes — combinar declarados + auto-mount del db-init si aplica.
|
|
65
|
+
const volumes: string[] = [...(svc.volumes ?? [])];
|
|
66
|
+
const dbInit = dbInitByService.get(name);
|
|
67
|
+
if (dbInit) {
|
|
68
|
+
volumes.push(`./${dbInit.path}:${dbInit.mountTarget}:ro`);
|
|
69
|
+
}
|
|
70
|
+
if (volumes.length > 0) {
|
|
71
|
+
entry.volumes = volumes;
|
|
72
|
+
for (const v of volumes) {
|
|
73
|
+
const namedVol = extractNamedVolume(v);
|
|
74
|
+
if (namedVol) namedVolumes.add(namedVol);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (svc.command !== undefined) {
|
|
79
|
+
entry.command = svc.command;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (svc.needs && svc.needs.length > 0) {
|
|
83
|
+
entry.depends_on = renderDependsOn(svc.needs, oneshotSet);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
entry.networks = ['default'];
|
|
87
|
+
// oneshot: corre una vez y termina sin restartear.
|
|
88
|
+
entry.restart = svc.kind === 'oneshot' ? 'no' : 'unless-stopped';
|
|
89
|
+
|
|
90
|
+
services[name] = entry;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (stack.gateway) {
|
|
94
|
+
services.gateway = renderGatewayService(stack, oneshotSet);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const compose: Record<string, unknown> = {
|
|
98
|
+
name: stack.name,
|
|
99
|
+
services,
|
|
100
|
+
networks: {
|
|
101
|
+
default: { name: `${stack.name}_default` },
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (namedVolumes.size > 0) {
|
|
106
|
+
compose.volumes = Object.fromEntries(
|
|
107
|
+
[...namedVolumes].sort().map((n) => [n, { name: `${stack.name}_${n}` }]),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return stringifyYaml(compose, { lineWidth: 0 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderDependsOn(needs: string[], oneshotSet: Set<string>): unknown {
|
|
115
|
+
// Si TODOS los needs son services normales, formato array (más simple/legible).
|
|
116
|
+
// Si alguno es oneshot, usar formato objeto extendido para poder declarar
|
|
117
|
+
// condition: service_completed_successfully en los oneshots.
|
|
118
|
+
const hasOneshot = needs.some((n) => oneshotSet.has(n));
|
|
119
|
+
if (!hasOneshot) return [...needs];
|
|
120
|
+
|
|
121
|
+
const out: Record<string, { condition: string }> = {};
|
|
122
|
+
for (const n of needs) {
|
|
123
|
+
out[n] = {
|
|
124
|
+
condition: oneshotSet.has(n) ? 'service_completed_successfully' : 'service_started',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderGatewayService(
|
|
131
|
+
stack: Stack,
|
|
132
|
+
oneshotSet: Set<string>,
|
|
133
|
+
): Record<string, unknown> {
|
|
134
|
+
const targets = [...new Set(stack.gateway!.routes.map((r) => r.service))];
|
|
135
|
+
return {
|
|
136
|
+
container_name: `${stack.name}-gateway`,
|
|
137
|
+
image: 'nginx:1.27-alpine',
|
|
138
|
+
ports: [`${stack.gateway!.port}:80`],
|
|
139
|
+
volumes: ['./nginx.conf:/etc/nginx/nginx.conf:ro'],
|
|
140
|
+
depends_on: renderDependsOn(targets, oneshotSet),
|
|
141
|
+
networks: ['default'],
|
|
142
|
+
restart: 'unless-stopped',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractNamedVolume(spec: string): string | null {
|
|
147
|
+
const colonIdx = spec.indexOf(':');
|
|
148
|
+
if (colonIdx <= 0) return null;
|
|
149
|
+
const left = spec.slice(0, colonIdx);
|
|
150
|
+
if (left.startsWith('/') || left.startsWith('.') || left.includes('$')) return null;
|
|
151
|
+
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(left)) return null;
|
|
152
|
+
return left;
|
|
153
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Stack } from '../schema.js';
|
|
2
|
+
|
|
3
|
+
export type DbInitFile = {
|
|
4
|
+
path: string; // relativo al outDir (.stack/)
|
|
5
|
+
content: string;
|
|
6
|
+
service: string; // service postgres-like al que pertenece
|
|
7
|
+
mountTarget: string; // path adentro del container donde se monta
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Genera SQL scripts de "CREATE DATABASE" idempotente para cada service
|
|
11
|
+
// con databases:. Postgres NO soporta IF NOT EXISTS para DATABASE, así que
|
|
12
|
+
// usamos un bloque DO con consulta a pg_database.
|
|
13
|
+
export function renderDbInit(stack: Stack): DbInitFile[] {
|
|
14
|
+
const files: DbInitFile[] = [];
|
|
15
|
+
|
|
16
|
+
for (const [name, svc] of Object.entries(stack.services)) {
|
|
17
|
+
if (!svc.databases || svc.databases.length === 0) continue;
|
|
18
|
+
files.push({
|
|
19
|
+
path: `db-init/${name}.sql`,
|
|
20
|
+
content: renderPostgresInit(svc.databases),
|
|
21
|
+
service: name,
|
|
22
|
+
mountTarget: `/docker-entrypoint-initdb.d/00-om-databases.sql`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderPostgresInit(databases: string[]): string {
|
|
30
|
+
const lines: string[] = ['-- Generado por om — CREATE DATABASE idempotente.'];
|
|
31
|
+
for (const db of databases) {
|
|
32
|
+
// Escape simple del identificador (alphanumeric + underscore + dash permitidos en pg).
|
|
33
|
+
const safe = db.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
34
|
+
if (safe !== db) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`databases: nombre inválido '${db}' — solo se permiten [a-zA-Z0-9_-]`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
lines.push(`SELECT 'CREATE DATABASE ${safe}'`);
|
|
40
|
+
lines.push(`WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${safe}')\\gexec`);
|
|
41
|
+
}
|
|
42
|
+
lines.push('');
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Stack } from '../schema.js';
|
|
2
|
+
|
|
3
|
+
export function renderEnv(
|
|
4
|
+
stack: Stack,
|
|
5
|
+
opts: { reposDir: string; stackDir: string },
|
|
6
|
+
): string {
|
|
7
|
+
const lines: string[] = [
|
|
8
|
+
'# Generado por om — interpolado por docker compose.',
|
|
9
|
+
`COMPOSE_PROJECT_NAME=${stack.name}`,
|
|
10
|
+
`REPOS_DIR=${opts.reposDir}`,
|
|
11
|
+
`STACK_DIR=${opts.stackDir}`,
|
|
12
|
+
'',
|
|
13
|
+
];
|
|
14
|
+
return lines.join('\n');
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Stack } from '../schema.js';
|
|
2
|
+
|
|
3
|
+
export function renderNginx(stack: Stack): string {
|
|
4
|
+
if (!stack.gateway) return '';
|
|
5
|
+
|
|
6
|
+
const usedServices = [...new Set(stack.gateway.routes.map((r) => r.service))];
|
|
7
|
+
|
|
8
|
+
const upstreams = usedServices
|
|
9
|
+
.map((svcName) => {
|
|
10
|
+
const svc = stack.services[svcName]!;
|
|
11
|
+
return `upstream ${svcName} {\n server ${svcName}:${svc.port};\n}`;
|
|
12
|
+
})
|
|
13
|
+
.join('\n\n');
|
|
14
|
+
|
|
15
|
+
// Orden por especificidad (longitud del path) — más largo gana. nginx hace
|
|
16
|
+
// longest-prefix match nativo, pero ordenar mantiene el archivo legible.
|
|
17
|
+
const sortedRoutes = [...stack.gateway.routes].sort(
|
|
18
|
+
(a, b) => effectivePathLength(b.path) - effectivePathLength(a.path),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const locations = sortedRoutes.map((route) => renderLocation(route)).join('\n\n');
|
|
22
|
+
|
|
23
|
+
const serverName = stack.gateway.server_name ?? '_';
|
|
24
|
+
|
|
25
|
+
return `# Generado por om — no editar a mano
|
|
26
|
+
worker_processes 1;
|
|
27
|
+
|
|
28
|
+
events {
|
|
29
|
+
worker_connections 1024;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
http {
|
|
33
|
+
sendfile on;
|
|
34
|
+
keepalive_timeout 65;
|
|
35
|
+
client_max_body_size 50m;
|
|
36
|
+
|
|
37
|
+
${indent(upstreams, 4)}
|
|
38
|
+
|
|
39
|
+
server {
|
|
40
|
+
listen 80;
|
|
41
|
+
server_name ${serverName};
|
|
42
|
+
|
|
43
|
+
${locations}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderLocation(route: {
|
|
50
|
+
path: string;
|
|
51
|
+
service: string;
|
|
52
|
+
strip_prefix?: boolean;
|
|
53
|
+
rewrite?: string;
|
|
54
|
+
}): string {
|
|
55
|
+
const directives: string[] = [];
|
|
56
|
+
|
|
57
|
+
if (route.rewrite) {
|
|
58
|
+
directives.push(`rewrite ${route.rewrite};`);
|
|
59
|
+
} else if (route.strip_prefix) {
|
|
60
|
+
// /provider-api/foo → /foo
|
|
61
|
+
const prefix = route.path.replace(/^=\s*/, '').replace(/\/$/, '');
|
|
62
|
+
if (prefix.length > 0) {
|
|
63
|
+
directives.push(`rewrite ^${prefix}/(.*)$ /$1 break;`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
directives.push(
|
|
68
|
+
`proxy_pass http://${route.service};`,
|
|
69
|
+
`proxy_http_version 1.1;`,
|
|
70
|
+
`proxy_set_header Host $host;`,
|
|
71
|
+
`proxy_set_header X-Real-IP $remote_addr;`,
|
|
72
|
+
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`,
|
|
73
|
+
`proxy_set_header X-Forwarded-Proto $scheme;`,
|
|
74
|
+
`proxy_set_header Upgrade $http_upgrade;`,
|
|
75
|
+
`proxy_set_header Connection "upgrade";`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const body = directives.map((l) => ` ${l}`).join('\n');
|
|
79
|
+
return ` location ${route.path} {\n${body}\n }`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function effectivePathLength(path: string): number {
|
|
83
|
+
// Para ordenamiento: ignorar modificador "= " o "^~ " del prefix.
|
|
84
|
+
return path.replace(/^(=|\^~)\s*/, '').length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function indent(text: string, spaces: number): string {
|
|
88
|
+
const pad = ' '.repeat(spaces);
|
|
89
|
+
return text
|
|
90
|
+
.split('\n')
|
|
91
|
+
.map((line) => (line.length > 0 ? pad + line : line))
|
|
92
|
+
.join('\n');
|
|
93
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { repoSlug } from '../repo.js';
|
|
2
|
+
import { hasRepo, type Stack } from '../schema.js';
|
|
3
|
+
|
|
4
|
+
export type ScriptFile = {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
executable: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Para acciones sobre el stack (up/down/logs/ps) usar el CLI `om` directamente.
|
|
11
|
+
// Acá solo generamos el caso único que el CLI no cubre: correr un service nativo
|
|
12
|
+
// en el host con env apuntando al resto del compose. Se emite en bash (.sh) y
|
|
13
|
+
// PowerShell (.ps1) para soporte cross-platform.
|
|
14
|
+
// Solo aplica a services con repo: (los que tienen código local que se puede correr nativo).
|
|
15
|
+
export function renderScripts(stack: Stack): ScriptFile[] {
|
|
16
|
+
const files: ScriptFile[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [name, svc] of Object.entries(stack.services)) {
|
|
19
|
+
if (!hasRepo(svc)) continue;
|
|
20
|
+
files.push({
|
|
21
|
+
path: `scripts/${name}.sh`,
|
|
22
|
+
content: svcStandaloneBash(stack, name),
|
|
23
|
+
executable: true,
|
|
24
|
+
});
|
|
25
|
+
files.push({
|
|
26
|
+
path: `scripts/${name}.ps1`,
|
|
27
|
+
content: svcStandalonePwsh(stack, name),
|
|
28
|
+
executable: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function svcStandaloneBash(stack: Stack, svcName: string): string {
|
|
36
|
+
const svc = stack.services[svcName]!;
|
|
37
|
+
if (!hasRepo(svc)) return '';
|
|
38
|
+
|
|
39
|
+
const envLines: string[] = [`export PORT="${svc.port}"`];
|
|
40
|
+
for (const [k, v] of Object.entries(svc.env ?? {})) {
|
|
41
|
+
envLines.push(`export ${k}="${escapeForBash(rewriteForHost(v, stack))}"`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const slug = repoSlug(svc.repo);
|
|
45
|
+
|
|
46
|
+
return `#!/usr/bin/env bash
|
|
47
|
+
# Generado por om — no editar a mano.
|
|
48
|
+
# Corre ${svcName} nativo en el host (sin docker), conectado al resto del stack.
|
|
49
|
+
# Útil para debugging con IDE/debugger nativo.
|
|
50
|
+
set -euo pipefail
|
|
51
|
+
cd "$(dirname "$0")/.."
|
|
52
|
+
|
|
53
|
+
REPOS_DIR="\${REPOS_DIR:-$(pwd)/../repos}"
|
|
54
|
+
SERVICE_DIR="$REPOS_DIR/${slug}"
|
|
55
|
+
|
|
56
|
+
if [ ! -d "$SERVICE_DIR" ]; then
|
|
57
|
+
echo "no se encuentra el repo en $SERVICE_DIR — clonalo primero" >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
${envLines.join('\n')}
|
|
62
|
+
|
|
63
|
+
cd "$SERVICE_DIR"
|
|
64
|
+
|
|
65
|
+
if [ -n "\${OM_CMD:-}" ]; then
|
|
66
|
+
exec $OM_CMD
|
|
67
|
+
elif [ -f package.json ] && command -v npm >/dev/null 2>&1; then
|
|
68
|
+
exec npm run dev
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
echo "no se detectó comando de arranque — setea OM_CMD" >&2
|
|
72
|
+
exit 1
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function svcStandalonePwsh(stack: Stack, svcName: string): string {
|
|
77
|
+
const svc = stack.services[svcName]!;
|
|
78
|
+
if (!hasRepo(svc)) return '';
|
|
79
|
+
|
|
80
|
+
const envLines: string[] = [`$env:PORT = "${svc.port}"`];
|
|
81
|
+
for (const [k, v] of Object.entries(svc.env ?? {})) {
|
|
82
|
+
envLines.push(`$env:${k} = "${escapeForPwsh(rewriteForHost(v, stack))}"`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const slug = repoSlug(svc.repo);
|
|
86
|
+
|
|
87
|
+
return `# Generado por om — no editar a mano.
|
|
88
|
+
# Corre ${svcName} nativo en el host (sin docker), conectado al resto del stack.
|
|
89
|
+
# Útil para debugging con IDE/debugger nativo.
|
|
90
|
+
$ErrorActionPreference = 'Stop'
|
|
91
|
+
Set-Location (Join-Path $PSScriptRoot '..')
|
|
92
|
+
|
|
93
|
+
$ReposDir = if ($env:REPOS_DIR) { $env:REPOS_DIR } else { Join-Path (Get-Location) '..\\repos' }
|
|
94
|
+
$ServiceDir = Join-Path $ReposDir '${slug}'
|
|
95
|
+
|
|
96
|
+
if (-not (Test-Path $ServiceDir)) {
|
|
97
|
+
Write-Error "no se encuentra el repo en $ServiceDir — clonalo primero"
|
|
98
|
+
exit 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
${envLines.join('\n')}
|
|
102
|
+
|
|
103
|
+
Set-Location $ServiceDir
|
|
104
|
+
|
|
105
|
+
if ($env:OM_CMD) {
|
|
106
|
+
Invoke-Expression $env:OM_CMD
|
|
107
|
+
} elseif ((Test-Path 'package.json') -and (Get-Command npm -ErrorAction SilentlyContinue)) {
|
|
108
|
+
& npm run dev
|
|
109
|
+
} else {
|
|
110
|
+
Write-Error "no se detectó comando de arranque — setea OM_CMD"
|
|
111
|
+
exit 1
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rewriteForHost(value: string, stack: Stack): string {
|
|
117
|
+
// En el compose "api" resuelve por DNS interno; corriendo nativo en el host
|
|
118
|
+
// hay que ir a localhost:<expose_host>. Matchea tanto //host:port (URL plain)
|
|
119
|
+
// como @host:port (URL con userinfo).
|
|
120
|
+
return value.replace(
|
|
121
|
+
/(\/\/|@)([a-z][a-z0-9_-]*):(\d+)/g,
|
|
122
|
+
(match, prefix: string, target: string, _port: string) => {
|
|
123
|
+
const targetSvc = stack.services[target];
|
|
124
|
+
if (!targetSvc) return match;
|
|
125
|
+
if (targetSvc.expose_host === undefined) return match;
|
|
126
|
+
return `${prefix}localhost:${targetSvc.expose_host}`;
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function escapeForBash(value: string): string {
|
|
132
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function escapeForPwsh(value: string): string {
|
|
136
|
+
// En PowerShell double-quoted strings: " se escapa como `", $ como `$, ` como ``.
|
|
137
|
+
return value.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$');
|
|
138
|
+
}
|
|
139
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { repoSlug } from '../repo.js';
|
|
2
|
+
import { hasRepo, type Stack } from '../schema.js';
|
|
3
|
+
|
|
4
|
+
// Genera el contenido de .vscode/launch.json para attach al inspector Node
|
|
5
|
+
// de cada service con debug_port + launch del browser para services con
|
|
6
|
+
// vscode.browser. Incluye una compound config para attach a todos a la vez.
|
|
7
|
+
export function renderVscodeLaunch(stack: Stack): string {
|
|
8
|
+
const configurations: Array<Record<string, unknown>> = [];
|
|
9
|
+
const compounds: Array<Record<string, unknown>> = [];
|
|
10
|
+
const attachNames: string[] = [];
|
|
11
|
+
|
|
12
|
+
for (const [name, svc] of Object.entries(stack.services)) {
|
|
13
|
+
if (svc.debug_port !== undefined) {
|
|
14
|
+
const cfgName = `[${stack.name}] Attach (${name})`;
|
|
15
|
+
attachNames.push(cfgName);
|
|
16
|
+
configurations.push(attachConfig(stack, name, svc.debug_port, cfgName));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (svc.vscode?.browser !== undefined) {
|
|
20
|
+
configurations.push(browserConfig(stack, name, svc));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (attachNames.length > 1) {
|
|
25
|
+
compounds.push({
|
|
26
|
+
name: `[${stack.name}] Attach all`,
|
|
27
|
+
configurations: attachNames,
|
|
28
|
+
stopAll: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const launch: Record<string, unknown> = {
|
|
33
|
+
version: '0.2.0',
|
|
34
|
+
configurations,
|
|
35
|
+
};
|
|
36
|
+
if (compounds.length > 0) launch.compounds = compounds;
|
|
37
|
+
|
|
38
|
+
return JSON.stringify(launch, null, 2) + '\n';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function attachConfig(
|
|
42
|
+
stack: Stack,
|
|
43
|
+
svcName: string,
|
|
44
|
+
debugPort: number,
|
|
45
|
+
name: string,
|
|
46
|
+
): Record<string, unknown> {
|
|
47
|
+
const svc = stack.services[svcName]!;
|
|
48
|
+
const cfg: Record<string, unknown> = {
|
|
49
|
+
name,
|
|
50
|
+
type: 'node',
|
|
51
|
+
request: 'attach',
|
|
52
|
+
address: 'localhost',
|
|
53
|
+
port: debugPort,
|
|
54
|
+
restart: true,
|
|
55
|
+
timeout: 30000,
|
|
56
|
+
skipFiles: ['<node_internals>/**'],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Si el service monta el código del host adentro del container, mapear paths
|
|
60
|
+
// para que VS Code resuelva los source files al filesystem local.
|
|
61
|
+
if (hasRepo(svc) && svc.working_dir) {
|
|
62
|
+
cfg.localRoot = `\${workspaceFolder}/repos/${repoSlug(svc.repo)}`;
|
|
63
|
+
cfg.remoteRoot = svc.working_dir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return cfg;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function browserConfig(
|
|
70
|
+
stack: Stack,
|
|
71
|
+
svcName: string,
|
|
72
|
+
svc: Stack['services'][string],
|
|
73
|
+
): Record<string, unknown> {
|
|
74
|
+
const browser = svc.vscode!.browser!;
|
|
75
|
+
const label = browser.label ?? `Browser (${svcName})`;
|
|
76
|
+
const url =
|
|
77
|
+
browser.url ??
|
|
78
|
+
(stack.gateway ? `http://localhost:${stack.gateway.port}/` : `http://localhost:${svc.port}/`);
|
|
79
|
+
|
|
80
|
+
const cfg: Record<string, unknown> = {
|
|
81
|
+
name: `[${stack.name}] ${label}`,
|
|
82
|
+
type: 'pwa-chrome',
|
|
83
|
+
request: 'launch',
|
|
84
|
+
url,
|
|
85
|
+
sourceMaps: true,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (hasRepo(svc)) {
|
|
89
|
+
cfg.webRoot = `\${workspaceFolder}/repos/${repoSlug(svc.repo)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return cfg;
|
|
93
|
+
}
|
package/src/repo.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Helpers de URL/slug de repos — usados por compose, scripts, sync y deben coincidir.
|
|
2
|
+
|
|
3
|
+
export function isLocalRepo(repo: string): boolean {
|
|
4
|
+
return repo.startsWith('./') || repo.startsWith('../') || repo.startsWith('/');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeGitUrl(repo: string): string {
|
|
8
|
+
if (/^https?:\/\//.test(repo)) return repo;
|
|
9
|
+
if (/^git@/.test(repo)) return repo;
|
|
10
|
+
if (/^ssh:\/\//.test(repo)) return repo;
|
|
11
|
+
|
|
12
|
+
if (/^[a-z0-9.-]+\/[^/]+\/[^/]+/.test(repo)) {
|
|
13
|
+
const withGit = repo.endsWith('.git') ? repo : `${repo}.git`;
|
|
14
|
+
return `https://${withGit}`;
|
|
15
|
+
}
|
|
16
|
+
return repo;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function repoSlug(repo: string): string {
|
|
20
|
+
if (isLocalRepo(repo)) {
|
|
21
|
+
const parts = repo.replace(/\/$/, '').split('/');
|
|
22
|
+
return parts[parts.length - 1] ?? repo;
|
|
23
|
+
}
|
|
24
|
+
const cleaned = repo.replace(/\.git$/, '').replace(/\/$/, '');
|
|
25
|
+
const colonIdx = cleaned.indexOf(':');
|
|
26
|
+
const tail =
|
|
27
|
+
colonIdx >= 0 && !cleaned.startsWith('http') ? cleaned.slice(colonIdx + 1) : cleaned;
|
|
28
|
+
const parts = tail.split('/');
|
|
29
|
+
return parts[parts.length - 1] ?? tail;
|
|
30
|
+
}
|