octo-dev 0.2.2
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 +270 -0
- package/package.json +62 -0
- package/scripts/install.sh +117 -0
- package/src/build/adapters/docker-build-engine.adapter.ts +39 -0
- package/src/build/affected-detector.ts +126 -0
- package/src/build/build-orchestrator.ts +169 -0
- package/src/build/build-scheduler.ts +174 -0
- package/src/build/ports/build-engine.port.ts +38 -0
- package/src/cli/build.command.ts +101 -0
- package/src/cli/bump.command.ts +98 -0
- package/src/cli/down.command.ts +36 -0
- package/src/cli/graph.command.ts +40 -0
- package/src/cli/index.ts +80 -0
- package/src/cli/init.command.ts +106 -0
- package/src/cli/status.command.ts +46 -0
- package/src/cli/up.command.ts +52 -0
- package/src/graph/aggregated-graph.ts +77 -0
- package/src/graph/build-graph.ts +125 -0
- package/src/graph/dependency-graph.ts +82 -0
- package/src/graph/index.ts +4 -0
- package/src/graph/topological-sort.ts +104 -0
- package/src/hooks/hook-runner.ts +57 -0
- package/src/infra/compose-aggregator.ts +152 -0
- package/src/infra/compose-smart-merger.ts +93 -0
- package/src/infra/infra-manager.ts +157 -0
- package/src/manifest/manifest-discovery.ts +144 -0
- package/src/manifest/manifest-parser.ts +109 -0
- package/src/manifest/manifest-printer.ts +75 -0
- package/src/manifest/manifest-schema.ts +34 -0
- package/src/shared/errors.ts +43 -0
- package/src/shared/logger.ts +14 -0
- package/src/shared/process-runner.ts +47 -0
- package/src/shared/shutdown.ts +36 -0
- package/src/version/changelog-generator.ts +112 -0
- package/src/version/version-bumper.ts +116 -0
- package/src/version/version-propagator.ts +120 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { run } from '../shared/process-runner.js';
|
|
2
|
+
import { logger } from '../shared/logger.js';
|
|
3
|
+
import type { OctoManifest, HookDefinition } from '../manifest/manifest-schema.js';
|
|
4
|
+
|
|
5
|
+
export type HookTrigger = 'pre-build' | 'pre-bump';
|
|
6
|
+
|
|
7
|
+
export interface HookContext {
|
|
8
|
+
target: string;
|
|
9
|
+
workingDir: string;
|
|
10
|
+
manifest: OctoManifest;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HookResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
hook: string;
|
|
16
|
+
output?: string;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Executes hooks sequentially in declaration order. Aborts on first failure. */
|
|
21
|
+
export async function runHooks(trigger: HookTrigger, context: HookContext): Promise<HookResult[]> {
|
|
22
|
+
const hooks: HookDefinition[] = context.manifest.hooks?.[trigger] ?? [];
|
|
23
|
+
|
|
24
|
+
if (hooks.length === 0) return [];
|
|
25
|
+
|
|
26
|
+
const results: HookResult[] = [];
|
|
27
|
+
|
|
28
|
+
for (const hook of hooks) {
|
|
29
|
+
logger.info(`Running hook "${hook.name}" (${trigger})...`);
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
|
|
32
|
+
const result = await run(hook.command, [], { cwd: context.workingDir });
|
|
33
|
+
const durationMs = Date.now() - start;
|
|
34
|
+
const output = (result.stdout + result.stderr).trim() || undefined;
|
|
35
|
+
|
|
36
|
+
if (result.exitCode !== 0) {
|
|
37
|
+
logger.error(`Hook "${hook.name}" failed (exit ${result.exitCode})`);
|
|
38
|
+
results.push({ success: false, hook: hook.name, output, durationMs });
|
|
39
|
+
throw new HookError(hook.name, trigger, output);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
results.push({ success: true, hook: hook.name, output, durationMs });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class HookError extends Error {
|
|
49
|
+
constructor(
|
|
50
|
+
public readonly hook: string,
|
|
51
|
+
public readonly trigger: HookTrigger,
|
|
52
|
+
public readonly output?: string,
|
|
53
|
+
) {
|
|
54
|
+
super(`Hook "${hook}" failed during ${trigger}${output ? `: ${output}` : ''}`);
|
|
55
|
+
this.name = 'HookError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
|
|
5
|
+
/** A discovered docker-compose.yml from a service directory */
|
|
6
|
+
export interface DiscoveredCompose {
|
|
7
|
+
serviceName: string;
|
|
8
|
+
path: string;
|
|
9
|
+
content: DockerComposeDocument;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Parsed docker-compose document structure */
|
|
13
|
+
export interface DockerComposeDocument {
|
|
14
|
+
services?: Record<string, any>;
|
|
15
|
+
networks?: Record<string, any>;
|
|
16
|
+
volumes?: Record<string, any>;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Merged compose output */
|
|
21
|
+
export interface MergedCompose {
|
|
22
|
+
services: Record<string, any>;
|
|
23
|
+
networks: Record<string, any>;
|
|
24
|
+
volumes: Record<string, any>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Conflict detected during merge */
|
|
28
|
+
export interface ComposeConflict {
|
|
29
|
+
type: 'port' | 'volume_name' | 'service_name';
|
|
30
|
+
sources: string[];
|
|
31
|
+
description: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Result of a merge operation */
|
|
35
|
+
export interface MergeResult {
|
|
36
|
+
merged: MergedCompose;
|
|
37
|
+
conflicts: ComposeConflict[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ComposeAggregator {
|
|
41
|
+
discover(servicePaths: string[]): DiscoveredCompose[];
|
|
42
|
+
merge(composes: DiscoveredCompose[]): MergeResult;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract host ports from a compose service ports definition */
|
|
46
|
+
function extractHostPorts(ports: any[]): string[] {
|
|
47
|
+
const result: string[] = [];
|
|
48
|
+
for (const p of ports) {
|
|
49
|
+
const portStr = typeof p === 'object' && p.published != null
|
|
50
|
+
? String(p.published)
|
|
51
|
+
: String(p);
|
|
52
|
+
// Match host port from formats like "8080:80", "127.0.0.1:8080:80", "8080:80/tcp"
|
|
53
|
+
const match = portStr.match(/(?:[\d.]+:)?(\d+):\d+/);
|
|
54
|
+
if (match) result.push(match[1]);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createComposeAggregator(): ComposeAggregator {
|
|
60
|
+
return {
|
|
61
|
+
discover(servicePaths: string[]): DiscoveredCompose[] {
|
|
62
|
+
const results: DiscoveredCompose[] = [];
|
|
63
|
+
for (const svcPath of servicePaths) {
|
|
64
|
+
const composePath = join(svcPath, 'docker-compose.yml');
|
|
65
|
+
if (!existsSync(composePath)) continue;
|
|
66
|
+
const raw = readFileSync(composePath, 'utf-8');
|
|
67
|
+
const content = (parse(raw) ?? {}) as DockerComposeDocument;
|
|
68
|
+
const serviceName = svcPath.split('/').pop() ?? svcPath;
|
|
69
|
+
results.push({ serviceName, path: composePath, content });
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
merge(composes: DiscoveredCompose[]): MergeResult {
|
|
75
|
+
const merged: MergedCompose = { services: {}, networks: {}, volumes: {} };
|
|
76
|
+
const conflicts: ComposeConflict[] = [];
|
|
77
|
+
|
|
78
|
+
// Track origins for conflict detection
|
|
79
|
+
const serviceOrigins = new Map<string, string[]>();
|
|
80
|
+
const portOrigins = new Map<string, string[]>();
|
|
81
|
+
|
|
82
|
+
for (const compose of composes) {
|
|
83
|
+
const { content, path: sourcePath } = compose;
|
|
84
|
+
if (!content) continue;
|
|
85
|
+
|
|
86
|
+
// Merge services
|
|
87
|
+
if (content.services) {
|
|
88
|
+
for (const [name, def] of Object.entries(content.services)) {
|
|
89
|
+
if (!serviceOrigins.has(name)) serviceOrigins.set(name, []);
|
|
90
|
+
serviceOrigins.get(name)!.push(sourcePath);
|
|
91
|
+
|
|
92
|
+
// Detect port conflicts
|
|
93
|
+
if (def?.ports && Array.isArray(def.ports)) {
|
|
94
|
+
for (const hostPort of extractHostPorts(def.ports)) {
|
|
95
|
+
const key = hostPort;
|
|
96
|
+
if (!portOrigins.has(key)) portOrigins.set(key, []);
|
|
97
|
+
portOrigins.get(key)!.push(sourcePath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// First definition wins (deterministic)
|
|
102
|
+
if (!merged.services[name]) {
|
|
103
|
+
merged.services[name] = def;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Merge networks
|
|
109
|
+
if (content.networks) {
|
|
110
|
+
for (const [name, def] of Object.entries(content.networks)) {
|
|
111
|
+
if (!merged.networks[name]) {
|
|
112
|
+
merged.networks[name] = def;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Merge volumes
|
|
118
|
+
if (content.volumes) {
|
|
119
|
+
for (const [name, def] of Object.entries(content.volumes)) {
|
|
120
|
+
if (!merged.volumes[name]) {
|
|
121
|
+
merged.volumes[name] = def;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Report service name conflicts
|
|
128
|
+
for (const [name, sources] of serviceOrigins) {
|
|
129
|
+
if (sources.length > 1) {
|
|
130
|
+
conflicts.push({
|
|
131
|
+
type: 'service_name',
|
|
132
|
+
sources,
|
|
133
|
+
description: `Service "${name}" declared in multiple compose files`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Report port conflicts
|
|
139
|
+
for (const [port, sources] of portOrigins) {
|
|
140
|
+
if (sources.length > 1) {
|
|
141
|
+
conflicts.push({
|
|
142
|
+
type: 'port',
|
|
143
|
+
sources,
|
|
144
|
+
description: `Host port ${port} mapped in multiple compose files`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { merged, conflicts };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Ollama } from 'ollama';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createComposeAggregator, type DiscoveredCompose, type MergedCompose } from './compose-aggregator.js';
|
|
4
|
+
import { logger } from '../shared/logger.js';
|
|
5
|
+
|
|
6
|
+
/** Zod schema to validate LLM output */
|
|
7
|
+
const MergedComposeSchema = z.object({
|
|
8
|
+
services: z.record(z.string(), z.any()).default({}),
|
|
9
|
+
networks: z.record(z.string(), z.any()).default({}),
|
|
10
|
+
volumes: z.record(z.string(), z.any()).default({}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export interface ComposeSmartMerger {
|
|
14
|
+
deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Build the structured prompt for Phi-4 */
|
|
18
|
+
function buildPrompt(composes: DiscoveredCompose[]): string {
|
|
19
|
+
const composesText = composes
|
|
20
|
+
.map((c) => `--- ${c.serviceName} (${c.path}) ---\n${JSON.stringify(c.content, null, 2)}`)
|
|
21
|
+
.join('\n\n');
|
|
22
|
+
|
|
23
|
+
return `You are a Docker Compose expert. Given multiple docker-compose files from different services, merge them into a single unified compose file.
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
1. Deduplicate containers that represent the same infrastructure (e.g. multiple postgres definitions → keep one)
|
|
27
|
+
2. When images differ, prefer the most recent version
|
|
28
|
+
3. Merge all environment variables from duplicates without losing any
|
|
29
|
+
4. Consolidate networks and volumes, removing redundancies
|
|
30
|
+
5. Preserve all port mappings, flagging conflicts if any
|
|
31
|
+
|
|
32
|
+
Input compose files:
|
|
33
|
+
${composesText}
|
|
34
|
+
|
|
35
|
+
Return ONLY valid JSON with this exact structure:
|
|
36
|
+
{"services": {...}, "networks": {...}, "volumes": {...}}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Check if Ollama is available with phi4 model */
|
|
40
|
+
async function isOllamaAvailable(client: Ollama): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
const models = await client.list();
|
|
43
|
+
return models.models.some((m) => m.name.startsWith('phi4'));
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createComposeSmartMerger(): ComposeSmartMerger {
|
|
50
|
+
const client = new Ollama();
|
|
51
|
+
const aggregator = createComposeAggregator();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose> {
|
|
55
|
+
if (composes.length === 0) {
|
|
56
|
+
return { services: {}, networks: {}, volumes: {} };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Try LLM-based merge
|
|
60
|
+
if (await isOllamaAvailable(client)) {
|
|
61
|
+
try {
|
|
62
|
+
logger.info('Usando Phi-4 para merge inteligente de compose files...');
|
|
63
|
+
const prompt = buildPrompt(composes);
|
|
64
|
+
const response = await client.generate({
|
|
65
|
+
model: 'phi4',
|
|
66
|
+
prompt,
|
|
67
|
+
format: 'json',
|
|
68
|
+
stream: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const parsed = JSON.parse(response.response);
|
|
72
|
+
const validated = MergedComposeSchema.safeParse(parsed);
|
|
73
|
+
|
|
74
|
+
if (validated.success) {
|
|
75
|
+
logger.info('Merge inteligente concluído com sucesso.');
|
|
76
|
+
return validated.data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logger.warn('Output da LLM falhou na validação Zod. Usando fallback determinístico.');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
+
logger.warn(`Erro ao usar Phi-4: ${msg}. Usando fallback determinístico.`);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
logger.info('Ollama/Phi-4 indisponível. Usando merge determinístico.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fallback: deterministic merge
|
|
89
|
+
const { merged } = aggregator.merge(composes);
|
|
90
|
+
return merged;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { stringify } from 'yaml';
|
|
5
|
+
import { run } from '../shared/process-runner.js';
|
|
6
|
+
import { logger } from '../shared/logger.js';
|
|
7
|
+
import { OctoError } from '../shared/errors.js';
|
|
8
|
+
import { createComposeSmartMerger } from './compose-smart-merger.js';
|
|
9
|
+
import { createComposeAggregator, type MergedCompose } from './compose-aggregator.js';
|
|
10
|
+
|
|
11
|
+
export type ContainerState = 'running' | 'stopped' | 'unhealthy';
|
|
12
|
+
|
|
13
|
+
export interface ContainerStatus {
|
|
14
|
+
name: string;
|
|
15
|
+
image: string;
|
|
16
|
+
state: ContainerState;
|
|
17
|
+
port: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InfraResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InfraManager {
|
|
26
|
+
up(services?: string[]): Promise<InfraResult>;
|
|
27
|
+
down(options: { volumes?: boolean }): Promise<InfraResult>;
|
|
28
|
+
status(): Promise<ContainerStatus[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HEALTHCHECK_TIMEOUT_MS = 60_000;
|
|
32
|
+
const HEALTHCHECK_POLL_MS = 2_000;
|
|
33
|
+
|
|
34
|
+
/** Write merged compose to a temp file and return its path */
|
|
35
|
+
async function writeTempCompose(merged: MergedCompose): Promise<string> {
|
|
36
|
+
const tempPath = join(tmpdir(), `octo-compose-${Date.now()}.yml`);
|
|
37
|
+
const content = stringify(merged);
|
|
38
|
+
await writeFile(tempPath, content, 'utf-8');
|
|
39
|
+
return tempPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Poll healthcheck for a container, returns true if healthy within timeout */
|
|
43
|
+
async function waitForHealthcheck(containerName: string): Promise<boolean> {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
while (Date.now() - start < HEALTHCHECK_TIMEOUT_MS) {
|
|
46
|
+
const result = await run('docker', [
|
|
47
|
+
'inspect', '--format', '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', containerName,
|
|
48
|
+
]);
|
|
49
|
+
const status = result.stdout.trim();
|
|
50
|
+
if (status === 'healthy' || status === 'none') return true;
|
|
51
|
+
if (status === 'unhealthy') return false;
|
|
52
|
+
await new Promise((r) => setTimeout(r, HEALTHCHECK_POLL_MS));
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Show last 20 lines of container log */
|
|
58
|
+
async function showContainerLogs(containerName: string): Promise<void> {
|
|
59
|
+
const result = await run('docker', ['logs', '--tail', '20', containerName]);
|
|
60
|
+
const output = result.stdout || result.stderr;
|
|
61
|
+
if (output) logger.error(`Últimas 20 linhas de log (${containerName}):\n${output}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createInfraManager(servicePaths: string[]): InfraManager {
|
|
65
|
+
const aggregator = createComposeAggregator();
|
|
66
|
+
const smartMerger = createComposeSmartMerger();
|
|
67
|
+
let composePath: string | undefined;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async up(services?: string[]): Promise<InfraResult> {
|
|
71
|
+
const paths = services && services.length > 0
|
|
72
|
+
? servicePaths.filter((p) => services.some((s) => p.endsWith(s)))
|
|
73
|
+
: servicePaths;
|
|
74
|
+
|
|
75
|
+
const discovered = aggregator.discover(paths);
|
|
76
|
+
if (discovered.length === 0) {
|
|
77
|
+
return { success: true, message: 'Nenhum docker-compose.yml encontrado.' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
logger.info(`Descobertos ${discovered.length} compose file(s). Merging...`);
|
|
81
|
+
const merged = await smartMerger.deduplicate(discovered);
|
|
82
|
+
composePath = await writeTempCompose(merged);
|
|
83
|
+
|
|
84
|
+
logger.info('Subindo containers...');
|
|
85
|
+
const result = await run('docker', ['compose', '-f', composePath, 'up', '-d']);
|
|
86
|
+
if (result.exitCode !== 0) {
|
|
87
|
+
return { success: false, message: `docker compose up falhou: ${result.stderr}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Wait for healthchecks
|
|
91
|
+
const serviceNames = Object.keys(merged.services);
|
|
92
|
+
for (const svc of serviceNames) {
|
|
93
|
+
const healthy = await waitForHealthcheck(svc);
|
|
94
|
+
if (!healthy) {
|
|
95
|
+
logger.error(`Healthcheck timeout para container "${svc}".`);
|
|
96
|
+
await showContainerLogs(svc);
|
|
97
|
+
// Stop dependents but don't tear down everything
|
|
98
|
+
return { success: false, message: `Healthcheck timeout: ${svc}` };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { success: true, message: `${serviceNames.length} container(s) iniciado(s).` };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async down(options: { volumes?: boolean }): Promise<InfraResult> {
|
|
106
|
+
// Discover and merge to get the compose file path
|
|
107
|
+
if (!composePath) {
|
|
108
|
+
const discovered = aggregator.discover(servicePaths);
|
|
109
|
+
if (discovered.length === 0) {
|
|
110
|
+
return { success: true, message: 'Nenhum container para parar.' };
|
|
111
|
+
}
|
|
112
|
+
const merged = await smartMerger.deduplicate(discovered);
|
|
113
|
+
composePath = await writeTempCompose(merged);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const args = ['compose', '-f', composePath, 'down'];
|
|
117
|
+
if (options.volumes) args.push('--volumes');
|
|
118
|
+
|
|
119
|
+
const result = await run('docker', args);
|
|
120
|
+
if (result.exitCode !== 0) {
|
|
121
|
+
return { success: false, message: `docker compose down falhou: ${result.stderr}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { success: true, message: 'Containers parados.' };
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async status(): Promise<ContainerStatus[]> {
|
|
128
|
+
if (!composePath) {
|
|
129
|
+
const discovered = aggregator.discover(servicePaths);
|
|
130
|
+
if (discovered.length === 0) return [];
|
|
131
|
+
const merged = await smartMerger.deduplicate(discovered);
|
|
132
|
+
composePath = await writeTempCompose(merged);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await run('docker', ['compose', '-f', composePath, 'ps', '--format', 'json']);
|
|
136
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) return [];
|
|
137
|
+
|
|
138
|
+
const containers: ContainerStatus[] = [];
|
|
139
|
+
// docker compose ps --format json outputs one JSON object per line
|
|
140
|
+
for (const line of result.stdout.trim().split('\n')) {
|
|
141
|
+
try {
|
|
142
|
+
const entry = JSON.parse(line);
|
|
143
|
+
const state: ContainerState =
|
|
144
|
+
entry.Health === 'unhealthy' ? 'unhealthy' :
|
|
145
|
+
entry.State === 'running' ? 'running' : 'stopped';
|
|
146
|
+
containers.push({
|
|
147
|
+
name: entry.Name ?? entry.Service ?? '',
|
|
148
|
+
image: entry.Image ?? '',
|
|
149
|
+
state,
|
|
150
|
+
port: entry.Publishers?.map((p: any) => `${p.PublishedPort}:${p.TargetPort}`).join(', ') ?? '',
|
|
151
|
+
});
|
|
152
|
+
} catch { /* skip malformed lines */ }
|
|
153
|
+
}
|
|
154
|
+
return containers;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { ManifestError } from '../shared/errors.js';
|
|
4
|
+
import { logger } from '../shared/logger.js';
|
|
5
|
+
|
|
6
|
+
const MANIFEST_FILENAME = 'octo.yaml';
|
|
7
|
+
const MAX_DEPTH = 5;
|
|
8
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist']);
|
|
9
|
+
|
|
10
|
+
export interface DiscoveredManifest {
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
isRoot: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Checks if a directory name should be skipped during scan */
|
|
17
|
+
function shouldSkip(dirName: string): boolean {
|
|
18
|
+
return dirName.startsWith('.') || SKIP_DIRS.has(dirName);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursively scans directories for octo.yaml files.
|
|
23
|
+
* Skips node_modules, dist, and dot-prefixed directories.
|
|
24
|
+
* Max scan depth: 5 levels.
|
|
25
|
+
*/
|
|
26
|
+
function scanForManifests(dir: string, rootDir: string, depth: number): DiscoveredManifest[] {
|
|
27
|
+
if (depth > MAX_DEPTH) return [];
|
|
28
|
+
|
|
29
|
+
const results: DiscoveredManifest[] = [];
|
|
30
|
+
const manifestPath = join(dir, MANIFEST_FILENAME);
|
|
31
|
+
|
|
32
|
+
if (existsSync(manifestPath)) {
|
|
33
|
+
const dirName = relative(rootDir, dir) || '.';
|
|
34
|
+
results.push({
|
|
35
|
+
name: dirName === '.' ? 'root' : dirName,
|
|
36
|
+
path: manifestPath,
|
|
37
|
+
isRoot: dir === rootDir,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let entries: string[];
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(dir);
|
|
44
|
+
} catch {
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (shouldSkip(entry)) continue;
|
|
50
|
+
const fullPath = join(dir, entry);
|
|
51
|
+
try {
|
|
52
|
+
if (statSync(fullPath).isDirectory()) {
|
|
53
|
+
results.push(...scanForManifests(fullPath, rootDir, depth + 1));
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Skip inaccessible entries
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Discovers all octo.yaml manifests starting from rootDir.
|
|
65
|
+
* If a root manifest exists alongside sub-manifests, prioritizes root.
|
|
66
|
+
*/
|
|
67
|
+
export function discoverManifests(rootDir: string): DiscoveredManifest[] {
|
|
68
|
+
const all = scanForManifests(rootDir, rootDir, 0);
|
|
69
|
+
|
|
70
|
+
// If root manifest exists, filter out sub-manifests already referenced
|
|
71
|
+
const rootManifest = all.find((m) => m.isRoot);
|
|
72
|
+
if (rootManifest && all.length > 1) {
|
|
73
|
+
// Prioritize root — return only root manifest
|
|
74
|
+
return [rootManifest];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return all;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolves execution mode based on manifest discovery.
|
|
82
|
+
* - standalone: exactly one octo.yaml in execution dir, no sub-manifests
|
|
83
|
+
* - aggregated: multiple octo.yaml files in subdirectories
|
|
84
|
+
*/
|
|
85
|
+
export function resolveMode(rootDir: string): 'standalone' | 'aggregated' {
|
|
86
|
+
const all = scanForManifests(rootDir, rootDir, 0);
|
|
87
|
+
const rootManifest = all.find((m) => m.isRoot);
|
|
88
|
+
const subManifests = all.filter((m) => !m.isRoot);
|
|
89
|
+
|
|
90
|
+
// Root manifest with sub-manifests → standalone (root takes priority)
|
|
91
|
+
if (rootManifest && subManifests.length > 0) {
|
|
92
|
+
return 'standalone';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Exactly one manifest in execution dir → standalone
|
|
96
|
+
if (rootManifest && subManifests.length === 0) {
|
|
97
|
+
return 'standalone';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Multiple sub-manifests without root → aggregated
|
|
101
|
+
if (subManifests.length > 1) {
|
|
102
|
+
return 'aggregated';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Single sub-manifest → standalone
|
|
106
|
+
return 'standalone';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detects name collisions across discovered manifests.
|
|
111
|
+
* Throws ManifestError if two manifests declare the same service/package name.
|
|
112
|
+
*/
|
|
113
|
+
export function detectNameCollisions(manifests: DiscoveredManifest[]): void {
|
|
114
|
+
const seen = new Map<string, string>(); // name → path
|
|
115
|
+
|
|
116
|
+
for (const manifest of manifests) {
|
|
117
|
+
if (seen.has(manifest.name)) {
|
|
118
|
+
const existing = seen.get(manifest.name)!;
|
|
119
|
+
throw new ManifestError(
|
|
120
|
+
`Name collision detected: "${manifest.name}" declared in both "${existing}" and "${manifest.path}"`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
seen.set(manifest.name, manifest.path);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Displays discovered projects in aggregated mode.
|
|
129
|
+
* Called at the beginning of execution when in aggregated mode.
|
|
130
|
+
*/
|
|
131
|
+
export function displayDiscoveredProjects(rootDir: string): DiscoveredManifest[] {
|
|
132
|
+
const manifests = discoverManifests(rootDir);
|
|
133
|
+
const mode = resolveMode(rootDir);
|
|
134
|
+
|
|
135
|
+
if (mode === 'aggregated') {
|
|
136
|
+
logger.info(`Modo agregado: ${manifests.length} projetos descobertos`);
|
|
137
|
+
for (const m of manifests) {
|
|
138
|
+
logger.info(` → ${m.name} (${relative(rootDir, m.path)})`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
detectNameCollisions(manifests);
|
|
143
|
+
return manifests;
|
|
144
|
+
}
|