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/src/schema.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { z } from 'zod';
2
+
3
+ const serviceNameSchema = z
4
+ .string()
5
+ .min(1)
6
+ .regex(/^[a-z][a-z0-9_-]*$/, 'service name debe ser kebab/snake-case y empezar con letra');
7
+
8
+ const envValueSchema = z.union([z.string(), z.number(), z.boolean()]).transform(String);
9
+ const envMapSchema = z.record(z.string(), envValueSchema);
10
+
11
+ const routeSchema = z.object({
12
+ path: z.string().min(1),
13
+ service: serviceNameSchema,
14
+ strip_prefix: z.boolean().optional(),
15
+ rewrite: z.string().optional(),
16
+ });
17
+
18
+ const gatewaySchema = z.object({
19
+ port: z.number().int().positive(),
20
+ server_name: z.string().optional(),
21
+ routes: z.array(routeSchema).min(1),
22
+ });
23
+
24
+ const vscodeBrowserSchema = z.object({
25
+ url: z.string().optional(),
26
+ label: z.string().optional(),
27
+ });
28
+
29
+ const vscodeServiceSchema = z.object({
30
+ browser: vscodeBrowserSchema.optional(),
31
+ });
32
+
33
+ // Una entrada de service única. `image` y `build` son mutuamente excluyentes
34
+ // pero al menos uno tiene que estar. `repo` es opcional para ambos (sirve a sync).
35
+ // Para oneshot, `port` también es opcional (jobs efímeros no escuchan).
36
+ const serviceSchema = z
37
+ .object({
38
+ kind: z.enum(['service', 'oneshot']).default('service'),
39
+ image: z.string().optional(),
40
+ build: z.string().optional(),
41
+ repo: z.string().optional(),
42
+ ref: z.string().optional(),
43
+ working_dir: z.string().optional(),
44
+ port: z.number().int().positive().optional(),
45
+ debug_port: z.number().int().positive().optional(),
46
+ env: envMapSchema.optional(),
47
+ needs: z.array(serviceNameSchema).optional(),
48
+ expose_host: z.number().int().positive().optional(),
49
+ volumes: z.array(z.string()).optional(),
50
+ command: z.union([z.string(), z.array(z.string())]).optional(),
51
+ databases: z.array(z.string().min(1)).optional(),
52
+ vscode: vscodeServiceSchema.optional(),
53
+ })
54
+ .superRefine((svc, ctx) => {
55
+ const hasImage = svc.image !== undefined;
56
+ const hasBuild = svc.build !== undefined;
57
+ if (hasImage === hasBuild) {
58
+ ctx.addIssue({
59
+ code: z.ZodIssueCode.custom,
60
+ message: hasImage
61
+ ? 'no pueden coexistir image: y build: en el mismo service'
62
+ : 'el service necesita image: o build:',
63
+ });
64
+ }
65
+ if (hasBuild && svc.repo === undefined) {
66
+ ctx.addIssue({
67
+ code: z.ZodIssueCode.custom,
68
+ path: ['repo'],
69
+ message: 'build: requiere repo: para resolver el build context',
70
+ });
71
+ }
72
+ if (svc.kind === 'service' && svc.port === undefined) {
73
+ ctx.addIssue({
74
+ code: z.ZodIssueCode.custom,
75
+ path: ['port'],
76
+ message: 'port: es obligatorio para kind: service (omitilo solo en kind: oneshot)',
77
+ });
78
+ }
79
+ if (svc.databases !== undefined && svc.databases.length > 0) {
80
+ const img = svc.image ?? '';
81
+ if (!/^postgres(:|$)/i.test(img)) {
82
+ ctx.addIssue({
83
+ code: z.ZodIssueCode.custom,
84
+ path: ['databases'],
85
+ message: `databases: solo soportado en image: postgres (recibido: ${img || '(none)'})`,
86
+ });
87
+ }
88
+ }
89
+ });
90
+
91
+ export const stackSchema = z
92
+ .object({
93
+ name: z
94
+ .string()
95
+ .min(1)
96
+ .regex(/^[a-z][a-z0-9-]*$/, 'name debe ser kebab-case y empezar con letra'),
97
+ gateway: gatewaySchema.optional(),
98
+ services: z
99
+ .record(serviceNameSchema, serviceSchema)
100
+ .refine((s) => Object.keys(s).length > 0, { message: 'el stack debe tener al menos un service' }),
101
+ })
102
+ .superRefine((stack, ctx) => {
103
+ const names = new Set(Object.keys(stack.services));
104
+
105
+ if (stack.gateway) {
106
+ for (const [i, route] of stack.gateway.routes.entries()) {
107
+ if (!names.has(route.service)) {
108
+ ctx.addIssue({
109
+ code: z.ZodIssueCode.custom,
110
+ path: ['gateway', 'routes', i, 'service'],
111
+ message: `route apunta a service inexistente: ${route.service}`,
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ for (const [svcName, svc] of Object.entries(stack.services)) {
118
+ for (const [i, dep] of (svc.needs ?? []).entries()) {
119
+ if (!names.has(dep)) {
120
+ ctx.addIssue({
121
+ code: z.ZodIssueCode.custom,
122
+ path: ['services', svcName, 'needs', i],
123
+ message: `needs apunta a service inexistente: ${dep}`,
124
+ });
125
+ }
126
+ if (dep === svcName) {
127
+ ctx.addIssue({
128
+ code: z.ZodIssueCode.custom,
129
+ path: ['services', svcName, 'needs', i],
130
+ message: `un service no puede depender de sí mismo`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ const hostPorts = new Map<number, string[]>();
137
+ if (stack.gateway) hostPorts.set(stack.gateway.port, ['gateway']);
138
+ for (const [svcName, svc] of Object.entries(stack.services)) {
139
+ if (svc.expose_host !== undefined) {
140
+ const list = hostPorts.get(svc.expose_host) ?? [];
141
+ list.push(svcName);
142
+ hostPorts.set(svc.expose_host, list);
143
+ }
144
+ if (svc.debug_port !== undefined) {
145
+ const list = hostPorts.get(svc.debug_port) ?? [];
146
+ list.push(`${svcName}.debug_port`);
147
+ hostPorts.set(svc.debug_port, list);
148
+ }
149
+ }
150
+ for (const [port, owners] of hostPorts) {
151
+ if (owners.length > 1) {
152
+ ctx.addIssue({
153
+ code: z.ZodIssueCode.custom,
154
+ path: ['services'],
155
+ message: `puerto host ${port} reclamado por: ${owners.join(', ')}`,
156
+ });
157
+ }
158
+ }
159
+ });
160
+
161
+ export type Stack = z.infer<typeof stackSchema>;
162
+ export type Service = Stack['services'][string];
163
+
164
+ export function hasBuild(svc: Service): boolean {
165
+ return svc.build !== undefined;
166
+ }
167
+
168
+ export function hasRepo(svc: Service): svc is Service & { repo: string } {
169
+ return typeof svc.repo === 'string' && svc.repo.length > 0;
170
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { isLocalRepo, normalizeGitUrl, repoSlug } from './repo.js';
5
+ import { hasRepo, type Stack } from './schema.js';
6
+
7
+ export type SyncResult = {
8
+ service: string;
9
+ repo: string;
10
+ action:
11
+ | 'cloned'
12
+ | 'pulled'
13
+ | 'switched'
14
+ | 'local-ok'
15
+ | 'local-missing'
16
+ | 'failed'
17
+ | 'skipped-no-repo'
18
+ | 'skipped-dup';
19
+ message?: string;
20
+ };
21
+
22
+ export function syncStack(
23
+ stack: Stack,
24
+ opts: { workDir: string; reposDir: string },
25
+ ): SyncResult[] {
26
+ const results: SyncResult[] = [];
27
+ const seenSlugs = new Set<string>();
28
+
29
+ for (const [name, svc] of Object.entries(stack.services)) {
30
+ if (!hasRepo(svc)) {
31
+ results.push({ service: name, repo: '(no repo)', action: 'skipped-no-repo' });
32
+ continue;
33
+ }
34
+ const slug = repoSlug(svc.repo);
35
+ if (seenSlugs.has(slug)) {
36
+ results.push({
37
+ service: name,
38
+ repo: svc.repo,
39
+ action: 'skipped-dup',
40
+ message: `mismo repo que un service anterior (slug=${slug})`,
41
+ });
42
+ continue;
43
+ }
44
+ seenSlugs.add(slug);
45
+ results.push(syncOne(name, svc.repo, svc.ref, opts));
46
+ }
47
+
48
+ return results;
49
+ }
50
+
51
+ function syncOne(
52
+ service: string,
53
+ repo: string,
54
+ ref: string | undefined,
55
+ opts: { workDir: string; reposDir: string },
56
+ ): SyncResult {
57
+ if (isLocalRepo(repo)) {
58
+ const abs = isAbsolute(repo) ? repo : resolve(opts.workDir, repo);
59
+ if (existsSync(abs)) {
60
+ return { service, repo, action: 'local-ok', message: abs };
61
+ }
62
+ return { service, repo, action: 'local-missing', message: `no existe: ${abs}` };
63
+ }
64
+
65
+ const url = normalizeGitUrl(repo);
66
+ const slug = repoSlug(repo);
67
+ const targetDir = join(opts.reposDir, slug);
68
+
69
+ if (existsSync(join(targetDir, '.git'))) {
70
+ // Repo ya clonado. Si declara ref distinto al actual, switchear; sino pull --ff-only.
71
+ if (ref !== undefined) {
72
+ const current = git(['rev-parse', '--abbrev-ref', 'HEAD'], targetDir);
73
+ const currentBranch = current.stdout.trim();
74
+ if (currentBranch !== ref) {
75
+ const fetchRes = git(['fetch', 'origin', ref], targetDir);
76
+ if (fetchRes.status !== 0) {
77
+ return {
78
+ service,
79
+ repo,
80
+ action: 'failed',
81
+ message: `git fetch origin ${ref} falló: ${fetchRes.stderr}`,
82
+ };
83
+ }
84
+ const checkoutRes = git(['checkout', ref], targetDir);
85
+ if (checkoutRes.status !== 0) {
86
+ return {
87
+ service,
88
+ repo,
89
+ action: 'failed',
90
+ message: `git checkout ${ref} falló: ${checkoutRes.stderr}`,
91
+ };
92
+ }
93
+ return { service, repo, action: 'switched', message: `${targetDir} → ${ref}` };
94
+ }
95
+ }
96
+ const pullRes = git(['pull', '--ff-only'], targetDir);
97
+ if (pullRes.status !== 0) {
98
+ return { service, repo, action: 'failed', message: `git pull falló: ${pullRes.stderr}` };
99
+ }
100
+ return { service, repo, action: 'pulled', message: targetDir };
101
+ }
102
+
103
+ const cloneArgs = ['clone'];
104
+ if (ref !== undefined) cloneArgs.push('--branch', ref);
105
+ cloneArgs.push(url, targetDir);
106
+ const cloneRes = git(cloneArgs, opts.reposDir);
107
+ if (cloneRes.status !== 0) {
108
+ return { service, repo, action: 'failed', message: `git clone falló: ${cloneRes.stderr}` };
109
+ }
110
+ return {
111
+ service,
112
+ repo,
113
+ action: 'cloned',
114
+ message: ref !== undefined ? `${targetDir} (ref=${ref})` : targetDir,
115
+ };
116
+ }
117
+
118
+ function git(args: string[], cwd: string): { status: number; stderr: string; stdout: string } {
119
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
120
+ return {
121
+ status: res.status ?? 1,
122
+ stderr: (res.stderr ?? '').trim(),
123
+ stdout: res.stdout ?? '',
124
+ };
125
+ }