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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabian Giordano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# om
|
|
2
|
+
|
|
3
|
+
Orquestador declarativo mínimo: un `stack.yaml` describe la arquitectura, el CLI renderiza `docker-compose.yaml` + `nginx.conf` + scripts y levanta el stack.
|
|
4
|
+
|
|
5
|
+
Pensado para stacks chicos donde el orch completo (engine + launcher + profiles + SOPS + k8s + …) es demasiada maquinaria.
|
|
6
|
+
|
|
7
|
+
## Requisitos
|
|
8
|
+
|
|
9
|
+
- Node 18+ (Mac / Linux / Windows)
|
|
10
|
+
- Docker Desktop o Docker Engine + Compose plugin
|
|
11
|
+
|
|
12
|
+
## Instalación
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm link # registra el comando `om` globalmente
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Funciona igual en Mac, Linux y Windows — `npm link` crea el wrapper apropiado por OS.
|
|
20
|
+
|
|
21
|
+
## Layout del proyecto
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
mi-app/
|
|
25
|
+
├── arch/ # toda la definición de arquitectura
|
|
26
|
+
│ ├── stack.yaml
|
|
27
|
+
│ ├── init/ # archivos auxiliares montados como volúmenes
|
|
28
|
+
│ ├── certs/ # claves públicas .pem (versionables)
|
|
29
|
+
│ └── secrets/ # claves privadas .pem (gitignored)
|
|
30
|
+
├── repos/ # gitignored (clones de los repos del stack)
|
|
31
|
+
└── .stack/ # gitignored (output del gen)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`om` reconoce `arch/stack.yaml` automáticamente — `repos/` y `.stack/` se anclan al padre. También se puede tener `stack.yaml` en la raíz directamente (sin `arch/`), si el stack es muy simple.
|
|
35
|
+
|
|
36
|
+
## Flujo end-to-end
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
mkdir mi-app && cd mi-app
|
|
40
|
+
om init # crea arch/stack.yaml + arch/{init,certs,secrets}/
|
|
41
|
+
om sync # clone/pull de los repos declarados → repos/
|
|
42
|
+
om gen # render → .stack/
|
|
43
|
+
om up # docker compose up -d
|
|
44
|
+
om logs api # seguir logs del service api
|
|
45
|
+
om stop # parar sin remove
|
|
46
|
+
om restart api # reiniciar un service
|
|
47
|
+
om down # tear down completo
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Sin path explícito, `om` busca el `stack.yaml` más cercano subiendo desde la carpeta actual.
|
|
51
|
+
|
|
52
|
+
## Inyectar archivos en env vars
|
|
53
|
+
|
|
54
|
+
Para PEMs, certs, secrets o cualquier archivo cuyo contenido tenga que ir en una env var del container:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
services:
|
|
58
|
+
api:
|
|
59
|
+
env:
|
|
60
|
+
CHAT_OS_SIGNING_PRIVATE_KEY_PEM: ${file:secrets/chat-os-signing-private.pem}
|
|
61
|
+
CHAT_OS_PUBLIC_KEY_PEM: ${file:certs/chat-os-signing-public.pem}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
El path es relativo al `stack.yaml`. El contenido se inline al `environment:` del container en tiempo de render.
|
|
65
|
+
|
|
66
|
+
## Crear DBs declarativamente
|
|
67
|
+
|
|
68
|
+
Para services postgres, listá las DBs lógicas en `databases:`. `om` genera un script SQL idempotente y lo auto-monta en `/docker-entrypoint-initdb.d/`:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
services:
|
|
72
|
+
chat-os-db:
|
|
73
|
+
image: postgres:16-alpine
|
|
74
|
+
port: 5432
|
|
75
|
+
env:
|
|
76
|
+
POSTGRES_USER: chat_os
|
|
77
|
+
POSTGRES_PASSWORD: chat_os
|
|
78
|
+
databases:
|
|
79
|
+
- chat_os_dev
|
|
80
|
+
- chat_os_implementation_dev
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Limitación**: postgres solo ejecuta `/docker-entrypoint-initdb.d/` cuando el data volume está vacío. Si más adelante agregás una DB nueva al stack y el volume ya existe, el script no se va a ejecutar. Para ese caso, usar `kind: oneshot` (ver abajo).
|
|
84
|
+
|
|
85
|
+
## Init jobs con `kind: oneshot`
|
|
86
|
+
|
|
87
|
+
Un service `oneshot` corre una vez, termina, y los services que lo necesitan esperan a que termine OK antes de arrancar (vía docker compose `service_completed_successfully`).
|
|
88
|
+
|
|
89
|
+
Útil para: seeds de DB, migraciones one-shot, crear DBs en un postgres pre-existente, etc.
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
services:
|
|
93
|
+
chat-os-db:
|
|
94
|
+
image: postgres:16-alpine
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
chat-os-db-bootstrap:
|
|
98
|
+
kind: oneshot
|
|
99
|
+
image: postgres:16-alpine
|
|
100
|
+
needs: [chat-os-db]
|
|
101
|
+
command:
|
|
102
|
+
- sh
|
|
103
|
+
- -c
|
|
104
|
+
- |
|
|
105
|
+
until pg_isready -h chat-os-db -U chat_os; do sleep 1; done
|
|
106
|
+
psql -h chat-os-db -U chat_os -d postgres -tc \
|
|
107
|
+
"SELECT 1 FROM pg_database WHERE datname='extra_db'" | grep -q 1 \
|
|
108
|
+
|| psql -h chat-os-db -U chat_os -d postgres -c "CREATE DATABASE extra_db"
|
|
109
|
+
env:
|
|
110
|
+
PGPASSWORD: chat_os
|
|
111
|
+
|
|
112
|
+
# otros services que dependen de la DB ya bootstrap-eada:
|
|
113
|
+
app:
|
|
114
|
+
image: node:20
|
|
115
|
+
needs: [chat-os-db-bootstrap] # espera service_completed_successfully
|
|
116
|
+
...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Montar archivos como volúmenes
|
|
120
|
+
|
|
121
|
+
Para init scripts u otros archivos que el container espera leer del filesystem (ej `/docker-entrypoint-initdb.d/`), usar `${STACK_DIR}` que apunta al dir del `stack.yaml`:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
services:
|
|
125
|
+
db:
|
|
126
|
+
image: postgres:16
|
|
127
|
+
volumes:
|
|
128
|
+
- ${STACK_DIR}/init/init-dbs.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Comandos
|
|
132
|
+
|
|
133
|
+
| comando | descripción |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `init` | crea `stack.yaml` template en la carpeta actual |
|
|
136
|
+
| `sync` | clone/pull de los repos declarados en `services.*.repo` |
|
|
137
|
+
| `gen [path] [--out dir]` | rinde `docker-compose.yaml` + `nginx.conf` + scripts en `.stack/` |
|
|
138
|
+
| `validate [path]` | solo valida el schema del stack |
|
|
139
|
+
| `up [args]` | `docker compose up -d` |
|
|
140
|
+
| `down [args]` | `docker compose down` (remueve containers) |
|
|
141
|
+
| `stop [args]` | `docker compose stop` (preserva containers) |
|
|
142
|
+
| `restart [svc]` | `docker compose restart` |
|
|
143
|
+
| `build [svc]` | `docker compose build` |
|
|
144
|
+
| `logs [svc]` | `docker compose logs -f --tail=200` |
|
|
145
|
+
| `ps` | `docker compose ps` |
|
|
146
|
+
|
|
147
|
+
## Forma del stack.yaml
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
name: mi-app
|
|
151
|
+
gateway:
|
|
152
|
+
port: 8080
|
|
153
|
+
routes:
|
|
154
|
+
- path: /api
|
|
155
|
+
service: api
|
|
156
|
+
- path: /
|
|
157
|
+
service: web
|
|
158
|
+
|
|
159
|
+
services:
|
|
160
|
+
api:
|
|
161
|
+
repo: github.com/me/api
|
|
162
|
+
build: ./Dockerfile
|
|
163
|
+
port: 3000
|
|
164
|
+
env:
|
|
165
|
+
DATABASE_URL: postgres://db:5432/app
|
|
166
|
+
needs: [db]
|
|
167
|
+
|
|
168
|
+
web:
|
|
169
|
+
repo: github.com/me/web
|
|
170
|
+
build: ./Dockerfile
|
|
171
|
+
port: 3001
|
|
172
|
+
env:
|
|
173
|
+
API_URL: http://api:3000
|
|
174
|
+
|
|
175
|
+
db:
|
|
176
|
+
image: postgres:16
|
|
177
|
+
port: 5432
|
|
178
|
+
expose_host: 5433
|
|
179
|
+
env:
|
|
180
|
+
POSTGRES_DB: app
|
|
181
|
+
POSTGRES_PASSWORD: app
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Output (en `.stack/`)
|
|
185
|
+
|
|
186
|
+
- `docker-compose.yaml` — listo para `docker compose up`
|
|
187
|
+
- `nginx.conf` — gateway con upstreams resueltos (si declaraste `gateway`)
|
|
188
|
+
- `.env` — vars que el compose interpola
|
|
189
|
+
- `scripts/<svc>.sh` y `scripts/<svc>.ps1` — correr el módulo nativo (sin docker), con env apuntando al resto del stack. Bash para Mac/Linux, PowerShell para Windows.
|
|
190
|
+
|
|
191
|
+
Para acciones sobre el stack entero (`up`, `down`, `logs`, `ps`) usá el CLI directamente — funciona igual en los tres OS.
|
package/bin/om.cjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shim cross-platform: registra el loader CJS de tsx y require el cli TS directo.
|
|
3
|
+
// Evita spawn-ear `tsx` por path (que se rompe en Windows con .cmd vs binario raw).
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
require('tsx/cjs');
|
|
7
|
+
require(path.join(__dirname, '..', 'src', 'cli.ts'));
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orch-mini",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Orquestador declarativo mínimo: un stack.yaml describe la arquitectura, om renderiza docker-compose + nginx + scripts y levanta el stack.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"om": "bin/om.cjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "tsx src/cli.ts",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"gen:example": "tsx src/cli.ts gen examples/simple/stack.yaml"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"docker-compose",
|
|
25
|
+
"orchestrator",
|
|
26
|
+
"stack",
|
|
27
|
+
"nginx",
|
|
28
|
+
"yaml",
|
|
29
|
+
"cli",
|
|
30
|
+
"devops",
|
|
31
|
+
"monorepo"
|
|
32
|
+
],
|
|
33
|
+
"author": "Fabian Giordano <fgiordano@naturapy.net>",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"homepage": "https://github.com/logieinc/orch-mini#readme",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/logieinc/orch-mini.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/logieinc/orch-mini/issues"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"tsx": "^4.19.0",
|
|
48
|
+
"yaml": "^2.6.0",
|
|
49
|
+
"zod": "^3.23.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^20.0.0",
|
|
53
|
+
"typescript": "^5.6.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { initStack } from './init.js';
|
|
5
|
+
import { loadStack, type LoadedStack } from './parser.js';
|
|
6
|
+
import { renderCompose } from './renderer/compose.js';
|
|
7
|
+
import { renderDbInit } from './renderer/db-init.js';
|
|
8
|
+
import { renderEnv } from './renderer/env.js';
|
|
9
|
+
import { renderNginx } from './renderer/nginx.js';
|
|
10
|
+
import { renderScripts } from './renderer/scripts.js';
|
|
11
|
+
import { renderVscodeLaunch } from './renderer/vscode.js';
|
|
12
|
+
import { syncStack, type SyncResult } from './sync.js';
|
|
13
|
+
|
|
14
|
+
const COMMANDS = [
|
|
15
|
+
'init',
|
|
16
|
+
'sync',
|
|
17
|
+
'gen',
|
|
18
|
+
'validate',
|
|
19
|
+
'vscode',
|
|
20
|
+
'up',
|
|
21
|
+
'down',
|
|
22
|
+
'stop',
|
|
23
|
+
'restart',
|
|
24
|
+
'build',
|
|
25
|
+
'logs',
|
|
26
|
+
'ps',
|
|
27
|
+
'help',
|
|
28
|
+
] as const;
|
|
29
|
+
type Command = (typeof COMMANDS)[number];
|
|
30
|
+
|
|
31
|
+
function printUsage(): void {
|
|
32
|
+
console.log(`om — orquestador declarativo mínimo
|
|
33
|
+
|
|
34
|
+
setup:
|
|
35
|
+
om init crea un stack.yaml template en la carpeta actual
|
|
36
|
+
om sync clone/pull de los repos declarados (services.*.repo)
|
|
37
|
+
om gen [stack.yaml] [--out <dir>] rinde compose + nginx + scripts en .stack/
|
|
38
|
+
om validate [stack.yaml] solo valida el stack
|
|
39
|
+
om vscode genera .vscode/launch.json (attach + browser)
|
|
40
|
+
|
|
41
|
+
runtime:
|
|
42
|
+
om up [args...] docker compose up -d
|
|
43
|
+
om down [args...] docker compose down (remueve containers)
|
|
44
|
+
om stop [args...] docker compose stop (sin remove)
|
|
45
|
+
om restart [service] docker compose restart
|
|
46
|
+
om build [service] docker compose build
|
|
47
|
+
om logs [service] docker compose logs -f --tail=200
|
|
48
|
+
om ps docker compose ps
|
|
49
|
+
|
|
50
|
+
om help muestra esta ayuda
|
|
51
|
+
|
|
52
|
+
Sin path explícito, busca el stack.yaml más cercano subiendo desde la carpeta actual.
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function main(argv: string[]): number {
|
|
57
|
+
const [cmd, ...rest] = argv;
|
|
58
|
+
|
|
59
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
60
|
+
printUsage();
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!COMMANDS.includes(cmd as Command)) {
|
|
65
|
+
console.error(`comando desconocido: ${cmd}\n`);
|
|
66
|
+
printUsage();
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
switch (cmd as Command) {
|
|
72
|
+
case 'init':
|
|
73
|
+
return runInit();
|
|
74
|
+
case 'sync':
|
|
75
|
+
return runSync();
|
|
76
|
+
case 'validate':
|
|
77
|
+
return runValidate(rest);
|
|
78
|
+
case 'gen':
|
|
79
|
+
return runGen(rest);
|
|
80
|
+
case 'vscode':
|
|
81
|
+
return runVscode();
|
|
82
|
+
case 'up':
|
|
83
|
+
return runDockerCompose(['up', '-d', ...rest]);
|
|
84
|
+
case 'down':
|
|
85
|
+
return runDockerCompose(['down', ...rest]);
|
|
86
|
+
case 'stop':
|
|
87
|
+
return runDockerCompose(['stop', ...rest]);
|
|
88
|
+
case 'restart':
|
|
89
|
+
return runDockerCompose(['restart', ...rest]);
|
|
90
|
+
case 'build':
|
|
91
|
+
return runDockerCompose(['build', ...rest]);
|
|
92
|
+
case 'logs':
|
|
93
|
+
return runDockerCompose(['logs', '-f', '--tail=200', ...rest]);
|
|
94
|
+
case 'ps':
|
|
95
|
+
return runDockerCompose(['ps', ...rest]);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseStackArg(args: string[]): { stackPath: string | undefined; rest: string[] } {
|
|
105
|
+
if (args[0] && !args[0].startsWith('-')) {
|
|
106
|
+
return { stackPath: args[0], rest: args.slice(1) };
|
|
107
|
+
}
|
|
108
|
+
return { stackPath: undefined, rest: args };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runInit(): number {
|
|
112
|
+
const result = initStack(process.cwd());
|
|
113
|
+
if (result.created) {
|
|
114
|
+
console.log(`✓ ${result.message}`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
console.error(result.message);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runSync(): number {
|
|
122
|
+
const loaded = loadStack(undefined);
|
|
123
|
+
const reposDir = resolve(loaded.workspaceRoot, 'repos');
|
|
124
|
+
mkdirSync(reposDir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
console.log(
|
|
127
|
+
`sync de ${Object.keys(loaded.stack.services).length} services en ${
|
|
128
|
+
relative(process.cwd(), reposDir) || '.'
|
|
129
|
+
}`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const results = syncStack(loaded.stack, { workDir: loaded.workspaceRoot, reposDir });
|
|
133
|
+
|
|
134
|
+
let failed = 0;
|
|
135
|
+
for (const r of results) {
|
|
136
|
+
const icon = iconFor(r);
|
|
137
|
+
const detail = r.message ? ` — ${r.message}` : '';
|
|
138
|
+
console.log(` ${icon} ${r.service.padEnd(20)} ${r.action}${detail}`);
|
|
139
|
+
if (r.action === 'failed' || r.action === 'local-missing') failed++;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return failed > 0 ? 1 : 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function iconFor(r: SyncResult): string {
|
|
146
|
+
switch (r.action) {
|
|
147
|
+
case 'cloned':
|
|
148
|
+
case 'pulled':
|
|
149
|
+
case 'switched':
|
|
150
|
+
case 'local-ok':
|
|
151
|
+
return '✓';
|
|
152
|
+
case 'skipped-no-repo':
|
|
153
|
+
case 'skipped-dup':
|
|
154
|
+
return '·';
|
|
155
|
+
case 'failed':
|
|
156
|
+
case 'local-missing':
|
|
157
|
+
return '✗';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function runValidate(args: string[]): number {
|
|
162
|
+
const { stackPath } = parseStackArg(args);
|
|
163
|
+
const { stack } = loadStack(stackPath);
|
|
164
|
+
console.log(`✓ ${stack.name} — ${Object.keys(stack.services).length} services`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runVscode(): number {
|
|
169
|
+
const loaded = loadStack(undefined);
|
|
170
|
+
const vscodeDir = resolve(loaded.workspaceRoot, '.vscode');
|
|
171
|
+
mkdirSync(vscodeDir, { recursive: true });
|
|
172
|
+
const target = join(vscodeDir, 'launch.json');
|
|
173
|
+
const content = renderVscodeLaunch(loaded.stack);
|
|
174
|
+
writeFileSync(target, content);
|
|
175
|
+
|
|
176
|
+
const debugCount = Object.values(loaded.stack.services).filter((s) => s.debug_port).length;
|
|
177
|
+
const browserCount = Object.values(loaded.stack.services).filter((s) => s.vscode?.browser).length;
|
|
178
|
+
|
|
179
|
+
console.log(`✓ generado ${relative(process.cwd(), target)}`);
|
|
180
|
+
console.log(` ${debugCount} attach configs · ${browserCount} browser launchers`);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function runGen(args: string[]): number {
|
|
185
|
+
const { stackPath, rest } = parseStackArg(args);
|
|
186
|
+
const outIdx = rest.indexOf('--out');
|
|
187
|
+
const explicitOut = outIdx >= 0 ? rest[outIdx + 1] : undefined;
|
|
188
|
+
|
|
189
|
+
const loaded = loadStack(stackPath);
|
|
190
|
+
const outDir = resolve(explicitOut ?? loaded.outDir);
|
|
191
|
+
const reposDir = resolve(loaded.workspaceRoot, 'repos');
|
|
192
|
+
|
|
193
|
+
mkdirSync(outDir, { recursive: true });
|
|
194
|
+
|
|
195
|
+
const files: Array<{ path: string; content: string; mode?: number }> = [
|
|
196
|
+
{ path: 'docker-compose.yaml', content: renderCompose(loaded.stack) },
|
|
197
|
+
{ path: '.env', content: renderEnv(loaded.stack, { reposDir, stackDir: loaded.workDir }) },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
if (loaded.stack.gateway) {
|
|
201
|
+
files.push({ path: 'nginx.conf', content: renderNginx(loaded.stack) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const dbInit of renderDbInit(loaded.stack)) {
|
|
205
|
+
files.push({ path: dbInit.path, content: dbInit.content });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const script of renderScripts(loaded.stack)) {
|
|
209
|
+
files.push({
|
|
210
|
+
path: script.path,
|
|
211
|
+
content: script.content,
|
|
212
|
+
...(script.executable ? { mode: 0o755 } : {}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
const fullPath = join(outDir, file.path);
|
|
218
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
219
|
+
writeFileSync(fullPath, file.content);
|
|
220
|
+
if (file.mode !== undefined) chmodSync(fullPath, file.mode);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
console.log(`✓ render completo en ${relative(process.cwd(), outDir) || '.'}`);
|
|
224
|
+
console.log(
|
|
225
|
+
` ${files.length} archivos · ${Object.keys(loaded.stack.services).length} services${
|
|
226
|
+
loaded.stack.gateway ? ' · gateway' : ''
|
|
227
|
+
}`,
|
|
228
|
+
);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function runDockerCompose(dockerArgs: string[]): number {
|
|
233
|
+
const loaded = loadStack(undefined);
|
|
234
|
+
ensureGenerated(loaded);
|
|
235
|
+
|
|
236
|
+
const composePath = join(loaded.outDir, 'docker-compose.yaml');
|
|
237
|
+
const envPath = join(loaded.outDir, '.env');
|
|
238
|
+
|
|
239
|
+
const res = spawnSync(
|
|
240
|
+
'docker',
|
|
241
|
+
['compose', '--env-file', envPath, '-f', composePath, ...dockerArgs],
|
|
242
|
+
{ stdio: 'inherit' },
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
if (res.error) {
|
|
246
|
+
console.error(`error ejecutando docker compose: ${res.error.message}`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
return res.status ?? 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ensureGenerated(loaded: LoadedStack): void {
|
|
253
|
+
const composePath = join(loaded.outDir, 'docker-compose.yaml');
|
|
254
|
+
if (!existsSync(composePath)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`no se encuentra ${relative(process.cwd(), composePath)}\n` +
|
|
257
|
+
`correr 'om gen' primero`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
process.exit(main(process.argv.slice(2)));
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, parse } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const STACK_FILENAME = 'stack.yaml';
|
|
5
|
+
// Subdirs convencionales donde el stack.yaml puede vivir agrupado con archivos
|
|
6
|
+
// auxiliares (init scripts, certs, secrets). Si lo encuentra ahí, el padre se
|
|
7
|
+
// trata como workspaceRoot (donde van repos/ y .stack/).
|
|
8
|
+
export const STACK_SUBDIRS = ['arch'] as const;
|
|
9
|
+
|
|
10
|
+
export function findStack(startDir: string = process.cwd()): string | null {
|
|
11
|
+
let dir = startDir;
|
|
12
|
+
const root = parse(dir).root;
|
|
13
|
+
while (true) {
|
|
14
|
+
for (const candidate of candidatesIn(dir)) {
|
|
15
|
+
if (existsSync(candidate)) return candidate;
|
|
16
|
+
}
|
|
17
|
+
if (dir === root) return null;
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir) return null;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function candidatesIn(dir: string): string[] {
|
|
25
|
+
return [join(dir, STACK_FILENAME), ...STACK_SUBDIRS.map((s) => join(dir, s, STACK_FILENAME))];
|
|
26
|
+
}
|
package/src/file-refs.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Patrón ${file:<path>} — todo el string. No se soporta interpolación parcial
|
|
5
|
+
// (un PEM no se va a embeber en medio de otra cosa).
|
|
6
|
+
const FILE_REF = /^\$\{file:([^}]+)\}$/;
|
|
7
|
+
|
|
8
|
+
// Reemplaza valores ${file:path} por el contenido del archivo (paths
|
|
9
|
+
// relativos al workDir del stack). Recorre objetos y arrays recursivamente.
|
|
10
|
+
export function expandFileRefs(value: unknown, workDir: string): unknown {
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
const m = FILE_REF.exec(value);
|
|
13
|
+
if (!m) return value;
|
|
14
|
+
const rel = m[1]!.trim();
|
|
15
|
+
const abs = isAbsolute(rel) ? rel : resolve(workDir, rel);
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(abs, 'utf8');
|
|
18
|
+
} catch (err) {
|
|
19
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
20
|
+
throw new Error(`\${file:${rel}}: no se pudo leer ${abs} — ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return value.map((v) => expandFileRefs(v, workDir));
|
|
25
|
+
}
|
|
26
|
+
if (value !== null && typeof value === 'object') {
|
|
27
|
+
const out: Record<string, unknown> = {};
|
|
28
|
+
for (const [k, v] of Object.entries(value)) {
|
|
29
|
+
out[k] = expandFileRefs(v, workDir);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const TEMPLATE = `# stack.yaml — describí la arquitectura acá, después corré: om sync && om gen && om up
|
|
5
|
+
#
|
|
6
|
+
# Layout: este archivo vive en arch/stack.yaml. om reconoce arch/ automáticamente —
|
|
7
|
+
# repos/ y .stack/ quedan en el padre.
|
|
8
|
+
# arch/
|
|
9
|
+
# ├── stack.yaml (este archivo)
|
|
10
|
+
# ├── init/ (archivos auxiliares montados como volúmenes, ej init scripts de DB)
|
|
11
|
+
# ├── certs/ (claves públicas .pem versionables, leídas con \${file:certs/...})
|
|
12
|
+
# └── secrets/ (claves privadas .pem gitignored, leídas con \${file:secrets/...})
|
|
13
|
+
|
|
14
|
+
name: mi-stack
|
|
15
|
+
|
|
16
|
+
# gateway: opcional. Si lo declarás, om levanta nginx y rutea por path.
|
|
17
|
+
# Quitalo si tu stack no necesita un proxy.
|
|
18
|
+
gateway:
|
|
19
|
+
port: 8080
|
|
20
|
+
routes:
|
|
21
|
+
- path: /api
|
|
22
|
+
service: api
|
|
23
|
+
- path: /
|
|
24
|
+
service: web
|
|
25
|
+
|
|
26
|
+
services:
|
|
27
|
+
api:
|
|
28
|
+
repo: github.com/me/api # se clona en repos/api con 'om sync'
|
|
29
|
+
build: ./Dockerfile
|
|
30
|
+
port: 3000
|
|
31
|
+
env:
|
|
32
|
+
DATABASE_URL: postgres://app:app@db:5432/app
|
|
33
|
+
# Referencias a otros services por nombre: el container "db" resuelve internamente.
|
|
34
|
+
needs:
|
|
35
|
+
- db
|
|
36
|
+
|
|
37
|
+
web:
|
|
38
|
+
repo: github.com/me/web
|
|
39
|
+
build: ./Dockerfile
|
|
40
|
+
port: 3001
|
|
41
|
+
env:
|
|
42
|
+
API_URL: http://api:3000
|
|
43
|
+
# Para hablar desde el browser pasá por el gateway:
|
|
44
|
+
PUBLIC_API_URL: http://localhost:8080/api
|
|
45
|
+
|
|
46
|
+
db:
|
|
47
|
+
image: postgres:16-alpine
|
|
48
|
+
port: 5432
|
|
49
|
+
expose_host: 5433 # opcional: publicar al host (psql -h localhost -p 5433)
|
|
50
|
+
env:
|
|
51
|
+
POSTGRES_DB: app
|
|
52
|
+
POSTGRES_USER: app
|
|
53
|
+
POSTGRES_PASSWORD: app
|
|
54
|
+
volumes:
|
|
55
|
+
- mi-stack-db:/var/lib/postgresql/data
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const SECRETS_GITIGNORE = `# Las claves privadas no se commitean. Solo este archivo queda en git.
|
|
59
|
+
*
|
|
60
|
+
!.gitignore
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const ROOT_GITIGNORE = `repos/
|
|
64
|
+
.stack/
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
export function initStack(targetDir: string): { path: string; created: boolean; message: string } {
|
|
68
|
+
const archDir = join(targetDir, 'arch');
|
|
69
|
+
const target = join(archDir, 'stack.yaml');
|
|
70
|
+
if (existsSync(target)) {
|
|
71
|
+
return { path: target, created: false, message: `ya existe ${target}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
mkdirSync(join(archDir, 'init'), { recursive: true });
|
|
75
|
+
mkdirSync(join(archDir, 'certs'), { recursive: true });
|
|
76
|
+
mkdirSync(join(archDir, 'secrets'), { recursive: true });
|
|
77
|
+
|
|
78
|
+
writeFileSync(target, TEMPLATE);
|
|
79
|
+
writeFileSync(join(archDir, 'secrets', '.gitignore'), SECRETS_GITIGNORE);
|
|
80
|
+
|
|
81
|
+
const rootGitignore = join(targetDir, '.gitignore');
|
|
82
|
+
if (!existsSync(rootGitignore)) {
|
|
83
|
+
writeFileSync(rootGitignore, ROOT_GITIGNORE);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
path: target,
|
|
88
|
+
created: true,
|
|
89
|
+
message: `creado ${target} y arch/{init,certs,secrets}/ — editá el yaml y corré 'om sync && om gen && om up'`,
|
|
90
|
+
};
|
|
91
|
+
}
|